diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs
deleted file mode 100644
index 94a02b7806dc..000000000000
--- a/.github/scripts/thread-transfer-report.cjs
+++ /dev/null
@@ -1,429 +0,0 @@
-const fs = require("node:fs");
-const path = require("node:path");
-
-const ARTIFACT_NAME = "thread-transfer-results";
-const RESULT_FILE = "thread-transfer-result.json";
-const COMMENT_MARKER = "";
-const PROVIDERS = ["codex", "claudeAgent"];
-const OBSERVED_KEYS = [
- "totalWireBytes",
- "threadSnapshotWireBytes",
- "threadSnapshotDecodedBytes",
- "measuredTurnWebSocketWireBytes",
- "measuredTurnWebSocketDecodedBytes",
- "measuredTurnWebSocketMessages",
-];
-const CEILING_KEYS = [
- "totalWireBytes",
- "threadSnapshotWireBytes",
- "measuredTurnWebSocketWireBytes",
- "measuredTurnWebSocketDecodedBytes",
- "measuredTurnWebSocketMessages",
-];
-const SCENARIO_KEYS = [
- "id",
- "historyTurns",
- "historyCommandToolsPerTurn",
- "historyMcpResultBytes",
- "measuredCommandTools",
- "measuredMcpResultBytes",
-];
-
-function resultShaMarker(sha) {
- return ``;
-}
-
-function assertObject(value, label) {
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
- throw new Error(`${label} must be an object`);
- }
-}
-
-function assertExactKeys(value, expected, label) {
- assertObject(value, label);
- const actual = Object.keys(value).sort();
- const wanted = [...expected].sort();
- if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
- throw new Error(`${label} has unexpected fields`);
- }
-}
-
-function assertMetric(value, label) {
- if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) {
- throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`);
- }
-}
-
-function validateResult(value) {
- assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result");
- if (value.schemaVersion !== 1) {
- throw new Error("result.schemaVersion must be 1");
- }
-
- assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario");
- if (value.scenario.id !== "thread-transfer-v1") {
- throw new Error("result.scenario.id is not supported");
- }
- for (const key of SCENARIO_KEYS.slice(1)) {
- assertMetric(value.scenario[key], `result.scenario.${key}`);
- }
-
- assertExactKeys(value.providers, PROVIDERS, "result.providers");
- for (const provider of PROVIDERS) {
- const entry = value.providers[provider];
- assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`);
- assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`);
- assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`);
- for (const key of OBSERVED_KEYS) {
- assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`);
- }
- for (const key of CEILING_KEYS) {
- assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`);
- }
- }
-
- return value;
-}
-
-function readResult(directory) {
- if (!directory) return undefined;
- const file = path.join(directory, RESULT_FILE);
- if (!fs.existsSync(file)) return undefined;
- const stat = fs.lstatSync(file);
- if (!stat.isFile() || stat.size > 64 * 1_024) {
- throw new Error("thread transfer result must be a regular file smaller than 64 KiB");
- }
- return validateResult(JSON.parse(fs.readFileSync(file, "utf8")));
-}
-
-function formatBytes(bytes) {
- if (bytes < 1_024) return `${bytes} B`;
- if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`;
- return `${(bytes / 1_024).toFixed(1)} KiB`;
-}
-
-function formatValue(value, kind) {
- return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value);
-}
-
-function formatImpact(current, baseline, kind) {
- if (baseline === undefined) return "—";
- const delta = current - baseline;
- const prefix = delta > 0 ? "+" : delta < 0 ? "−" : "";
- const magnitude = formatValue(Math.abs(delta), kind);
- const percent =
- baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`;
- return `${prefix}${magnitude}${percent}`;
-}
-
-function sameScenario(left, right) {
- return SCENARIO_KEYS.every((key) => left[key] === right[key]);
-}
-
-const METRICS = [
- { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" },
- { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" },
- {
- key: "measuredTurnWebSocketWireBytes",
- label: "Live turn WebSocket wire",
- kind: "bytes",
- },
- {
- key: "measuredTurnWebSocketDecodedBytes",
- label: "Live turn WebSocket decoded",
- kind: "bytes",
- },
- { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" },
-];
-
-function renderComment(input) {
- const current = input.current;
- const baseline = input.baseline;
- const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario);
- const rows = [];
- const ceilingChanges = [];
- let failed = false;
-
- for (const provider of PROVIDERS) {
- for (const metric of METRICS) {
- const observed = current.providers[provider].observed[metric.key];
- const ceiling = current.providers[provider].ceiling[metric.key];
- const baselineObserved = comparable
- ? baseline.providers[provider].observed[metric.key]
- : undefined;
- const pass = observed <= ceiling;
- failed ||= !pass;
- rows.push(
- `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`,
- );
-
- if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) {
- ceilingChanges.push(
- `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`,
- );
- }
- }
- }
-
- const baselineLink = input.baselineRun
- ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})`
- : "unavailable";
- const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`;
- const notices = [];
- if (!baseline) {
- notices.push(
- "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.",
- );
- } else if (!comparable) {
- notices.push(
- "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.",
- );
- } else if (!input.baselineRun.matchesBase) {
- notices.push(
- "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.",
- );
- }
- if (ceilingChanges.length > 0) {
- notices.push(
- `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`,
- );
- }
-
- return [
- COMMENT_MARKER,
- resultShaMarker(input.currentRun.sha),
- "## Thread transfer impact",
- "",
- failed
- ? "❌ One or more thread transfer ceilings were exceeded."
- : "✅ Thread transfer remains within every enforced ceiling.",
- ...(notices.length > 0 ? ["", ...notices] : []),
- "",
- "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |",
- "| --- | --- | ---: | ---: | ---: | ---: | --- |",
- ...rows,
- "",
- `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`,
- "",
- "",
- "Scenario and decoded snapshot size
",
- "",
- `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`,
- "",
- ...PROVIDERS.map(
- (provider) =>
- `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`,
- ),
- "",
- " ",
- "",
- "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._",
- ].join("\n");
-}
-
-async function artifactsForRun(github, owner, repo, runId) {
- return github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
- owner,
- repo,
- run_id: runId,
- per_page: 100,
- });
-}
-
-function findResultArtifact(artifacts) {
- return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired);
-}
-
-async function resolve({ github, context, core }) {
- const source = context.payload.workflow_run;
- const { owner, repo } = context.repo;
- if (source.event !== "pull_request") {
- core.setOutput("publish", "false");
- return;
- }
-
- let pullNumber = source.pull_requests?.[0]?.number;
- if (!pullNumber) {
- const associated = await github.paginate(
- github.rest.repos.listPullRequestsAssociatedWithCommit,
- { owner, repo, commit_sha: source.head_sha, per_page: 100 },
- );
- const matchingPulls = associated.filter(
- (pull) =>
- pull.state === "open" &&
- pull.head.sha === source.head_sha &&
- pull.head.ref === source.head_branch,
- );
- if (matchingPulls.length !== 1) {
- core.info(
- `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`,
- );
- core.setOutput("publish", "false");
- return;
- }
- pullNumber = matchingPulls[0].number;
- }
- if (!pullNumber) {
- core.info("No open pull request is associated with the completed CI run.");
- core.setOutput("publish", "false");
- return;
- }
-
- const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
- if (pull.head.sha !== source.head_sha) {
- core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`);
- core.setOutput("publish", "false");
- return;
- }
-
- const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id);
- const sourceArtifact = findResultArtifact(sourceArtifacts);
- const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
- owner,
- repo,
- workflow_id: source.workflow_id,
- branch: pull.base.ref,
- event: "push",
- status: "success",
- per_page: 100,
- });
- const orderedRuns = [
- ...workflowRuns.filter((run) => run.head_sha === pull.base.sha),
- ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha),
- ].slice(0, 20);
-
- let baselineRun;
- for (const run of orderedRuns) {
- const artifacts = await artifactsForRun(github, owner, repo, run.id);
- if (findResultArtifact(artifacts)) {
- baselineRun = run;
- break;
- }
- }
-
- core.setOutput("publish", "true");
- core.setOutput("pull_number", String(pullNumber));
- core.setOutput("pr_artifact", sourceArtifact ? "true" : "false");
- core.setOutput("pr_run_id", String(source.id));
- core.setOutput("pr_sha", source.head_sha);
- core.setOutput("pr_conclusion", source.conclusion ?? "unknown");
- core.setOutput("baseline_artifact", baselineRun ? "true" : "false");
- core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : "");
- core.setOutput("baseline_sha", baselineRun?.head_sha ?? "");
- core.setOutput(
- "baseline_matches_base",
- baselineRun?.head_sha === pull.base.sha ? "true" : "false",
- );
-}
-
-async function upsertComment(github, context, pullNumber, body, options = {}) {
- const { owner, repo } = context.repo;
- const comments = await github.paginate(github.rest.issues.listComments, {
- owner,
- repo,
- issue_number: pullNumber,
- per_page: 100,
- });
- const existing = comments.find(
- (comment) =>
- comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER),
- );
- if (
- options.preserveResultSha &&
- existing?.body?.includes(resultShaMarker(options.preserveResultSha))
- ) {
- return;
- }
- if (existing) {
- await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
- } else {
- await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body });
- }
-}
-
-async function upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- expectedSha,
- body,
- options,
-) {
- const { owner, repo } = context.repo;
- const { data: pull } = await github.rest.pulls.get({
- owner,
- repo,
- pull_number: pullNumber,
- });
- if (pull.head.sha !== expectedSha) {
- core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`);
- return false;
- }
-
- await upsertComment(github, context, pullNumber, body, options);
- return true;
-}
-
-async function publish({ github, context, core }) {
- const pullNumber = Number(process.env.PR_NUMBER);
- if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) {
- throw new Error("PR_NUMBER is invalid");
- }
-
- const current = readResult(process.env.PR_RESULT_DIR);
- const currentRun = {
- sha: process.env.PR_SHA,
- conclusion: process.env.PR_CONCLUSION,
- url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`,
- };
- if (!current) {
- await upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- currentRun.sha,
- [
- COMMENT_MARKER,
- "## Thread transfer impact",
- "",
- `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`,
- "",
- "_This comment will update automatically after the next completed run._",
- ].join("\n"),
- { preserveResultSha: currentRun.sha },
- );
- return;
- }
-
- const baseline = readResult(process.env.BASELINE_RESULT_DIR);
- const baselineRun = baseline
- ? {
- sha: process.env.BASELINE_SHA,
- matchesBase: process.env.BASELINE_MATCHES_BASE === "true",
- url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`,
- }
- : undefined;
- const body = renderComment({ current, baseline, currentRun, baselineRun });
- const published = await upsertCommentForCurrentHead(
- github,
- context,
- core,
- pullNumber,
- currentRun.sha,
- body,
- );
- if (published) {
- core.info(`Updated thread transfer report on PR #${pullNumber}.`);
- }
-}
-
-module.exports = {
- publish,
- readResult,
- renderComment,
- resolve,
- upsertCommentForCurrentHead,
- validateResult,
-};
diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs
deleted file mode 100644
index 4935864e46f0..000000000000
--- a/.github/scripts/thread-transfer-report.test.cjs
+++ /dev/null
@@ -1,292 +0,0 @@
-const assert = require("node:assert/strict");
-const test = require("node:test");
-
-const {
- renderComment,
- resolve,
- upsertCommentForCurrentHead,
- validateResult,
-} = require("./thread-transfer-report.cjs");
-
-function result(overrides = {}) {
- const observed = {
- totalWireBytes: 2_200_000,
- threadSnapshotWireBytes: 1_950_000,
- threadSnapshotDecodedBytes: 9_100_000,
- measuredTurnWebSocketWireBytes: 250_000,
- measuredTurnWebSocketDecodedBytes: 1_150_000,
- measuredTurnWebSocketMessages: 15,
- };
- const ceiling = {
- totalWireBytes: 2_900_000,
- threadSnapshotWireBytes: 2_600_000,
- measuredTurnWebSocketWireBytes: 320_000,
- measuredTurnWebSocketDecodedBytes: 1_550_000,
- measuredTurnWebSocketMessages: 20,
- };
- return {
- schemaVersion: 1,
- scenario: {
- id: "thread-transfer-v1",
- historyTurns: 10,
- historyCommandToolsPerTurn: 5,
- historyMcpResultBytes: 900_000,
- measuredCommandTools: 20,
- measuredMcpResultBytes: 1_100_000,
- },
- providers: {
- codex: { observed: { ...observed, ...overrides }, ceiling },
- claudeAgent: { observed, ceiling },
- },
- };
-}
-
-test("validates the fixed artifact schema", () => {
- assert.equal(validateResult(result()).schemaVersion, 1);
- assert.throws(
- () => validateResult({ ...result(), injectedMarkdown: "@everyone" }),
- /unexpected fields/,
- );
- assert.throws(
- () => validateResult(result({ totalWireBytes: "lots" })),
- /non-negative safe integer/,
- );
-});
-
-test("renders baseline, impact, ceiling, and ceiling changes", () => {
- const baseline = result();
- const current = result({ measuredTurnWebSocketWireBytes: 260_000 });
- current.providers.codex.ceiling = {
- ...current.providers.codex.ceiling,
- measuredTurnWebSocketWireBytes: 330_000,
- };
- const comment = renderComment({
- current,
- baseline,
- currentRun: {
- sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
- conclusion: "success",
- url: "https://github.com/pingdotgg/t3code/actions/runs/2",
- },
- baselineRun: {
- sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- matchesBase: true,
- url: "https://github.com/pingdotgg/t3code/actions/runs/1",
- },
- });
-
- assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/);
- assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/);
- assert.match(comment, /This PR changes transfer ceilings/);
- assert.match(comment, /312\.5 KiB → 322\.3 KiB/);
- assert.match(comment, //);
- assert.match(
- comment,
- //,
- );
-});
-
-test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => {
- const outputs = {};
- const listWorkflowRunArtifacts = () => {};
- const listWorkflowRuns = () => {};
- const listPullRequestsAssociatedWithCommit = () => {};
- const github = {
- paginate: async (method, input) => {
- if (method === listPullRequestsAssociatedWithCommit) {
- return [
- {
- number: 5350,
- state: "open",
- head: { sha: "head-sha", ref: "feature-branch", repo: null },
- },
- ];
- }
- if (method === listWorkflowRunArtifacts) {
- return [
- {
- name: "thread-transfer-results",
- expired: false,
- runId: input.run_id,
- },
- ];
- }
- if (method === listWorkflowRuns) {
- return [{ id: 1, head_sha: "base-sha" }];
- }
- throw new Error("unexpected pagination call");
- },
- rest: {
- actions: { listWorkflowRunArtifacts, listWorkflowRuns },
- pulls: {
- get: async () => ({
- data: {
- head: { sha: "head-sha" },
- base: { sha: "base-sha", ref: "main" },
- },
- }),
- },
- repos: { listPullRequestsAssociatedWithCommit },
- },
- };
- await resolve({
- github,
- context: {
- repo: { owner: "pingdotgg", repo: "t3code" },
- payload: {
- workflow_run: {
- id: 2,
- event: "pull_request",
- workflow_id: 3,
- head_sha: "head-sha",
- head_branch: "feature-branch",
- head_repository: { full_name: "pingdotgg/t3code" },
- conclusion: "success",
- pull_requests: [],
- },
- },
- },
- core: {
- info: () => {},
- setOutput: (key, value) => {
- outputs[key] = value;
- },
- },
- });
-
- assert.equal(outputs.publish, "true");
- assert.equal(outputs.pull_number, "5350");
- assert.equal(outputs.pr_artifact, "true");
- assert.equal(outputs.baseline_run_id, "1");
- assert.equal(outputs.baseline_matches_base, "true");
-});
-
-test("does not guess when a fallback commit belongs to multiple PRs", async () => {
- const outputs = {};
- const listPullRequestsAssociatedWithCommit = () => {};
- let fetchedPull = false;
- await resolve({
- github: {
- paginate: async (method) => {
- assert.equal(method, listPullRequestsAssociatedWithCommit);
- return [5350, 5351].map((number) => ({
- number,
- state: "open",
- head: {
- sha: "head-sha",
- ref: "feature-branch",
- repo: { full_name: "pingdotgg/t3code" },
- },
- }));
- },
- rest: {
- actions: {},
- pulls: {
- get: async () => {
- fetchedPull = true;
- },
- },
- repos: { listPullRequestsAssociatedWithCommit },
- },
- },
- context: {
- repo: { owner: "pingdotgg", repo: "t3code" },
- payload: {
- workflow_run: {
- id: 2,
- event: "pull_request",
- workflow_id: 3,
- head_sha: "head-sha",
- head_branch: "feature-branch",
- head_repository: { full_name: "pingdotgg/t3code" },
- conclusion: "success",
- pull_requests: [],
- },
- },
- },
- core: {
- info: () => {},
- setOutput: (key, value) => {
- outputs[key] = value;
- },
- },
- });
-
- assert.equal(outputs.publish, "false");
- assert.equal(fetchedPull, false);
-});
-
-test("does not publish a stale result after the PR head advances", async () => {
- let listedComments = false;
- const info = [];
- const published = await upsertCommentForCurrentHead(
- {
- paginate: async () => {
- listedComments = true;
- return [];
- },
- rest: {
- issues: {
- listComments: () => {},
- createComment: () => {
- throw new Error("must not create a stale comment");
- },
- updateComment: () => {
- throw new Error("must not update a stale comment");
- },
- },
- pulls: {
- get: async () => ({ data: { head: { sha: "new-head-sha" } } }),
- },
- },
- },
- { repo: { owner: "pingdotgg", repo: "t3code" } },
- { info: (message) => info.push(message) },
- 5350,
- "old-head-sha",
- "stale body",
- );
-
- assert.equal(published, false);
- assert.equal(listedComments, false);
- assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]);
-});
-
-test("preserves a successful result when a same-SHA rerun has no artifact", async () => {
- let updatedComment = false;
- const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const published = await upsertCommentForCurrentHead(
- {
- paginate: async () => [
- {
- id: 1,
- user: { login: "github-actions[bot]" },
- body: `\n`,
- },
- ],
- rest: {
- issues: {
- listComments: () => {},
- createComment: () => {
- updatedComment = true;
- },
- updateComment: () => {
- updatedComment = true;
- },
- },
- pulls: {
- get: async () => ({ data: { head: { sha } } }),
- },
- },
- },
- { repo: { owner: "pingdotgg", repo: "t3code" } },
- { info: () => {} },
- 5350,
- sha,
- "missing artifact warning",
- { preserveResultSha: sha },
- );
-
- assert.equal(published, true);
- assert.equal(updatedComment, false);
-});
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 611d4cf44f75..f3c062cf9c15 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -86,6 +86,16 @@ jobs:
!/.repos/
sparse-checkout-cone-mode: false
+ # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present),
+ # but ACP process-tree live tests compile a small pthread fixture with `cc`
+ # and soft-skip when it is missing. Install build-essential so that path
+ # always runs in CI instead of silently no-oping.
+ - name: Install C toolchain for process-tree fixtures
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends build-essential
+ command -v cc
+
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml
deleted file mode 100644
index 23eec72923bd..000000000000
--- a/.github/workflows/thread-transfer-report.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: Thread Transfer Report
-
-on:
- workflow_run:
- workflows: [CI]
- types: [completed]
-
-permissions:
- actions: read
- contents: read
- pull-requests: write
-
-jobs:
- publish:
- name: Publish PR comment
- if: github.event.workflow_run.event == 'pull_request'
- runs-on: ubuntu-24.04
- concurrency:
- group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }}
- cancel-in-progress: true
- steps:
- # workflow_run has a write-capable token even for fork PRs. Only load the
- # publisher from the trusted default branch and never execute PR code.
- - name: Checkout trusted publisher
- uses: actions/checkout@v6
- with:
- ref: ${{ github.event.repository.default_branch }}
- sparse-checkout: .github/scripts
-
- - name: Test trusted publisher
- run: node --test .github/scripts/thread-transfer-report.test.cjs
-
- - id: resolve
- name: Resolve PR and baseline artifacts
- uses: actions/github-script@v8
- with:
- script: |
- const reporter = require("./.github/scripts/thread-transfer-report.cjs");
- await reporter.resolve({ github, context, core });
-
- - name: Download PR result
- if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true'
- uses: actions/download-artifact@v8
- with:
- name: thread-transfer-results
- path: ${{ runner.temp }}/thread-transfer/pr
- github-token: ${{ secrets.GITHUB_TOKEN }}
- run-id: ${{ steps.resolve.outputs.pr_run_id }}
-
- - name: Download main baseline
- if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true'
- uses: actions/download-artifact@v8
- with:
- name: thread-transfer-results
- path: ${{ runner.temp }}/thread-transfer/main
- github-token: ${{ secrets.GITHUB_TOKEN }}
- run-id: ${{ steps.resolve.outputs.baseline_run_id }}
-
- - name: Update thread transfer comment
- if: steps.resolve.outputs.publish == 'true'
- uses: actions/github-script@v8
- env:
- PR_NUMBER: ${{ steps.resolve.outputs.pull_number }}
- PR_SHA: ${{ steps.resolve.outputs.pr_sha }}
- PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }}
- PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }}
- PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr
- BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }}
- BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }}
- BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }}
- BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main
- with:
- script: |
- const reporter = require("./.github/scripts/thread-transfer-report.cjs");
- await reporter.publish({ github, context, core });
diff --git a/.gitignore b/.gitignore
index 8482c5a290e1..38bac103f1f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ squashfs-root/
.vercel
.gstack/
.plans/
+/audits/
dist-electron/
.electron-runtime/
.showcase/
diff --git a/README.md b/README.md
index 28cc78162085..9f745e13909f 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Project settings](./docs/user/project-settings.md)
+- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts
index 0e5fbecd0224..5ded307da128 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
@@ -112,6 +112,8 @@ describe("DesktopEnvironment", () => {
assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
+ assert.equal(environment.userDataDirName, "t3code");
+ assert.equal(environment.legacyUserDataDirName, "T3 Code (Alpha)");
assert.equal(environment.otlpProtocol, "http/json");
}),
);
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
index 5a5a20780a65..1ec0ebf1874d 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
@@ -121,7 +121,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.readMagicDnsName ??
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
- Effect.orElseSucceed(() => null),
+ Effect.orElseSucceed((): string | null => null),
);
const dnsName =
input.statusJson === undefined
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 53044dcf7e5d..d45a1015e029 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -56,6 +56,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
+ persistComposerContextStrip: true,
providerModelPreferences: {},
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/public/app-desktop.webp
new file mode 100644
index 000000000000..11b51331eef3
Binary files /dev/null and b/apps/marketing/public/app-desktop.webp differ
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index 669bdce8a72d..e3f5e3b6bd12 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -236,7 +236,7 @@ const mobileEndorsementRows = [

diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css
index d63fe16d578b..6e9c07a52ee8 100644
--- a/apps/mobile/generated-uniwind-themes.css
+++ b/apps/mobile/generated-uniwind-themes.css
@@ -5,6 +5,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -15,6 +17,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -38,6 +41,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -50,6 +55,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -65,6 +72,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -75,6 +84,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -98,6 +108,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -110,6 +122,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -200,6 +214,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -210,6 +226,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -233,6 +250,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -245,6 +264,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -335,6 +356,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -345,6 +368,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -368,6 +392,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -380,6 +406,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -470,6 +498,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -480,6 +510,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -503,6 +534,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -515,6 +548,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -605,6 +640,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -615,6 +652,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -638,6 +676,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -650,6 +690,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -740,6 +782,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -750,6 +794,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -773,6 +818,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -785,6 +832,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -875,6 +924,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -885,6 +936,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -908,6 +960,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -920,6 +974,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1010,6 +1066,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1020,6 +1078,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1043,6 +1102,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1055,6 +1116,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1145,6 +1208,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1155,6 +1220,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1178,6 +1244,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1190,6 +1258,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1280,6 +1350,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1290,6 +1362,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1313,6 +1386,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1325,6 +1400,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1415,6 +1492,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1425,6 +1504,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1448,6 +1528,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1460,6 +1542,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
index 6f2428a6a7de..795057691c83 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
@@ -87,6 +87,12 @@ public class T3ComposerEditorModule: Module {
Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
view.setSpellCheck(spellCheck)
}
+ Prop("submitTitle") { (view: T3ComposerEditorView, title: String) in
+ view.setSubmitTitle(title)
+ }
+ Prop("alternateSubmitTitle") { (view: T3ComposerEditorView, title: String) in
+ view.setAlternateSubmitTitle(title)
+ }
Prop("enterBehavior") { (view: T3ComposerEditorView, behavior: String) in
view.setEnterBehavior(behavior)
}
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
index 50ac2afbcb46..b3031e538a3a 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -94,34 +94,43 @@ private final class ComposerTextView: UITextView {
var onPasteText: ((String, NSRange) -> Void)?
var clipboardFragment = ""
var onAttributedMutation: (() -> Void)?
- var onSubmit: (() -> Void)?
+ var onSubmit: ((Bool) -> Void)?
var isReadOnly = false
var textPasteThresholdBytes = 0
var maxInputChars = Int.max
var enterBehavior: ComposerEnterBehavior = .send
+ /// Shortcut HUD titles. JS supplies what the two sends actually do right now
+ /// ("Queue Message" / "Steer Message"), so the iPad Command-hold list names
+ /// the outcome rather than a generic "Send".
+ var submitTitle = "Send Message"
+ var alternateSubmitTitle = "Send Message"
private var bypassTextPasteInterception = false
override var keyCommands: [UIKeyCommand]? {
var commands = super.keyCommands ?? []
guard !isReadOnly, markedTextRange == nil else { return commands }
- let submit = UIKeyCommand(
- input: "\r",
- modifierFlags: .command,
- action: #selector(submitMessage(_:))
- )
- submit.discoverabilityTitle = "Send Message"
- submit.wantsPriorityOverSystemBehavior = true
- commands.append(submit)
+ // The plainer chord always performs the configured follow-up behavior and
+ // the more-modified one performs its opposite, so Command is the "other
+ // way" modifier whichever Return behavior is configured.
if enterBehavior == .send {
let submitOnReturn = UIKeyCommand(
input: "\r",
modifierFlags: [],
action: #selector(submitMessage(_:))
)
- submitOnReturn.discoverabilityTitle = "Send Message"
+ submitOnReturn.discoverabilityTitle = submitTitle
submitOnReturn.wantsPriorityOverSystemBehavior = true
commands.append(submitOnReturn)
+ let submitAlternate = UIKeyCommand(
+ input: "\r",
+ modifierFlags: .command,
+ action: #selector(submitMessageAlternate(_:))
+ )
+ submitAlternate.discoverabilityTitle = alternateSubmitTitle
+ submitAlternate.wantsPriorityOverSystemBehavior = true
+ commands.append(submitAlternate)
+
let newline = UIKeyCommand(
input: "\r",
modifierFlags: .shift,
@@ -130,6 +139,24 @@ private final class ComposerTextView: UITextView {
newline.discoverabilityTitle = "New Line"
newline.wantsPriorityOverSystemBehavior = true
commands.append(newline)
+ } else {
+ let submit = UIKeyCommand(
+ input: "\r",
+ modifierFlags: .command,
+ action: #selector(submitMessage(_:))
+ )
+ submit.discoverabilityTitle = submitTitle
+ submit.wantsPriorityOverSystemBehavior = true
+ commands.append(submit)
+
+ let submitAlternate = UIKeyCommand(
+ input: "\r",
+ modifierFlags: [.command, .shift],
+ action: #selector(submitMessageAlternate(_:))
+ )
+ submitAlternate.discoverabilityTitle = alternateSubmitTitle
+ submitAlternate.wantsPriorityOverSystemBehavior = true
+ commands.append(submitAlternate)
}
if textPasteThresholdBytes > 0 {
let pasteAsText = UIKeyCommand(
@@ -146,7 +173,12 @@ private final class ComposerTextView: UITextView {
@objc private func submitMessage(_ sender: UIKeyCommand) {
guard !isReadOnly, markedTextRange == nil else { return }
- onSubmit?()
+ onSubmit?(false)
+ }
+
+ @objc private func submitMessageAlternate(_ sender: UIKeyCommand) {
+ guard !isReadOnly, markedTextRange == nil else { return }
+ onSubmit?(true)
}
@objc private func insertNewline(_ sender: UIKeyCommand) {
@@ -488,8 +520,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
textView.onAttributedMutation = { [weak self] in
self?.emitTextChange()
}
- textView.onSubmit = { [weak self] in
- self?.onComposerSubmit([:])
+ textView.onSubmit = { [weak self] alternate in
+ self?.onComposerSubmit(["alternate": alternate])
}
let contextTap = UITapGestureRecognizer(target: self, action: #selector(openContext(_:)))
contextTap.cancelsTouchesInView = false
@@ -696,6 +728,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
}
+ func setSubmitTitle(_ title: String) {
+ textView.submitTitle = title
+ }
+
+ func setAlternateSubmitTitle(_ title: String) {
+ textView.alternateSubmitTitle = title
+ }
+
func setTextPasteThresholdBytes(_ threshold: Int) {
textView.textPasteThresholdBytes = threshold
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index b1d736621407..a0306f615282 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -95,7 +95,7 @@
"expo-image-picker": "~57.0.14",
"expo-linking": "~57.0.8",
"expo-network": "~57.0.1",
- "expo-notifications": "~57.0.15",
+ "expo-notifications": "57.0.15",
"expo-paste-input": "^0.1.15",
"expo-quick-actions": "^6.0.2",
"expo-secure-store": "~57.0.2",
diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift
new file mode 100644
index 000000000000..6d13972d5742
--- /dev/null
+++ b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift
@@ -0,0 +1,189 @@
+import Foundation
+
+// The registry runs on macOS without starting a simulator. Only the OS-facing
+// types are replaced; the tests compile the dependency's actual Swift source.
+public enum UIBackgroundFetchResult {
+ case noData
+}
+
+public struct UNNotificationPresentationOptions: OptionSet {
+ public let rawValue: Int
+ public init(rawValue: Int) { self.rawValue = rawValue }
+}
+
+public final class UNNotification: NSObject {}
+
+public final class UNNotificationResponse: NSObject {
+ let identifier: String
+ init(_ identifier: String) { self.identifier = identifier }
+}
+
+public protocol UNUserNotificationCenterDelegate: AnyObject {}
+
+public final class UNUserNotificationCenter: NSObject {
+ private static let instance = UNUserNotificationCenter()
+ public weak var delegate: UNUserNotificationCenterDelegate?
+ public static func current() -> UNUserNotificationCenter { instance }
+}
+
+private final class TestDelegate: NotificationDelegate {
+ private let lock = NSLock()
+ private var recordedEvents: [String] = []
+ var onEvent: ((String) -> Void)?
+ var onResponse: ((UNNotificationResponse) -> Bool)?
+
+ var events: [String] { lock.withLock { recordedEvents } }
+
+ private func record(_ event: String) {
+ lock.withLock { recordedEvents.append(event) }
+ onEvent?(event)
+ }
+
+ func didRegister(_ deviceToken: String) { record("registered") }
+ func didFailRegistration(_ error: Error) { record("failed") }
+ func openSettings(_ notification: UNNotification?) { record("settings") }
+
+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
+ record("present")
+ return false
+ }
+
+ func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
+ record("background")
+ return false
+ }
+
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
+ record(response.identifier)
+ return onResponse?(response) ?? false
+ }
+}
+
+private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
+ if !condition() {
+ throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
+ }
+}
+
+private func deliver(_ identifier: String) {
+ NotificationCenterManager.shared.userNotificationCenter(
+ UNUserNotificationCenter.current(),
+ didReceive: UNNotificationResponse(identifier),
+ withCompletionHandler: {}
+ )
+}
+
+private func reentrantCallbacks() throws {
+ let manager = NotificationCenterManager.shared
+ let center = UNUserNotificationCenter.current()
+ let callbacks: [(String, () -> Void)] = [
+ ("registered", { manager.didRegister("token") }),
+ ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }),
+ ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }),
+ ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }),
+ ("background", { manager.didReceive([:], completionHandler: { _ in }) })
+ ]
+
+ for (event, callback) in callbacks {
+ let original = TestDelegate()
+ let replacement = TestDelegate()
+ original.onEvent = { [weak original] _ in
+ guard let original else { return }
+ manager.removeDelegate(original)
+ manager.addDelegate(replacement)
+ }
+ manager.addDelegate(original)
+ callback()
+ try require(original.events == [event], "The original delegate must receive the callback once")
+ try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot")
+ callback()
+ try require(replacement.events == [event], "Reentrant registration must take effect for the next callback")
+ manager.removeDelegate(replacement)
+ }
+}
+
+private func registrationDuringDelivery() throws {
+ let manager = NotificationCenterManager.shared
+ let original = TestDelegate()
+ let receiver = TestDelegate()
+ receiver.onResponse = { _ in true }
+ original.onResponse = { _ in
+ manager.addDelegate(receiver)
+ return false
+ }
+ manager.addDelegate(original)
+ deliver("handoff")
+ try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response")
+ manager.removeDelegate(original)
+ manager.removeDelegate(receiver)
+}
+
+private func responseDuringReplay() throws {
+ let manager = NotificationCenterManager.shared
+ deliver("first")
+ let original = TestDelegate()
+ original.onResponse = { response in
+ if response.identifier == "first" {
+ deliver("second")
+ return true
+ }
+ return false
+ }
+ manager.addDelegate(original)
+ let receiver = TestDelegate()
+ receiver.onResponse = { _ in true }
+ manager.addDelegate(receiver)
+ try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks")
+ manager.removeDelegate(original)
+ manager.removeDelegate(receiver)
+}
+
+private func concurrentRegistrations() throws {
+ let manager = NotificationCenterManager.shared
+ let delegates = (0..<512).map { _ in
+ let delegate = TestDelegate()
+ delegate.onResponse = { _ in true }
+ return delegate
+ }
+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
+ manager.addDelegate(delegates[index])
+ if index.isMultiple(of: 16) {
+ manager.didRegister("during-add")
+ deliver("during-add")
+ }
+ }
+ let before = delegates.map { $0.events.count }
+ manager.didRegister("after-add")
+ for (index, delegate) in delegates.enumerated() {
+ try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once")
+ }
+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
+ manager.removeDelegate(delegates[index])
+ if index.isMultiple(of: 16) {
+ manager.didRegister("during-remove")
+ deliver("during-remove")
+ }
+ }
+ let removed = delegates.map { $0.events.count }
+ manager.didRegister("after-remove")
+ try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks")
+}
+
+@main
+private enum RegressionTests {
+ static func main() {
+ do {
+ switch CommandLine.arguments.last {
+ case "reentrant": try reentrantCallbacks()
+ case "handoff": try registrationDuringDelivery()
+ case "pending": try responseDuringReplay()
+ case "concurrent": try concurrentRegistrations()
+ default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2)
+ }
+ print("passed")
+ } catch {
+ FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
+ exit(1)
+ }
+ }
+}
diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m
new file mode 100644
index 000000000000..7124178383d4
--- /dev/null
+++ b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m
@@ -0,0 +1,55 @@
+@interface TestPermissionRequester : NSObject
+@end
+
+@implementation TestPermissionRequester
++ (NSString *)permissionType { return NSStringFromClass(self); }
+- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; }
+- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject {
+ resolve([self getPermissions]);
+}
+@end
+
+typedef struct {
+ __unsafe_unretained EXPermissionsService *service;
+ __unsafe_unretained NSArray *requesters;
+ size_t offset;
+} Worker;
+
+static void *exerciseRegistry(void *context) {
+ Worker *worker = context;
+ for (size_t index = 0; index < 250; index++) {
+ @autoreleasepool {
+ id requester = worker->requesters[(index + worker->offset) % worker->requesters.count];
+ [worker->service registerRequesters:@[requester]];
+ id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]];
+ assert(resolved == requester);
+ NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]];
+ assert([permissions[@"status"] isEqualToString:@"granted"]);
+ }
+ }
+ return NULL;
+}
+
+int main(void) {
+ @autoreleasepool {
+ EXPermissionsService *service = [EXPermissionsService new];
+ NSMutableArray *requesters = [NSMutableArray new];
+ for (int i = 0; i < 32; i++) {
+ NSString *name = [NSString stringWithFormat:@"TestPermission%d", i];
+ Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0);
+ objc_registerClassPair(cls);
+ [requesters addObject:[cls new]];
+ }
+ Worker workers[8];
+ pthread_t threads[8];
+ for (size_t index = 0; index < 8; index++) {
+ workers[index] = (Worker){ service, requesters, index };
+ assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0);
+ }
+ for (size_t index = 0; index < 8; index++) {
+ assert(pthread_join(threads[index], NULL) == 0);
+ }
+ puts("passed");
+ }
+ return 0;
+}
diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts
index 6878dc0ee785..92636df85a7a 100644
--- a/apps/mobile/scripts/generate-uniwind-themes.mts
+++ b/apps/mobile/scripts/generate-uniwind-themes.mts
@@ -57,6 +57,8 @@ const ADAPTIVE_COLORS: Readonly {
+ let directory: string;
+ let executable: string;
+
+ beforeAll(() => {
+ directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-"));
+ executable = NodePath.join(directory, "notification-regression");
+ const source = NodeFS.readFileSync(
+ new URL(
+ "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+ const manager = NodePath.join(directory, "NotificationCenterManager.swift");
+ NodeFS.writeFileSync(
+ manager,
+ source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""),
+ );
+ NodeChildProcess.execFileSync(
+ "swiftc",
+ [
+ "-swift-version",
+ "5",
+ "-sanitize=thread",
+ manager,
+ NodeURL.fileURLToPath(
+ new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url),
+ ),
+ "-o",
+ executable,
+ ],
+ { timeout: 30_000, encoding: "utf8" },
+ );
+ });
+
+ afterAll(() => {
+ if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
+ });
+
+ it.each([
+ ["reentrant", "allows callbacks to replace delegates without deadlocking"],
+ ["handoff", "delivers responses to delegates registering during delivery"],
+ ["pending", "retains new responses received while replaying pending responses"],
+ ["concurrent", "registers, removes, and broadcasts concurrently without data races"],
+ ])("%s: %s", (name) => {
+ const output = NodeChildProcess.execFileSync(executable, [name], {
+ encoding: "utf8",
+ timeout: 15_000,
+ });
+ expect(output.trim()).toBe("passed");
+ });
+ },
+);
diff --git a/apps/mobile/scripts/permissions-service.test.ts b/apps/mobile/scripts/permissions-service.test.ts
new file mode 100644
index 000000000000..f1851723550a
--- /dev/null
+++ b/apps/mobile/scripts/permissions-service.test.ts
@@ -0,0 +1,68 @@
+// @effect-diagnostics nodeBuiltinImport:off - Compiles the native dependency regression directly.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodeModule from "node:module";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import { expect, it } from "vite-plus/test";
+
+// oxlint-disable-next-line t3code/no-global-process-runtime -- This test compiles against the host Foundation framework.
+it.skipIf(NodeOS.platform() !== "darwin")(
+ "registers and reads native permissions concurrently without corrupting the registry",
+ () => {
+ const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-permissions-test-"));
+ try {
+ const require = NodeModule.createRequire(import.meta.url);
+ const core = NodePath.dirname(
+ NodeModule.createRequire(require.resolve("expo/package.json")).resolve(
+ "expo-modules-core/package.json",
+ ),
+ );
+ const read = (relativePath: string) =>
+ NodeFS.readFileSync(NodePath.join(core, relativePath), "utf8").replace(
+ /^#import .*$/gm,
+ "",
+ );
+ const source = NodePath.join(directory, "regression.m");
+ NodeFS.writeFileSync(
+ source,
+ [
+ "#import ",
+ "#import ",
+ "#include ",
+ "#include ",
+ "typedef void (^EXPromiseResolveBlock)(id result);",
+ "typedef void (^EXPromiseRejectBlock)(NSString *, NSString *, NSError *);",
+ "#define RCTLogWarn(...)",
+ read("ios/Interfaces/Permissions/EXPermissionsInterface.h"),
+ read("ios/Legacy/Services/Permissions/EXPermissionsService.h"),
+ read("ios/Legacy/Services/Permissions/EXPermissionsService.m"),
+ NodeFS.readFileSync(
+ new URL("./fixtures/PermissionsServiceRegression.m", import.meta.url),
+ "utf8",
+ ),
+ ].join("\n"),
+ );
+ const executable = NodePath.join(directory, "regression");
+ NodeChildProcess.execFileSync(
+ "clang",
+ [
+ "-fobjc-arc",
+ "-fblocks",
+ "-fsanitize=thread",
+ "-framework",
+ "Foundation",
+ source,
+ "-o",
+ executable,
+ ],
+ { encoding: "utf8", timeout: 30_000 },
+ );
+ expect(
+ NodeChildProcess.execFileSync(executable, { encoding: "utf8", timeout: 15_000 }).trim(),
+ ).toBe("passed");
+ } finally {
+ NodeFS.rmSync(directory, { recursive: true, force: true });
+ }
+ },
+);
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 71efbb11cff2..e467589a002a 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -34,6 +34,8 @@ import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
+import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
+import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
@@ -59,6 +61,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
+import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen";
import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import {
@@ -203,6 +206,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Keyboard",
},
}),
+ SettingsFollowUp: createNativeStackScreen({
+ screen: SettingsFollowUpRouteScreen,
+ linking: "follow-ups",
+ options: {
+ title: "Follow-ups",
+ },
+ }),
SettingsClientStorage: createNativeStackScreen({
screen: SettingsClientStorageRouteScreen,
linking: "client-storage",
@@ -387,6 +397,8 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
+ "ThreadAgents",
+ "ThreadQueue",
"ThreadReviewComment",
"ThreadSettingsSheet",
]);
@@ -584,6 +596,24 @@ export const RootStack = createNativeStackNavigator({
}),
},
}),
+ ThreadQueue: createNativeStackScreen({
+ screen: ThreadQueueSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.65, 0.95],
+ sheetGrabberVisible: true,
+ },
+ }),
+ ThreadAgents: createNativeStackScreen({
+ screen: ThreadAgentsSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.5, 0.9],
+ sheetGrabberVisible: true,
+ },
+ }),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx
index c8f1d4385517..1643f3f6fdeb 100644
--- a/apps/mobile/src/components/BrandMark.tsx
+++ b/apps/mobile/src/components/BrandMark.tsx
@@ -3,14 +3,9 @@ import { Image } from "expo-image";
import { View } from "react-native";
import { AppText as Text } from "./AppText";
+import { T3_CODE_BRAND_MARK_SOURCE } from "./brandAssets";
const appVariant = Constants.expoConfig?.extra?.appVariant;
-const BRAND_MARK_SOURCE =
- appVariant === "development"
- ? require("../../../../assets/dev/blueprint-ios-1024.png")
- : appVariant === "preview"
- ? require("../../../../assets/nightly/nightly-ios-1024.png")
- : require("../../../../assets/prod/black-ios-1024.png");
const DEFAULT_STAGE_LABEL =
appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
@@ -22,7 +17,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab
return (
["name"];
readonly onPress: () => void;
readonly variant?: "primary" | "danger";
+ // Forwarded so a ControlPillMenu can drive this button as its long-press
+ // anchor: Android injects onLongPress, iOS injects onTouchStart and onPress.
+ readonly onLongPress?: PressableProps["onLongPress"];
+ readonly onTouchStart?: PressableProps["onTouchStart"];
}) {
return (
(null);
/* ─── Component ──────────────────────────────────────────────────────── */
-export function ProjectFavicon(props: {
+export const ProjectFavicon = memo(function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
readonly open?: boolean;
readonly size?: number;
@@ -41,15 +41,29 @@ export function ProjectFavicon(props: {
faviconPath: props.faviconPath,
}),
);
- const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
+ const renderableFaviconUrl = useMemo(
+ () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl),
+ [faviconUrl],
+ );
// Inline images are self-contained; remote URLs key on their revision so signed-token
// rotation reuses the disk cache while a changed icon starts from the loading state.
- const cacheKey =
- renderableFaviconUrl && props.workspaceRoot
- ? renderableFaviconUrl.startsWith("data:")
- ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
- : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
- : null;
+ const cacheKey = useMemo(
+ () =>
+ renderableFaviconUrl && props.workspaceRoot
+ ? renderableFaviconUrl.startsWith("data:")
+ ? getProjectFaviconResourceKey(
+ props.environmentId,
+ props.workspaceRoot,
+ props.faviconPath,
+ )
+ : getProjectFaviconCacheKey(
+ props.environmentId,
+ props.workspaceRoot,
+ renderableFaviconUrl,
+ )
+ : null,
+ [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath],
+ );
return (
);
-}
+});
function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 374738d0aeca..2dec5d4444be 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -2,14 +2,66 @@ import { Image } from "expo-image";
import { Path, Svg } from "react-native-svg";
import { View } from "react-native";
import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
+import { useState } from "react";
+import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./AppText";
type ProviderIconProps = {
readonly provider: string | null | undefined;
+ readonly iconUrl?: string | null | undefined;
readonly size?: number;
};
+function AcpRegistryFallbackIcon(props: { readonly color: string; readonly size: number }) {
+ return (
+
+ );
+}
+
+function AcpRegistryProviderIcon(props: {
+ readonly color: string;
+ readonly iconUrl: string | null | undefined;
+ readonly size: number;
+}) {
+ const iconUrl = resolveOfficialAcpRegistryIconUrl(props.iconUrl);
+ const [image, setImage] = useState<{
+ readonly iconUrl: string;
+ readonly status: "loaded" | "failed";
+ } | null>(null);
+ const currentImage = image?.iconUrl === iconUrl ? image : null;
+ const loaded = currentImage?.status === "loaded";
+
+ return (
+
+ {!loaded ? : null}
+ {iconUrl !== null && currentImage?.status !== "failed" ? (
+ setImage({ iconUrl, status: "failed" })}
+ onLoad={() => setImage({ iconUrl, status: "loaded" })}
+ />
+ ) : null}
+
+ );
+}
+
export function ProviderIcon(props: ProviderIconProps) {
const { themeAppearance } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";
@@ -25,6 +77,9 @@ export function ProviderIcon(props: ProviderIconProps) {
/>
);
}
+ if (props.provider === "acpRegistry") {
+ return ;
+ }
if (props.provider === "claudeAgent") {
return (
@@ -64,6 +119,20 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "pi") {
+ const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F";
+ return (
+
+ );
+ }
+
if (props.provider === "opencode") {
return (
) : null}
- {message.text.trim().length > 0 ? (
+ {presentation.text.trim().length > 0 ? (
@@ -1668,7 +1770,7 @@ function renderFeedEntry(
/>
) : null}
-
+
);
}
@@ -1721,6 +1823,14 @@ function renderFeedEntry(
})}
{showAssistantMeta ? (
+ {message.projectedItem ? (
+
+ ) : null}
(null);
const disclosureSettleSecondFrameRef = useRef(null);
const disclosureAnchorKeyRef = useRef(null);
- const headerMaterialVisibleRef = useRef(false);
- const previousLatestTurnRef = useRef(props.latestTurn);
+ const previousLatestTurnRef = useRef(props.latestRun);
const userScrollSettleTimerRef = useRef | null>(null);
+ const headerMaterialVisibleRef = useRef(false);
const { width: windowWidth, fontScale } = useWindowDimensions();
const { appearance } = useAppearancePreferences();
const workRowSizing = useMemo(
@@ -1967,11 +2077,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const [viewportHeight, setViewportHeight] = useState(0);
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
- // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
- // whenever the viewport drifts back inside its geometric threshold, which
- // yanked users off history they were reading every time a stream chunk grew
- // a row. Scrolling away or expanding a disclosure above the end breaks
- // follow; reaching the end (or sending / switching threads) re-arms it.
+ // Live-follow latch (#5566). LegendList's maintainScrollAtEnd alone re-pins
+ // the feed whenever the viewport drifts back inside its geometric threshold,
+ // which yanked users off history they were reading every time a stream chunk
+ // grew a row. Follow breaks when the user scrolls up and away, and re-arms
+ // only when the list actually returns to the end (or on send / thread switch).
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
@@ -1999,22 +2109,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record;
readonly expandedWorkRows: Record;
- readonly expandedTurnIds: ReadonlySet;
- readonly expandedReasoningMessageIds: ReadonlySet;
+ readonly expandedTurnIds: ReadonlySet;
}>({
copiedRowId: null,
expandedWorkGroups: {},
expandedWorkRows: {},
expandedTurnIds: new Set(),
- expandedReasoningMessageIds: new Set(),
});
- const {
- copiedRowId,
- expandedWorkGroups,
- expandedWorkRows,
- expandedTurnIds,
- expandedReasoningMessageIds,
- } = interactionState;
+ const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
const [expandedFile, setExpandedFile] = useState(null);
const [expandedVideo, setExpandedVideo] = useState(null);
const fileShareSourceIdentifier = useId();
@@ -2266,9 +2368,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
const reviewCommentColors = useReviewCommentColors();
- // One definition of "still live", shared with the fold derivation: two
- // copies of this test are what let a row and the fold beside it disagree.
- const unsettledTurnId = deriveUnsettledTurnId(props.latestTurn ?? null);
+ const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
// LegendList does not invalidate visible rows when only the renderItem closure changes.
// Include turn completion so unchanged message rows reveal their footer and spacing
// even when the final message update arrives before the turn settles.
@@ -2277,8 +2377,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
dispatchingMessageId: props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
@@ -2291,8 +2391,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
@@ -2386,13 +2486,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]);
- const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
- const nextWidth = Math.round(event.nativeEvent.layout.width);
- const nextHeight = Math.round(event.nativeEvent.layout.height);
- setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
- setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
- }, []);
-
// Thread identity is env-scoped: two environments can hold the same
// ThreadId, and keying resets (or the list mount) on the bare id would
// carry stale scroll/follow state across an environment switch.
@@ -2403,11 +2496,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
() => new Map(),
[feedThreadKey],
);
-
- useEffect(() => {
- reportHeaderMaterialVisibility(false);
- }, [feedThreadKey, reportHeaderMaterialVisibility]);
-
// A thread switch opens pinned to the end; a send explicitly returns to the
// live edge (ThreadDetailScreen scrolls the new message into place). Both
// re-arm follow regardless of where the user had scrolled before.
@@ -2424,23 +2512,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
}, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
- const expandedWorkGroupIds = useMemo(() => {
- const ids = new Set();
- for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
- if (expanded) {
- ids.add(groupId);
- }
- }
- return ids;
- }, [expandedWorkGroups]);
+ const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
+ const nextWidth = Math.round(event.nativeEvent.layout.width);
+ const nextHeight = Math.round(event.nativeEvent.layout.height);
+ setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
+ setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
+ }, []);
+
+ useEffect(() => {
+ reportHeaderMaterialVisibility(false);
+ }, [feedThreadKey, reportHeaderMaterialVisibility]);
+
const presentedFeed = useMemo(
() =>
appendPendingThreadMessages(
deriveThreadFeedPresentation(
props.feed,
- props.latestTurn,
+ props.latestRun,
expandedTurnIds,
- expandedWorkGroupIds,
+ new Set(
+ Object.entries(expandedWorkGroups)
+ .filter(([, expanded]) => expanded)
+ .map(([groupId]) => groupId),
+ ),
props.activeWorkStartedAt,
),
props.feed,
@@ -2449,10 +2543,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[
props.queuedMessages,
expandedTurnIds,
- expandedWorkGroupIds,
+ expandedWorkGroups,
props.activeWorkStartedAt,
props.feed,
- props.latestTurn,
+ props.latestRun,
],
);
// The empty↔filled key below remounts the list and resets its imperative
@@ -2472,29 +2566,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
resolveChatListAnchoredEndSpace(
presentedFeed,
props.anchorMessageId,
- (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null),
+ (entry) => (entry.type === "message" ? entry.id : null),
{ anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
),
[presentedFeed, props.anchorMessageId, anchorTopInset],
);
const terminalAssistantMessageIds = useMemo(() => {
- const terminalIdsByTurn = new Map();
+ const terminalIdsByTurn = new Map();
for (const entry of props.feed) {
- if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
- terminalIdsByTurn.set(entry.message.turnId, entry.message.id);
+ if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
+ terminalIdsByTurn.set(entry.message.runId, entry.message.id);
}
}
return new Set(terminalIdsByTurn.values());
}, [props.feed]);
useEffect(() => {
const previous = previousLatestTurnRef.current;
- previousLatestTurnRef.current = props.latestTurn;
- if (!props.latestTurn || !previous) {
+ previousLatestTurnRef.current = props.latestRun;
+ if (!props.latestRun || !previous) {
return;
}
- if (props.latestTurn.turnId === previous.turnId) {
- if (previous.state === "running" && props.latestTurn.state === "interrupted") {
- const interruptedTurnId = props.latestTurn.turnId;
+ if (props.latestRun.runId === previous.runId) {
+ if (previous.status === "running" && props.latestRun.status === "interrupted") {
+ const interruptedTurnId = props.latestRun.runId;
setInteractionState((current) => ({
...current,
expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
@@ -2503,14 +2597,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
return;
}
setInteractionState((current) => {
- if (!current.expandedTurnIds.has(previous.turnId)) {
+ if (!current.expandedTurnIds.has(previous.runId)) {
return current;
}
const next = new Set(current.expandedTurnIds);
- next.delete(previous.turnId);
+ next.delete(previous.runId);
return { ...current, expandedTurnIds: next };
});
- }, [props.latestTurn]);
+ }, [props.latestRun]);
useEffect(() => {
return () => {
@@ -2565,13 +2659,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
if (disclosureAnchorKeyRef.current !== null) {
settleDisclosureAfterLayout();
}
- }, [
- expandedTurnIds,
- expandedWorkGroups,
- expandedWorkRows,
- expandedReasoningMessageIds,
- settleDisclosureAfterLayout,
- ]);
+ }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
const handleItemSizeChanged = useCallback(() => {
if (disclosureAnchorKeyRef.current !== null) {
@@ -2611,8 +2699,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}, []);
const onToggleWorkGroup = useCallback(
- (groupId: string, anchorKey: string) => {
- suspendEndScrollMaintenanceForDisclosure(anchorKey);
+ (groupId: string, anchorKey?: string) => {
+ suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`);
setInteractionState((current) => ({
...current,
expandedWorkGroups: {
@@ -2625,8 +2713,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const onToggleWorkRow = useCallback(
- (rowId: string, anchorKey: string) => {
- suspendEndScrollMaintenanceForDisclosure(anchorKey);
+ (rowId: string, anchorKey?: string) => {
+ suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null);
setInteractionState((current) => ({
...current,
expandedWorkRows: {
@@ -2639,14 +2727,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const onToggleTurnFold = useCallback(
- (turnId: TurnId) => {
- suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+ (runId: RunId) => {
+ suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`);
setInteractionState((current) => {
const next = new Set(current.expandedTurnIds);
- if (next.has(turnId)) {
- next.delete(turnId);
+ if (next.has(runId)) {
+ next.delete(runId);
} else {
- next.add(turnId);
+ next.add(runId);
}
return { ...current, expandedTurnIds: next };
});
@@ -2654,24 +2742,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[suspendEndScrollMaintenanceForDisclosure],
);
- const onToggleReasoning = useCallback(
- (messageId: string) => {
- // The anchor must be the feed row id, which for a message row is the
- // message id, or position restoration is skipped for every row.
- suspendEndScrollMaintenanceForDisclosure(messageId);
- setInteractionState((current) => {
- const next = new Set(current.expandedReasoningMessageIds);
- if (next.has(messageId)) {
- next.delete(messageId);
- } else {
- next.add(messageId);
- }
- return { ...current, expandedReasoningMessageIds: next };
- });
- },
- [suspendEndScrollMaintenanceForDisclosure],
- );
-
const onPressPreview = useCallback((source: FilePreviewSource) => {
setExpandedFile((current) => current ?? source);
}, []);
@@ -2691,21 +2761,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// scrolling up through unmeasured content corrects each row's height as it
// mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
// exact; message rows stay undefined and use LegendList's per-type running
- // average once one of their type has been measured.
+ // average once one of their type has been measured. Prominent v2 items,
+ // expanded details, and compaction rows retain native measurement; their
+ // cards and related-thread links can exceed the compact row height.
const getFixedItemSize = useCallback(
(entry: ThreadFeedEntry) => {
if (workRowSizing.fixedRowHeight === undefined) {
return undefined;
}
switch (entry.type) {
- case "message":
- // A collapsed reasoning row is the same chrome as a work toggle.
- return entry.message.role === "reasoning" &&
- !expandedReasoningMessageIds.has(entry.message.id)
- ? WORK_GROUP_TOGGLE_HEIGHT
- : undefined;
- case "turn-fold":
- return TURN_FOLD_HEIGHT;
+ case "run-fold":
+ return resolveThreadFeedFixedItemSize(entry.type);
case "work-toggle":
case "thinking":
return WORK_GROUP_TOGGLE_HEIGHT;
@@ -2715,14 +2781,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
// Expanded rows append a variable detail block — fall back to
// measurement for those groups.
- return entry.activities.some((activity) => expandedWorkRows[activity.id])
+ return entry.activities.some(
+ (activity) => activity.prominent || expandedWorkRows[activity.id],
+ )
? undefined
: collapsedWorkLogHeight(entry.activities);
default:
return undefined;
}
},
- [expandedReasoningMessageIds, expandedWorkRows, workRowSizing.fixedRowHeight],
+ [expandedWorkRows, workRowSizing.fixedRowHeight],
);
// Disclosures can mount existing offscreen rows as well as new work rows.
@@ -2738,19 +2806,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
environmentId: props.environmentId,
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
+ onUseArtifactTemplate: props.onUseArtifactTemplate,
+ threadId: props.threadId,
copiedRowId,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
- isWorking: props.activeWorkStartedAt !== null,
onCopyWorkRow,
onToggleWorkGroup,
onToggleWorkRow,
onToggleTurnFold,
- onToggleReasoning,
onPressPreview,
onPressVideo,
markdownLinkHandlers,
@@ -2765,8 +2832,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
themeAppearance,
userBubbleMaxWidth,
markdownContentWidth,
+ threadTitle: props.threadTitle,
skills: props.skills,
- onUseArtifactTemplate: props.onUseArtifactTemplate,
+ workspaceRoot: props.workspaceRoot,
})}
@@ -2777,12 +2845,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
copiedRowId,
disclosureToggleSettling,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
- props.activeWorkStartedAt,
iconSubtleColor,
screenColor,
userBubbleColor,
@@ -2796,13 +2862,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
markdownLinkHandlers,
onPressPreview,
onPressVideo,
- onToggleReasoning,
onToggleTurnFold,
onToggleWorkGroup,
onToggleWorkRow,
props.environmentId,
props.onUseArtifactTemplate,
+ props.threadId,
+ props.threadTitle,
props.skills,
+ props.workspaceRoot,
renderMarkdownImage,
renderViewedImage,
],
@@ -2853,7 +2921,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
- // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch):
+ // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
// lets its scroll math clamp programmatic scrolls to -headerInset
// instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
// content rest below the transparent header rather than at frame top.
@@ -2865,16 +2933,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// ThreadDetailScreen); this tells LegendList's scroll math about the
// extra so programmatic end scrolls land at the true resting offset.
contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
- // Android: the composer overlay only exists as the keyboard
- // integration's animated bottom padding, which the list's scroll
- // math cannot see until the inset reports above land — and those
- // arrive via runOnJS, racing the remounted list's one-shot initial
- // scroll-at-end. Seed the estimated overlay height as a declarative
- // contentInset floor: LegendList consumes it in JS math only
- // (Android's ScrollView has no native contentInset prop) and the
- // first reported override REPLACES it instead of adding to it.
- // Not on iOS: there the prop would reach UIKit and inset natively
- // on top of the animated padding.
+ // Android's initial end scroll can run before the keyboard integration
+ // reports the composer height. Seed that estimate for LegendList's
+ // scroll math until the first reported inset replaces it.
{...(initialContentInset ? { contentInset: initialContentInset } : {})}
// The keyboard integration's offset math (end pinning, max scroll)
// must add the same UIKit-added extra, or its keyboard-open end
@@ -2947,16 +3008,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
ListHeaderComponent={
<>
{usesNativeAutomaticInsets ? null : }
- {props.loadEarlier != null ? (
-
-
- {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"}
-
-
+ {props.historyControls ? (
+
) : null}
>
}
@@ -2985,3 +3038,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
});
+
+function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) {
+ const theme = useUniwindTheme();
+ const mutedColor = theme["--color-icon-subtle"];
+ const accentColor = theme["--color-primary"];
+ if (!props.hasMoreHistory && props.error === null) {
+ return null;
+ }
+ return (
+
+ {props.hasMoreHistory ? (
+
+ {props.loading ? (
+
+ ) : (
+
+ )}
+
+ {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
+
+
+ ) : null}
+ {props.error !== null ? (
+
+ {props.error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 31b65f49353a..d55ef3dd0e1d 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -86,6 +86,8 @@ export type ThreadGitMenuProps = {
readonly gitOperationLabel: string | null;
readonly onOpenFilesInspector?: () => void;
readonly onOpenGitInspector?: () => void;
+ /** Present only on a thread whose work can be merged into the one it came from. */
+ readonly onMergeBack?: () => void;
readonly onPull: () => Promise;
readonly onRunAction: (input: GitActionRequestInput) => Promise;
};
@@ -352,6 +354,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
onPress: model.openReview,
type: "action",
},
+ ...(props.onMergeBack
+ ? [
+ {
+ description: "Bring this thread's latest turn into its source",
+ icon: { name: "arrow.triangle.merge", type: "sfSymbol" as const },
+ label: "Merge back to source",
+ onPress: props.onMergeBack,
+ type: "action" as const,
+ },
+ ]
+ : []),
{
description: "Commit, files, branches",
icon: { name: "ellipsis", type: "sfSymbol" },
@@ -381,6 +394,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
props.canOpenFiles,
props.canOpenTerminal,
props.gitStatus,
+ props.onMergeBack,
props.onOpenNewTerminal,
props.onOpenTerminal,
props.onRunProjectScript,
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 26093e87536f..fba2e4ead66b 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -11,7 +11,6 @@ import {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
-import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
@@ -27,12 +26,12 @@ import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
-import { useProjects, useThreadShells } from "../../state/entities";
+import { useProjects, useNavigationThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { usePendingThreadOrder } from "../../state/thread-order";
-import { environmentServerConfigsAtom } from "../../state/server";
+import { threadListEnvironmentsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useWorkspaceState } from "../../state/workspace";
@@ -80,7 +79,6 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2SnoozedShelfHeader,
} from "./thread-list-v2-items";
-import { resolveThreadProviderInstance } from "./thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
@@ -154,7 +152,7 @@ function ThreadNavigationSidebarPane(
const insets = useSafeAreaInsets();
const projects = useProjects();
- const threads = useThreadShells();
+ const threads = useNavigationThreadShells();
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const searchInputRef = useRef(null);
@@ -416,86 +414,25 @@ function ThreadNavigationSidebarPane(
}, [threadListV2Enabled]);
// Threads on servers without the settlement capability never classify as
// settled (the user could neither un-settle nor pin them).
- const serverConfigs = useAtomValue(environmentServerConfigsAtom);
- const settlementEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadSettlement === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const snoozeEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadSnooze === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const pinningEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadPinning === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const pinReorderEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadPinReorder === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const activeReorderEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadActiveReorder === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const titleRegenerationEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadTitleRegeneration === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const machineByEnvironmentId = useMemo(
- () =>
- new Map(
- [...serverConfigs].map(
- ([environmentId, config]) =>
- [environmentId, resolveEnvironmentMachineKind(config)] as const,
- ),
- ),
- [serverConfigs],
- );
+ const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
+ const {
+ providersByEnvironmentId,
+ machineByEnvironmentId,
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ pinningEnvironmentIds,
+ pinReorderEnvironmentIds,
+ activeReorderEnvironmentIds,
+ titleRegenerationEnvironmentIds,
+ } = listEnvironments;
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
const threadMovePlanners = useMemo(() => {
const sectionPlanner = (section: "pinned" | "active") =>
createThreadMovePlanner({
allThreads: threads,
section,
- reorderableEnvironmentIds: new Set(
- [...serverConfigs].flatMap(([id, config]) =>
- (section === "pinned"
- ? config.environment.capabilities.threadPinReorder
- : config.environment.capabilities.threadActiveReorder) === true
- ? [id]
- : [],
- ),
- ),
+ reorderableEnvironmentIds:
+ section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds,
ordered: getThreadListV2OrderedSection({
threads,
section,
@@ -508,7 +445,8 @@ function ThreadNavigationSidebarPane(
});
return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
}, [
- serverConfigs,
+ pinReorderEnvironmentIds,
+ activeReorderEnvironmentIds,
threads,
pendingOrder,
queuedThreadKeys,
@@ -789,7 +727,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
savedConnectionsById,
- serverConfigs,
+ listEnvironments,
snoozePresetMinute: nowMinute,
threadSearchMatchByKey,
}),
@@ -798,7 +736,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
savedConnectionsById,
- serverConfigs,
+ listEnvironments,
nowMinute,
threadSearchMatchByKey,
],
@@ -911,7 +849,7 @@ function ThreadNavigationSidebarPane(
snoozeWakeLabelText={item.snoozeWakeLabelText}
project={projectByKey.get(scopeKey) ?? null}
projectTitle={projectTitleByProjectKey.get(scopeKey)}
- providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
+ providers={providersByEnvironmentId.get(thread.environmentId)}
environmentLabel={
Object.keys(savedConnectionsById).length > 1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
@@ -1101,7 +1039,7 @@ function ThreadNavigationSidebarPane(
props.selectedThreadKey,
props.width,
savedConnectionsById,
- serverConfigs,
+ providersByEnvironmentId,
shelfPreferencesLoaded,
threadSearchMatchByKey,
titleRegenerationEnvironmentIds,
diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
new file mode 100644
index 000000000000..92d2ef98e083
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
@@ -0,0 +1,504 @@
+import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
+import { useAtomValue } from "@effect/atom-react";
+import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
+import { Image } from "expo-image";
+import * as Haptics from "expo-haptics";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { Animated, Platform, Pressable, ScrollView, View } from "react-native";
+import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
+import ReanimatedSwipeable, {
+ type SwipeableMethods,
+} from "react-native-gesture-handler/ReanimatedSwipeable";
+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { ControlPillMenu } from "../../components/ControlPill";
+import { scopedThreadKey } from "../../lib/scopedEntities";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
+import { useAssetUrl } from "../../state/assets";
+import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
+import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import {
+ buildCancelQueuedRunCommand,
+ resolveQueueDropBeforeRunId,
+ resolveThreadQueueRowControls,
+} from "./threadQueueControlPresentation";
+
+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
+const REMOVE_ACTION_WIDTH = 76;
+const THUMBNAIL_LIMIT = 3;
+
+type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
+type QueueAction = "steer" | "edit" | "up" | "down" | "remove";
+
+export function useThreadQueueWorkflow(target: QueueTarget) {
+ return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target));
+}
+
+export function useThreadQueuedCount(target: QueueTarget) {
+ return useAtomValue(environmentThreadDetails.queuedCountAtom(target));
+}
+
+export function ThreadQueueSheet({ route }: StaticScreenProps) {
+ const target = route.params;
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const theme = useUniwindTheme();
+ const workflow = useThreadQueueWorkflow(target);
+ const threadKey = scopedThreadKey(target.environmentId, target.threadId);
+ const editing = useQueuedRunEdit(threadKey);
+ const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message");
+ const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message");
+ const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message");
+ const [busyRunId, setBusyRunId] = useState(null);
+ const busyRef = useRef(false);
+ const [draggedRunId, setDraggedRunId] = useState(null);
+ const rowLayouts = useRef(new Map());
+ const drag = useRef<{ runId: RunId; order: string } | null>(null);
+ const [translation] = useState(() => new Animated.Value(0));
+ const queuedRuns = workflow?.queuedRuns ?? [];
+ const order = queuedRuns.map(({ run }) => run.id).join(",");
+
+ useEffect(() => {
+ if (drag.current && drag.current.order !== order) {
+ drag.current = null;
+ setDraggedRunId(null);
+ translation.setValue(0);
+ }
+ }, [order, translation]);
+
+ // Nothing left to manage: the sheet closes rather than sitting on an empty
+ // list the user has to dismiss themselves.
+ const hadQueuedRuns = useRef(queuedRuns.length > 0);
+ useEffect(() => {
+ if (queuedRuns.length > 0) {
+ hadQueuedRuns.current = true;
+ return;
+ }
+ if (hadQueuedRuns.current) navigation.goBack();
+ }, [navigation, queuedRuns.length]);
+
+ const move = async (runId: RunId, beforeRunId: RunId | null) => {
+ if (busyRef.current || !workflow?.canReorder) return;
+ busyRef.current = true;
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ try {
+ await reorder({ ...target, input: { threadId: target.threadId, runId, beforeRunId } });
+ } finally {
+ busyRef.current = false;
+ setBusyRunId(null);
+ }
+ };
+
+ const act = async (runId: RunId, action: QueueAction) => {
+ if (busyRef.current) return;
+ const index = queuedRuns.findIndex(({ run }) => run.id === runId);
+ if (index < 0) return;
+ if (action === "up" && index > 0) {
+ await move(runId, queuedRuns[index - 1]!.run.id);
+ return;
+ }
+ if (action === "down" && index < queuedRuns.length - 1) {
+ await move(runId, queuedRuns[index + 2]?.run.id ?? null);
+ return;
+ }
+ if (action === "edit") {
+ const entry = queuedRuns[index]!;
+ void Haptics.selectionAsync();
+ beginQueuedRunEdit(threadKey, {
+ runId,
+ messageId: entry.messageId,
+ originalText: entry.text,
+ existingAttachments: entry.attachments,
+ ...(entry.context ? { context: entry.context } : {}),
+ });
+ navigation.goBack();
+ return;
+ }
+ if (action !== "steer" && action !== "remove") return;
+ busyRef.current = true;
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ try {
+ if (action === "remove") {
+ await cancel(buildCancelQueuedRunCommand({ ...target, runId }));
+ } else if (workflow?.activeRun && workflow.canPromoteToSteer) {
+ await promote({
+ ...target,
+ input: {
+ threadId: target.threadId,
+ queuedRunId: runId,
+ targetRunId: workflow.activeRun.id,
+ },
+ });
+ }
+ } finally {
+ busyRef.current = false;
+ setBusyRunId(null);
+ }
+ };
+
+ const canReorder = workflow?.canReorder === true && queuedRuns.length > 1;
+ const content = (
+
+ {queuedRuns.length === 0 ? (
+
+ No messages waiting in this queue.
+
+ ) : null}
+ {queuedRuns.map(({ run, text, attachments }, index) => {
+ const controls = resolveThreadQueueRowControls({
+ busy: busyRunId !== null || draggedRunId !== null,
+ canPromoteToSteer: workflow?.canPromoteToSteer ?? false,
+ canReorder: workflow?.canReorder ?? false,
+ index,
+ isEditing: editing?.runId === run.id,
+ queuedCount: queuedRuns.length,
+ text,
+ });
+ const title =
+ controls.displayText || (attachments.length > 0 ? "Attachments" : "Queued message");
+ return (
+ rowLayouts.current.set(run.id, nativeEvent.layout)}
+ >
+
+ {canReorder ? (
+ // Outside the swipeable: two pans on one row would race, and
+ // the handle owns vertical movement while the row owns sideways.
+ void act(run.id, action)}
+ onStart={() => {
+ drag.current = { runId: run.id, order };
+ translation.setValue(0);
+ setDraggedRunId(run.id);
+ void Haptics.selectionAsync();
+ }}
+ onMove={(y) => translation.setValue(y)}
+ onEnd={(y, success) => {
+ const started = drag.current;
+ drag.current = null;
+ setDraggedRunId(null);
+ translation.setValue(0);
+ // A remote reorder or a newly started run invalidates this drag.
+ if (!success || started?.order !== order || started.runId !== run.id) return;
+ const before = resolveQueueDropBeforeRunId(
+ queuedRuns.map(({ run: item }) => ({
+ id: item.id,
+ ...rowLayouts.current.get(item.id),
+ })),
+ run.id,
+ y,
+ );
+ if (before !== undefined) void move(run.id, before);
+ }}
+ />
+ ) : null}
+ void act(run.id, "remove")}
+ >
+
+ void act(run.id, nativeEvent.event as QueueAction)
+ }
+ >
+ void act(run.id, "edit")}
+ className="min-h-14 flex-row items-center gap-2.5 py-2.5 active:opacity-70"
+ >
+
+
+ {title}
+
+ {controls.isEditing ? (
+
+ Editing
+
+ ) : null}
+ {workflow?.canPromoteToSteer ? (
+ void act(run.id, "steer")}
+ className="h-8 shrink-0 justify-center rounded-full bg-primary px-3 active:opacity-70 disabled:opacity-40"
+ >
+
+ Steer
+
+
+ ) : null}
+
+
+
+
+
+ );
+ })}
+
+ );
+
+ if (Platform.OS === "ios") {
+ // A plain formSheet screen never renders a stack header, so it comes from
+ // a nested native stack inside the sheet (same shape as the git sheet).
+ return (
+
+
+
+
+ {content}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ navigation.goBack()} />
+ {content}
+
+
+ );
+}
+
+/** Swipe left to remove, the one destructive action that needs no menu. */
+function QueueRowSwipeable(props: {
+ readonly enabled: boolean;
+ readonly background: string;
+ readonly onRemove: () => void;
+ readonly children: React.ReactNode;
+}) {
+ const swipeableRef = useRef(null);
+ return (
+ {
+ if (direction !== "right") return;
+ swipeableRef.current?.close();
+ props.onRemove();
+ }}
+ renderRightActions={() => (
+
+
+ Remove
+
+ )}
+ >
+ {props.children}
+
+ );
+}
+
+function QueueAttachmentThumbnails(props: {
+ readonly environmentId: EnvironmentId;
+ readonly attachments: ReadonlyArray;
+}) {
+ const images = props.attachments.filter((attachment) => attachment.mimeType.startsWith("image/"));
+ const shown = images.slice(0, THUMBNAIL_LIMIT);
+ const overflow = props.attachments.length - shown.length;
+ if (props.attachments.length === 0) return null;
+ return (
+
+ {shown.map((attachment) => (
+
+ ))}
+ {overflow > 0 ? (
+
+
+ {shown.length === 0 ? `${overflow}` : `+${overflow}`}
+
+
+ ) : null}
+
+ );
+}
+
+function QueueAttachmentThumbnail(props: {
+ readonly environmentId: EnvironmentId;
+ readonly attachment: ChatAttachment;
+}) {
+ const url = useAssetUrl(props.environmentId, {
+ _tag: "attachment",
+ attachmentId: props.attachment.id,
+ fileName: props.attachment.name,
+ mimeType: props.attachment.mimeType,
+ disposition: "inline",
+ });
+ if (url === null) {
+ return ;
+ }
+ return (
+
+ );
+}
+
+function QueueDragHandle(props: {
+ disabled: boolean;
+ title: string;
+ canMoveUp: boolean;
+ canMoveDown: boolean;
+ onStep: (action: "up" | "down") => void;
+ onStart: () => void;
+ onMove: (y: number) => void;
+ onEnd: (y: number, success: boolean) => void;
+}) {
+ const latest = useRef(props);
+ useLayoutEffect(() => {
+ latest.current = props;
+ });
+ const gesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(!props.disabled)
+ .minDistance(0)
+ .shouldCancelWhenOutside(false)
+ .runOnJS(true)
+ .onStart(() => latest.current.onStart())
+ .onUpdate((event) => latest.current.onMove(event.translationY))
+ .onFinalize((event, success) => latest.current.onEnd(event.translationY, success)),
+ [props.disabled],
+ );
+ return (
+
+ {
+ if (props.disabled) return;
+ if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
+ if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
+ }}
+ className="h-12 w-8 items-center justify-center"
+ >
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index f78553dbb7e9..54cfe53c1e36 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -13,10 +13,6 @@ import {
ThreadId,
type ProjectScript,
} from "@t3tools/contracts";
-import {
- requestOlderThreadTurns,
- threadHasOlderTurns,
-} from "@t3tools/client-runtime/state/threads";
import {
projectScriptCwd,
projectScriptRuntimeEnv,
@@ -24,8 +20,8 @@ import {
} from "@t3tools/shared/projectScripts";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useWorkspaceState } from "../../state/workspace";
-import { useEnvironmentShellState } from "../../state/shell";
+import { useConnectionsReady } from "../../state/workspace";
+import { useEnvironmentShellReadiness } from "../../state/shell";
import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
@@ -75,6 +71,8 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
+import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment } from "../../state/threads";
import { projectThreadContentPresentation } from "./threadContentPresentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
@@ -152,7 +150,7 @@ function ThreadUnavailableScreen(props: {
}
export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
- const { state: workspaceState } = useWorkspaceState();
+ const connectionsReady = useConnectionsReady();
const { connectionState } = useRemoteConnectionStatus();
const { selectedThread } = useThreadSelection();
const params = props.route.params;
@@ -160,7 +158,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
const threadIdRaw = firstRouteParam(params.threadId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
- const routeEnvironmentShellState = useEnvironmentShellState(environmentId);
+ const routeEnvironmentShellState = useEnvironmentShellReadiness(environmentId);
const { onReconnectEnvironment } = useRemoteConnections();
const navigation = useNavigation();
const routeConnectionState =
@@ -189,10 +187,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
}
const stillHydrating = threadRouteIsHydrating({
- isLoadingConnections: workspaceState.isLoadingConnections,
+ isLoadingConnections: !connectionsReady,
connectionState: routeConnectionState,
shellStatus: routeEnvironmentShellState.status,
- shellHasError: Option.isSome(routeEnvironmentShellState.error),
+ shellHasError: routeEnvironmentShellState.hasError,
detailStatus: selectedThreadDetailState.status,
detailHasError: Option.isSome(selectedThreadDetailState.error),
});
@@ -245,27 +243,71 @@ function ThreadRouteContent(
} = useThreadSelection();
const selectedThreadDetailState = props.selectedThreadDetailState;
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
- // "Load earlier turns" header state for windowed (paginated) thread loads.
- const loadEarlierTurns = useMemo(() => {
- if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) {
- return null;
- }
- return {
- loading:
- selectedThreadDetailState.page._tag === "Some" &&
- selectedThreadDetailState.page.value.loadingOlder,
- onLoadEarlier: () => {
- requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id);
- },
- };
- }, [selectedThread, selectedThreadDetailState]);
const { selectedThreadCwd } = useSelectedThreadWorktree();
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
+ const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
+ label: "load earlier thread history",
+ reportFailure: false,
+ });
+ const historyControls = useMemo(() => {
+ const history = selectedThreadDetailState.history;
+ if (!selectedThread) {
+ return undefined;
+ }
+ if (!history.hasMoreHistory && history.error === null) {
+ return undefined;
+ }
+ return {
+ hasMoreHistory: history.hasMoreHistory,
+ loading: history.loading,
+ error: history.error,
+ onLoadEarlier: () => {
+ void loadEarlierHistory({
+ environmentId: selectedThread.environmentId,
+ input: { threadId: selectedThread.id },
+ });
+ },
+ };
+ }, [loadEarlierHistory, selectedThread, selectedThreadDetailState.history]);
const navigation = useNavigation();
+ const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
+ const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail);
+ const mergeBackRun =
+ selectedThreadDetail === null ? null : resolveLatestMergeBackRun(selectedThreadDetail);
+ const mergeBackBusyRef = useRef(false);
+ const handleMergeBack = useCallback(async () => {
+ if (
+ mergeBackBusyRef.current ||
+ !selectedThread ||
+ mergeBackTargetThreadId === null ||
+ mergeBackRun === null
+ ) {
+ return;
+ }
+ mergeBackBusyRef.current = true;
+ try {
+ const result = await mergeBack({
+ environmentId: selectedThread.environmentId,
+ input: {
+ sourceThreadId: selectedThread.id,
+ targetThreadId: mergeBackTargetThreadId,
+ runId: mergeBackRun.id,
+ creationSource: "mobile",
+ },
+ });
+ if (result._tag !== "Success") return;
+ navigation.navigate("Thread", {
+ environmentId: selectedThread.environmentId,
+ threadId: mergeBackTargetThreadId,
+ });
+ } finally {
+ mergeBackBusyRef.current = false;
+ }
+ }, [mergeBack, mergeBackRun, mergeBackTargetThreadId, navigation, selectedThread]);
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
@@ -376,7 +418,7 @@ function ThreadRouteContent(
}),
[knownTerminalSessions, selectedThreadProject?.workspaceRoot],
);
- const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;
+ const selectedThreadDetailWorktreePath = selectedThreadDetail?.thread.worktreePath ?? null;
const handleReconnectEnvironment = useCallback(() => {
if (!environmentId) {
return;
@@ -530,23 +572,17 @@ function ThreadRouteContent(
void navigation.navigate("Connections");
}, [navigation]);
const handleStopThread = useCallback(() => {
- if (
- !selectedThread ||
- (selectedThread.session?.status !== "running" &&
- selectedThread.session?.status !== "starting")
- ) {
+ if (!selectedThread || composer.interruptibleRunId === null) {
return;
}
return interruptThreadTurn({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
- ...(selectedThread.session.activeTurnId
- ? { turnId: selectedThread.session.activeTurnId }
- : {}),
+ runId: composer.interruptibleRunId,
},
});
- }, [interruptThreadTurn, selectedThread]);
+ }, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
@@ -672,6 +708,10 @@ function ThreadRouteContent(
onOpenFilesInspector:
fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
+ onMergeBack:
+ mergeBackTargetThreadId !== null && mergeBackRun !== null
+ ? () => void handleMergeBack()
+ : undefined,
currentBranch: selectedThread?.branch ?? null,
gitStatus: gitStatus.data,
gitOperationLabel: gitState.gitOperationLabel,
@@ -764,6 +804,13 @@ function ThreadRouteContent(
icon: "point.topleft.down.curvedto.point.bottomright.up",
onPress: handleOpenGitInspector,
});
+ if (mergeBackTargetThreadId !== null && mergeBackRun !== null) {
+ actions.push({
+ accessibilityLabel: "Merge back to source",
+ icon: "arrow.triangle.merge",
+ onPress: () => void handleMergeBack(),
+ });
+ }
if (fileInspector.supported && selectedThreadCwd !== null) {
actions.push({
accessibilityLabel: "Toggle inspector",
@@ -774,10 +821,13 @@ function ThreadRouteContent(
return actions;
}, [
fileInspector.supported,
+ handleMergeBack,
handleOpenFilesInspector,
handleOpenTerminal,
handleOpenGitInspector,
handleToggleInspector,
+ mergeBackRun,
+ mergeBackTargetThreadId,
props.onReturnToThread,
selectedThreadCwd,
selectedThreadProject?.workspaceRoot,
@@ -813,23 +863,8 @@ function ThreadRouteContent(
}),
);
}, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]);
- // A worktree bootstrap records a running setup on the thread before its
- // turn, so a thread opened from another device (or after a restart) shows
- // the same preparing state the sending client does. A starting session is
- // not enough on its own: an ordinary first turn projects one too.
- const awaitingBootstrapTurn = useMemo(
- () =>
- selectedThreadDetail !== null &&
- selectedThreadDetail.latestTurn === null &&
- selectedThreadDetail.activities.some(
- (activity) =>
- activity.kind === "worktree-setup" &&
- typeof activity.payload === "object" &&
- activity.payload !== null &&
- (activity.payload as { phase?: unknown }).phase === "running",
- ),
- [selectedThreadDetail],
- );
+ const awaitingBootstrapTurn =
+ selectedThreadDetail?.runs.some((run) => run.status === "preparing") ?? false;
const creationState = ((): ThreadDetailScreenProps["creationState"] => {
if (selectedThreadCreation === null) {
return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
@@ -912,6 +947,7 @@ function ThreadRouteContent(
feedbackSubmissions={composer.feedbackSubmissions}
onDismissFeedback={composer.dismissFeedback}
selectedThreadFeed={composer.selectedThreadFeed}
+ activityRun={composer.selectedThreadActivityRun}
activeWorkStartedAt={composer.activeWorkStartedAt}
isCompacting={composer.isCompacting}
creationState={creationState}
@@ -925,7 +961,16 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
- loadEarlier={loadEarlierTurns}
+ historyControls={historyControls}
+ activeThreadBusy={composer.activeThreadBusy}
+ canStopThread={composer.interruptibleRunId !== null}
+ queuedRunEdit={composer.queuedRunEdit}
+ composerDraftKey={composer.composerDraftKey}
+ followUpBehavior={composer.followUpBehavior}
+ canSteerActiveTurn={composer.canSteerActiveTurn}
+ isSavingQueuedEdit={composer.isSavingQueuedEdit}
+ onCancelQueuedRunEdit={composer.cancelQueuedRunEdit}
+ onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index 2d4276606ec3..72110890c408 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -43,6 +43,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { rememberModelOptions } from "../../state/use-model-option-memory";
import {
NativeHeaderToolbar,
NativeStackScreenOptions,
@@ -62,7 +63,11 @@ import {
NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
-import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
+import {
+ compatibleRuntimeModeForChoices,
+ runtimeModeChoicesForSupportedModes,
+ selectableChoices,
+} from "./thread-settings-options";
import {
canCommitPendingModel,
modelMatchesCatalogQuery,
@@ -168,6 +173,7 @@ function ModelRow(props: {
/** Provider catalog header with its harness logo and disclosure state. */
function ProviderHeader(props: {
readonly driver: string | undefined;
+ readonly iconUrl: string | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
@@ -176,7 +182,7 @@ function ProviderHeader(props: {
}) {
const content = (
<>
-
+
{props.label}
{props.collapsible ? (
<>
@@ -374,6 +380,7 @@ type ThreadSettingsSessionValue = {
readonly providerInstanceId?: ProviderInstanceId;
readonly providerGroups: ReadonlyArray;
readonly runtimeMode: RuntimeMode;
+ readonly runtimeModeChoices: ReturnType;
readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
readonly displayedDescriptors: ReadonlyArray;
readonly providerExpansionOverrides: ReadonlySet;
@@ -434,6 +441,20 @@ function ThreadSettingsSessionProvider(
: props.optionDescriptors,
[pendingModel, props.optionDescriptors],
);
+ const displayedModel = useMemo(
+ () =>
+ pendingModel ??
+ props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ??
+ null,
+ [isApplied, pendingModel, props.providerGroups],
+ );
+ const runtimeModeChoices = runtimeModeChoicesForSupportedModes(
+ displayedModel?.supportedRuntimeModes,
+ );
+ const compatibleRuntimeMode = compatibleRuntimeModeForChoices(
+ props.runtimeMode,
+ runtimeModeChoices,
+ );
const hasLegacyModels = useMemo(
() => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)),
@@ -461,6 +482,7 @@ function ThreadSettingsSessionProvider(
return;
}
if (pendingModel) {
+ rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next);
setPendingModel({
...pendingModel,
selection: { ...pendingModel.selection, options: next },
@@ -501,7 +523,8 @@ function ThreadSettingsSessionProvider(
environmentId: props.environmentId,
providerInstanceId: props.providerInstanceId,
providerGroups: props.providerGroups,
- runtimeMode: props.runtimeMode,
+ runtimeMode: compatibleRuntimeMode,
+ runtimeModeChoices,
onUpdateRuntimeMode: props.onUpdateRuntimeMode,
displayedDescriptors,
providerExpansionOverrides,
@@ -523,6 +546,7 @@ function ThreadSettingsSessionProvider(
[
applyOptionChange,
commitPendingModel,
+ compatibleRuntimeMode,
displayedDescriptors,
providerExpansionOverrides,
hasLegacyModels,
@@ -535,7 +559,7 @@ function ThreadSettingsSessionProvider(
providerFilter,
props.onUpdateRuntimeMode,
props.providerGroups,
- props.runtimeMode,
+ runtimeModeChoices,
searchQuery,
showLegacyToggle,
toggleProvider,
@@ -560,6 +584,7 @@ function useThreadSettingsSession() {
type ThreadSettingsProviderCatalog = {
readonly key: string;
readonly driver: string | undefined;
+ readonly iconUrl: string | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
@@ -625,6 +650,7 @@ function ThreadSettingsProviderListHeader(props: {
collapsible={props.provider.collapsible}
collapsed={props.provider.collapsed}
driver={props.provider.driver}
+ iconUrl={props.provider.iconUrl}
label={props.provider.label}
modelCount={props.provider.modelCount}
onToggle={onToggle}
@@ -670,6 +696,7 @@ function useThreadSettingsCatalogItems(
const provider: ThreadSettingsProviderCatalog = {
key: group.providerKey,
driver,
+ iconUrl: group.models[0]?.providerIconUrl,
label: group.providerLabel,
collapsible,
collapsed,
@@ -760,7 +787,8 @@ function ThreadSettingsOptionsItem(props: {
isLast
label="Runtime"
value={
- RUNTIME_MODE_CHOICES.find((choice) => choice.mode === session.runtimeMode)?.label
+ session.runtimeModeChoices.find((choice) => choice.mode === session.runtimeMode)
+ ?.label
}
onPress={() => props.onOpenSubmenu({ kind: "runtime" })}
/>
@@ -910,7 +938,7 @@ function ThreadSettingsChoiceContent(props: {
const submenuContent =
props.submenu.kind === "runtime"
? {
- rows: RUNTIME_MODE_CHOICES.map((choice) => ({
+ rows: session.runtimeModeChoices.map((choice) => ({
id: choice.mode,
label: choice.label,
description: choice.description,
diff --git a/apps/mobile/src/features/threads/composerSendPresentation.test.ts b/apps/mobile/src/features/threads/composerSendPresentation.test.ts
new file mode 100644
index 000000000000..f36d45997f1e
--- /dev/null
+++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveComposerSendPresentation } from "./composerSendPresentation";
+
+const idle = {
+ editingQueuedMessage: false,
+ running: false,
+ canSteer: false,
+ followUpBehavior: "queue",
+ deliveryDeferred: false,
+} as const;
+
+describe("resolveComposerSendPresentation", () => {
+ it("sends plainly while the thread is idle", () => {
+ const presentation = resolveComposerSendPresentation(idle);
+
+ expect(presentation.label).toBe("Send");
+ expect(presentation.icon).toBe("arrow.up");
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ expect(presentation.action).toBeNull();
+ });
+
+ it("says Queue while the outbox is holding the message back", () => {
+ expect(resolveComposerSendPresentation({ ...idle, deliveryDeferred: true }).label).toBe(
+ "Queue",
+ );
+ });
+
+ it("follows the configured behavior once a turn is running", () => {
+ const queueing = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "queue",
+ });
+ const steering = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "steer",
+ });
+
+ expect(queueing.label).toBe("Queue");
+ expect(queueing.icon).toBe("list.number");
+ expect(queueing.action).toBe("queue");
+ expect(queueing.alternate).toBe("steer");
+ expect(steering.label).toBe("Steer");
+ expect(steering.icon).toBe("arrow.turn.left.up");
+ expect(steering.action).toBe("steer");
+ expect(steering.alternate).toBe("queue");
+ expect(steering.offersFollowUpChoice).toBe(true);
+ });
+
+ it("never promises steering the provider cannot do", () => {
+ const presentation = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: false,
+ followUpBehavior: "steer",
+ });
+
+ expect(presentation.label).toBe("Queue");
+ expect(presentation.action).toBe("queue");
+ expect(presentation.alternate).toBeNull();
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ });
+
+ it("keeps the save affordance while a queued message is being edited", () => {
+ const presentation = resolveComposerSendPresentation({
+ ...idle,
+ editingQueuedMessage: true,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "steer",
+ });
+
+ expect(presentation.label).toBe("Update queued message");
+ expect(presentation.icon).toBe("checkmark");
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/threads/composerSendPresentation.ts b/apps/mobile/src/features/threads/composerSendPresentation.ts
new file mode 100644
index 000000000000..e8d02f4edf67
--- /dev/null
+++ b/apps/mobile/src/features/threads/composerSendPresentation.ts
@@ -0,0 +1,67 @@
+import {
+ alternateComposerDispatchAction,
+ type ActiveTurnComposerAction,
+} from "@t3tools/client-runtime/state/composer-dispatch";
+
+import type { FollowUpBehavior } from "../../lib/followUpBehavior";
+
+export interface ComposerSendPresentation {
+ readonly label: string;
+ readonly icon: "arrow.up" | "checkmark" | "list.number" | "arrow.turn.left.up";
+ /** What a plain tap does while a turn runs, or null when the turn is idle. */
+ readonly action: ActiveTurnComposerAction | null;
+ /** What the long-press menu and the Command chord do instead. */
+ readonly alternate: ActiveTurnComposerAction | null;
+ /** The follow-up menu is meaningless outside a running turn or during an edit. */
+ readonly offersFollowUpChoice: boolean;
+}
+
+const ACTION_LABEL: Record = {
+ queue: "Queue",
+ steer: "Steer",
+ restart: "Restart",
+};
+
+/**
+ * What the composer's primary button says and does. Steering is only offered
+ * when the provider can actually steer the live turn, so the button never
+ * promises something the server would have to silently downgrade.
+ */
+export function resolveComposerSendPresentation(input: {
+ readonly editingQueuedMessage: boolean;
+ readonly running: boolean;
+ readonly canSteer: boolean;
+ readonly followUpBehavior: FollowUpBehavior;
+ /** Outbox reasons the send waits rather than leaving immediately. */
+ readonly deliveryDeferred: boolean;
+}): ComposerSendPresentation {
+ if (input.editingQueuedMessage) {
+ return {
+ label: "Update queued message",
+ icon: "checkmark",
+ action: null,
+ alternate: null,
+ offersFollowUpChoice: false,
+ };
+ }
+ if (!input.running) {
+ return {
+ label: input.deliveryDeferred ? "Queue" : "Send",
+ icon: "arrow.up",
+ action: null,
+ alternate: null,
+ offersFollowUpChoice: false,
+ };
+ }
+ // Without steering support the choice collapses: every follow-up queues, so
+ // offering a menu with one usable entry would be noise.
+ const action: ActiveTurnComposerAction = input.canSteer ? input.followUpBehavior : "queue";
+ const alternate = alternateComposerDispatchAction(action);
+ return {
+ label: ACTION_LABEL[action],
+ icon: action === "steer" ? "arrow.turn.left.up" : "list.number",
+ action,
+ alternate: input.canSteer ? alternate : null,
+ offersFollowUpChoice: input.canSteer,
+ };
+}
diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx
index 89cf39e89e39..63742cac3eb4 100644
--- a/apps/mobile/src/features/threads/floating-working-control.tsx
+++ b/apps/mobile/src/features/threads/floating-working-control.tsx
@@ -1,3 +1,4 @@
+import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { GlassContainer, GlassView } from "expo-glass-effect";
import { type ReactNode, useEffect, useRef, useState } from "react";
@@ -65,10 +66,28 @@ export function FloatingWorkingControl(props: {
readonly status: FloatingWorkingStatus | null;
readonly showScrollToEnd: boolean;
readonly onScrollToEnd: () => void;
+ readonly agents: SubagentPillSegment | null;
+ readonly onOpenAgents: () => void;
+ readonly queuedCount: number;
+ readonly onOpenQueue: () => void;
}) {
const { width: windowWidth } = useWindowDimensions();
const [overlayWidth, setOverlayWidth] = useState(windowWidth);
- const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16);
+ const [queueWidth, setQueueWidth] = useState(0);
+ const [agentsWidth, setAgentsWidth] = useState(0);
+ const hasQueue = props.queuedCount > 0;
+ const agents = props.agents;
+ const hasAgents = agents !== null;
+ // Segments keep their measured width; only the status label absorbs the
+ // remainder, so a long "Working 12m 04s" truncates before a count does.
+ const labelWidth = Math.max(
+ 0,
+ Math.min(overlayWidth, windowWidth) -
+ CONTROL_HEIGHT -
+ 32 -
+ (hasQueue ? queueWidth : 0) -
+ (hasAgents ? agentsWidth : 0),
+ );
const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
useEffect(() => {
@@ -100,6 +119,7 @@ export function FloatingWorkingControl(props: {
// Forget the width while no label is shown so the next one appears at its
// own size instead of animating from the previous label's.
const hasStatus = props.status !== null;
+ const hasCapsule = hasStatus || hasQueue || hasAgents;
useEffect(() => {
if (!hasStatus) {
measuredWidthRef.current = null;
@@ -113,18 +133,20 @@ export function FloatingWorkingControl(props: {
// a label it has not sized to yet.
const capsuleSizerStyle = useAnimatedStyle(() => ({ width: capsuleWidth.value ?? 0 }));
- if (props.status === null && !props.showScrollToEnd) {
+ if (!hasCapsule && !props.showScrollToEnd) {
return null;
}
- // Only the connection label is a button (tap to reconnect); the others
- // pass touches through to the feed like before.
- const statusInteractive = props.status?.kind === "connection";
+ // The queue, agents, and reconnect labels have separate tap targets.
+ const statusInteractive = props.status?.kind === "connection" || hasQueue || hasAgents;
// The host stays centered on the capsule, but its measurement constraint
// comes from the overlay, independent of the capsule's current width.
const statusContent =
props.status !== null ? (
- <>
+
- >
+
) : null;
+ const capsuleContent = (
+
+ {statusContent}
+ {agents !== null ? (
+ setAgentsWidth(event.nativeEvent.layout.width)}
+ className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
+ >
+ {hasStatus ? : null}
+
+
+ {agents.label}
+
+
+ ) : null}
+ {hasQueue ? (
+ setQueueWidth(event.nativeEvent.layout.width)}
+ style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}
+ className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"
+ >
+ {hasStatus || hasAgents ? : null}
+
+
+ {props.queuedCount} queued
+
+
+ ) : null}
+
+ );
+
return (
- {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (
+ {hasCapsule && NATIVE_LIQUID_GLASS_SUPPORTED ? (
- {statusContent}
+ {capsuleContent}
- ) : props.status !== null ? (
+ ) : hasCapsule ? (
- {statusContent}
+ {capsuleContent}
void;
}) {
- const [nowMs, setNowMs] = useState(() => Date.now());
+ return (
+
+
+
+ );
+}
+export function WorkingTimer(props: { readonly startedAt: string }) {
+ const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
- setNowMs(Date.now());
const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
return () => clearInterval(intervalId);
- }, [props.startedAt]);
-
- const duration = formatWorkingDuration(props.startedAt, nowMs);
- const label = `Working for ${duration}`;
-
+ }, []);
return (
-
- Working for
-
- {duration}
-
-
+
+ Working {formatWorkingDuration(props.startedAt, nowMs)}
+
);
}
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 5c74e52cfac1..3de4c7de21a1 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -66,6 +66,10 @@ import {
capturePendingTaskEditorWriteBaseline,
flushPendingTaskEditorWrite,
} from "../../state/pending-task-editor-writes";
+import {
+ rememberModelOptions,
+ withRememberedModelOptions,
+} from "../../state/use-model-option-memory";
import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
import { vcsEnvironment } from "../../state/vcs";
import {
@@ -231,6 +235,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const groupingSettings = useMobileProjectGroupingSettings();
const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } =
useLegacyPlanModeState();
+
const projectScopes = useMemo(
() =>
sortHomeProjectScopes({
@@ -549,7 +554,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!option) {
return;
}
- const selection = options ? { ...option.selection, options } : option.selection;
+ const selection = withRememberedModelOptions(
+ options ? { ...option.selection, options } : option.selection,
+ );
const provider = selectedEnvironmentServerConfig?.providers.find(
(candidate) => candidate.instanceId === selection.instanceId,
);
@@ -568,6 +575,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!selectedModel || !selectedProjectDraftKey) {
return;
}
+ rememberModelOptions(selectedModel.instanceId, selectedModel.model, options ?? []);
const nextSelection: ModelSelection = options
? { ...selectedModel, options }
: {
diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts
index 3c35eb482a3c..fdb4de20b806 100644
--- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts
+++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts
@@ -41,7 +41,7 @@ describe("pending timeline messages", () => {
it("keeps pending messages after newer agent activity in queue order", () => {
const activity = {
type: "thinking",
- turnId: null,
+ runId: null,
id: "thinking",
createdAt: "2026-09-06T11:00:00.000Z",
} as const;
diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts
index 14708314bbc7..833c30b78d9e 100644
--- a/apps/mobile/src/features/threads/pending-thread-feed.ts
+++ b/apps/mobile/src/features/threads/pending-thread-feed.ts
@@ -29,11 +29,14 @@ export function appendPendingThreadMessages(
id: pendingMessage.messageId,
role: "user",
text: pendingMessage.text,
+ attachments: [],
context: pendingMessage.context,
createdAt: pendingMessage.createdAt,
updatedAt: pendingMessage.createdAt,
- turnId: null,
+ runId: null,
streaming: false,
+ visibility: "local",
+ sourceThreadId: pendingMessage.threadId,
},
})),
];
diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts
new file mode 100644
index 000000000000..5612cfbf70d5
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts
@@ -0,0 +1,43 @@
+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ resolveThreadActivityMetadata,
+ resolveThreadActivityStatus,
+} from "./thread-activity-row-presentation";
+
+describe("thread activity row presentation", () => {
+ it("shows the provider and model as compact metadata", () => {
+ expect(
+ resolveThreadActivityMetadata({
+ providerDriver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ }),
+ ).toBe("Codex · gpt-5.4");
+ });
+
+ it("falls back to the provider instance and removes duplicate metadata", () => {
+ expect(
+ resolveThreadActivityMetadata({
+ providerDriver: null,
+ providerInstanceId: ProviderInstanceId.make("custom-agent"),
+ model: "custom-agent",
+ }),
+ ).toBe("custom-agent");
+ });
+
+ it("maps lifecycle status to dot tone and an accessible label", () => {
+ expect(resolveThreadActivityStatus("idle")).toEqual({ label: "Idle", tone: "neutral" });
+ expect(resolveThreadActivityStatus("running")).toEqual({ label: "Running", tone: "active" });
+ expect(resolveThreadActivityStatus("completed")).toEqual({
+ label: "Completed",
+ tone: "success",
+ });
+ expect(resolveThreadActivityStatus("failed")).toEqual({ label: "Failed", tone: "danger" });
+ expect(resolveThreadActivityStatus("interrupted")).toEqual({
+ label: "Interrupted",
+ tone: "warning",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.ts
new file mode 100644
index 000000000000..d5bde12ddb7f
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-activity-row-presentation.ts
@@ -0,0 +1,42 @@
+import {
+ PROVIDER_DISPLAY_NAMES,
+ type OrchestrationV2TurnItem,
+ type ProviderDriverKind,
+ type ProviderInstanceId,
+} from "@t3tools/contracts";
+
+export function resolveThreadActivityMetadata(input: {
+ readonly providerDriver: ProviderDriverKind | null;
+ readonly providerInstanceId: ProviderInstanceId | null;
+ readonly model: string | null;
+}): string {
+ const providerLabel = input.providerDriver
+ ? (PROVIDER_DISPLAY_NAMES[input.providerDriver] ?? input.providerInstanceId)
+ : input.providerInstanceId;
+ const values = [providerLabel, input.model].filter(
+ (value): value is string => value !== null && value.length > 0,
+ );
+ return [...new Set(values)].join(" · ");
+}
+
+export function resolveThreadActivityStatus(status: OrchestrationV2TurnItem["status"]): {
+ readonly label: string;
+ readonly tone: "active" | "danger" | "success" | "warning" | "neutral";
+} {
+ const label = status.charAt(0).toUpperCase() + status.slice(1).replaceAll("_", " ");
+ switch (status) {
+ case "idle":
+ return { label, tone: "neutral" };
+ case "completed":
+ return { label, tone: "success" };
+ case "failed":
+ return { label, tone: "danger" };
+ case "cancelled":
+ case "interrupted":
+ return { label, tone: "warning" };
+ case "pending":
+ case "running":
+ case "waiting":
+ return { label, tone: "active" };
+ }
+}
diff --git a/apps/mobile/src/features/threads/thread-feed-item-size.test.ts b/apps/mobile/src/features/threads/thread-feed-item-size.test.ts
new file mode 100644
index 000000000000..d2780fff584f
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-feed-item-size.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
+
+describe("resolveThreadFeedFixedItemSize", () => {
+ it("leaves activity groups to native measurement", () => {
+ expect(resolveThreadFeedFixedItemSize("activity-group")).toBeUndefined();
+ });
+
+ it("keeps fixed timeline chrome on the premeasured path", () => {
+ expect(resolveThreadFeedFixedItemSize("run-fold")).toBe(42);
+ expect(resolveThreadFeedFixedItemSize("work-toggle")).toBe(28);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-feed-item-size.ts b/apps/mobile/src/features/threads/thread-feed-item-size.ts
new file mode 100644
index 000000000000..b3c3ea15288b
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-feed-item-size.ts
@@ -0,0 +1,22 @@
+import { THREAD_WORK_ROW_MIN_HEIGHT } from "../../lib/layout";
+import type { ThreadFeedEntry } from "../../lib/threadActivity";
+
+// These rows are pure timeline chrome whose rendered height is independent of
+// their content. Content-driven rows must be measured by LegendList: returning
+// a fixed size makes the list skip native measurement entirely.
+const TURN_FOLD_HEIGHT = 42;
+const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
+
+export function resolveThreadFeedFixedItemSize(
+ entryType: ThreadFeedEntry["type"],
+): number | undefined {
+ switch (entryType) {
+ case "run-fold":
+ return TURN_FOLD_HEIGHT;
+ case "work-toggle":
+ return WORK_GROUP_TOGGLE_HEIGHT;
+ case "activity-group":
+ case "message":
+ return undefined;
+ }
+}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 02870c3ff22e..356522e8479f 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -1,3 +1,4 @@
+import { resolveThreadProviderInstance } from "./thread-provider-instance";
import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
@@ -17,13 +18,13 @@ import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps }
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { ControlPillMenu } from "../../components/ControlPill";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ProjectFavicon } from "../../components/ProjectFavicon";
-import { ProviderInstanceIcon } from "../../components/ProviderIcon";
-import type { ThreadRowProviderInstance } from "./thread-provider-instance";
+import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
@@ -32,9 +33,11 @@ import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
- resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
+ resolveThreadListV2SnoozeMenuSelection,
+ threadHasUnseenCompletion,
resolveThreadListV2Status,
+ resolveThreadListV2ProviderDrivers,
resolveThreadListV2SwipeActions,
type ThreadListV2Status,
} from "./threadListV2";
@@ -357,7 +360,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly snoozePresetMinute: string;
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
- readonly providerInstance: ThreadRowProviderInstance | null;
+ /** Keep the environment's provider array stable across unrelated list updates. */
+ readonly providers: ReadonlyArray | undefined;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
the web sidebar's remote-environment cloud icon, but as text since
@@ -439,6 +443,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;
+ const { providerDrivers, providerInstance, providerIconUrl } = useMemo(() => {
+ const provider = props.providers?.find(
+ (candidate) =>
+ candidate.instanceId ===
+ (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
+ );
+ return {
+ providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers),
+ providerInstance: resolveThreadProviderInstance(props.providers, thread),
+ providerIconUrl: provider?.iconUrl,
+ };
+ }, [thread, props.providers]);
+
const pr = useThreadPr(thread);
const { materialYouStyleLayoutActive } = useAppearancePreferences();
@@ -449,18 +466,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;
- // The provider badge's border blends into the row's own surface, which
- // differs by pane and (for the sidebar pane) selection: the sidebar row
- // background becomes the selected fill or the drawer surface, while the
- // flat "screen" pane rows always sit on the screen background.
const providerIconSurfaceColor = sidebarPane
? selected
? selectedBackgroundColor
: drawerColor
: screenColor;
-
const status = resolveThreadListV2Status(thread);
- const statusLabel = STATUS_LABEL_BY_STATUS[status];
+ // "Done" marks a completion the user has not opened yet — same emerald
+ // label as the web sidebar, sourced from the server-side visited watermark
+ // so checking a thread on any device clears it everywhere.
+ const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
+ const statusLabel =
+ STATUS_LABEL_BY_STATUS[status] ??
+ (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
// Settled rows label by the same stamp they sort by, so order and label
// can't disagree. updatedAt is always present, so the resolver never
// returns null here.
@@ -666,6 +684,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnpin,
handleUnsettle,
handleUnsnooze,
+ setCustomSnoozeOpen,
snoozePresets,
],
);
@@ -804,7 +823,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : null}
- {status === "failed" && thread.session?.lastError ? (
+ {status === "failed" && thread.runtime?.lastError ? (
- {thread.session.lastError}
+ {thread.runtime.lastError}
) : thread.branch || props.environmentLabel ? (
/* "branch · machine" share one truncating line. The machine sits
@@ -922,15 +941,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : null}
- {props.providerInstance ? (
-
+ {providerInstance ? (
+ // Earlier owners peek out behind the current provider so a
+ // handed-off thread shows where it has been. The current owner
+ // keeps its account badge so same-driver instances stay distinct.
+
+ {providerDrivers.slice(0, -1).map((driver, index) => (
+
+
+
+ ))}
+
+
) : null}
>
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
index 2afef9759070..3e1f7930aef1 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
@@ -61,8 +61,14 @@ describe("resolveThreadProviderInstance", () => {
const threadA = makeThread(environmentA, "codex");
const threadB = makeThread(environmentB, "codex");
- expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800");
- expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined();
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentA)?.providers, threadA)
+ ?.accentColor,
+ ).toBe("#ff8800");
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentB)?.providers, threadB)
+ ?.accentColor,
+ ).toBeUndefined();
});
it("labels a custom instance by its id so its initials differ from the default", () => {
@@ -78,14 +84,52 @@ describe("resolveThreadProviderInstance", () => {
]);
expect(
- resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName,
+ resolveThreadProviderInstance(
+ serverConfigs.get(environmentId)?.providers,
+ makeThread(environmentId, "codex"),
+ )?.displayName,
).toBe("Codex");
expect(
- resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal"))
- ?.displayName,
+ resolveThreadProviderInstance(
+ serverConfigs.get(environmentId)?.providers,
+ makeThread(environmentId, "codex_personal"),
+ )?.displayName,
).toBe("Codex Personal");
});
+ it("uses the current runtime owner after a provider handoff", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const serverConfigs = new Map([
+ [
+ environmentId,
+ makeConfig([
+ { instanceId: "claudeAgent", driver: "claudeAgent" },
+ { instanceId: "codex", driver: "codex", displayName: "Codex" },
+ { instanceId: "codex_work", driver: "codex", displayName: "Codex" },
+ ]),
+ ],
+ ]);
+ const thread = {
+ ...makeThread(environmentId, "claudeAgent"),
+ runtime: {
+ status: "running" as const,
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex_work"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: "2026-06-01T00:01:00.000Z",
+ },
+ };
+
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread),
+ ).toMatchObject({
+ driverKind: "codex",
+ displayName: "Codex Work",
+ showBadge: true,
+ });
+ });
+
it("hides the badge for a single instance with no accent color", () => {
const environmentId = EnvironmentId.make("environment-a");
const serverConfigs = new Map([
@@ -93,6 +137,8 @@ describe("resolveThreadProviderInstance", () => {
]);
const thread = makeThread(environmentId, "codex");
- expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread)?.showBadge,
+ ).toBe(false);
});
});
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts
index 29dbe8828b66..1253af49d445 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.ts
@@ -4,8 +4,9 @@ import {
resolveProviderInstanceDisplayName,
shouldShowInstanceBadge,
} from "@t3tools/client-runtime/state/provider-instance-display";
-import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
+import type { ProviderDriverKind } from "@t3tools/contracts";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
/** What a thread row needs to draw the provider glyph and its account badge. */
export interface ThreadRowProviderInstance {
readonly driverKind: ProviderDriverKind;
@@ -20,11 +21,11 @@ export interface ThreadRowProviderInstance {
* names a different account on every server.
*/
export function resolveThreadProviderInstance(
- serverConfigs: ReadonlyMap,
+ providers: ReadonlyArray | undefined,
thread: EnvironmentThreadShell,
): ThreadRowProviderInstance | null {
- const providers = serverConfigs.get(thread.environmentId)?.providers ?? [];
- const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ if (providers === undefined) return null;
+ const instanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
const snapshot = providers.find((provider) => provider.instanceId === instanceId);
if (!snapshot) return null;
const entry = {
diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts
index 041f8b9de010..7b13b0141edb 100644
--- a/apps/mobile/src/features/threads/thread-settings-options.test.ts
+++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts
@@ -1,7 +1,7 @@
import type { ProviderOptionDescriptor } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { selectableChoices } from "./thread-settings-options";
+import { runtimeModeChoicesForSupportedModes, selectableChoices } from "./thread-settings-options";
const effortDescriptor: Extract = {
id: "effort",
@@ -27,3 +27,9 @@ describe("selectableChoices", () => {
]);
});
});
+
+describe("runtimeModeChoicesForSupportedModes", () => {
+ it("keeps controls usable when forward-compatible decoding removes every advertised mode", () => {
+ expect(runtimeModeChoicesForSupportedModes([])).toHaveLength(4);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts
index b678154f83bb..0aa375704290 100644
--- a/apps/mobile/src/features/threads/thread-settings-options.ts
+++ b/apps/mobile/src/features/threads/thread-settings-options.ts
@@ -36,6 +36,23 @@ export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
},
];
+export function runtimeModeChoicesForSupportedModes(
+ supportedRuntimeModes: ReadonlyArray | undefined,
+) {
+ return supportedRuntimeModes && supportedRuntimeModes.length > 0
+ ? RUNTIME_MODE_CHOICES.filter((choice) => supportedRuntimeModes.includes(choice.mode))
+ : RUNTIME_MODE_CHOICES;
+}
+
+export function compatibleRuntimeModeForChoices(
+ runtimeMode: RuntimeMode,
+ choices: ReadonlyArray<{ readonly mode: RuntimeMode }>,
+): RuntimeMode {
+ return choices.some((choice) => choice.mode === runtimeMode)
+ ? runtimeMode
+ : (choices[0]?.mode ?? runtimeMode);
+}
+
export function selectableChoices(
descriptor: Extract,
) {
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index b2c31a785639..fa08b7c57a5f 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -754,7 +754,8 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
: null;
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const displayText = workEntryRowLabel(row.workEntry, expanded);
- const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
+ const isSystemNotice = row.projectedItem.item.type === "system_notice";
+ const iconIsDestructive = !isSystemNotice && (row.icon === "alert" || row.icon === "warning");
const failed = row.status === "failure";
const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon;
const icon = toolPresentation?.icon ?? workRowSymbolName(row.icon);
@@ -827,7 +828,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
)}
numberOfLines={expanded ? undefined : 1}
>
- {displayText}
+ {isSystemNotice ? row.summary : displayText}
{answerPreview ? (
;
- readonly iconSubtleColor: ColorValue;
- readonly expanded: boolean;
- readonly label: string;
- readonly streaming: boolean;
- readonly onToggle: () => void;
- readonly children: ReactNode;
-}) {
- return (
-
- {
- void Haptics.selectionAsync();
- props.onToggle();
- }}
- className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
- style={{ minHeight: props.rowSizing.estimatedRowHeight }}
- >
- {props.streaming ? (
-
- ) : (
- <>
-
-
-
-
- {props.label}
-
- >
- )}
-
-
- {props.expanded ? (
-
-
- {props.children}
-
-
- ) : null}
-
- );
-}
-
function ToolActivityIconView(props: {
readonly environmentId: EnvironmentId;
readonly icon?: ToolActivityIcon;
@@ -1346,6 +1268,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return { ios: "eye", android: "visibility" };
case "edit":
return { ios: "square.and.pencil", android: "edit" };
+ case "thread-create":
+ return { ios: "bubble.left", android: "chat" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts
new file mode 100644
index 000000000000..a8f9343085b8
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts
@@ -0,0 +1,26 @@
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+
+import { buildThreadActivityFileParams } from "./threadActivityFileNavigation";
+
+describe("thread activity file navigation", () => {
+ it("keeps inherited activity file links on the currently selected thread", () => {
+ const sourceThreadId = ThreadId.make("source-thread");
+ const currentThreadId = ThreadId.make("current-thread");
+
+ const params = buildThreadActivityFileParams({
+ environmentId: EnvironmentId.make("environment"),
+ currentThreadId,
+ activitySourceThreadId: sourceThreadId,
+ relativePath: "apps/mobile/src/index.ts",
+ line: 12,
+ });
+
+ expect(params).toEqual({
+ environmentId: "environment",
+ threadId: "current-thread",
+ path: ["apps", "mobile", "src", "index.ts"],
+ line: "12",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.ts
new file mode 100644
index 000000000000..21dfc44c0268
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadActivityFileNavigation.ts
@@ -0,0 +1,22 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+interface ThreadActivityFileContext {
+ readonly environmentId: EnvironmentId;
+ readonly currentThreadId: ThreadId;
+ readonly activitySourceThreadId: ThreadId;
+ readonly relativePath: string;
+ readonly line?: number | null;
+}
+
+export function buildThreadActivityFileParams(input: ThreadActivityFileContext) {
+ // Activity provenance may come from a parent thread, but file routes stay scoped
+ // to the thread whose workspace is currently selected.
+ return {
+ environmentId: String(input.environmentId),
+ threadId: String(input.currentThreadId),
+ path: input.relativePath.split("/").filter((segment) => segment.length > 0),
+ ...(Number.isFinite(input.line) && Number(input.line) > 0
+ ? { line: String(Math.floor(Number(input.line))) }
+ : {}),
+ };
+}
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
new file mode 100644
index 000000000000..bf095f541873
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
+
+const base = {
+ title: null,
+ prompt: "Audit the timestamps",
+ status: "running" as const,
+ result: null,
+ childThreadId: "thread-child" as never,
+};
+
+describe("resolveSubagentRowPresentation", () => {
+ it("leads with progress while the agent is working", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ progress: "Reading files",
+ result: "stale result",
+ });
+
+ expect(row.detail).toBe("Reading files");
+ expect(row.tone).toBe("working");
+ expect(row.live).toBe(true);
+ });
+
+ it("leads with the result once the agent has settled", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ status: "completed",
+ progress: "Reading files",
+ result: "Found two\n problems",
+ });
+
+ expect(row.detail).toBe("Found two problems");
+ expect(row.tone).toBe("completed");
+ expect(row.statusLabel).toBe("Completed");
+ });
+
+ it("shows a failure's text, since that is where the error lands", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "failed", result: "Timed out" });
+
+ expect(row.detail).toBe("Timed out");
+ expect(row.tone).toBe("failed");
+ });
+
+ it("falls back to a trimmed prompt when the agent has no title", () => {
+ const long = resolveSubagentRowPresentation({ ...base, prompt: "x".repeat(200) });
+ const titled = resolveSubagentRowPresentation({ ...base, title: "Subagent: /root/my_worker" });
+
+ expect(long.title).toHaveLength(80);
+ expect(long.title.endsWith("...")).toBe(true);
+ expect(titled.title).toBe("My Worker");
+ });
+
+ it("only offers a thread to open when the agent has one", () => {
+ expect(resolveSubagentRowPresentation(base).canOpenThread).toBe(true);
+ expect(resolveSubagentRowPresentation({ ...base, childThreadId: null }).canOpenThread).toBe(
+ false,
+ );
+ });
+
+ it("uses the status label when there is nothing to report yet", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "pending" });
+
+ expect(row.detail).toBeNull();
+ expect(row.statusLabel).toBe("Working");
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
new file mode 100644
index 000000000000..d1b85b896470
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
@@ -0,0 +1,79 @@
+import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
+import {
+ isActiveSubagentStatus,
+ isTerminalSubagentStatus,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import type { OrchestrationV2Subagent } from "@t3tools/contracts";
+
+const PROMPT_TITLE_LIMIT = 80;
+
+export type SubagentRowTone = "working" | "completed" | "failed" | "stopped";
+
+export interface SubagentRowPresentation {
+ readonly title: string;
+ /** Live agents lead with progress; settled ones lead with what came out. */
+ readonly detail: string | null;
+ readonly statusLabel: string;
+ readonly tone: SubagentRowTone;
+ readonly live: boolean;
+ /** Provider-native tasks have no thread of their own to open. */
+ readonly canOpenThread: boolean;
+}
+
+function rowTitle(subagent: Pick): string {
+ const title = subagent.title?.trim();
+ if (title) return formatSubagentDisplayTitle(title);
+ const prompt = subagent.prompt.trim();
+ if (prompt.length === 0) return "Subagent";
+ return prompt.length > PROMPT_TITLE_LIMIT
+ ? `${prompt.slice(0, PROMPT_TITLE_LIMIT - 3)}...`
+ : prompt;
+}
+
+function rowTone(status: OrchestrationV2Subagent["status"]): SubagentRowTone {
+ if (isActiveSubagentStatus(status)) return "working";
+ if (status === "completed") return "completed";
+ if (status === "failed") return "failed";
+ return "stopped";
+}
+
+function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string {
+ switch (status) {
+ case "pending":
+ case "running":
+ return "Working";
+ case "waiting":
+ return "Waiting";
+ case "idle":
+ return "Idle";
+ case "completed":
+ return "Completed";
+ case "failed":
+ return "Failed";
+ case "cancelled":
+ return "Cancelled";
+ case "interrupted":
+ return "Interrupted";
+ }
+}
+
+export function resolveSubagentRowPresentation(
+ subagent: Pick<
+ OrchestrationV2Subagent,
+ "title" | "prompt" | "status" | "progress" | "result" | "childThreadId"
+ >,
+): SubagentRowPresentation {
+ const live = isActiveSubagentStatus(subagent.status);
+ const progress = subagent.progress?.trim() ?? "";
+ const result = subagent.result?.trim() ?? "";
+ const settled = isTerminalSubagentStatus(subagent.status);
+ const detail = settled ? result || progress : progress || result;
+ return {
+ title: rowTitle(subagent),
+ detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null,
+ statusLabel: rowStatusLabel(subagent.status),
+ tone: rowTone(subagent.status),
+ live,
+ canOpenThread: subagent.childThreadId !== null,
+ };
+}
diff --git a/apps/mobile/src/features/threads/threadForkNavigation.test.ts b/apps/mobile/src/features/threads/threadForkNavigation.test.ts
new file mode 100644
index 000000000000..a56cb3da2e36
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadForkNavigation.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { waitForThreadShellReady } from "./threadForkNavigation";
+
+describe("waitForThreadShellReady", () => {
+ it("returns true when the forked thread shell arrives before the deadline", async () => {
+ let elapsedMs = 0;
+
+ const ready = await waitForThreadShellReady({
+ read: () => elapsedMs >= 80,
+ timeoutMs: 120,
+ pollIntervalMs: 40,
+ now: () => elapsedMs,
+ delay: async (durationMs) => {
+ elapsedMs += durationMs;
+ },
+ });
+
+ expect(ready).toBe(true);
+ });
+
+ it("returns false instead of navigating when the shell never arrives", async () => {
+ let elapsedMs = 0;
+
+ const ready = await waitForThreadShellReady({
+ read: () => false,
+ timeoutMs: 80,
+ pollIntervalMs: 40,
+ now: () => elapsedMs,
+ delay: async (durationMs) => {
+ elapsedMs += durationMs;
+ },
+ });
+
+ expect(ready).toBe(false);
+ expect(elapsedMs).toBe(80);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadForkNavigation.ts b/apps/mobile/src/features/threads/threadForkNavigation.ts
new file mode 100644
index 000000000000..97ebf9c97bc0
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadForkNavigation.ts
@@ -0,0 +1,26 @@
+const DEFAULT_TIMEOUT_MS = 2_000;
+const DEFAULT_POLL_INTERVAL_MS = 40;
+
+export async function waitForThreadShellReady(input: {
+ readonly read: () => boolean;
+ readonly timeoutMs?: number;
+ readonly pollIntervalMs?: number;
+ readonly now?: () => number;
+ readonly delay?: (durationMs: number) => Promise;
+}): Promise {
+ const now = input.now ?? Date.now;
+ const delay =
+ input.delay ??
+ ((durationMs: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, durationMs);
+ }));
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
+
+ while (!input.read() && now() < deadline) {
+ await delay(Math.min(pollIntervalMs, deadline - now()));
+ }
+
+ return input.read();
+}
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index afc00ef9ab40..49a509397c1c 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -16,12 +16,13 @@ import {
MessageId,
ProjectId,
ProviderInstanceId,
+ RunId,
ThreadId,
- TurnId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import { makeThreadShellFixture } from "../../test-fixtures";
import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
import {
buildThreadListV2Items,
@@ -40,31 +41,14 @@ const environmentId = EnvironmentId.make("environment-1");
function makeThread(
input: Partial & Pick,
): EnvironmentThreadShell {
- return {
+ return makeThreadShellFixture({
environmentId,
- projectId: ProjectId.make("project-1"),
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- pullRequests: [],
- latestTurn: null,
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- archivedAt: null,
- settledOverride: null,
- settledAt: null,
- session: null,
- latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
- hasActionableProposedPlan: false,
...input,
- };
+ });
}
const NOW = "2026-06-02T00:00:00.000Z";
+
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
@@ -144,18 +128,16 @@ describe("resolveThreadListV2Enabled", () => {
});
describe("resolveThreadListV2Status", () => {
- it("prioritizes approval over a running session", () => {
+ it("prioritizes approval over a running runtime", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
hasPendingApprovals: true,
- session: {
- threadId: ThreadId.make("t"),
+ runtime: {
status: "running",
+ activeRunId: RunId.make("run-t"),
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
- runtimeMode: "full-access",
- activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
@@ -163,6 +145,26 @@ describe("resolveThreadListV2Status", () => {
expect(resolveThreadListV2Status(thread)).toBe("approval");
});
+ it("reports waiting when presentation parks runtime idle for background tasks", () => {
+ expect(
+ resolveThreadListV2Status(
+ makeThread({
+ id: ThreadId.make("t"),
+ title: "t",
+ pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
+ runtime: {
+ status: "idle",
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: NOW,
+ },
+ }),
+ ),
+ ).toBe("waiting");
+ });
+
it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
@@ -329,19 +331,6 @@ describe("sortThreadsForListV2", () => {
]);
expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
});
-
- it("surfaces an un-settled thread at the top via its re-entry stamp", () => {
- const sorted = sortThreadsForListV2([
- {
- id: "old-unsettled",
- createdAt: "2026-06-01T08:00:00.000Z",
- unsettledAt: "2026-06-01T13:00:00.000Z",
- },
- { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
- { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
- ]);
- expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]);
- });
});
describe("getThreadListV2OrderedSection", () => {
@@ -485,6 +474,60 @@ describe("buildThreadListV2Items", () => {
expect(layout.settledCount).toBe(0);
});
+ it("hides snoozed threads and counts them — visibility parity with web", () => {
+ const layout = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ makeThread({
+ id: ThreadId.make("snoozed"),
+ title: "Snoozed",
+ snoozedUntil: "2026-06-03T09:00:00.000Z",
+ snoozedAt: "2026-06-01T12:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.make("woken"),
+ title: "Woken",
+ // Wake time already passed: back in the active list.
+ snoozedUntil: "2026-06-01T18:00:00.000Z",
+ snoozedAt: "2026-06-01T12:00:00.000Z",
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ // Same createdAt → static sort tiebreaks by id; the point is the woken
+ // thread is BACK in the card block and the snoozed one is gone.
+ expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]);
+ expect(layout.snoozedCount).toBe(1);
+ });
+
+ it("moves a settled pinned thread into the settled shelf — parity with web (#7969)", () => {
+ const layout = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ makeThread({
+ id: ThreadId.make("pinned-settled"),
+ title: "Pinned while settled",
+ pinnedAt: "2026-06-01T12:00:00.000Z",
+ // Stale settled state (the decider clears it on pin): the pin wins.
+ settledOverride: "settled",
+ settledAt: "2026-06-01T12:00:00.000Z",
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ // Since #7969 a settled thread leaves the active block even while pinned;
+ // the pin re-applies when the thread is un-settled.
+ expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
+ expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
+ expect(layout.settledCount).toBe(1);
+ });
+
it("snooze hides a pinned thread and wake restores it to the pinned block", () => {
const snoozedInput = {
threads: [
@@ -824,10 +867,11 @@ describe("buildThreadListV2Items", () => {
});
it("scopes the flat list to one project", () => {
+ const projectId = ProjectId.make("project-1");
const otherProjectId = ProjectId.make("project-2");
const { items } = buildThreadListV2Items({
threads: [
- makeThread({ id: ThreadId.make("included"), title: "Included" }),
+ makeThread({ id: ThreadId.make("included"), projectId, title: "Included" }),
makeThread({
id: ThreadId.make("excluded"),
projectId: otherProjectId,
@@ -835,7 +879,7 @@ describe("buildThreadListV2Items", () => {
}),
],
environmentId: null,
- projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
+ projectRefs: [{ environmentId, projectId }],
searchQuery: "",
now: NOW,
});
@@ -845,19 +889,21 @@ describe("buildThreadListV2Items", () => {
it("scopes the flat list to every environment member of a logical project", () => {
const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const projectId = ProjectId.make("project-1");
const { items } = buildThreadListV2Items({
threads: [
- makeThread({ id: ThreadId.make("local"), title: "Local" }),
+ makeThread({ id: ThreadId.make("local"), projectId, title: "Local" }),
makeThread({
environmentId: remoteEnvironmentId,
id: ThreadId.make("remote"),
+ projectId,
title: "Remote",
}),
],
environmentId: null,
projectRefs: [
- { environmentId, projectId: ProjectId.make("project-1") },
- { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") },
+ { environmentId, projectId },
+ { environmentId: remoteEnvironmentId, projectId },
],
searchQuery: "",
now: NOW,
@@ -880,9 +926,9 @@ describe("buildThreadListV2Items settled paging", () => {
latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
// A turn adopted the message (same requestedAt): without it the
// thread reads as a queued turn start, which never settles.
- latestTurn: {
- turnId: TurnId.make(`turn-${index}`),
- state: "completed",
+ latestRun: {
+ runId: RunId.make(`run-${index}`),
+ status: "completed",
requestedAt: `2026-06-01T0${index}:00:00.000Z`,
startedAt: `2026-06-01T0${index}:00:00.000Z`,
completedAt: `2026-06-01T0${index}:10:00.000Z`,
@@ -1482,3 +1528,31 @@ describe("cross-section thread drops", () => {
).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
});
});
+
+it("excludes subagents from navigation, search and ordering while retaining user forks", () => {
+ const root = makeThread({ id: ThreadId.make("root"), title: "Root" });
+ const child = makeThread({
+ id: ThreadId.make("child"),
+ title: "Child",
+ lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "subagent" },
+ });
+ const fork = makeThread({
+ id: ThreadId.make("fork"),
+ title: "Fork",
+ lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "fork" },
+ });
+ const threads = [root, child, fork];
+ expect(
+ buildThreadListV2Items({ threads, environmentId: null, searchQuery: "", now: NOW }).items.map(
+ (item) => item.thread.id,
+ ),
+ ).toEqual([fork.id, root.id]);
+ expect(
+ buildThreadListV2Items({ threads, environmentId: null, searchQuery: "Child", now: NOW }).items,
+ ).toEqual([]);
+ expect(
+ getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
+ (thread) => thread.id,
+ ),
+ ).toEqual([fork.id, root.id]);
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index e1cb8e9ece7f..e056deeac283 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -8,6 +8,7 @@ import {
} from "@t3tools/client-runtime/state/thread-settled";
import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
sortActiveThreadsByOrderKey,
@@ -16,6 +17,7 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
@@ -26,15 +28,38 @@ import {
export { snoozeWakeLabel };
+/**
+ * Provider drivers for a row's trailing icon stack, back to front. Instances
+ * missing from the environment's config are skipped, and an unresolved
+ * current provider yields nothing so the row never draws a stale stack.
+ */
+export function resolveThreadListV2ProviderDrivers(
+ thread: Pick,
+ providers: ReadonlyArray | undefined,
+): ReadonlyArray {
+ if (providers === undefined) return [];
+ const stack = resolveThreadProviderStack(thread);
+ const drivers = stack.flatMap((instanceId) => {
+ const driver = providers.find((provider) => provider.instanceId === instanceId)?.driver;
+ return driver === undefined ? [] : [driver];
+ });
+ const currentDriver = providers.find(
+ (provider) => provider.instanceId === stack[stack.length - 1],
+ )?.driver;
+ return currentDriver === undefined ? [] : drivers;
+}
+
/**
* Thread List v2 model, ported from the web sidebar v2
* (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
*
- * Four visual states, three colors: color is reserved for "act now"
- * (approval), "in motion" (working), and "broken" (failed). Ready is the
- * unlabeled resting state.
+ * Six visual states. Color distinguishes approval, input, active work, and
+ * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
+ * parked on open background tasks, grey like working rather than a false Done.
+ * The orchestrator v2 presentation bridge parks runtime at idle when the
+ * post-settlement background roster is nonempty.
*/
-export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
+export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
export function resolveThreadListV2SnoozeMenuSelection(input: {
@@ -95,7 +120,7 @@ export function resolveThreadListV2SwipeActions(input: {
export function resolveThreadListV2SnoozeGateExpiryMs(
thread: Pick<
EnvironmentThreadShell,
- "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session"
+ "hasPendingApprovals" | "hasPendingUserInput" | "latestRun" | "latestUserMessageAt" | "runtime"
>,
options: { readonly now: string },
): number | null {
@@ -132,8 +157,29 @@ export function resolveThreadListV2Enabled(input: {
return input.legacyPreference !== true;
}
+/**
+ * Completed-but-not-yet-seen, mirroring the web sidebar's
+ * hasUnseenCompletion. The visited watermark is server state
+ * (thread.lastVisitedAt), so the marker agrees across web and mobile.
+ * Never-visited threads count as read — a fresh environment must not light
+ * up its whole history — and pre-tracking servers (field absent) never
+ * report unread.
+ */
+export function threadHasUnseenCompletion(
+ thread: Pick,
+): boolean {
+ const completedAt = thread.latestRun?.completedAt;
+ if (!completedAt) return false;
+ const completedAtMs = Date.parse(completedAt);
+ if (Number.isNaN(completedAtMs)) return false;
+ if (!thread.lastVisitedAt) return false;
+ const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
+ if (Number.isNaN(lastVisitedAtMs)) return true;
+ return completedAtMs > lastVisitedAtMs;
+}
+
export function resolveThreadListV2Status(
- thread: Pick,
+ thread: Pick,
): ThreadListV2Status {
if (thread.hasPendingApprovals) {
return "approval";
@@ -141,10 +187,16 @@ export function resolveThreadListV2Status(
if (thread.hasPendingUserInput) {
return "input";
}
- if (thread.session?.status === "running" || thread.session?.status === "starting") {
+ if (
+ thread.runtime !== null &&
+ ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
+ ) {
return "working";
}
- if (thread.session?.status === "error") {
+ if (thread.runtime?.status === "idle") {
+ return "waiting";
+ }
+ if (thread.runtime?.status === "failed") {
return "failed";
}
return "ready";
@@ -182,7 +234,8 @@ export function getThreadListV2OrderedSection(input: {
readonly queuedThreadKeys?: ReadonlySet;
}): EnvironmentThreadShell[] {
const threads = input.threads.filter((thread) => {
- if (thread.archivedAt !== null) return false;
+ if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
+ return false;
if (
(input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
thread.settledOverride === "settled" &&
@@ -393,7 +446,8 @@ export function buildThreadListV2Items(input: {
const snoozed: EnvironmentThreadShell[] = [];
let nextSnoozeWakeAt: string | null = null;
for (const thread of input.threads) {
- // Callers pass live shells. The server stamps settledOverride for the tail.
+ if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue;
+ // The server stamps settledOverride for the tail.
if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
continue;
diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts
index dcff521342c6..7bb9eda5a9c8 100644
--- a/apps/mobile/src/features/threads/threadPresentation.ts
+++ b/apps/mobile/src/features/threads/threadPresentation.ts
@@ -1,6 +1,8 @@
import type { StatusTone } from "../../components/StatusPill";
-import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts";
-import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import {
+ threadRuntimeIsActive,
+ type EnvironmentThreadShell,
+} from "@t3tools/client-runtime/state/shell";
export type ThreadStatusKind =
| "pending-approval"
@@ -20,14 +22,10 @@ export interface ThreadStatusPresentation extends StatusTone {
readonly pulse: boolean;
}
-function isLatestTurnSettled(
- latestTurn: OrchestrationLatestTurn | null,
- session: OrchestrationSession | null,
-): boolean {
- if (!latestTurn?.startedAt) return false;
- if (!latestTurn.completedAt) return false;
- if (!session) return true;
- return session.status !== "running";
+function isLatestRunSettled(thread: EnvironmentThreadShell): boolean {
+ if (!thread.latestRun?.startedAt) return false;
+ if (!thread.latestRun.completedAt) return false;
+ return !threadRuntimeIsActive(thread.runtime);
}
/**
@@ -62,7 +60,9 @@ export function resolveThreadStatus(
};
}
- if (thread.session?.status === "running") {
+ const runtimeStatus = thread.runtime?.status;
+
+ if (runtimeStatus === "running" || runtimeStatus === "waiting") {
return {
kind: "working",
label: "Working",
@@ -74,7 +74,7 @@ export function resolveThreadStatus(
};
}
- if (thread.session?.status === "starting") {
+ if (runtimeStatus === "preparing" || runtimeStatus === "queued" || runtimeStatus === "starting") {
return {
kind: "connecting",
label: "Connecting",
@@ -86,7 +86,7 @@ export function resolveThreadStatus(
};
}
- if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
+ if (runtimeStatus === "failed" || thread.latestRun?.status === "failed") {
return {
kind: "error",
label: "Error",
@@ -100,7 +100,7 @@ export function resolveThreadStatus(
const hasPlanReadyPrompt =
thread.interactionMode === "plan" &&
- isLatestTurnSettled(thread.latestTurn, thread.session) &&
+ isLatestRunSettled(thread) &&
thread.hasActionableProposedPlan;
if (hasPlanReadyPrompt) {
return {
diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts
new file mode 100644
index 000000000000..3926f2847625
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ buildCancelQueuedRunCommand,
+ resolveThreadQueueRowControls,
+ resolveQueueDropBeforeRunId,
+} from "./threadQueueControlPresentation";
+
+describe("threadQueueControlPresentation", () => {
+ it("preserves queue reorder and steer controls with removal", () => {
+ const controls = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 1,
+ queuedCount: 3,
+ text: "Please review the follow-up change.",
+ });
+
+ expect(controls.displayText).toBe("Please review the follow-up change.");
+ expect(controls.canMoveUp).toBe(true);
+ expect(controls.canMoveDown).toBe(true);
+ expect(controls.canSteer).toBe(true);
+ expect(controls.canDismiss).toBe(true);
+ expect(controls.dismissAccessibilityLabel).toBe(REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL);
+ });
+
+ it("disables edge reorder controls and busy dismissal", () => {
+ const first = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: false,
+ canReorder: true,
+ index: 0,
+ queuedCount: 2,
+ text: "First",
+ });
+ const busy = resolveThreadQueueRowControls({
+ busy: true,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 0,
+ queuedCount: 1,
+ text: "Queued message",
+ });
+
+ expect(first.canMoveUp).toBe(false);
+ expect(first.canMoveDown).toBe(true);
+ expect(first.canSteer).toBe(false);
+ expect(busy.canDismiss).toBe(false);
+ expect(busy.canMoveUp).toBe(false);
+ expect(busy.canSteer).toBe(false);
+ });
+
+ it("keeps the row already open in the composer from being reopened or steered", () => {
+ const editing = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 1,
+ isEditing: true,
+ queuedCount: 3,
+ text: "Being edited",
+ });
+
+ expect(editing.isEditing).toBe(true);
+ expect(editing.canEdit).toBe(false);
+ expect(editing.canSteer).toBe(false);
+ // Reordering and removing a message stay available while it is edited.
+ expect(editing.canMoveUp).toBe(true);
+ expect(editing.canDismiss).toBe(true);
+ });
+
+ it("builds cancelQueuedRun command arguments for removal", () => {
+ expect(
+ buildCancelQueuedRunCommand({
+ environmentId: "environment:test" as never,
+ runId: "run:queued" as never,
+ threadId: "thread:test" as never,
+ }),
+ ).toEqual({
+ environmentId: "environment:test",
+ input: {
+ runId: "run:queued",
+ threadId: "thread:test",
+ },
+ });
+ });
+});
+
+describe("queue drag insertion", () => {
+ const rows = [
+ { id: "first" as never, y: 0, height: 80 },
+ { id: "second" as never, y: 80, height: 140 },
+ { id: "third" as never, y: 220, height: 80 },
+ ];
+
+ it("moves between variable-height rows and to either end", () => {
+ expect(resolveQueueDropBeforeRunId(rows, rows[0]!.id, 140)).toBe("third");
+ expect(resolveQueueDropBeforeRunId(rows, rows[0]!.id, 300)).toBeNull();
+ expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, -300)).toBe("first");
+ });
+
+ it("does not send a reorder for an unchanged or unmeasured drop", () => {
+ expect(resolveQueueDropBeforeRunId(rows, rows[1]!.id, 0)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, 20)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId([{ id: rows[0]!.id }], rows[0]!.id, 10)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId(rows, "missing" as never, 100)).toBeUndefined();
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
new file mode 100644
index 000000000000..107892e2869f
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
@@ -0,0 +1,76 @@
+import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
+
+export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message";
+
+export interface ThreadQueueRowControls {
+ readonly canDismiss: boolean;
+ readonly canEdit: boolean;
+ readonly canMoveDown: boolean;
+ readonly canMoveUp: boolean;
+ readonly canSteer: boolean;
+ readonly dismissAccessibilityLabel: string;
+ readonly displayText: string;
+ readonly isEditing: boolean;
+}
+
+export function resolveThreadQueueRowControls(input: {
+ readonly busy: boolean;
+ readonly canPromoteToSteer: boolean;
+ readonly canReorder: boolean;
+ readonly index: number;
+ /** This row's message is already open in the composer. */
+ readonly isEditing?: boolean;
+ readonly queuedCount: number;
+ readonly text: string;
+}): ThreadQueueRowControls {
+ const mutationEnabled = !input.busy;
+ const isEditing = input.isEditing === true;
+
+ return {
+ canDismiss: !input.busy,
+ // Re-opening the row already in the composer would reload it and throw
+ // away whatever has been typed since.
+ canEdit: mutationEnabled && !isEditing,
+ canMoveDown: mutationEnabled && input.canReorder && input.index < input.queuedCount - 1,
+ canMoveUp: mutationEnabled && input.canReorder && input.index > 0,
+ canSteer: mutationEnabled && input.canPromoteToSteer && !isEditing,
+ dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ displayText: input.text,
+ isEditing,
+ };
+}
+
+export function buildCancelQueuedRunCommand(input: {
+ readonly environmentId: EnvironmentId;
+ readonly runId: RunId;
+ readonly threadId: ThreadId;
+}): {
+ readonly environmentId: EnvironmentId;
+ readonly input: {
+ readonly runId: RunId;
+ readonly threadId: ThreadId;
+ };
+} {
+ return {
+ environmentId: input.environmentId,
+ input: {
+ runId: input.runId,
+ threadId: input.threadId,
+ },
+ };
+}
+
+/** Return the insertion anchor after a drag, or undefined when the order is unchanged. */
+export function resolveQueueDropBeforeRunId(
+ rows: ReadonlyArray<{ id: RunId; y?: number; height?: number }>,
+ runId: RunId,
+ translationY: number,
+): RunId | null | undefined {
+ const sourceIndex = rows.findIndex((row) => row.id === runId);
+ const source = rows[sourceIndex];
+ if (!source || rows.some((row) => row.y === undefined || row.height === undefined)) return;
+ const center = source.y! + source.height! / 2 + translationY;
+ const remaining = rows.filter((row) => row.id !== runId);
+ const before = remaining.find((row) => center < row.y! + row.height! / 2)?.id ?? null;
+ return before === (rows[sourceIndex + 1]?.id ?? null) ? undefined : before;
+}
diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts
new file mode 100644
index 000000000000..f29c4bba1643
--- /dev/null
+++ b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
+
+describe("user message intent badge", () => {
+ it("does not label ordinary turn-start messages", () => {
+ expect(resolveUserMessageIntentBadge(undefined)).toBeNull();
+ expect(resolveUserMessageIntentBadge("turn_start")).toBeNull();
+ });
+
+ it("labels messages waiting behind the active turn", () => {
+ expect(resolveUserMessageIntentBadge("queued_turn")).toEqual({
+ label: "queued",
+ accessibilityLabel: "Queued behind the active turn",
+ tone: "queued",
+ });
+ });
+
+ it("labels messages that steer the active turn", () => {
+ expect(resolveUserMessageIntentBadge("steer")).toEqual({
+ label: "steer",
+ accessibilityLabel: "Steered the active turn",
+ tone: "steer",
+ });
+ });
+
+ it("preserves the queued origin after promotion to steer", () => {
+ expect(resolveUserMessageIntentBadge("promoted_queued_to_steer")).toEqual({
+ label: "queued → steer",
+ accessibilityLabel: "Originally queued, then promoted to steer the active turn",
+ tone: "steer",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.ts
new file mode 100644
index 000000000000..c72c1c342d83
--- /dev/null
+++ b/apps/mobile/src/features/threads/userMessageIntentBadge.ts
@@ -0,0 +1,35 @@
+import type { OrchestrationV2UserMessageInputIntent } from "@t3tools/contracts";
+
+export interface UserMessageIntentBadgePresentation {
+ readonly label: string;
+ readonly accessibilityLabel: string;
+ readonly tone: "queued" | "steer";
+}
+
+export function resolveUserMessageIntentBadge(
+ intent: OrchestrationV2UserMessageInputIntent | undefined,
+): UserMessageIntentBadgePresentation | null {
+ switch (intent) {
+ case "queued_turn":
+ return {
+ label: "queued",
+ accessibilityLabel: "Queued behind the active turn",
+ tone: "queued",
+ };
+ case "steer":
+ return {
+ label: "steer",
+ accessibilityLabel: "Steered the active turn",
+ tone: "steer",
+ };
+ case "promoted_queued_to_steer":
+ return {
+ label: "queued → steer",
+ accessibilityLabel: "Originally queued, then promoted to steer the active turn",
+ tone: "steer",
+ };
+ case "turn_start":
+ case undefined:
+ return null;
+ }
+}
diff --git a/apps/mobile/src/lib/followUpBehavior.ts b/apps/mobile/src/lib/followUpBehavior.ts
new file mode 100644
index 000000000000..dfb5cae60ac6
--- /dev/null
+++ b/apps/mobile/src/lib/followUpBehavior.ts
@@ -0,0 +1,13 @@
+import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
+
+/**
+ * What the send button does while a turn is already running: `queue` waits for
+ * the turn to finish, `steer` interrupts it with the new message.
+ *
+ * Web keeps the same choice in its per-client settings. Mobile has no
+ * client-settings sync, so it is stored per device alongside the other
+ * composer preferences.
+ */
+export type FollowUpBehavior = Extract;
+
+export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = "queue";
diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts
index 98896b990ec4..463ff5cc3662 100644
--- a/apps/mobile/src/lib/modelOptions.test.ts
+++ b/apps/mobile/src/lib/modelOptions.test.ts
@@ -54,6 +54,44 @@ describe("mobile model options", () => {
]);
});
+ it("carries configured ACP identity into model and provider catalogs", () => {
+ const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg";
+ const config = {
+ providers: [
+ {
+ instanceId: "acpRegistry_antigravity",
+ driver: "acpRegistry",
+ displayName: "Antigravity",
+ iconUrl,
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [
+ {
+ slug: "default",
+ name: "Default",
+ isCustom: false,
+ capabilities: null,
+ },
+ ],
+ },
+ ],
+ } as unknown as ServerConfig;
+
+ const [group] = groupByProvider(buildModelOptions(config, null));
+
+ expect(group).toMatchObject({
+ providerKey: "acpRegistry_antigravity",
+ providerLabel: "Antigravity",
+ models: [
+ {
+ providerDriver: "acpRegistry",
+ providerIconUrl: iconUrl,
+ },
+ ],
+ });
+ });
+
it("distinguishes same-name OpenCode models without changing their routing", () => {
const sources = [
{ id: "anthropic", label: "Anthropic" },
@@ -146,6 +184,13 @@ describe("mobile model options", () => {
expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
expect(option?.selection.options).toBeUndefined();
+ const [emptyOption] = buildModelOptions(config, {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-test",
+ options: [],
+ });
+ expect(emptyOption?.selection).toEqual(option?.selection);
+
const [explicitOption] = buildModelOptions(config, {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-test",
@@ -154,6 +199,56 @@ describe("mobile model options", () => {
expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);
});
+ it("limits existing threads to their provider while new tasks keep every provider", () => {
+ const providers = ["codex", "claudeAgent"].map((instanceId) => ({
+ instanceId,
+ driver: instanceId,
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [{ slug: "test", name: instanceId, capabilities: null }],
+ }));
+ const config = { providers } as unknown as ServerConfig;
+ const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" };
+
+ expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([
+ "codex",
+ "claudeAgent",
+ ]);
+ expect(buildModelOptions(config, selection, selection.instanceId)).toEqual(
+ buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"),
+ );
+ });
+
+ it.each(["disabled", "unavailable", "missing"] as const)(
+ "retains the selected %s provider's fallback in a filtered catalog",
+ (state) => {
+ const selection = {
+ instanceId: ProviderInstanceId.make("google_work"),
+ model: "saved-model",
+ options: [{ id: "native-option", value: "saved-choice" }],
+ };
+ const provider = {
+ instanceId: selection.instanceId,
+ driver: "antigravity",
+ displayName: "Google Work",
+ enabled: state !== "disabled",
+ installed: true,
+ availability: state === "unavailable" ? "unavailable" : "available",
+ auth: { status: "authenticated" },
+ models: [{ slug: selection.model, name: "Saved model", capabilities: null }],
+ };
+ const config = {
+ providers: state === "missing" ? [] : [provider],
+ settings: { providerInstances: { google_work: { driver: "antigravity" } } },
+ } as unknown as ServerConfig;
+ const options = buildModelOptions(config, selection, selection.instanceId);
+ expect(options).toEqual(buildModelOptions(config, selection));
+ expect(options).toHaveLength(1);
+ expect(options[0]).toMatchObject({ selection, isUnavailable: true });
+ },
+ );
+
it("rejects stored selections whose provider is not usable", () => {
const config = {
providers: [
diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index 4e8295733141..4469becee1a2 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -1,6 +1,8 @@
+import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
ModelSelection,
+ RuntimeMode,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import {
@@ -15,6 +17,8 @@ export type ModelOption = {
readonly providerKey: string;
readonly providerLabel: string;
readonly providerDriver: string;
+ readonly supportedRuntimeModes?: ReadonlyArray;
+ readonly providerIconUrl?: string | undefined;
readonly isDefault: boolean;
readonly isLegacy: boolean;
readonly isUnavailable?: boolean;
@@ -36,6 +40,7 @@ function providerDisplayLabel(provider: {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent") return "Claude";
+ if (provider.driver === "pi") return "Pi";
return provider.instanceId;
}
@@ -46,6 +51,9 @@ function normalizeSelectionOptions(
if (!capabilities) {
return selection;
}
+ if (!selection.options?.length) {
+ return { instanceId: selection.instanceId, model: selection.model };
+ }
const options = buildExplicitProviderOptionSelectionsFromDescriptors(
getProviderOptionDescriptors({
caps: capabilities,
@@ -150,11 +158,13 @@ export function resolveNewTaskModelSelection(input: {
export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
+ providerInstanceId?: ModelSelection["instanceId"],
): ReadonlyArray {
const options = new Map();
for (const provider of config?.providers ?? []) {
if (
+ (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
!provider.enabled ||
!provider.installed ||
provider.auth.status === "unauthenticated" ||
@@ -173,6 +183,10 @@ export function buildModelOptions(
providerKey: provider.instanceId,
providerLabel,
providerDriver: provider.driver,
+ ...(provider.supportedRuntimeModes === undefined
+ ? {}
+ : { supportedRuntimeModes: provider.supportedRuntimeModes }),
+ ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
capabilities: model.capabilities,
@@ -187,7 +201,10 @@ export function buildModelOptions(
}
}
- if (fallbackModelSelection) {
+ if (
+ fallbackModelSelection &&
+ (providerInstanceId === undefined || fallbackModelSelection.instanceId === providerInstanceId)
+ ) {
const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
const existing = options.get(key);
if (existing) {
@@ -254,3 +271,53 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr
models: group.models,
}));
}
+
+function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
+ return {
+ id: `model:${option.key}`,
+ title: option.label,
+ state:
+ option.selection.instanceId === selectedModel?.instanceId &&
+ option.selection.model === selectedModel.model
+ ? "on"
+ : undefined,
+ };
+}
+
+export function buildModelMenuActions(
+ groups: ReadonlyArray,
+ selectedModel: ModelSelection | null,
+): MenuAction[] {
+ return groups.flatMap((group) => {
+ const currentModels = group.models.filter((model) => !model.isLegacy);
+ const legacyModels = group.models.filter((model) => model.isLegacy);
+ const selected = group.models.find(
+ (model) =>
+ model.selection.instanceId === selectedModel?.instanceId &&
+ model.selection.model === selectedModel.model,
+ );
+
+ return [
+ ...(currentModels.length > 0
+ ? [
+ {
+ id: `provider:${group.providerKey}`,
+ title: group.providerLabel,
+ subtitle: selected && !selected.isLegacy ? selected.label : undefined,
+ subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
+ },
+ ]
+ : []),
+ ...(legacyModels.length > 0
+ ? [
+ {
+ id: `legacy-models:${group.providerKey}`,
+ title: `${group.providerLabel} legacy models`,
+ subtitle: selected?.isLegacy ? selected.label : undefined,
+ subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
+ },
+ ]
+ : []),
+ ];
+ });
+}
diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts
index 57df389cff3e..f67a4649face 100644
--- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts
+++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts
@@ -19,6 +19,40 @@ describe("project thread title", () => {
expect(deriveThreadTitleFromPrompt(" \n ")).toBe("New thread");
});
+ it("derives attachment-only titles from prepared image metadata", () => {
+ const uploadedAttachments = [
+ {
+ type: "image" as const,
+ id: "prepared-photo",
+ name: "photo.png",
+ mimeType: "image/png",
+ sizeBytes: 3,
+ },
+ ];
+ const input = buildProjectThreadStartTurnInput({
+ projectId: ProjectId.make("project"),
+ projectCwd: "/workspace",
+ threadId: "image-thread",
+ commandId: "image-command",
+ messageId: "image-message",
+ createdAt: "2026-09-04T00:00:00Z",
+ text: "",
+ uploadedAttachments,
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ workspaceMode: "local",
+ branch: null,
+ worktreePath: null,
+ startFromOrigin: false,
+ worktreeBranchName: "unused",
+ });
+
+ expect(input.titleSeed).toBe("Image: photo.png");
+ expect(input.bootstrap.createThread.title).toBe(input.titleSeed);
+ expect(input.message.attachments).toEqual(uploadedAttachments);
+ });
+
it.each([
{
comment: undefined,
@@ -26,7 +60,7 @@ describe("project thread title", () => {
},
{
comment: 'Why "shared"?',
- title: 'Keep `cache[key]` & shared. Retry! Comment: Why "shared"?',
+ title: "Keep `cache[key]` & shared. Retry! Commen...",
},
])("uses readable titles and intact links with comment $comment", ({ comment, title }) => {
const quoteText = "Keep `cache[key]` & shared.\n Retry!";
diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts
index f895888e1486..b6a9bbdd6afc 100644
--- a/apps/mobile/src/lib/projectThreadStartTurn.ts
+++ b/apps/mobile/src/lib/projectThreadStartTurn.ts
@@ -8,20 +8,11 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
+import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import type { UploadedMobileAttachment } from "./attachmentUpload";
-export function deriveThreadTitleFromPrompt(value: string): string {
- const trimmed = assistantCitationsToPlainText(value).trim();
- if (trimmed.length === 0) {
- return "New thread";
- }
-
- const compact = trimmed.replace(/\s+/g, " ");
- return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
-}
-
export interface ProjectThreadStartTurnSpec {
readonly projectId: ProjectId;
readonly projectCwd: string;
@@ -50,10 +41,11 @@ export interface ProjectThreadStartTurnSpec {
* offline outbox drain so both deliver identical commands.
*/
export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) {
- const title = deriveThreadTitleFromPrompt(spec.text);
+ const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.uploadedAttachments });
const isWorktree = spec.workspaceMode === "worktree";
return {
commandId: CommandId.make(spec.commandId),
+ creationSource: "mobile" as const,
threadId: ThreadId.make(spec.threadId),
message: {
messageId: MessageId.make(spec.messageId),
@@ -92,3 +84,13 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
createdAt: spec.createdAt,
};
}
+
+export function deriveThreadTitleFromPrompt(value: string): string {
+ const trimmed = assistantCitationsToPlainText(value).trim();
+ if (trimmed.length === 0) {
+ return "New thread";
+ }
+
+ const compact = trimmed.replace(/\s+/g, " ");
+ return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
+}
diff --git a/apps/mobile/src/lib/scopedEntities.ts b/apps/mobile/src/lib/scopedEntities.ts
index 34709957fd48..6464b3561919 100644
--- a/apps/mobile/src/lib/scopedEntities.ts
+++ b/apps/mobile/src/lib/scopedEntities.ts
@@ -1,4 +1,4 @@
-import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
return `${environmentId}:${projectId}`;
@@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId
export function scopedRequestKey(
environmentId: EnvironmentId,
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
): string {
return `${environmentId}:${requestId}`;
}
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index 4e301288c94f..de9e473df74d 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -1,2062 +1,872 @@
-import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
-import { beforeEach, describe, expect, it } from "vite-plus/test";
-
import {
- ApprovalRequestId,
- EventId,
MessageId,
- ProjectId,
+ RuntimeRequestId,
+ NodeId,
+ PlanId,
ProviderInstanceId,
+ ProviderDriverKind,
+ ProviderThreadId,
+ RunId,
+ RunAttemptId,
+ ScheduledTaskId,
ThreadId,
- TurnId,
- type OrchestrationThread,
- type OrchestrationThreadActivity,
+ TurnItemId,
+ type OrchestrationV2RunAttempt,
+ type OrchestrationV2ProjectedTurnItem,
+ type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
+import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
import {
- agentSpawnSummary,
- buildPendingUserInputAnswers,
buildThreadFeed,
deriveThreadFeedPresentation,
- isPendingUserInputOptionSelected,
- setPendingUserInputCustomAnswer,
- togglePendingUserInputOptionSelection,
- workEntryRowLabel,
+ threadFeedActivityIsVisible,
+ threadFeedRunIsUnsettled,
type ThreadFeedActivity,
type ThreadFeedEntry,
- type WorkLogEntry,
+ togglePendingUserInputOptionSelection,
+ setPendingUserInputCustomAnswer,
+ isPendingUserInputOptionSelected,
+ buildPendingUserInputAnswers,
} from "./threadActivity";
-// Match Hermes: these ES2023 array methods are absent on mobile.
-beforeEach(() => {
- const methods = ["toSorted", "toReversed"] as const;
- const descriptors = methods.map((method) =>
- Object.getOwnPropertyDescriptor(Array.prototype, method),
- );
- for (const method of methods) Reflect.deleteProperty(Array.prototype, method);
- return () => {
- for (const [index, method] of methods.entries()) {
- const descriptor = descriptors[index];
- if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor);
- }
- };
+const threadId = ThreadId.make("thread-1");
+const sourceThreadId = ThreadId.make("thread-source");
+const runId = RunId.make("run-1");
+
+it("keeps historical plan detail accessible from its paged turn item", () => {
+ const item = {
+ ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1),
+ type: "proposed_plan",
+ planId: "plan-historical",
+ markdown: "Full historical plan text",
+ streaming: false,
+ } as OrchestrationV2TurnItem;
+
+ const entries = buildThreadFeed([projected(item, 0)]);
+ const activity = entries.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
+ expect(activity?.detail).toBe("Full historical plan text");
+ expect(activity?.getFullDetail()).toContain("Full historical plan text");
});
-const singleSelectQuestion = {
- id: "runtime",
- header: "Runtime",
- question: "Which runtime should be used?",
- options: [
- { label: "Go", description: "One binary" },
- { label: "Node.js", description: "Reuse TypeScript" },
- ],
- multiSelect: false,
-} as const;
+function base(id: string, updatedAt: string, ordinal: number) {
+ const timestamp = DateTime.makeUnsafe(updatedAt);
+ return {
+ id: TurnItemId.make(id),
+ threadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal,
+ status: "completed" as const,
+ title: null,
+ startedAt: timestamp,
+ completedAt: timestamp,
+ updatedAt: timestamp,
+ };
+}
-const multiSelectQuestion = {
- id: "scope",
- header: "Scope",
- question: "Which data should be collected?",
- options: [
- { label: "Orders", description: "Receipts" },
- { label: "Listings", description: "Inventory" },
- ],
- multiSelect: true,
-} as const;
+function projected(
+ item: OrchestrationV2TurnItem,
+ position: number,
+ visibility: OrchestrationV2ProjectedTurnItem["visibility"] = "local",
+): OrchestrationV2ProjectedTurnItem {
+ return {
+ position,
+ visibility,
+ sourceThreadId: visibility === "local" ? threadId : sourceThreadId,
+ sourceItemId: item.id,
+ item,
+ };
+}
-const nativeQuestion = {
- id: "choice",
- header: "File",
- question: "Which file should be used?",
- options: [
- { label: "Use this", description: "First file", value: " choice " },
- { label: "Use this", description: "Second file", value: "choice" },
- ],
- multiSelect: false,
- allowCustomAnswer: false,
-} as const;
+function userMessage(updatedAt = "2026-06-20T00:00:01.000Z") {
+ return {
+ ...base("item-user", updatedAt, 0),
+ type: "user_message" as const,
+ messageId: MessageId.make("message-user"),
+ createdBy: "user" as const,
+ creationSource: "mobile" as const,
+ inputIntent: "turn_start" as const,
+ text: "Run checks",
+ attachments: [],
+ };
+}
-describe("pending user input answers", () => {
- it("accepts free-text answers to async questions without options", () => {
- const question = {
- id: "0",
- header: "Question",
- question: "What should it be named?",
- options: [],
- allowCustomAnswer: true,
- multiSelect: false,
- };
- const requested = makeActivity({
- id: EventId.make("async-question"),
- kind: "user-input.requested",
- summary: "User input requested",
- createdAt: "2026-09-03T00:00:00.000Z",
- payload: { requestId: "async-1", responseMode: "message", questions: [question] },
- });
- const questions = derivePendingRequests([requested]).userInputs[0]?.questions;
- expect(questions).toEqual([question]);
- expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({
- "0": "Example",
- });
- });
+function command(updatedAt = "2026-06-20T00:00:02.000Z") {
+ return {
+ ...base("item-command", updatedAt, 1),
+ type: "command_execution" as const,
+ input: "vp check",
+ output: "ok",
+ exitCode: 0,
+ };
+}
- it("preserves native choice values and custom-answer rules from activities", () => {
- const requested = makeActivity({
- id: EventId.make("native-question"),
- kind: "user-input.requested",
- summary: "User input requested",
- createdAt: "2026-09-02T00:00:00.000Z",
- payload: {
- requestId: "interaction_1",
- questions: [nativeQuestion, singleSelectQuestion],
- },
- });
+function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
+ return {
+ ...base("item-assistant", updatedAt, 2),
+ type: "assistant_message" as const,
+ messageId: MessageId.make("message-assistant"),
+ text: "Done",
+ streaming: false,
+ };
+}
- expect(derivePendingRequests([requested]).userInputs).toEqual([
+describe("buildThreadFeed", () => {
+ it("omits cached tool output and patch bodies from expanded and copied activity", () => {
+ const rawOutput = "RAW_TOOL_OUTPUT";
+ const items: OrchestrationV2TurnItem[] = [
+ { ...command(), output: rawOutput },
{
- requestId: "interaction_1",
- createdAt: requested.createdAt,
- dismissible: false,
- questions: [nativeQuestion, singleSelectQuestion],
+ ...base("dynamic-output", "2026-06-20T00:00:03.000Z", 2),
+ type: "dynamic_tool",
+ toolName: "example",
+ input: { query: "keep input" },
+ output: { text: rawOutput },
},
- ]);
+ {
+ ...base("file-output", "2026-06-20T00:00:04.000Z", 3),
+ type: "file_change",
+ fileName: "src/example.ts",
+ diffStr: rawOutput,
+ oldStr: rawOutput,
+ newStr: rawOutput,
+ },
+ ];
+ const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap(
+ (entry) => (entry.type === "activity-group" ? entry.activities : []),
+ );
+ expect(activities).toHaveLength(3);
+ for (const activity of activities) {
+ expect(activity.workEntry.detail).toBeUndefined();
+ expect(activity.getFullDetail()).not.toContain(rawOutput);
+ expect(activity.getCopyText()).not.toContain(rawOutput);
+ }
+ expect(activities[0]?.detail).toBe("vp check");
+ expect(activities[1]?.getFullDetail()).toContain("keep input");
+ expect(activities[2]?.detail).toBe("src/example.ts");
+ expect(items[0]).toMatchObject({ output: rawOutput });
});
- it("replaces single-select options and toggles multi-select options", () => {
- expect(
- togglePendingUserInputOptionSelection(
- singleSelectQuestion,
- { selectedOptionValues: ["Go"] },
- "Node.js",
+ it("recognizes automation attribution after projecting a user message", () => {
+ const feed = buildThreadFeed([
+ projected(
+ {
+ ...userMessage(),
+ createdBy: "agent",
+ creationSource: "server",
+ scheduledTaskId: ScheduledTaskId.make("daily-audit"),
+ },
+ 0,
),
- ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
+ ]);
+ const messageEntry = feed.find((entry) => entry.type === "message");
- const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
- const ordersAndListings = togglePendingUserInputOptionSelection(
- multiSelectQuestion,
- orders,
- "Listings",
- );
- expect(ordersAndListings).toEqual({
- customAnswer: "",
- selectedOptionValues: ["Orders", "Listings"],
+ expect(messageEntry).toBeDefined();
+ expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({
+ text: "Run checks",
+ isAutomation: true,
});
- expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
- ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
-
- const paddedOrders = togglePendingUserInputOptionSelection(
- multiSelectQuestion,
- undefined,
- " Orders ",
- );
- expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
- expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
- ).toEqual({ customAnswer: "" });
});
- it("builds array answers for multi-select questions", () => {
- expect(
- buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
- runtime: { selectedOptionValues: ["Go"] },
- scope: { selectedOptionValues: ["Orders", "Listings"] },
- }),
- ).toEqual({
- runtime: "Go",
- scope: ["Orders", "Listings"],
+ it("adds local feedback messages to an otherwise server-authored feed", () => {
+ const feed = buildThreadFeed([], {
+ localMessages: [
+ {
+ id: MessageId.make("feedback-local"),
+ role: "assistant",
+ text: "Feedback sent to OpenAI.\n\nThread ID: `codex-thread-1`",
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-08-29T00:00:00.000Z",
+ updatedAt: "2026-08-29T00:00:00.000Z",
+ },
+ ],
+ });
+
+ expect(feed).toHaveLength(1);
+ expect(feed[0]).toMatchObject({
+ type: "message",
+ message: {
+ id: "feedback-local",
+ role: "assistant",
+ text: expect.stringContaining("codex-thread-1"),
+ },
});
});
- it("clears selected options while a custom answer is active", () => {
+ it("anchors feedback before later committed turns and appends true optimistic messages", () => {
+ const laterUser = {
+ ...userMessage("2026-08-29T00:00:05.000Z"),
+ id: TurnItemId.make("item-later-user"),
+ messageId: MessageId.make("message-later-user"),
+ ordinal: 2,
+ text: "Later user turn",
+ };
+ const laterAssistant = {
+ ...assistantMessage("2026-08-29T00:00:04.000Z"),
+ id: TurnItemId.make("item-later-assistant"),
+ messageId: MessageId.make("message-later-assistant"),
+ ordinal: 3,
+ text: "Later assistant turn",
+ };
+ const localMessage = (id: string, role: "user" | "assistant") => ({
+ id: MessageId.make(id),
+ role,
+ text: id,
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-08-29T00:00:03.000Z",
+ updatedAt: "2026-08-29T00:00:03.000Z",
+ });
+ const feed = buildThreadFeed(
+ [
+ projected(userMessage("2026-08-29T00:00:01.000Z"), 0),
+ projected(laterUser, 1),
+ projected(laterAssistant, 2),
+ ],
+ {
+ anchoredMessages: [
+ localMessage("feedback-user", "user"),
+ localMessage("feedback-assistant", "assistant"),
+ localMessage("message-later-user", "user"),
+ ],
+ localMessages: [
+ {
+ ...localMessage("optimistic-user", "user"),
+ createdAt: "2026-08-29T00:00:00.000Z",
+ },
+ ],
+ },
+ );
+ const messages = feed.filter((entry) => entry.type === "message");
+
+ expect(messages.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "feedback-user",
+ "feedback-assistant",
+ "message-later-user",
+ "message-later-assistant",
+ "optimistic-user",
+ ]);
expect(
- setPendingUserInputCustomAnswer(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders", "Listings"] },
- "Orders first",
- ),
- ).toEqual({ customAnswer: "Orders first" });
+ messages
+ .filter((entry) => entry.id.startsWith("feedback-"))
+ .every((entry) => entry.message.projectedItem === undefined),
+ ).toBe(true);
});
- it("matches selected options against normalized legacy labels", () => {
+ it("keeps prominent activity visible while it is running", () => {
expect(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"] },
- " Orders ",
- ),
+ threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }),
).toBe(true);
expect(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
- " Orders ",
- ),
+ threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }),
).toBe(false);
});
- it("keeps custom answers enabled for legacy questions", () => {
- expect(
- buildPendingUserInputAnswers([singleSelectQuestion], {
- runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " },
- }),
- ).toEqual({ runtime: "Use Bun" });
- });
-
- it("keeps duplicate labels and whitespace-sensitive native values separate", () => {
- const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice ");
- expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true);
- expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({
- choice: " choice ",
- });
-
- const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice");
- expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false);
- expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({
- choice: "choice",
- });
- });
-
- it("keeps exact native values in multi-select answers", () => {
- const question = { ...nativeQuestion, multiSelect: true };
- const first = togglePendingUserInputOptionSelection(question, undefined, " choice ");
- const both = togglePendingUserInputOptionSelection(question, first, "choice");
- expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({
- choice: [" choice ", "choice"],
- });
-
- const second = togglePendingUserInputOptionSelection(question, both, " choice ");
- expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({
- choice: ["choice"],
- });
- });
-
- it("ignores custom answers when a question only accepts choices", () => {
- const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" };
- expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft);
- expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({
- choice: " choice ",
- });
- });
-
- it.each([
- { customAnswer: "Other" },
- { selectedOptionValues: ["Use this"] },
- { selectedOptionValues: ["not offered"] },
- { selectedOptionValues: [" choice "] },
- ])("requires an offered value for a choice-only question: %j", (draft) => {
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull();
+ it("keeps provider notices visible outside completed work folds without failure styling", () => {
+ const message = "Safeguards flagged this message. Switched to Opus 4.8.";
+ const item = {
+ ...base("item-system-notice", "2026-06-20T00:00:02.000Z", 1),
+ type: "system_notice" as const,
+ message,
+ };
+ const feed = buildThreadFeed([projected(item, 0)]);
+ const presented = deriveThreadFeedPresentation(feed, null, new Set());
+ const activities = presented.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
+ expect(activities).toHaveLength(1);
+ expect(activities[0]).toMatchObject({
+ summary: message,
+ detail: message,
+ prominent: true,
+ toolLike: false,
+ status: null,
+ icon: "warning",
+ workEntry: { tone: "info", itemType: "system_notice" },
+ });
+ expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
});
-});
-
-function makeActivity(
- input: Partial &
- Pick,
-): OrchestrationThreadActivity {
- return {
- tone: "info",
- payload: {},
- turnId: null,
- ...input,
- };
-}
-
-function makeThread(
- input: Partial & Pick,
-): OrchestrationThread {
- return {
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- pullRequests: [],
- latestTurn: null,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- archivedAt: null,
- deletedAt: null,
- messages: [],
- proposedPlans: [],
- activities: [],
- checkpoints: [],
- session: null,
- ...input,
- settledOverride: input.settledOverride ?? null,
- settledAt: input.settledAt ?? null,
- };
-}
-describe("buildThreadFeed", () => {
- it("reuses unchanged feed and presentation rows during an assistant text update", () => {
- const completedTurnId = TurnId.make("completed-turn");
- const activeTurnId = TurnId.make("active-turn");
- const thread = makeThread({
- id: ThreadId.make("feed-reuse"),
- projectId: ProjectId.make("project-1"),
- title: "Feed reuse",
- messages: [
+ it("presents provider retries as visible work-log activity", () => {
+ const retryBase = {
+ ...base("item-provider-retry", "2026-06-20T00:00:02.000Z", 1),
+ type: "error" as const,
+ failure: {
+ class: "transport_error" as const,
+ message: "The response stream disconnected.",
+ code: "responseStreamDisconnected",
+ retryable: true,
+ },
+ retry: {
+ attempt: 2,
+ maxAttempts: 5,
+ retryDelayMs: null,
+ },
+ };
+ const runningFeed = buildThreadFeed([
+ projected(
{
- id: MessageId.make("completed-message"),
- role: "assistant",
- text: "Completed response",
- turnId: completedTurnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:01.000Z",
- updatedAt: "2026-04-01T00:00:01.000Z",
+ ...retryBase,
+ status: "running",
+ title: "Provider retry",
+ completedAt: null,
},
+ 0,
+ ),
+ ]);
+ const recoveredFeed = buildThreadFeed([
+ projected(
{
- id: MessageId.make("streaming-message"),
- role: "assistant",
- text: "Current response",
- turnId: activeTurnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:04.000Z",
- updatedAt: "2026-04-01T00:00:04.000Z",
+ ...retryBase,
+ status: "completed",
+ title: "Provider recovered",
},
- ],
- activities: [
- makeActivity({
- id: EventId.make("completed-tool"),
- kind: "tool.completed",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: completedTurnId,
- payload: { itemType: "file_read", status: "completed" },
- }),
- makeActivity({
- id: EventId.make("active-tool"),
- kind: "tool.updated",
- summary: "Run checks",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: activeTurnId,
- payload: { itemType: "command_execution", command: "vp test", status: "inProgress" },
- }),
- ],
- });
- const latestTurn = {
- turnId: activeTurnId,
- state: "running" as const,
- startedAt: "2026-04-01T00:00:03.000Z",
- completedAt: null,
- };
- const expandedTurns = new Set([completedTurnId]);
- const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]);
- const previousFeed = buildThreadFeed(thread);
- const previousRows = deriveThreadFeedPresentation(
- previousFeed,
- latestTurn,
- expandedTurns,
- expandedGroups,
- latestTurn.startedAt,
- );
- const updatedMessage = {
- ...thread.messages[1]!,
- text: "Current response with more text",
- updatedAt: "2026-04-01T00:00:05.000Z",
- };
- const nextFeed = buildThreadFeed({
- ...thread,
- messages: [thread.messages[0]!, updatedMessage],
- });
- const nextRows = deriveThreadFeedPresentation(
- nextFeed,
- latestTurn,
- expandedTurns,
- expandedGroups,
- latestTurn.startedAt,
- );
-
- expect(nextFeed).toHaveLength(previousFeed.length);
- expect(nextRows).toHaveLength(previousRows.length);
- for (const [before, after] of [
- [previousFeed, nextFeed],
- [previousRows, nextRows],
- ] as const) {
- for (const [index, row] of after.entries()) {
- if (row.id === updatedMessage.id) {
- expect(row).not.toBe(before[index]);
- expect(row).toMatchObject({ message: updatedMessage });
- } else {
- expect(row).toBe(before[index]);
- }
- }
- }
- expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true);
- expect(nextRows.some((row) => row.type === "activity-group")).toBe(true);
- });
-
- it("regroups cached activities for message changes and pagination", () => {
- const messages = [2, 4].map((second) => ({
- id: MessageId.make(`message-${second}`),
- role: "assistant" as const,
- text: second === 2 ? "" : "Response",
- streaming: false,
- turnId: null,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- updatedAt: `2026-04-01T00:00:0${second}.000Z`,
- }));
- const thread = makeThread({
- id: ThreadId.make("feed-regroup"),
- projectId: ProjectId.make("project-1"),
- title: "Feed grouping",
- messages,
- activities: [1, 3, 5].map((second) =>
- makeActivity({
- id: EventId.make(`work-${second}`),
- kind: "runtime.warning",
- summary: `Notice ${second}`,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- }),
+ 0,
),
- });
- const initial = buildThreadFeed(thread);
- expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]);
- const split = buildThreadFeed({
- ...thread,
- messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!],
- });
- expect(split.map((row) => row.id)).toEqual([
- "work-1",
- "message-2",
- "work-3",
- "message-4",
- "work-5",
]);
- expect(split[0]).not.toBe(initial[0]);
- expect(split.at(-1)).toBe(initial.at(-1));
- expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] });
+ const failedFeed = buildThreadFeed([
+ projected(
+ {
+ ...retryBase,
+ status: "failed",
+ title: "Provider retry failed",
+ },
+ 0,
+ ),
+ projected(command("2026-06-20T00:00:03.000Z"), 1),
+ ]);
+ const runningActivity = runningFeed.find((entry) => entry.type === "activity-group")
+ ?.activities[0];
+ const recoveredActivity = recoveredFeed.find((entry) => entry.type === "activity-group")
+ ?.activities[0];
+ if (runningActivity === undefined || recoveredActivity === undefined) {
+ throw new Error("Expected provider retry work-log activities.");
+ }
- const reordered = buildThreadFeed({
- ...thread,
- messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }],
- });
- expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]);
- expect(reordered[0]).toMatchObject({
- activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }],
+ expect(runningActivity).toMatchObject({
+ summary: "Provider retry",
+ status: "neutral",
+ toolLike: false,
});
- const olderMessage = {
- ...messages[1]!,
- id: MessageId.make("older-message"),
- createdAt: "2026-04-01T00:00:00.000Z",
- };
- const page = buildThreadFeed(thread, {
- loadedMessages: [messages[1]!],
- localMessages: [olderMessage],
+ expect(threadFeedActivityIsVisible(runningActivity)).toBe(true);
+ expect(recoveredActivity).toMatchObject({
+ summary: "Provider recovered",
+ status: "success",
+ toolLike: false,
});
- expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]);
- const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] });
- expect(prepended.map((row) => row.id)).toEqual([
- "older-message",
- "work-1",
- "message-4",
- "work-5",
+ const failedPresentation = deriveThreadFeedPresentation(
+ failedFeed,
+ { runId, status: "running", startedAt: null, completedAt: null },
+ new Set(),
+ );
+ expect(failedPresentation.map((entry) => entry.type)).toEqual([
+ "activity-group",
+ "work-toggle",
]);
- expect(prepended.at(-1)).toBe(page.at(-1));
+ expect(
+ failedPresentation[0]?.type === "activity-group"
+ ? failedPresentation[0].activities[0]?.summary
+ : null,
+ ).toBe("Provider retry failed");
});
- it("keeps context compaction as a standalone timeline row", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-context-compaction"),
- projectId: ProjectId.make("project-1"),
- title: "Context compaction",
- activities: [
- makeActivity({
- id: EventId.make("context-compaction"),
- kind: "context-compaction",
- tone: "info",
- summary: "Compacted context 899K → 19K tokens",
- createdAt: "2026-09-01T00:00:00.000Z",
- turnId: TurnId.make("turn-context-compaction"),
- }),
- ],
- });
+ it.each(["pending", "running", "completed"] as const)(
+ "omits %s task progress without hiding adjacent conversation items",
+ (stepStatus) => {
+ const todoItem = {
+ ...base("item-tasks", "2026-06-20T00:00:02.500Z", 2),
+ type: "todo_list" as const,
+ planId: PlanId.make("plan-tasks"),
+ steps: [{ id: "step-1", text: "Verify the change", status: stepStatus }],
+ } satisfies OrchestrationV2TurnItem;
+ const user = projected(userMessage(), 0);
+ const tool = projected(command(), 1);
+ const assistant = projected(assistantMessage(), 3);
+
+ expect(buildThreadFeed([user, tool, projected(todoItem, 2), assistant])).toEqual(
+ buildThreadFeed([user, tool, assistant]),
+ );
+ },
+ );
- const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set());
- expect(presented).toMatchObject([
+ it("hides synthetic workspace preparation activity", () => {
+ const workspacePreparation = projected(
{
- type: "activity-group",
- id: "context-compaction",
- activities: [{ summary: "Compacted context 899K → 19K tokens" }],
+ ...command(),
+ title: "Workspace ready",
+ input: "Preparing workspace",
+ output: "Workspace preparation completed.",
},
- ]);
- });
+ 0,
+ );
- it("keeps long Claude commands expandable without repeating them in full detail", () => {
- const command = `printf 'first line\nsecond line'\n&& printf done`;
- const thread = makeThread({
- id: ThreadId.make("thread-long-command"),
- projectId: ProjectId.make("project-1"),
- title: "Long command",
- activities: [
- makeActivity({
- id: EventId.make("long-command"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`,
- data: { toolName: "Bash", command },
- },
- }),
- ],
- });
+ expect(buildThreadFeed([workspacePreparation])).toEqual([]);
+ });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row).toMatchObject({ detail: command, canExpand: true });
- expect(row?.getFullDetail()).toBe(command);
- expect(row?.getCopyText()).toBe(`Command run\n${command}`);
+ it("does not treat a queued-only run as live feed activity", () => {
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "queued",
+ startedAt: null,
+ completedAt: null,
+ }),
+ ).toBe(false);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "running",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "waiting",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
});
- it("keeps command output when it equals the displayed command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-matching-command-output"),
- projectId: ProjectId.make("project-1"),
- title: "Matching output",
- activities: [
- makeActivity({
- id: EventId.make("matching-command-output"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`,
- data: { toolName: "Bash", command, rawOutput: { content: command } },
- },
- }),
- ],
- });
+ it("adds queued input only after dispatch creates its turn item", () => {
+ const dispatchedRunId = RunId.make("run-dispatched-queued");
+ const dispatchedMessageId = MessageId.make("message-dispatched-queued");
+ expect(buildThreadFeed([])).toEqual([]);
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.detail).toBe(command);
- expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
- expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`);
- });
-
- it("keeps OpenCode detail-only output when it equals the command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-opencode-detail-output"),
- projectId: ProjectId.make("project-1"),
- title: "OpenCode detail output",
- activities: [
- makeActivity({
- id: EventId.make("opencode-detail-output"),
- kind: "tool.completed",
- tone: "tool",
- summary: "bash",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "bash",
- detail: command,
- data: { command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBe(command);
- expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
- expect(row?.canExpand).toBe(true);
- expect(workEntryRowLabel(row!.workEntry, true)).toBe("Command");
- });
-
- it.each([
- {
- name: "a task summary that is its own detail",
- activity: {
- kind: "task.completed" as const,
- tone: "info" as const,
- summary: "Task completed",
- payload: {
- taskId: "bh2p996o4",
- status: "completed",
- title: "Check CI on the new head",
- summary: "Check CI on the new head",
- detail: "Check CI on the new head",
- agentKind: "background",
- taskType: "local_bash",
- },
- },
- label: "Check CI on the new head",
- canExpand: false,
- },
- {
- name: "a runtime warning with only its message",
- activity: {
- kind: "runtime.warning" as const,
- tone: "info" as const,
- summary: "Bash is unusable in this environment",
- payload: { detail: "Bash is unusable in this environment" },
- },
- label: "Bash is unusable in this environment",
- canExpand: true,
- },
- {
- name: "a multi-line task report",
- activity: {
- kind: "task.completed" as const,
- tone: "info" as const,
- summary: "Task completed",
- payload: {
- taskId: "bpxcizf97",
- status: "completed",
- title: "Audit the PR",
- detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.",
- agentKind: "background",
- taskType: "local_bash",
- },
- },
- label: "**Tooling note:** Bash is unusable. # Audit No blockers.",
- canExpand: true,
- },
- {
- name: "a command whose output differs from the command",
- activity: {
- kind: "tool.completed" as const,
- tone: "tool" as const,
- summary: "Command run",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: "Bash: printf hello",
- data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } },
- },
- },
- label: "printf hello",
- canExpand: true,
- },
- ])("sets expansion availability for $name: $canExpand", (input) => {
- const thread = makeThread({
- id: ThreadId.make("thread-expand-rule"),
- projectId: ProjectId.make("project-1"),
- title: "Expand rule",
- activities: [
- makeActivity({
- id: EventId.make("expand-rule"),
- createdAt: "2026-09-01T00:00:00.000Z",
- ...input.activity,
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(workEntryRowLabel(row!.workEntry)).toBe(input.label);
- expect(row?.canExpand).toBe(input.canExpand);
- });
-
- it.each(["runtime.error", "runtime.warning"] as const)(
- "shows and copies the message of %s without a duplicate expanded body",
- (kind) => {
- const message =
- "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 5:21 AM.";
- const thread = makeThread({
- id: ThreadId.make("runtime-message"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime message",
- activities: [
- makeActivity({
- id: EventId.make("runtime-message"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind,
- tone: "error",
- summary: "Runtime error",
- payload: { message },
- }),
- ],
- });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(workEntryRowLabel(row.workEntry, true)).toBe(message);
- expect(row.getFullDetail()).toBeNull();
- expect(row.getCopyText()).toBe(`Runtime error\n${message}`);
- },
- );
-
- it.each([
- {
- message: "fallback message",
- detail: "More specific detail",
- expected: "More specific detail",
- },
- { message: " ", expected: undefined },
- { message: { error: "not a string" }, expected: undefined },
- ])("preserves existing runtime details and ignores invalid messages: $message", (input) => {
- const thread = makeThread({
- id: ThreadId.make("runtime-detail"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime detail",
- activities: [
- makeActivity({
- id: EventId.make("runtime-detail"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind: "runtime.error",
- tone: "error",
- summary: "Runtime error",
- payload: { message: input.message, detail: input.detail },
- }),
- ],
- });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- expect(group.activities[0]?.workEntry.detail).toBe(input.expected);
- expect(group.activities[0]?.canExpand).toBe(Boolean(input.expected));
- });
-
- it.each([
- {
- summary: "Runtime error",
- kind: "runtime.error" as const,
- detail:
- "Request failed.\nThe service returned an unexpected response.\nRetry after checking the connection.",
- },
- {
- summary: "Web search",
- kind: "tool.completed" as const,
- detail: "https://itanium-cxx-abi.github.io/cxx-abi/abi.html",
- itemType: "web_search",
- },
- ])("expands the full $summary label once, retaining its formatting and copy text", (input) => {
- const thread = makeThread({
- id: ThreadId.make("expanded-label"),
- projectId: ProjectId.make("project-1"),
- title: "Expanded label",
- activities: [
- makeActivity({
- id: EventId.make("expanded-label"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind: input.kind,
- summary: input.summary,
- payload: { detail: input.detail, itemType: input.itemType },
- }),
- ],
- });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(workEntryRowLabel(row.workEntry)).toBe(input.detail.replace(/\s+/g, " "));
- expect(workEntryRowLabel(row.workEntry, true)).toBe(input.detail);
- expect(row.getFullDetail()).toBeNull();
- expect(row.getCopyText()).toBe(`${input.summary}\n${input.detail}`);
- });
-
- it("drops a truncated Claude echo of a long command", () => {
- const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
- const thread = makeThread({
- id: ThreadId.make("thread-truncated-echo"),
- projectId: ProjectId.make("project-1"),
- title: "Truncated echo",
- activities: [
- makeActivity({
- id: EventId.make("truncated-echo"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`.slice(0, 177) + "...",
- data: { toolName: "Bash", command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
- });
-
- it("drops an ACP command echo when the update omits the tool kind", () => {
- const command = "pnpm test";
- const thread = makeThread({
- id: ThreadId.make("thread-acp-no-kind"),
- projectId: ProjectId.make("project-1"),
- title: "ACP no kind",
- activities: [
- makeActivity({
- id: EventId.make("acp-no-kind"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Terminal",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Terminal",
- detail: command,
- data: { toolCallId: "tool-1", command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
- });
-
- it("drops ACP command metadata when detail only repeats the command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-acp-command-detail"),
- projectId: ProjectId.make("project-1"),
- title: "ACP command detail",
- activities: [
- makeActivity({
- id: EventId.make("acp-command-detail"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Terminal",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Terminal",
- detail: command,
- data: { kind: "execute", command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
- });
-
- it("does not show command output when the command input is missing", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-command-without-input"),
- projectId: ProjectId.make("project-1"),
- title: "Missing command input",
- activities: [
- makeActivity({
- id: EventId.make("command-without-input"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- data: { rawOutput: { content: "output without command metadata" } },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- expect(group.activities[0]?.detail).toBeNull();
- expect(group.activities[0]?.getFullDetail()).toBeNull();
- });
-
- it("keeps setup failures visible without routine setup notices before or after a turn", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-worktree-setup"),
- projectId: ProjectId.make("project-1"),
- title: "Worktree setup",
- activities: [
- makeActivity({
- id: EventId.make("setup-requested"),
- kind: "setup-script.requested",
- summary: "Starting setup script",
- createdAt: "2026-08-30T00:00:00.000Z",
- }),
- makeActivity({
- id: EventId.make("setup-started"),
- kind: "setup-script.started",
- summary: "Setup script started",
- createdAt: "2026-08-30T00:00:01.000Z",
- }),
- makeActivity({
- id: EventId.make("setup-failed"),
- kind: "setup-script.failed",
- summary: "Setup script failed to start",
- createdAt: "2026-08-30T00:00:02.000Z",
- tone: "error",
- payload: { detail: "Setup command was not found" },
- }),
- ],
- });
- const latestTurn = {
- turnId: TurnId.make("turn-after-setup"),
- state: "running" as const,
- requestedAt: "2026-08-30T00:00:03.000Z",
- startedAt: "2026-08-30T00:00:04.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
-
- for (const currentTurn of [null, latestTurn]) {
- const currentThread = { ...thread, latestTurn: currentTurn };
- const feed = buildThreadFeed(currentThread);
- expect(feed).toMatchObject([
+ const promotedEntries = buildThreadFeed([
+ projected(
{
- type: "activity-group",
- activities: [{ id: "setup-failed", status: "failure" }],
+ ...userMessage(),
+ id: TurnItemId.make("item-dispatched-queued"),
+ runId: dispatchedRunId,
+ messageId: dispatchedMessageId,
+ inputIntent: "turn_start",
},
- ]);
- const group = feed[0];
- if (group?.type !== "activity-group") throw new Error("Expected the setup failure group");
- expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found");
- }
- });
-
- it.each(["setup-script.requested", "setup-script.started"])(
- "keeps error-toned %s notices visible",
- (kind) => {
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-setup-error"),
- projectId: ProjectId.make("project-1"),
- title: "Setup error",
- activities: [
- makeActivity({
- id: EventId.make("setup-error"),
- kind,
- summary: "Setup failed",
- createdAt: "2026-08-30T00:00:00.000Z",
- tone: "error",
- }),
- ],
- }),
- );
-
- expect(feed).toMatchObject([
- { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] },
- ]);
- },
- );
-
- it("keeps historic work entries attributed to their turns", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-1"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime warning thread",
- latestTurn: {
- turnId: TurnId.make("turn-latest"),
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("activity-old"),
- kind: "runtime.warning",
- summary: "Runtime warning",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-old"),
- payload: {
- message: "Old warning",
- },
- }),
- makeActivity({
- id: EventId.make("activity-latest"),
- kind: "runtime.warning",
- summary: "Runtime warning",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: TurnId.make("turn-latest"),
- payload: {
- message: "Latest warning",
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- expect(feed).toMatchObject([
- {
- type: "activity-group",
- turnId: "turn-old",
- activities: [{ id: "activity-old", turnId: "turn-old" }],
- },
- {
- type: "activity-group",
- turnId: "turn-latest",
- activities: [{ id: "activity-latest", turnId: "turn-latest" }],
- },
+ 0,
+ ),
]);
+ expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]);
+ expect(
+ promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined,
+ ).toBe("turn_start");
});
- it("drops runtime warnings with no displayable content", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-noise"),
- projectId: ProjectId.make("project-1"),
- title: "Warning noise thread",
- activities: [
- makeActivity({
- id: EventId.make("activity-noise"),
- kind: "runtime.warning",
- summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-1"),
- }),
- makeActivity({
- id: EventId.make("activity-signal"),
- kind: "runtime.warning",
- summary: "Reconnecting... 2/5",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: TurnId.make("turn-1"),
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- expect(feed).toMatchObject([
+ it("hides the interruption request and keeps the terminal result", () => {
+ const request = projected(
{
- type: "activity-group",
- activities: [{ id: "activity-signal" }],
+ ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1),
+ type: "run_interrupt_request",
+ message: "Interrupt requested",
},
- ]);
- });
-
- it("collapses matching tool lifecycle rows like desktop", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-2"),
- projectId: ProjectId.make("project-1"),
- title: "Collapsed tools",
- latestTurn: {
- turnId: TurnId.make("turn-1"),
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:03.000Z",
- assistantMessageId: null,
+ 0,
+ );
+ const result = projected(
+ {
+ ...base("item-interrupt-result", "2026-06-20T00:00:03.000Z", 2),
+ type: "run_interrupt_result",
+ message: "Run interrupted before provider start",
},
- activities: [
- makeActivity({
- id: EventId.make("tool-updated"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Run tests",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId: TurnId.make("turn-1"),
- payload: {
- title: "Run tests",
- itemType: "command_execution",
- detail: "/bin/zsh -lc 'bun run test'",
- },
- }),
- makeActivity({
- id: EventId.make("tool-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run tests completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-1"),
- payload: {
- title: "Run tests",
- itemType: "command_execution",
- detail: "/bin/zsh -lc 'bun run test'",
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const group = feed[0];
-
- expect(group).toMatchObject({
- type: "activity-group",
- });
- if (!group || group.type !== "activity-group") {
- return;
- }
-
- expect(group.activities).toHaveLength(1);
- expect(group.activities[0]).toMatchObject({
- id: "tool-updated",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId: "turn-1",
- summary: "Run tests",
- detail: "bun run test",
- canExpand: true,
- icon: "command",
- toolLike: true,
- status: "success",
- });
- expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
- expect(group.activities[0]?.getCopyText()).toBe(
- "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
+ 1,
);
- });
- it("keeps viewed image metadata while collapsing a streamed Claude Read", () => {
- const turnId = TurnId.make("turn-image-read");
- const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`;
- const thread = makeThread({
- id: ThreadId.make("thread-image-read"),
- projectId: ProjectId.make("project-1"),
- title: "Image read",
- activities: [
- makeActivity({
- id: EventId.make("image-read-update"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Image view",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- payload: {
- toolCallId: "tool-read-image",
- itemType: "image_view",
- status: "inProgress",
- detail: `${imagePath.slice(0, 177)}...`,
- data: { imagePath },
- },
- }),
- makeActivity({
- id: EventId.make("image-read-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Image view",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- toolCallId: "tool-read-image",
- itemType: "image_view",
- status: "completed",
- detail: `${imagePath.slice(0, 177)}...`,
- data: {},
- },
- }),
- ],
- });
+ const activities = buildThreadFeed([request, result]).flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
- const group = buildThreadFeed(thread)[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [
+ expect(activities).toHaveLength(1);
+ expect(activities[0]?.summary).toBe("Run interrupted");
+ expect(activities[0]?.detail).toBe("Run interrupted before provider start");
+ expect(
+ deriveThreadFeedPresentation(
+ buildThreadFeed([request, result]),
{
- workEntry: {
- itemType: "image_view",
- viewedImagePath: imagePath,
- },
+ runId,
+ status: "interrupted",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
},
- ],
- });
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(row.getFullDetail()).toBeNull();
- expect(workEntryRowLabel(row.workEntry, true)).toBe(`${imagePath.slice(0, 177)}...`);
+ new Set(),
+ ).some((entry) => entry.type === "run-fold"),
+ ).toBe(false);
});
- it("keeps MCP inputs available to expanded mobile work rows", () => {
- const turnId = TurnId.make("turn-mcp");
- const thread = makeThread({
- id: ThreadId.make("thread-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Expandable MCP call",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:03.000Z",
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("mcp-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Call repository tool",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "Call repository tool",
- itemType: "mcp_tool_call",
- toolSurface: "computer",
- toolIcon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- toolSource: {
- key: "native-app:com.example.editor",
- name: "Computer Use",
- kind: "computer",
- icon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- },
- detail: "repository.search",
- status: "completed",
- data: {
- item: {
- server: "repository",
- tool: "search",
- arguments: { query: "work log" },
- },
- },
- },
- }),
- ],
- });
-
- const group = buildThreadFeed(thread)[0];
- expect(group).toMatchObject({ type: "activity-group" });
- if (!group || group.type !== "activity-group") {
- return;
- }
+ it("preserves authoritative V2 order instead of sorting reconstructed collections", () => {
+ const rows = [
+ projected(userMessage("2026-06-20T00:00:03.000Z"), 0),
+ projected(command("2026-06-20T00:00:01.000Z"), 1),
+ projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2),
+ ];
- expect(group.activities[0]?.icon).toBe("computer");
- expect(group.activities[0]?.workEntry.toolSurface).toBe("computer");
- expect(group.activities[0]?.workEntry.toolIcon).toEqual({
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- });
- expect(group.activities[0]?.workEntry.toolSource).toEqual({
- key: "native-app:com.example.editor",
- name: "Computer Use",
- kind: "computer",
- icon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- });
- expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
- expect(workEntryRowLabel(group.activities[0]!.workEntry, true)).toBe("repository.search");
- expect(group.activities[0]?.getFullDetail()).not.toContain("repository.search");
+ const feed = buildThreadFeed(rows);
+ expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]);
+ expect(feed.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "local:thread-1:item-command",
+ "message-assistant",
+ ]);
+ const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0];
+ expect(activity?.projectedItem).toBe(rows[1]);
+ expect(activity?.getFullDetail()).toContain('"input": "vp check"');
});
- it.each([
- {
- source: "raw MCP browser identity",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "preview_navigate" },
- status: "inProgress",
- displayName: "Navigating the preview browser",
- icon: "browser",
- },
- {
- source: "raw MCP orchestration identity",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "task_status" },
- status: "inProgress",
- displayName: "Getting delegated task status",
- icon: "t3-code",
- },
- {
- source: "provider-qualified title",
- label: "Call MCP tool",
- title: "mcp__t3-code__preview_snapshot",
- item: undefined,
- status: "inProgress",
- displayName: "Taking a snapshot of the preview page",
- icon: "browser",
- },
- {
- source: "provider-qualified label",
- label: "mcp__t3-code__task_status",
- title: undefined,
- item: undefined,
- status: "inProgress",
- displayName: "Getting delegated task status",
- icon: "t3-code",
- },
- {
- source: "browser identity without lifecycle status",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "preview_click" },
- status: undefined,
- displayName: "Clicking in the preview browser",
- liveDisplayName: "Clicking in the preview browser",
- settledDisplayName: "Clicked in the preview browser",
- icon: "browser",
- },
- {
- source: "orchestration identity without lifecycle status",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "task_status" },
- status: undefined,
- displayName: "Getting delegated task status",
- liveDisplayName: "Getting delegated task status",
- settledDisplayName: "Got delegated task status",
- icon: "t3-code",
- },
- ])(
- "uses friendly row and running labels from $source",
- ({ label, title, item, status, displayName, liveDisplayName, settledDisplayName, icon }) => {
- const turnId = TurnId.make("turn-friendly-mcp");
- const rawCommand = "node mcp-call.js";
- const rawDetail = '{"provider":"raw MCP output"}';
- const thread = makeThread({
- id: ThreadId.make("thread-friendly-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Friendly MCP labels",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("friendly-mcp"),
- kind: "tool.updated",
- tone: "tool",
- summary: label,
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title,
- itemType: "mcp_tool_call",
- detail: rawDetail,
- status,
- data: { item, command: rawCommand },
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const group = feed[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [{ summary: displayName, detail: rawCommand }],
- });
- if (!group || group.type !== "activity-group") return;
- const activity = group.activities[0]!;
- expect(activity.getFullDetail()).toContain(rawCommand);
- expect(activity.getFullDetail()).toContain(rawDetail);
- expect(activity.getCopyText()).toContain(rawCommand);
- expect(activity.getCopyText()).toContain(rawDetail);
- expect(activity.getCopyText()).not.toContain(displayName);
- if (item) expect(activity.getFullDetail()).toContain(JSON.stringify(item, null, 2));
- expect(
- deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
- ),
- ).toMatchObject([
- {
- type: "work-toggle",
- summary: liveDisplayName ?? displayName,
- summaryToolIcon: icon,
- live: true,
- },
- ]);
- if (settledDisplayName) {
- const settledRows = deriveThreadFeedPresentation(
- feed,
- {
- ...thread.latestTurn!,
- state: "completed",
- completedAt: "2026-04-01T00:00:03.000Z",
- },
- new Set([turnId]),
- new Set(),
- );
- expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({
- summary: settledDisplayName,
- summaryToolIcon: icon,
- live: false,
- });
- }
- },
- );
-
- it("retains Claude MCP metadata behind friendly row and running labels", () => {
- const turnId = TurnId.make("turn-claude-mcp");
- const toolData = {
- toolName: "mcp__t3-code__preview_click",
- input: { locator: { role: "button", name: "Continue" } },
- result: { content: "Clicked Continue" },
+ it("keeps adjacent work from different V2 attempts in separate groups", () => {
+ const firstRootNodeId = NodeId.make("node-attempt-1");
+ const secondRootNodeId = NodeId.make("node-attempt-2");
+ const firstCommand = { ...command(), nodeId: firstRootNodeId };
+ const secondCommand = {
+ ...command("2026-06-20T00:00:03.000Z"),
+ id: TurnItemId.make("item-command-retry"),
+ ordinal: 2,
+ nodeId: secondRootNodeId,
};
- const detail = "Click Continue";
- const thread = makeThread({
- id: ThreadId.make("thread-claude-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Claude MCP labels",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
+ const attempts = [
+ {
+ id: RunAttemptId.make("attempt-1"),
+ runId,
+ attemptOrdinal: 1,
+ rootNodeId: firstRootNodeId,
+ providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
+ providerThreadId: ProviderThreadId.make("provider-thread-1"),
+ providerTurnId: null,
+ reason: "initial",
+ status: "completed",
+ startedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
+ completedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
},
- activities: [
- makeActivity({
- id: EventId.make("claude-mcp-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "MCP tool call completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "MCP tool call",
- itemType: "mcp_tool_call",
- status: "completed",
- detail,
- data: toolData,
- },
- }),
- ],
- });
+ {
+ id: RunAttemptId.make("attempt-2"),
+ runId,
+ attemptOrdinal: 2,
+ rootNodeId: secondRootNodeId,
+ providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
+ providerThreadId: ProviderThreadId.make("provider-thread-1"),
+ providerTurnId: null,
+ reason: "retry",
+ status: "completed",
+ startedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
+ completedAt: DateTime.makeUnsafe("2026-06-20T00:00:03.000Z"),
+ },
+ ] satisfies ReadonlyArray;
- const feed = buildThreadFeed(thread);
- const group = feed[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [
- {
- summary: "Clicked in the preview browser",
- detail,
- workEntry: { label: "MCP tool call completed", toolTitle: "MCP tool call" },
- },
- ],
+ const feed = buildThreadFeed([projected(firstCommand, 0), projected(secondCommand, 1)], {
+ attempts,
});
- if (!group || group.type !== "activity-group") return;
- const activity = group.activities[0]!;
- const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`;
- expect(activity.workEntry.toolData).toBe(toolData);
- expect(activity.getFullDetail()).toBe(fullDetail);
- expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`);
+
+ expect(feed).toHaveLength(2);
expect(
- deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
+ feed.map((entry) =>
+ entry.type === "activity-group" ? entry.activities[0]?.attemptId : null,
),
- ).toMatchObject([
+ ).toEqual(["attempt-1", "attempt-2"]);
+ });
+
+ it("retains inherited and synthetic rows with their original projected identity", () => {
+ const inherited = projected(command(), 0, "inherited");
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:03.000Z",
+ 2,
+ );
+ const synthetic = projected(
{
- type: "work-toggle",
- summary: "Clicking in the preview browser",
- summaryToolIcon: "browser",
- live: true,
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId: sourceThreadId, runId },
+ targetThreadId: threadId,
},
+ 1,
+ "synthetic",
+ );
+
+ const feed = buildThreadFeed([inherited, synthetic]);
+ const activities = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
+ expect(activities.map((activity) => activity.projectedItem)).toEqual([inherited, synthetic]);
+ expect(activities.map((activity) => activity.projectedItem.visibility)).toEqual([
+ "inherited",
+ "synthetic",
]);
+ expect(activities.at(-1)?.prominent).toBe(true);
});
- it.each([
- {
- status: "completed",
- displayName: "Clicked in the preview browser",
- liveDisplayName: "Clicking in the preview browser",
- detail: "Clicked Continue",
- hasFailure: false,
- },
- {
- status: "failed",
- displayName: "Failed to click in the preview browser",
- liveDisplayName: "Failed to click in the preview browser",
- detail: "Timed out waiting for Continue",
- hasFailure: true,
- },
- ])(
- "uses the browser call label once its action settles as $status",
- ({ status, displayName, liveDisplayName, detail, hasFailure }) => {
- const turnId = TurnId.make("turn-preview-lifecycle");
- const toolCallId = "preview-click";
- const groupId = `work-group:tool:${turnId}:${toolCallId}`;
- const toolData = {
- server: "t3-code",
- tool: "preview_click",
- arguments: { locator: { role: "button", name: "Continue" } },
- };
- const thread = makeThread({
- id: ThreadId.make("thread-preview-lifecycle"),
- projectId: ProjectId.make("project-1"),
- title: "Browser tool lifecycle",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("preview-click-started"),
- kind: "tool.updated",
- tone: "tool",
- summary: "MCP tool call",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "MCP tool call",
- itemType: "mcp_tool_call",
- status: "inProgress",
- toolCallId,
- data: { item: toolData },
- },
- }),
- ],
- });
- const present = (currentThread: OrchestrationThread) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(currentThread),
- currentThread.latestTurn,
- new Set([turnId]),
- new Set([groupId]),
- currentThread.latestTurn?.state === "running" ? currentThread.latestTurn.startedAt : null,
- );
-
- expect(present(thread)).toMatchObject([
- {
- type: "work-toggle",
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: "Clicking in the preview browser",
- summaryToolIcon: "browser",
- live: true,
- shimmer: true,
- },
- {
- type: "activity-group",
- id: `work-details:${groupId}`,
- activities: [
- {
- id: "preview-click-started",
- summary: "Clicking in the preview browser",
- lifecycleStatus: "inProgress",
- live: true,
- },
- ],
- },
- ]);
-
- const terminalThread = {
- ...thread,
- activities: [
- ...thread.activities,
- makeActivity({
- id: EventId.make("preview-click-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "MCP tool call completed",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId,
- payload: { itemType: "mcp_tool_call", toolCallId, status, detail },
- }),
- ],
- };
- const terminalRows = present(terminalThread);
- expect(terminalRows).toMatchObject([
- {
- type: "work-toggle",
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: liveDisplayName,
- summaryToolIcon: "browser",
- hasFailure,
- live: true,
- // A successful trailing call keeps shining; a failure hands off to "Thinking".
- shimmer: !hasFailure,
- },
+ it("keeps orchestration relationship cards visible when a completed run is folded", () => {
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:02.500Z",
+ 2,
+ );
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected(
{
- type: "activity-group",
- id: `work-details:${groupId}`,
- activities: [
- {
- id: "preview-click-started",
- summary: displayName,
- lifecycleStatus: status,
- live: false,
- },
- ],
- },
- ...(hasFailure ? [{ type: "thinking", turnId }] : []),
- ]);
- const terminalGroup = terminalRows[1];
- if (terminalGroup?.type !== "activity-group") return;
- const activity = terminalGroup.activities[0]!;
- const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`;
- expect(activity.workEntry.toolData).toBe(toolData);
- expect(activity.getFullDetail()).toBe(fullDetail);
- expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`);
-
- const settledRows = present({
- ...terminalThread,
- latestTurn: {
- ...thread.latestTurn!,
- state: "completed",
- completedAt: "2026-04-01T00:00:04.000Z",
- },
- });
- expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: displayName,
- summaryKind: "browser",
- hasFailure,
- live: false,
- });
- expect(settledRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [{ id: "preview-click-started", summary: displayName, live: false }],
- });
- },
- );
-
- it.each([
- [0, "Used browser 3 times", "browser"],
- [2, "Ran 2 commands and used browser 3 times", "mixed"],
- ] as const)(
- "separates browser counts from %s completed commands",
- (commandCount, summary, summaryKind) => {
- const thread = makeThread({
- id: ThreadId.make("thread-browser-counts"),
- projectId: ProjectId.make("project-1"),
- title: "Browser group counts",
- activities: Array.from({ length: commandCount + 3 }, (_, index) =>
- makeActivity({
- id: EventId.make(`browser-count-${index}`),
- createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
- kind: "tool.completed",
- tone: "tool",
- summary: index < commandCount ? "Ran command" : "MCP tool call",
- payload: {
- toolCallId: `browser-count-${index}`,
- status: "completed",
- ...(index < commandCount
- ? {
- itemType: "command_execution",
- data: { item: { command: "/bin/bash -lc 'vp test run'" } },
- }
- : {
- itemType: "mcp_tool_call",
- data: { item: { server: "t3-code", tool: "preview_click" } },
- }),
- },
- }),
- ),
- });
- expect(
- deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set(), new Set()),
- ).toMatchObject([{ type: "work-toggle", summary, summaryKind, live: false }]);
- },
- );
-
- it("defers large tool output expansion until a work row is opened or copied", () => {
- let serializedToolOutputs = 0;
- const activities = Array.from({ length: 5_000 }, (_, index) =>
- makeActivity({
- id: EventId.make(`large-tool-${index}`),
- kind: "tool.completed",
- tone: "tool",
- summary: `Tool ${index}`,
- createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
- payload: {
- title: `Tool ${index}`,
- itemType: "mcp_tool_call",
- status: "completed",
- data: {
- item: {
- toJSON: () => {
- serializedToolOutputs += 1;
- return { output: "x".repeat(32_768) };
- },
- },
- },
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
},
- }),
- );
- const thread = makeThread({
- id: ThreadId.make("thread-large-tools"),
- projectId: ProjectId.make("project-1"),
- title: "Large tools",
- activities,
- });
-
- const feed = buildThreadFeed(thread);
- expect(serializedToolOutputs).toBe(0);
-
- const group = feed[0];
- expect(group).toMatchObject({ type: "activity-group" });
- if (!group || group.type !== "activity-group") {
- return;
- }
+ 2,
+ ),
+ projected(assistantMessage(), 3),
+ ]);
- expect(group.activities).toHaveLength(5_000);
- const expanded = deriveThreadFeedPresentation(
+ const collapsed = deriveThreadFeedPresentation(
feed,
- null,
+ {
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ },
new Set(),
- new Set(["work-group:large-tool-0"]),
);
- expect(expanded).toHaveLength(2);
- expect(expanded[1]).toMatchObject({
- type: "activity-group",
- id: "work-details:work-group:large-tool-0",
- });
- if (expanded[1]?.type === "activity-group") {
- expect(expanded[1].activities).toHaveLength(5_000);
- expect(expanded[1].activities[0]?.getFullDetail).toBe(group.activities[0]?.getFullDetail);
- }
- expect(serializedToolOutputs).toBe(0);
- expect(group.activities[0]?.getFullDetail()).toContain('"output"');
- expect(serializedToolOutputs).toBe(1);
- expect(group.activities[0]?.getCopyText()).toContain('"output"');
- expect(serializedToolOutputs).toBe(1);
+
+ expect(
+ collapsed.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some((activity) => activity.projectedItem.item.type === "fork"),
+ ),
+ ).toBe(true);
+ expect(
+ collapsed.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some(
+ (activity) => activity.projectedItem.item.type === "command_execution",
+ ),
+ ),
+ ).toBe(false);
});
- it("keeps the first and terminal assistant messages visible around settled work", () => {
- const turnId = TurnId.make("turn-1");
- const thread = makeThread({
- id: ThreadId.make("thread-3"),
- projectId: ProjectId.make("project-1"),
- title: "Folded work",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:18.000Z",
- assistantMessageId: MessageId.make("assistant-final"),
- },
- messages: [
- {
- id: MessageId.make("assistant-first"),
- role: "assistant",
- text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:02.000Z",
- updatedAt: "2026-04-01T00:00:03.000Z",
- },
- {
- id: MessageId.make("assistant-final"),
- role: "assistant",
- text: "Done.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:17.000Z",
- updatedAt: "2026-04-01T00:00:18.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("tool-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- title: "Read files",
- itemType: "file_read",
- status: "completed",
- },
- }),
- ],
- });
+ it("keeps opening and final assistant messages around the first hidden work", () => {
+ const opening = {
+ ...assistantMessage("2026-06-20T00:00:01.500Z"),
+ id: TurnItemId.make("item-opening"),
+ messageId: MessageId.make("message-opening"),
+ text: "I will check the deployment configuration.",
+ };
+ const middle = {
+ ...assistantMessage("2026-06-20T00:00:02.500Z"),
+ id: TurnItemId.make("item-middle"),
+ messageId: MessageId.make("message-middle"),
+ text: "The configuration is valid; checking the build next.",
+ };
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(opening, 1),
+ projected(command(), 2),
+ projected(middle, 3),
+ projected(assistantMessage(), 4),
+ ]);
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ };
+
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ expect(collapsed.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "run-fold:run-1",
+ "message-assistant",
+ ]);
+ expect(collapsed[1]).toMatchObject({ message: { text: opening.text } });
+ expect(collapsed[2]).toMatchObject({
+ type: "run-fold",
+ createdAt: "2026-06-20T00:00:02.000Z",
+ label: "Worked for 2.0s",
+ });
+
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
+ expect(expanded.map((entry) => entry.type)).toEqual([
+ "message",
+ "message",
+ "run-fold",
+ "work-toggle",
+ "message",
+ "message",
+ ]);
+ expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } });
+ });
- const feed = buildThreadFeed(thread);
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "assistant-final",
+ it("does not fold a response that only has opening and final messages", () => {
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(
+ {
+ ...assistantMessage("2026-06-20T00:00:02.000Z"),
+ id: TurnItemId.make("item-opening"),
+ messageId: MessageId.make("message-opening"),
+ text: "The result is ready.",
+ },
+ 1,
+ ),
+ projected(assistantMessage(), 2),
]);
- expect(collapsed[1]).toMatchObject({
- type: "turn-fold",
- label: "Worked for 17s",
- expanded: false,
- });
- const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
- expect(expanded.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "work-toggle:work-group:tool-completed",
- "assistant-final",
+ const presented = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(presented.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "message-assistant",
]);
+ });
- const interrupted = deriveThreadFeedPresentation(
- feed,
- { ...thread.latestTurn!, state: "interrupted", completedAt: "2026-04-01T00:00:20.000Z" },
- new Set(),
+ it("places the fold after leading resource cards and keeps later cards visible", () => {
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:02.000Z",
+ 2,
);
- expect(interrupted[1]).toMatchObject({
- type: "turn-fold",
- label: "You stopped after 19s",
- expanded: false,
+ const resourceItems = [
+ {
+ ...base("item-subagent", "2026-06-20T00:00:01.500Z", 1),
+ type: "subagent",
+ subagentId: NodeId.make("child-agent"),
+ origin: "app_owned",
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: sourceThreadId,
+ prompt: "Inspect the deployment configuration",
+ result: "Configuration is valid",
+ },
+ {
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
+ },
+ {
+ ...base("item-created-thread", "2026-06-20T00:00:04.000Z", 4),
+ type: "thread_created",
+ targetThreadId: sourceThreadId,
+ targetRunId: null,
+ targetProviderInstanceId: ProviderInstanceId.make("codex"),
+ targetModel: "gpt-5.4",
+ },
+ ] satisfies ReadonlyArray;
+ const projectedResources = [
+ projected(resourceItems[0]!, 1),
+ projected(resourceItems[1]!, 2),
+ projected(resourceItems[2]!, 4),
+ ];
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projectedResources[0]!,
+ projectedResources[1]!,
+ projected(command("2026-06-20T00:00:03.000Z"), 3),
+ projectedResources[2]!,
+ projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
+ ]);
+
+ const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(collapsed.map((entry) => entry.type)).toEqual([
+ "message",
+ "activity-group",
+ "activity-group",
+ "run-fold",
+ "activity-group",
+ "message",
+ ]);
+ expect(collapsed[3]).toMatchObject({
+ type: "run-fold",
+ createdAt: "2026-06-20T00:00:03.000Z",
});
- const retimed = deriveThreadFeedPresentation(
- buildThreadFeed({
- ...thread,
- messages: [
- thread.messages[0]!,
- { ...thread.messages[1]!, updatedAt: "2026-04-01T00:00:25.000Z" },
- ],
- }),
- null,
- new Set(),
- );
- expect(retimed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 23s" });
- expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s" });
+ expect(
+ collapsed.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? entry.activities.map((activity) => activity.projectedItem)
+ : [],
+ ),
+ ).toEqual(projectedResources);
});
- it("folds assistant messages between the first and terminal messages", () => {
- const turnId = TurnId.make("turn-1");
- const thread = makeThread({
- id: ThreadId.make("thread-middle-message"),
- projectId: ProjectId.make("project-1"),
- title: "Bounded narration",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:06.000Z",
- assistantMessageId: MessageId.make("assistant-final"),
- },
- messages: [
- {
- id: MessageId.make("assistant-first"),
- role: "assistant",
- text: "The main result is ready.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:01.000Z",
- updatedAt: "2026-04-01T00:00:02.000Z",
- },
- {
- id: MessageId.make("assistant-middle"),
- role: "assistant",
- text: "I am checking one more detail.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:03.000Z",
- updatedAt: "2026-04-01T00:00:04.000Z",
- },
- {
- id: MessageId.make("assistant-final"),
- role: "assistant",
- text: "Verification finished.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:05.000Z",
- updatedAt: "2026-04-01T00:00:06.000Z",
- },
- ],
- });
+ it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected(assistantMessage(), 2),
+ ]);
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ };
- const feed = buildThreadFeed(thread);
- const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
- expect(rows.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "assistant-final",
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
+ expect(expanded.map((entry) => entry.type)).toEqual([
+ "message",
+ "run-fold",
+ "work-toggle",
+ "message",
]);
});
- it("measures a steer-superseded turn from its user boundary through trailing work", () => {
- const firstTurnId = TurnId.make("turn-1");
- const secondTurnId = TurnId.make("turn-2");
- const thread = makeThread({
- id: ThreadId.make("thread-steered"),
- projectId: ProjectId.make("project-1"),
- title: "Steered work",
- latestTurn: {
- turnId: secondTurnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:14.000Z",
- startedAt: "2026-04-01T00:00:14.000Z",
+ it("keeps an active run expanded and detects failures from completed command output", () => {
+ const failedCommand: OrchestrationV2TurnItem = {
+ ...command(),
+ output: "sh: missing-command: command not found",
+ };
+ const feed = buildThreadFeed([projected(userMessage(), 0), projected(failedCommand, 1)]);
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ {
+ runId,
+ status: "running",
+ startedAt: "2026-06-20T00:00:01.000Z",
completedAt: null,
- assistantMessageId: MessageId.make("assistant-next"),
},
- messages: [
- {
- id: MessageId.make("user-1"),
- role: "user",
- text: "Do it once more.",
- turnId: null,
- streaming: false,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- },
- {
- id: MessageId.make("assistant-commentary"),
- role: "assistant",
- text: "Kicking off call 1.",
- turnId: firstTurnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:09.000Z",
- updatedAt: "2026-04-01T00:00:09.000Z",
- },
- {
- id: MessageId.make("user-2"),
- role: "user",
- text: "Actually do 15.",
- turnId: null,
- streaming: false,
- createdAt: "2026-04-01T00:00:14.000Z",
- updatedAt: "2026-04-01T00:00:14.000Z",
- },
- {
- id: MessageId.make("assistant-next"),
- role: "assistant",
- text: "One down - adjusting.",
- turnId: secondTurnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:17.000Z",
- updatedAt: "2026-04-01T00:00:17.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("work-1"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Ran command",
- createdAt: "2026-04-01T00:00:12.000Z",
- turnId: firstTurnId,
- payload: {
- title: "Ran command",
- itemType: "command_execution",
- status: "completed",
- },
- }),
- ],
- });
+ new Set(),
+ );
- const feed = buildThreadFeed(thread);
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({
- turnId: firstTurnId,
- label: "Worked for 12s",
+ expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
+ expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
+ summary: "vp check",
+ hiddenCount: 1,
+ hasFailure: true,
+ live: false,
});
});
- it("keeps an active turn expanded and classifies error-shaped tool output", () => {
- const turnId = TurnId.make("turn-running");
- const thread = makeThread({
- id: ThreadId.make("thread-4"),
- projectId: ProjectId.make("project-1"),
- title: "Running work",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("tool-succeeded"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run command",
- createdAt: "2026-04-01T00:00:04.000Z",
- turnId,
- payload: {
- title: "Run command",
- itemType: "command_execution",
- detail: "done",
- status: "completed",
- },
- }),
- makeActivity({
- id: EventId.make("tool-failed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run command",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- title: "Run command",
- itemType: "command_execution",
- detail: "zsh: command not found: nope",
- status: "completed",
- },
- }),
- ],
- });
+ it("uses a stable Thinking row while work has started without a projected item", () => {
+ const startedAt = "2026-04-01T00:00:01.000Z";
+ const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);
- const feed = buildThreadFeed(thread);
- expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([
- {
- type: "work-toggle",
- summary: "Ran 2 commands",
- hiddenCount: 2,
- hasFailure: true,
- },
+ expect(presented).toEqual([
+ { type: "thinking", id: "live-activity-row", createdAt: startedAt, runId: null },
]);
- expect(feed[0]).toMatchObject({
- type: "activity-group",
- activities: [{ status: "success" }, { status: "failure" }],
- });
- const expanded = deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(["work-group:tool-succeeded"]),
+ expect(deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt)[0]).toBe(
+ presented[0],
);
- expect(expanded.map((entry) => entry.id)).toEqual([
- "work-toggle:work-group:tool-succeeded",
- "work-details:work-group:tool-succeeded",
- ]);
- expect(expanded[1]).toMatchObject({
- type: "activity-group",
- activities: [
- { id: "tool-succeeded", status: "success", groupedToolDetail: true },
- { id: "tool-failed", status: "failure", groupedToolDetail: true },
- ],
- });
});
it("keeps expanded work in one group with stable row identities", () => {
@@ -2064,73 +874,67 @@ describe("buildThreadFeed", () => {
id: string,
createdAt: string,
status: ThreadFeedActivity["status"] = "success",
- toolSurface?: "browser" | "computer",
- toolIcon?: import("@t3tools/contracts").ToolActivityIcon,
): ThreadFeedActivity => ({
id,
createdAt,
- turnId: null,
+ runId: null,
+ attemptId: null,
summary: `Tool ${id}`,
detail: null,
canExpand: false,
getFullDetail: () => null,
getCopyText: () => id,
icon: "command",
+ logo: null,
toolLike: true,
+ prominent: false,
status,
+ lifecycleStatus: status === "neutral" ? "inProgress" : "completed",
workEntry: {
id,
createdAt,
- turnId: null,
label: `Tool ${id}`,
- command: `command ${id}`,
tone: "tool",
- ...(toolSurface ? { toolSurface } : {}),
- ...(toolIcon ? { toolIcon } : {}),
+ command: "vp check",
+ itemType: "command_execution",
+ toolLifecycleStatus: status === "neutral" ? "inProgress" : "completed",
},
+ projectedItem: projected(command(createdAt), 0),
});
const feed: ThreadFeedEntry[] = [
{
type: "activity-group",
id: "work-group-1",
createdAt: "2026-04-01T00:00:01.000Z",
- turnId: null,
+ runId: null,
activities: [
- activity("activity-1", "2026-04-01T00:00:01.000Z"),
- activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"),
- activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"),
- activity("activity-3", "2026-04-01T00:00:04.000Z", "success", "computer", {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- }),
+ activity("activity-neutral", "2026-04-01T00:00:01.000Z", "neutral"),
+ activity("activity-1", "2026-04-01T00:00:02.000Z"),
+ activity("activity-2", "2026-04-01T00:00:03.000Z"),
+ activity("activity-3", "2026-04-01T00:00:04.000Z"),
],
},
];
const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
- expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]);
+ expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-neutral"]);
expect(collapsed[0]).toMatchObject({
type: "work-toggle",
- groupId: "work-group:activity-1",
+ groupId: "work-group:activity-neutral",
hiddenCount: 3,
expanded: false,
summary: "Ran 3 commands",
- toolSurface: "computer",
- toolIcon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
});
const expanded = deriveThreadFeedPresentation(
feed,
null,
new Set(),
- new Set(["work-group:activity-1"]),
+ new Set(["work-group:activity-neutral"]),
);
expect(expanded.map((entry) => entry.id)).toEqual([
- "work-toggle:work-group:activity-1",
- "work-details:work-group:activity-1",
+ "work-toggle:work-group:activity-neutral",
+ "work-details:work-group:activity-neutral",
]);
expect(expanded[0]).toMatchObject({
type: "work-toggle",
@@ -2144,1191 +948,465 @@ describe("buildThreadFeed", () => {
{ id: "activity-3", groupedToolDetail: true, live: false },
],
});
- const unchanged = deriveThreadFeedPresentation(
- feed,
- null,
- new Set(),
- new Set(["work-group:activity-1", "unrelated-group"]),
- );
- expect(unchanged[0]).toBe(expanded[0]);
- expect(unchanged[1]).toBe(expanded[1]);
- expect(deriveThreadFeedPresentation(feed, null, new Set())).toEqual(collapsed);
});
- it.each(
- [
- "sudo -u root pnpm test",
- "/bin/zsh -lc 'sudo -u root pnpm test'",
- "/bin/bash -lc 'sudo -u root pnpm test'",
- ].flatMap((command) =>
- (
- [
- { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },
- { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true },
- { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },
- { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },
- { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },
- ] as const
- ).map((state) => ({ command, ...state })),
- ),
- )(
- "keeps the command summary in sync with $lifecycleStatus: $command",
- ({ command, lifecycleStatus, summary, shimmer }) => {
- const turnId = TurnId.make("turn-live-tools");
- const activity = (
- id: string,
- status: ThreadFeedActivity["status"],
- lifecycleStatus: ThreadFeedActivity["lifecycleStatus"],
- tone: "tool" | "error" = "tool",
- command?: string,
- ): ThreadFeedActivity => ({
- id,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- summary: `Tool ${id}`,
- detail: lifecycleStatus === "stopped" ? "Exit code 130" : null,
- canExpand: false,
- getFullDetail: () => null,
- getCopyText: () => id,
- icon: "command",
- toolLike: true,
- status,
- lifecycleStatus,
- workEntry: {
- id,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- label: `Tool ${id}`,
- tone,
- toolLifecycleStatus: lifecycleStatus,
- ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}),
- ...(command ? { command, itemType: "command_execution" as const } : {}),
- },
- });
- const feed: ThreadFeedEntry[] = [
- {
- type: "activity-group",
- id: "activity-1",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- activities: [
- activity("activity-1", "success", "completed"),
- activity("activity-2", "failure", "failed", "error"),
- activity(
- "activity-3",
- lifecycleStatus === "inProgress"
- ? "neutral"
- : lifecycleStatus === "completed"
- ? "success"
- : "failure",
- lifecycleStatus,
- "tool",
- command,
- ),
- ...(lifecycleStatus === "inProgress"
- ? [activity("activity-4", "success", "completed", "tool", "printf done")]
- : []),
- ],
- },
- ];
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
-
- const rows = deriveThreadFeedPresentation(
- feed,
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- );
- // The shimmering row is the turn's live slot; once it stops shimmering
- // the slot belongs to "Thinking" and the group keeps its own identity.
- expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
- ["work-toggle:work-group:activity-1", "work-toggle"],
- ["activity-2", "activity-group"],
- [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"],
- ]);
- expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
- false,
- false,
- true,
- ]);
- expect(rows[2]).toMatchObject({
- summary,
- summaryKind: "command",
- live: true,
- shimmer,
- });
- expect(rows[0]).toMatchObject({ live: false, shimmer: false });
- // Exactly one live activity: the shimmering call, or "Thinking" once it fails.
- expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1);
- expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking");
-
- const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
- expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false);
- expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { live: false, shimmer: false },
- {
- live: false,
- shimmer: false,
- summary: lifecycleStatus === "inProgress" ? "printf done" : command,
- },
- ]);
-
- const completedRows = deriveThreadFeedPresentation(
- feed,
- { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" },
- new Set([turnId]),
- new Set(),
- latestTurn.startedAt,
- );
- expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { live: false, shimmer: false },
- { live: false, shimmer: false },
- ]);
- },
- );
-
- it("shows one Thinking row while a turn works without live tool activity", () => {
- const turnId = TurnId.make("turn-thinking");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-thinking"),
- projectId: ProjectId.make("project-1"),
- title: "Thinking",
- latestTurn,
- messages: [
- {
- id: MessageId.make("user-1"),
- role: "user",
- text: "hello",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- },
- ],
- }),
- );
-
- const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
- expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);
- expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId });
- // The row identity is stable across re-derivations so the list can reuse it.
- expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
- rows[1],
- );
- // Idle threads show no live activity.
- expect(
- deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map(
- (entry) => entry.type,
- ),
- ).toEqual(["message"]);
+ it("retains Claude Read image previews without tool output", () => {
+ const item = {
+ ...base("image-read", "2026-06-20T00:00:04.000Z", 3),
+ type: "dynamic_tool" as const,
+ toolName: "Read",
+ input: { file_path: "/workspace/reference.png" },
+ viewedImagePath: "/workspace/reference.png",
+ } satisfies OrchestrationV2TurnItem;
+ const feed = buildThreadFeed([projected(item, 0)]);
+ const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
+ expect(activity?.workEntry.viewedImagePath).toBe("/workspace/reference.png");
});
- it("keeps one live slot while calls fail and restart", () => {
- // Recorded from a Claude session whose Bash was broken: every call went
- // inProgress → failed within two seconds. Each transition used to insert
- // or remove a Thinking row under the group; now the same row id holds
- // the live call and then "Thinking", so the list updates it in place.
- const turnId = TurnId.make("turn-failing-calls");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
+ it("pretty prints T3 MCP dynamic tool activities and attaches the product logo", () => {
+ const toolItem: OrchestrationV2TurnItem = {
+ ...base("item-t3-tool", "2026-06-20T00:00:04.000Z", 3),
+ type: "dynamic_tool",
+ toolName: "mcp__t3-code__t3_thread_read",
+ input: { threadId: "thread-child" },
+ output: { messages: [] },
};
- const call = (n: number, status: "inProgress" | "failed") =>
- makeActivity({
- id: EventId.make(`call-${n}-${status}`),
- kind: status === "failed" ? "tool.completed" : "tool.updated",
- tone: "tool",
- summary: "Command run",
- createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- itemType: "command_execution",
- toolCallId: `call-${n}`,
- title: "Command run",
- status,
- detail: `Bash: ls ${n}`,
- },
- });
- const liveIds = (activities: ReadonlyArray>) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-failing-calls"),
- projectId: ProjectId.make("project-1"),
- title: "Failing calls",
- latestTurn,
- activities,
- }),
- ),
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- ).map((row) => `${row.type}:${row.id}`);
- expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]);
- expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([
- "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
- "thinking:live-activity-row",
- ]);
- expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([
- "work-toggle:live-activity-row",
- ]);
- // A call whose end was never reported, in a run before an error row,
- // keeps its own identity: only the trailing run can hold the live slot.
- const errorRow = makeActivity({
- id: EventId.make("runtime-error"),
- kind: "runtime.error",
- tone: "error",
- summary: "Provider error",
- createdAt: "2026-04-01T00:00:02.500Z",
- turnId,
- payload: { message: "boom" },
- });
- expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([
- "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
- "activity-group:runtime-error",
- "work-toggle:live-activity-row",
- ]);
+ const feed = buildThreadFeed([projected(toolItem, 0)]);
+ const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
+
+ expect(activity?.summary).toBe("Read a T3 thread");
+ expect(activity?.logo).toBe("t3-code");
+ expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread");
});
- it("hands a settled tool run off to Thinking once assistant text streams after it", () => {
- const turnId = TurnId.make("turn-streaming-tail");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-streaming-tail"),
- projectId: ProjectId.make("project-1"),
- title: "Streaming tail",
- latestTurn,
- messages: [
- {
- id: MessageId.make("assistant-1"),
- role: "assistant",
- text: "Here is what I found",
- turnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:05.000Z",
- updatedAt: "2026-04-01T00:00:06.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("read-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read file",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- itemType: "file_read",
- toolCallId: "read-1",
- title: "Read file",
- status: "completed",
- detail: "src/index.ts",
+ it("uses canonical T3 orchestration summaries in compact work groups", () => {
+ const rows = [
+ projected(command("2026-06-20T00:00:01.000Z"), 0),
+ ...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map(
+ (toolName, index) =>
+ projected(
+ {
+ ...base(`item-send-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 2),
+ type: "dynamic_tool" as const,
+ toolName,
+ input: { threadId: `thread-${index}`, message: "Continue" },
+ output: { threadId: `thread-${index}`, messageId: `message-${index}` },
},
- }),
- ],
- }),
- );
+ index + 1,
+ ),
+ ),
+ projected(
+ {
+ ...command("2026-06-20T00:00:06.000Z"),
+ id: TurnItemId.make("item-command-2"),
+ ordinal: 6,
+ },
+ 4,
+ ),
+ ];
- const rows = deriveThreadFeedPresentation(
- feed,
- latestTurn,
+ const presented = deriveThreadFeedPresentation(
+ buildThreadFeed(rows),
+ { runId, status: "running", startedAt: null, completedAt: null },
new Set(),
- new Set(),
- latestTurn.startedAt,
);
- expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]);
- expect(rows[0]).toMatchObject({ live: false, shimmer: false });
- });
-
- it("preserves serialized shell wrappers with non-matching boundary quotes", () => {
- const turnId = TurnId.make("turn-serialized-shell-wrapper");
- const command =
- "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"";
- const thread = makeThread({
- id: ThreadId.make("thread-serialized-shell-wrapper"),
- projectId: ProjectId.make("project-1"),
- title: "Serialized shell wrapper",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("serialized-shell-wrapper"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Ran command",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- payload: {
- itemType: "command_execution",
- status: "inProgress",
- data: { item: { command } },
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- expect(feed[0]).toMatchObject({
- type: "activity-group",
- activities: [{ workEntry: { command } }],
- });
- if (feed[0]?.type === "activity-group") {
- expect(feed[0].activities[0]?.workEntry.rawCommand).toBeUndefined();
- }
- });
- it.each([
- ["inProgress", true],
- ["completed", false],
- ["failed", false],
- ["declined", false],
- ["stopped", false],
- ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => {
- const turnId = TurnId.make("turn-task-progress");
- const thread = makeThread({
- id: ThreadId.make("thread-task-progress"),
- projectId: ProjectId.make("project-1"),
- title: "Task lifecycle",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
+ expect(presented).toMatchObject([
+ {
+ type: "work-toggle",
+ summary: "Ran 2 commands and sent messages to 3 threads",
+ hiddenCount: 5,
+ hasFailure: false,
},
- activities: [
- makeActivity({
- id: EventId.make("task-progress"),
- kind: "task.progress",
- summary: "Task progress",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: { taskId: "task-1", status },
- }),
- ],
- });
-
- const rows = deriveThreadFeedPresentation(
- buildThreadFeed(thread),
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
- );
- expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer);
+ ]);
});
+});
- it("does not revive cached in-progress tools after work stops", () => {
- const turnId = TurnId.make("turn-stale-tool");
- const feed: ThreadFeedEntry[] = [
- {
- type: "activity-group",
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- activities: [
- {
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- summary: "Running tests",
- detail: null,
- canExpand: false,
- getFullDetail: () => null,
- getCopyText: () => "",
- icon: "command",
- toolLike: true,
- status: "neutral",
- lifecycleStatus: "inProgress",
- workEntry: {
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- label: "Running tests",
- tone: "tool",
- toolLifecycleStatus: "inProgress",
- },
- },
- ],
- },
+describe("retained v2 feed presentation", () => {
+ it("retains unchanged rows while the assistant streams", () => {
+ const rows = [
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected({ ...assistantMessage(), streaming: true }, 2),
];
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
+ const latestRun = {
+ runId,
+ status: "running" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
completedAt: null,
- assistantMessageId: null,
};
-
- expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]);
- expect(
- deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt),
- ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]);
- });
-
- it("collapses interleaved tool lifecycles by call identity", () => {
- const turnId = TurnId.make("turn-parallel-tools");
- const toolActivity = (
- id: string,
- toolCallId: string,
- kind: "tool.updated" | "tool.completed",
- status: "inProgress" | "completed",
- detail: string,
- nestedId = false,
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- tone: "tool",
- summary: `Run ${toolCallId} command`,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- payload: {
- ...(nestedId ? { data: { toolCallId } } : { toolCallId }),
- itemType: "command_execution",
- status,
- detail,
- },
- });
- const thread = makeThread({
- id: ThreadId.make("thread-parallel-tools"),
- projectId: ProjectId.make("project-1"),
- title: "Parallel tools",
- activities: [
- toolActivity("call-a-1", "call-a", "tool.updated", "inProgress", "starting"),
- toolActivity("call-b-2", "call-b", "tool.updated", "inProgress", "starting", true),
- toolActivity("call-a-3", "call-a", "tool.completed", "completed", "first output"),
- toolActivity("call-b-4", "call-b", "tool.completed", "completed", "second output", true),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const activityGroup = feed.find((entry) => entry.type === "activity-group");
- expect(activityGroup).toMatchObject({
- type: "activity-group",
- activities: [
- { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" },
- { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
- ],
- });
- expect(
- deriveThreadFeedPresentation(feed, null, new Set([turnId])).find(
- (entry) => entry.type === "work-toggle",
- ),
- ).toMatchObject({
- type: "work-toggle",
- hiddenCount: 2,
- summary: "Ran 2 commands",
- live: false,
- });
-
- const groupId = `work-group:tool:${turnId}:call-a`;
- const startedAt = "2026-04-01T00:00:00.000Z";
- const runningRows = deriveThreadFeedPresentation(
- buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }),
- { turnId, state: "running", startedAt, completedAt: null },
+ const before = buildThreadFeed(rows);
+ const beforePresentation = deriveThreadFeedPresentation(
+ before,
+ latestRun,
new Set(),
- new Set([groupId]),
- startedAt,
- );
- expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [
- { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false },
- { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true },
- ],
- });
-
- const completedRows = deriveThreadFeedPresentation(
- feed,
- null,
- new Set([turnId]),
- new Set([groupId]),
+ new Set(),
+ latestRun.startedAt,
);
- expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [
- { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
- { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
- ],
- });
-
- const correctedFeed = buildThreadFeed({
- ...thread,
- activities: thread.activities.map((activity) =>
- activity.id === "call-a-3"
- ? {
- ...activity,
- tone: "error",
- payload: {
- toolCallId: "call-a",
- itemType: "command_execution",
- status: "failed",
- detail: "Corrected failure output",
- },
- }
- : activity,
- ),
- });
- const correctedGroup = correctedFeed.find((entry) => entry.type === "activity-group");
- expect(correctedGroup).toMatchObject({
- activities: [
- { id: "call-a-1", lifecycleStatus: "failed", detail: "Corrected failure output" },
- { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
- ],
- });
- expect(correctedGroup?.activities[0]?.getCopyText()).toContain("Corrected failure output");
- expect(activityGroup?.activities[0]?.getCopyText()).toContain("first output");
- const correctedRows = deriveThreadFeedPresentation(
- correctedFeed,
- null,
- new Set([turnId]),
- new Set([groupId]),
+ const after = buildThreadFeed([
+ rows[0]!,
+ rows[1]!,
+ projected(
+ { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true },
+ 2,
+ ),
+ ]);
+ const afterPresentation = deriveThreadFeedPresentation(
+ after,
+ latestRun,
+ new Set(),
+ new Set(),
+ latestRun.startedAt,
);
- expect(correctedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: "call-a-1",
- activities: [{ status: "failure", workEntry: { tone: "error" } }],
- });
+ expect(after[0]).toBe(before[0]);
+ expect(after[1]).toBe(before[1]);
+ expect(after[2]).not.toBe(before[2]);
+ expect(afterPresentation[0]).toBe(beforePresentation[0]);
+ expect(afterPresentation[1]).toBe(beforePresentation[1]);
});
-});
-describe("quiet timeline: nested agents", () => {
- it.each(["task.updated", "task.progress"] as const)(
- "does not mark an ordinary task complete when it resumes through %s",
- (resumeKind) => {
- const thread = makeThread({
- id: ThreadId.make("resumed-agent"),
- projectId: ProjectId.make("project-1"),
- title: "Resumed agent",
- activities: (
- [
- ["task.progress", "running", "Review"],
- ["task.updated", "idle", "Task idle"],
- [resumeKind, "running", "Review resumed"],
- ] as const
- ).map(([kind, status, summary], index) =>
- makeActivity({
- id: EventId.make(`resumed-${index}`),
- kind,
- summary,
- createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
- payload: {
- taskId: "agent-1",
- agentKind: "agent",
- title: "Reviewer",
- status,
- detail: summary,
- },
- }),
- ),
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- // The agent folds into its spawn batch, which stays live after a resume.
- expect(rows).toMatchObject([
+ it.each(["running", "completed", "interrupted"] as const)(
+ "uses the compaction row as the live activity only while %s",
+ (status) => {
+ const compact = projected(
{
- lifecycleStatus: "inProgress",
- summary: "Kicked off 1 subagent · 1 working",
- workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } },
+ ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
+ type: "compaction",
+ status,
+ driver: null,
+ beforeTokenCount: 899_000,
+ ...(status === "completed" ? { afterTokenCount: 19_000 } : {}),
},
- ]);
+ 1,
+ );
+ const latestRun = {
+ runId,
+ status: "running" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ };
+ const rows = deriveThreadFeedPresentation(
+ buildThreadFeed([projected(userMessage(), 0), compact]),
+ latestRun,
+ new Set(),
+ new Set(),
+ latestRun.startedAt,
+ );
+ expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running");
+ expect(rows.find((row) => row.type === "activity-group")).toMatchObject({
+ activities: [
+ {
+ summary:
+ status === "running"
+ ? "Compacting context"
+ : status === "completed"
+ ? "Context compacted 899K → 19K tokens"
+ : "Context compacted",
+ },
+ ],
+ });
},
);
- it("folds a turn's direct spawns into one batch row that tracks their states", () => {
- const turnId = TurnId.make("turn-spawn");
- const agent = (
- id: string,
- kind: "task.started" | "task.progress" | "task.completed" | "task.updated",
- taskId: string,
- status: string,
- seconds: number,
- extra: Record = {},
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `${taskId} ${status}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId,
- agentKind: "agent",
- taskType: "local_agent",
- title: `Agent ${taskId}`,
- status,
- ...extra,
- },
- });
- const shell = makeActivity({
- id: EventId.make("shell-1"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- taskId: "sh-1",
- agentKind: "background",
- taskType: "local_bash",
- status: "completed",
- title: "Run tests",
- detail: "Run tests",
+ it("keeps a standalone compaction visible and folds it with other completed work", () => {
+ const compact = projected(
+ {
+ ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
+ type: "compaction",
+ driver: null,
+ summary: "Shorter context",
},
- });
- const activities = [
- agent("a-start", "task.started", "a", "running", 1),
- agent("b-start", "task.started", "b", "running", 2),
- agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }),
- shell,
- agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }),
- ];
- const rowsFor = (extraActivities: ReadonlyArray>) =>
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-spawn"),
- projectId: ProjectId.make("project-1"),
- title: "Spawns",
- activities: [...activities, ...extraActivities],
- }),
- ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
-
- // The batch anchors on the first task.started: a fixed id and timestamp,
- // unlike progress ticks (which the server rewrites in place).
- const running = rowsFor([]);
- expect(running.map((row) => [row.id, row.summary])).toEqual([
- ["a-start", "Kicked off 2 subagents · 2 working"],
- ["shell-1", "Run tests"],
+ 1,
+ );
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:04.000Z",
+ };
+ const onlyCompaction = deriveThreadFeedPresentation(
+ buildThreadFeed([projected(userMessage(), 0), compact]),
+ latestRun,
+ new Set(),
+ );
+ expect(onlyCompaction.map((entry) => entry.type)).toEqual(["message", "activity-group"]);
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ compact,
+ projected(command("2026-06-20T00:00:03.000Z"), 2),
+ projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3),
]);
- expect(running[0]).toMatchObject({
- createdAt: "2026-04-01T00:00:01.000Z",
- lifecycleStatus: "inProgress",
- workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
- });
-
- const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
- expect(oneDone[0]).toMatchObject({
- id: "a-start",
- summary: "Kicked off 2 subagents · 1 working",
- lifecycleStatus: "inProgress",
- });
+ expect(
+ deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type),
+ ).toEqual(["message", "run-fold", "message"]);
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
+ expect(
+ expanded.find(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities[0]?.projectedItem.item.type === "compaction",
+ ),
+ ).toMatchObject({ activities: [{ summary: "Context compacted" }] });
+ });
- const allDone = rowsFor([
- agent("a-done", "task.completed", "a", "completed", 7),
- agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
+ it("retains assistant image attachments from the wire", () => {
+ const image = {
+ type: "image" as const,
+ id: "assistant-image",
+ name: "result.png",
+ mimeType: "image/png",
+ sizeBytes: 100,
+ };
+ const feed = buildThreadFeed([
+ projected({ ...assistantMessage(), text: "", attachments: [image] }, 0),
+ ]);
+ expect(feed).toMatchObject([
+ { type: "message", message: { role: "assistant", attachments: [image] } },
]);
- expect(allDone[0]).toMatchObject({
- id: "a-start",
- summary: "Ran 2 subagents · 1 failed",
- lifecycleStatus: "failed",
- status: "failure",
- });
- expect(allDone).toHaveLength(2);
});
- it("folds the tool call that launched an agent into its spawn card", () => {
- const turnId = TurnId.make("turn-agent-tool");
- const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-agent-tool"),
- projectId: ProjectId.make("project-1"),
- title: "Agent tool",
- activities: [
- makeActivity({
- id: EventId.make("agent-call-updated"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Subagent task",
- createdAt: at(1),
- turnId,
- payload: {
- itemType: "collab_agent_tool_call",
- toolCallId: "toolu_agent",
- status: "inProgress",
- title: "Subagent task",
- detail: "Locate code",
- data: { toolName: "Agent" },
- },
- }),
- makeActivity({
- id: EventId.make("agent-started"),
- kind: "task.started",
- summary: "Locate code",
- createdAt: at(2),
- turnId,
- payload: {
- taskId: "a1",
- agentKind: "agent",
- taskType: "local_agent",
- title: "Locate code",
- toolUseId: "toolu_agent",
- },
- }),
- makeActivity({
- id: EventId.make("agent-done"),
- kind: "task.completed",
- summary: "Locate code",
- createdAt: at(3),
- turnId,
- payload: {
- taskId: "a1",
- agentKind: "agent",
- taskType: "local_agent",
- title: "Locate code",
- toolUseId: "toolu_agent",
- status: "completed",
- },
- }),
- makeActivity({
- id: EventId.make("agent-call-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Subagent task",
- createdAt: at(4),
- turnId,
- payload: {
- itemType: "collab_agent_tool_call",
- toolCallId: "toolu_agent",
- status: "completed",
- title: "Subagent task",
- detail: "Locate code",
- data: { toolName: "Agent" },
- },
- }),
- ],
- }),
+ it("keeps native application icons and source identity in collapsed and expanded work", () => {
+ const icon = {
+ _tag: "native-app" as const,
+ app: { _tag: "app-id" as const, appId: "com.example.Editor" },
+ };
+ const source = {
+ key: "native-app:com.example.editor",
+ name: "Editor",
+ kind: "computer" as const,
+ icon,
+ };
+ const rows = [0, 1].map((index) =>
+ projected(
+ {
+ ...base(`native-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 1),
+ type: "dynamic_tool" as const,
+ toolName: "computer.click",
+ input: { x: index, y: 1 },
+ output: null,
+ toolSurface: "computer" as const,
+ toolIcon: icon,
+ toolSource: source,
+ },
+ index,
+ ),
);
- const rows = feed.flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
+ const feed = buildThreadFeed(rows);
+ const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null };
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ const toggle = collapsed[0];
+ if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group");
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ latestRun,
+ new Set(),
+ new Set([toggle.groupId]),
);
- expect(rows).toEqual(["agent-started"]);
- expect(
- deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type),
- ).toEqual(["turn-fold", "agent-spawn"]);
+ expect(presented[0]).toMatchObject({
+ type: "work-toggle",
+ summary: "Used Editor",
+ toolSurface: "computer",
+ toolIcon: icon,
+ });
+ expect(presented[1]).toMatchObject({
+ type: "activity-group",
+ activities: [
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
+ ],
+ });
});
- it("presents a spawn batch as one card whose status line follows the newest member activity", () => {
- const turnId = TurnId.make("turn-spawn-card");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
- const agent = (
- id: string,
- kind: "task.started" | "task.progress" | "task.completed",
- taskId: string,
- seconds: number,
- extra: Record = {},
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `Agent ${taskId}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId,
- agentKind: "agent",
- taskType: "local_agent",
- title: `Agent ${taskId}`,
- ...extra,
- },
- });
- const presentFor = (activities: ReadonlyArray>) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-spawn-card"),
- projectId: ProjectId.make("project-1"),
- title: "Spawn card",
- latestTurn,
- activities,
- }),
+ it.each([
+ ["failed", "Failed to click in the preview browser", true],
+ ["cancelled", "Stopped clicking in the preview browser", false],
+ ] as const)(
+ "keeps %s calls terminal while the parent run remains live",
+ (status, summary, hasFailure) => {
+ const feed = buildThreadFeed([
+ projected(
+ {
+ ...base("preview-click", "2026-06-20T00:00:02.000Z", 1),
+ type: "dynamic_tool",
+ status,
+ toolName: "mcp__t3-code__preview_click",
+ input: { element: "button" },
+ output: null,
+ },
+ 0,
),
- latestTurn,
+ ]);
+ const rows = deriveThreadFeedPresentation(
+ feed,
+ { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
new Set(),
new Set(),
- latestTurn.startedAt,
+ "2026-06-20T00:00:01.000Z",
);
+ expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false });
+ },
+ );
- // A working card is the live activity; no Thinking row sits under it.
- const single = presentFor([agent("a-start", "task.started", "a", 1)]);
- expect(single.map((row) => row.type)).toEqual(["agent-spawn"]);
- expect(single[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- summary: { title: "Agent a", status: "Working", tone: "working" },
- });
-
- // The server upserts the progress row with a new createdAt each tick;
- // the card keeps its identity and only the status line changes.
- const tick = (seconds: number, detail: string) =>
- presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("task-progress:a", "task.progress", "a", seconds, { detail }),
- ]);
- expect(tick(2, "Reading a.ts")[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- createdAt: "2026-04-01T00:00:01.000Z",
- summary: { title: "Agent a", status: "Reading a.ts", tone: "working" },
- });
- expect(tick(3, "Reading b.ts")[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- createdAt: "2026-04-01T00:00:01.000Z",
- summary: { status: "Reading b.ts" },
- });
-
- const batch = presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("b-start", "task.started", "b", 2),
- agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }),
- agent("a-done", "task.completed", "a", 4, { status: "completed" }),
+ it("shows an idle native subagent without claiming completion", () => {
+ const rows = buildThreadFeed([
+ projected(
+ {
+ ...base("native-agent", "2026-06-20T00:00:02.000Z", 1),
+ type: "subagent",
+ status: "idle",
+ subagentId: NodeId.make("native-agent"),
+ origin: "provider_native",
+ driver: ProviderDriverKind.make("antigravity"),
+ providerInstanceId: ProviderInstanceId.make("antigravity"),
+ childThreadId: null,
+ title: "Search",
+ prompt: "Find relevant files",
+ result: null,
+ },
+ 0,
+ ),
]);
- expect(batch[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- summary: {
- title: "2 subagents",
- status: "Grepping",
- tone: "working",
- members: [
- { title: "Agent a", status: "completed", tone: "completed" },
- { title: "Agent b", status: "working", tone: "working", detail: "Grepping" },
- ],
- },
+ expect(rows[0]).toMatchObject({
+ type: "activity-group",
+ activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: true }],
});
-
- const settled = presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("b-start", "task.started", "b", 2),
- agent("a-done", "task.completed", "a", 4, { status: "completed" }),
- agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }),
+ expect(deriveThreadFeedPresentation(rows, null, new Set())).toMatchObject([
+ { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] },
]);
- expect(settled[0]).toMatchObject({
- type: "agent-spawn",
- summary: { title: "2 subagents", status: "1 failed", tone: "failed" },
- });
- expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]);
});
+});
- it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
- "replaces Antigravity batch progress with %s",
- (status) => {
- const detail =
- status === "idle"
- ? "Turn ended. Individual agent status is unavailable."
- : "Antigravity process stopped.";
- const thread = makeThread({
- id: ThreadId.make("antigravity-agents"),
- projectId: ProjectId.make("project-1"),
- title: "Antigravity subagents",
- activities: [
- ...["trajectory:4", "trajectory:5"].map((taskId, index) =>
- makeActivity({
- id: EventId.make(`progress-${index}`),
- kind: "task.progress",
- summary: "Antigravity subagent batch",
- createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
- payload: {
- taskId,
- taskType: "subagent_batch",
- agentKind: "agent",
- title: "Antigravity subagent batch",
- detail: "Antigravity subagent batch",
- status: "running",
- },
- }),
- ),
- makeActivity({
- id: EventId.make("agent-stopped"),
- kind: "task.updated",
- summary: `Task ${status}`,
- createdAt: "2026-04-01T00:00:03.000Z",
- payload: {
- taskId: "trajectory:4",
- taskType: "subagent_batch",
- agentKind: "agent",
- title: "Antigravity subagent batch",
- status,
- ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }),
- },
- }),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- // Turn-less batches never share a spawn group, so each keeps its own row.
- expect(rows).toHaveLength(2);
- expect(rows[0]).toMatchObject({
- lifecycleStatus: status === "failed" ? "failed" : "stopped",
- summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`,
- workEntry: {
- taskId: "trajectory:4",
- toolTitle: "Antigravity subagent batch",
- agentSpawn: { agents: [{ detail }] },
- },
- });
- expect(rows[0]?.getFullDetail()).toContain(detail);
- expect(rows[1]).toMatchObject({
- lifecycleStatus: "inProgress",
- summary: "Kicked off 1 subagent · 1 working",
- workEntry: { taskId: "trajectory:5" },
- });
- },
- );
+const singleSelectQuestion = {
+ id: "runtime",
+ header: "Runtime",
+ question: "Which runtime should be used?",
+ options: [
+ { label: "Go", description: "One binary" },
+ { label: "Node.js", description: "Reuse TypeScript" },
+ ],
+ multiSelect: false,
+} as const;
- it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => {
- const turnId = TurnId.make("turn-workflow");
- const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
- const thread = makeThread({
- id: ThreadId.make("thread-workflow"),
- projectId: ProjectId.make("project-1"),
- title: "Workflow",
- activities: [
- makeActivity({
- id: EventId.make("wf-progress"),
- kind: "task.progress",
- summary: "Workflow running",
- createdAt: at(1),
- turnId,
- payload: {
- taskId: "wf-1",
- taskType: "local_workflow",
- workflowName: "review",
- agentKind: "agent",
- title: "review",
- status: "running",
- },
- }),
- // Members are synthesized with timelineBypass and never render alone.
- ...[0, 1].map((index) =>
- makeActivity({
- id: EventId.make(`member-${index}`),
- kind: "task.progress",
- summary: `Agent ${index}`,
- createdAt: at(2 + index),
- turnId,
- payload: {
- taskId: `wf-1:wf:${index}`,
- agentKind: "agent",
- title: `Reviewer ${index}`,
- description: `Reviewer ${index}`,
- status: index === 0 ? "completed" : "running",
- parentAgentId: "wf-1",
- timelineBypass: true,
- },
- }),
- ),
- makeActivity({
- id: EventId.make("wf-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: at(10),
- turnId,
- payload: {
- taskId: "wf-1",
- taskType: "local_workflow",
- workflowName: "review",
- agentKind: "agent",
- status: "completed",
- title: "review",
- },
- }),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
+const multiSelectQuestion = {
+ id: "scope",
+ header: "Scope",
+ question: "Which data should be collected?",
+ options: [
+ { label: "Orders", description: "Receipts" },
+ { label: "Listings", description: "Inventory" },
+ ],
+ multiSelect: true,
+} as const;
+
+describe("pending user input answers", () => {
+ it("replaces single-select options and toggles multi-select options", () => {
+ expect(
+ togglePendingUserInputOptionSelection(
+ singleSelectQuestion,
+ { selectedOptionValues: ["Go"] },
+ "Node.js",
+ ),
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
+
+ const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
+ const ordersAndListings = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ orders,
+ "Listings",
);
- expect(rows).toHaveLength(1);
- // The member that never reported its own end settles with the coordinator.
- expect(rows[0]).toMatchObject({
- id: "wf-progress",
- summary: "Ran 2 subagents · completed",
- lifecycleStatus: "completed",
- workEntry: {
- agentSpawn: {
- workflowId: "wf-1",
- agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"],
- },
- },
+ expect(ordersAndListings).toEqual({
+ customAnswer: "",
+ selectedOptionValues: ["Orders", "Listings"],
});
- expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
+
+ const paddedOrders = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ undefined,
+ " Orders ",
+ );
+ expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
+ ).toEqual({ customAnswer: "" });
});
- it("summarizes a spawn card from the newest member report and the batch outcome", () => {
- type Member = NonNullable["agents"][number];
- const member = (title: string, status: Member["status"], detail: string, seconds: number) =>
- ({
- title,
- status,
- detail,
- updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- }) satisfies Member;
- const direct = (agents: ReadonlyArray) => ({
- workflowId: null,
- agentTaskIds: agents.map((_, index) => `a${index}`),
- agents,
+ it("builds array answers for multi-select questions", () => {
+ expect(
+ buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
+ runtime: { selectedOptionValues: ["Go"] },
+ scope: { selectedOptionValues: ["Orders", "Listings"] },
+ }),
+ ).toEqual({
+ runtime: "Go",
+ scope: ["Orders", "Listings"],
});
+ });
- // The newest report wins regardless of member order.
+ it("clears selected options while a custom answer is active", () => {
expect(
- agentSpawnSummary(
- direct([
- member("Agent 0", "inProgress", "Reading b.ts", 5),
- member("Agent 1", "inProgress", "Reading a.ts", 2),
- ]),
- "inProgress",
+ setPendingUserInputCustomAnswer(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders", "Listings"] },
+ "Orders first",
),
- ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" });
+ ).toEqual({ customAnswer: "Orders first" });
+ });
- // A declined request is a failed batch, not a completed one.
+ it("matches selected chips against normalized option labels", () => {
expect(
- agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"),
- ).toMatchObject({ status: "failed", tone: "failed" });
-
- // A coordinator that failed on its own reports the failure even when every
- // member succeeded; before any member reports, the card has a neutral title.
- const workflow = (agents: ReadonlyArray) => ({
- workflowId: "wf",
- agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)],
- agents: [member("review", "failed", "", 9), ...agents],
- });
- expect(
- agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"),
- ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" });
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"] },
+ " Orders ",
+ ),
+ ).toBe(true);
expect(
- agentSpawnSummary(
- { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] },
- "inProgress",
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
+ " Orders ",
),
- ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] });
+ ).toBe(false);
});
+});
- it("treats a Codex child's idle turn end as a finished batch member", () => {
- const turnId = TurnId.make("turn-codex");
- const child = (
- id: string,
- kind: "task.started" | "task.updated",
- status: string,
- seconds: number,
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `${status}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId: "child-1",
- agentKind: "agent",
- title: "math_one",
- status,
- timelineBypass: true,
- },
- });
- const thread = makeThread({
- id: ThreadId.make("thread-codex"),
- projectId: ProjectId.make("project-1"),
- title: "Codex children",
- activities: [
- child("c-start", "task.started", "running", 1),
- child("c-running", "task.updated", "running", 2),
- child("c-idle", "task.updated", "idle", 5),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- expect(rows).toHaveLength(1);
- expect(rows[0]).toMatchObject({
- summary: "Ran 1 subagent · completed",
- lifecycleStatus: "completed",
- });
- });
+describe("provider question values", () => {
+ const question = {
+ ...singleSelectQuestion,
+ allowCustomAnswer: false,
+ options: [
+ { label: "Same label", value: " exact first ", description: "First" },
+ { label: "Same label", value: "second", description: "Second" },
+ ],
+ } as const;
- it("keeps a nested agent's terminal row but hides its background work", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-nested"),
- projectId: ProjectId.make("project-1"),
- title: "Nested agents",
- activities: [
- // A subagent's own shell: internal, covered by the owner's liveness.
- makeActivity({
- id: EventId.make("shell-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" },
- }),
- // A nested AGENT's completion: mobile has no Agents sheet, so this
- // terminal row is the only signal it ever finished.
- makeActivity({
- id: EventId.make("nested-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:03.000Z",
- payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" },
- }),
- ],
+ it("submits raw option values and distinguishes duplicate labels", () => {
+ const first = togglePendingUserInputOptionSelection(question, undefined, " exact first ");
+ expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true);
+ expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false);
+ expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({
+ runtime: " exact first ",
});
+ expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first);
+ });
- const feed = buildThreadFeed(thread);
- const ids = feed.flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
- );
- expect(ids).toContain("nested-done");
- expect(ids).not.toContain("shell-done");
- expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
- {
- type: "agent-spawn",
- id: "agent-spawn:n-1",
- activity: { id: "nested-done" },
- summary: { title: "Task completed", status: "completed", tone: "completed" },
- },
- ]);
+ it("rejects arbitrary text when the provider only accepts offered options", () => {
+ expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({});
+ expect(
+ buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }),
+ ).toBeNull();
+ expect(
+ buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }),
+ ).toBeNull();
+ expect(
+ buildPendingUserInputAnswers([question], {
+ runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" },
+ }),
+ ).toEqual({ runtime: "second" });
});
});
@@ -3360,10 +1438,9 @@ it("accepts ready attachment-only answers while preserving selected options", ()
).toBeNull();
});
-it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => {
- const turnId = TurnId.make("turn-answer");
+it("makes attachment-only question answers expandable in the mobile feed", () => {
const answer = {
- requestId: ApprovalRequestId.make("question-request"),
+ requestId: RuntimeRequestId.make("question-request"),
answers: { q: "" },
questionTextById: { q: "Attach the specification" },
attachmentsByQuestionId: {
@@ -3378,76 +1455,64 @@ it("keeps attachment-only question answers expandable outside mobile work groups
],
},
};
- const thread = makeThread({
- id: ThreadId.make("thread-answer"),
- projectId: ProjectId.make("project-answer"),
- title: "Answer history",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-09-08T00:00:00.000Z",
- startedAt: "2026-09-08T00:00:00.000Z",
- completedAt: "2026-09-08T00:00:04.000Z",
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("tool-before-answer"),
- createdAt: "2026-09-08T00:00:01.000Z",
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- turnId,
- payload: { itemType: "command_execution", status: "completed" },
- }),
- makeActivity({
- id: EventId.make("answer-submitted"),
- createdAt: "2026-09-08T00:00:02.000Z",
- kind: "user-input.answer-submitted",
- summary: "Answered questions",
- turnId,
- payload: answer,
- }),
- makeActivity({
- id: EventId.make("tool-after-answer"),
- createdAt: "2026-09-08T00:00:03.000Z",
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- turnId,
- payload: { itemType: "command_execution", status: "completed" },
- }),
- ],
- });
- const feed = buildThreadFeed(thread);
- expect(feed).toHaveLength(3);
- const group = feed[1];
+ const [group] = buildThreadFeed([
+ projected(
+ {
+ ...base("answer-history", "2026-09-08T00:00:00.000Z", 0),
+ type: "user_input_request",
+ requestId: answer.requestId,
+ questions: [],
+ questionAnswer: answer,
+ },
+ 0,
+ ),
+ ]);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
expect(group.activities[0]).toMatchObject({
canExpand: true,
workEntry: { questionAnswer: answer },
});
- expect(group.activities[0]?.getFullDetail()).toBeNull();
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]);
- expect(collapsed[1]).toBe(group);
- const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
- expect(expanded.map((entry) => entry.type)).toEqual([
- "turn-fold",
- "work-toggle",
- "activity-group",
- "work-toggle",
+ expect(group.activities[0]?.getFullDetail()).toContain("spec.txt");
+});
+
+it("renders automatic completion as a neutral activity while retaining its details", () => {
+ const item = {
+ ...base("notification", "2026-06-20T00:00:01.000Z", 0),
+ type: "notification" as const,
+ source: { kind: "monitor" as const },
+ outcome: "updated" as const,
+ summary: "Monitor reported an update",
+ detail: "Build checks changed",
+ };
+ const feed = buildThreadFeed([
+ projected(item, 0),
+ projected(command(), 1),
+ projected(assistantMessage(), 2),
]);
- expect(expanded[2]).toBe(group);
- const running = deriveThreadFeedPresentation(
+ expect(feed[0]?.type).toBe("activity-group");
+ if (feed[0]?.type !== "activity-group") throw new Error("Expected notification activity");
+ const activity = feed[0].activities[0]!;
+ expect(activity.summary).toBe("Monitor reported an update");
+ expect(activity.detail).toBeNull();
+ expect(activity.status).toBeNull();
+ expect(activity.getFullDetail()).toContain(item.detail);
+ const presented = deriveThreadFeedPresentation(
feed,
- { ...thread.latestTurn!, state: "running", completedAt: null },
- new Set(),
+ {
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ },
new Set(),
- "2026-09-08T00:00:00.000Z",
);
- expect(running[0]?.type).toBe("work-toggle");
- expect(running[1]).toBe(group);
- expect(running[2]?.type).toBe("work-toggle");
+ expect(
+ presented.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some((activity) => activity.summary === "Monitor reported an update"),
+ ),
+ ).toBe(true);
+ expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message");
});
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 396addf14c2a..bf0a1b9f42f5 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1,45 +1,52 @@
-import * as Option from "effect/Option";
-import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
-import * as Schema from "effect/Schema";
-import {
- requestKindFromRequestType,
- type PendingApproval,
-} from "@t3tools/client-runtime/pending-requests";
-import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";
import type {
- OrchestrationLatestTurn,
- OrchestrationThread,
- OrchestrationThreadActivity,
- ToolLifecycleItemType,
- TurnId,
- UserInputQuestion,
-} from "@t3tools/contracts";
-import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+ ThreadPendingApproval,
+ ThreadPendingUserInput,
+ ThreadUserInputQuestion,
+} from "@t3tools/client-runtime/state/thread-requests";
+import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
+import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
+import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
+import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
import {
- commandDetailRepeatsCommand,
- extractCommandOutputText,
- extractWorkLogToolLifecycleStatus,
- isWorktreeSetupActivity,
+ contextCompactionLabel,
+ toolItemForDisplay,
+ workEntryDisplayIndicatesToolFailure,
liveActivityToolStatus,
- normalizeCompactToolLabel,
- omitSupersededLifecycleMarkers,
+ toolGroupAction,
resolveWorkEntryToolPresentation,
summarizeToolGroup,
- toolGroupAction,
toolGroupSummaryKind,
- workEntryIndicatesToolFailure,
- workEntryIndicatesToolSuccess,
- workLogEntryIsToolLike,
type ToolGroupSummaryKind,
+ type WorkLogPresentationEntry,
type WorkLogToolLifecycleStatus,
} from "@t3tools/client-runtime/work-log/presentation";
-import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
-import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
-
-import * as Arr from "effect/Array";
-import * as Order from "effect/Order";
+import {
+ resolveT3McpToolPresentation,
+ type T3McpToolLogo,
+ type T3McpToolPresentation,
+} from "@t3tools/shared/t3McpToolPresentation";
+import type {
+ ChatAttachment,
+ MessageId,
+ OrchestrationV2Actor,
+ OrchestrationV2CreationSource,
+ OrchestrationV2ExecutionNode,
+ OrchestrationMessage,
+ OrchestrationV2ProjectedTurnItem,
+ OrchestrationV2RunAttempt,
+ OrchestrationV2RunStatus,
+ OrchestrationV2TurnItem,
+ OrchestrationV2UserMessageInputIntent,
+ RunId,
+ RunAttemptId,
+ ScheduledTaskId,
+} from "@t3tools/contracts";
+import { ThreadId } from "@t3tools/contracts";
+import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+import * as DateTime from "effect/DateTime";
-export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
+export type PendingApproval = ThreadPendingApproval;
+export type PendingUserInput = ThreadPendingUserInput;
export interface PendingUserInputDraftAnswer {
readonly selectedOptionValues?: ReadonlyArray;
@@ -51,7 +58,8 @@ export interface PendingUserInputDraftAnswer {
export interface ThreadFeedActivity {
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
+ readonly attemptId: RunAttemptId | null;
readonly summary: string;
readonly detail: string | null;
readonly canExpand: boolean;
@@ -61,9 +69,9 @@ export interface ThreadFeedActivity {
| "agent"
| "alert"
| "browser"
+ | "computer"
| "check"
| "command"
- | "computer"
| "edit"
| "eye"
| "globe"
@@ -72,64 +80,34 @@ export interface ThreadFeedActivity {
| "warning"
| "wrench"
| "zap";
+ readonly logo: T3McpToolLogo | null;
readonly toolLike: boolean;
+ readonly prominent: boolean;
readonly status: "success" | "failure" | "neutral" | null;
- readonly lifecycleStatus?: WorkLogToolLifecycleStatus;
- readonly workEntry: WorkLogEntry;
+ readonly lifecycleStatus: WorkLogToolLifecycleStatus;
+ readonly workEntry: WorkLogPresentationEntry;
readonly groupedToolDetail?: boolean;
readonly live?: boolean;
-}
-
-export interface WorkLogEntry {
- readonly questionAnswer?: UserInputAttachmentAnswerPayload;
- id: string;
- createdAt: string;
- turnId: TurnId | null;
- label: string;
- detail?: string;
- viewedImagePath?: string;
- command?: string;
- rawCommand?: string;
- changedFiles?: ReadonlyArray;
- tone: "thinking" | "tool" | "info" | "error";
- toolTitle?: string;
- toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
- toolIcon?: import("@t3tools/contracts").ToolActivityIcon;
- toolSource?: import("@t3tools/contracts").ToolActivitySource;
- itemType?: ToolLifecycleItemType;
- requestKind?: PendingApproval["requestKind"];
- toolLifecycleStatus?: WorkLogToolLifecycleStatus;
- sourceActivityKind?: OrchestrationThreadActivity["kind"];
- toolCallId?: string;
- /**
- * One row per workflow run or per-turn batch of direct spawns, like web's
- * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row
- * also carries each agent's terminal state to derive its status label.
- */
- agentSpawn?: {
- readonly workflowId: string | null;
- readonly agentTaskIds: ReadonlyArray;
- readonly agents: ReadonlyArray<{
- readonly title: string;
- readonly status: WorkLogToolLifecycleStatus | undefined;
- readonly detail: string | undefined;
- /** When this member last reported, so the card can show the newest activity. */
- readonly updatedAt: string;
- }>;
- };
- toolData?: unknown;
-}
-
-interface DerivedWorkLogEntry extends WorkLogEntry {
- sourceActivityKind: OrchestrationThreadActivity["kind"];
- collapseKey?: string;
- /** Grouping key for subagent lifecycle rows (one row per agent). */
- taskId?: string;
- /** The tool call that launched this agent, when the provider reports one. */
- agentSpawnToolCallId?: string;
- isWorkflowCoordinator?: boolean;
- /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
- isBackgroundTask?: boolean;
+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
+}
+
+export interface ThreadFeedMessage {
+ readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
+ readonly id: MessageId;
+ readonly role: "user" | "assistant";
+ readonly text: string;
+ readonly attachments: ReadonlyArray;
+ readonly runId: RunId | null;
+ readonly streaming: boolean;
+ readonly inputIntent?: OrchestrationV2UserMessageInputIntent;
+ readonly createdBy?: OrchestrationV2Actor;
+ readonly creationSource?: OrchestrationV2CreationSource;
+ readonly scheduledTaskId?: ScheduledTaskId;
+ readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
+ readonly sourceThreadId: ThreadId;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
}
type RawThreadFeedEntry =
@@ -137,13 +115,13 @@ type RawThreadFeedEntry =
readonly type: "message";
readonly id: string;
readonly createdAt: string;
- readonly message: OrchestrationThread["messages"][number];
+ readonly message: ThreadFeedMessage;
}
| {
readonly type: "activity";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly activity: ThreadFeedActivity;
};
@@ -153,65 +131,50 @@ export type ThreadFeedEntry =
readonly type: "activity-group";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly activities: ReadonlyArray;
}
| {
readonly type: "work-toggle";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly groupId: string;
readonly hiddenCount: number;
readonly expanded: boolean;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
- readonly toolSurface?: WorkLogEntry["toolSurface"];
- readonly toolIcon?: WorkLogEntry["toolIcon"];
+ readonly toolSurface?: WorkLogPresentationEntry["toolSurface"];
+ readonly toolIcon?: WorkLogPresentationEntry["toolIcon"];
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
}
| {
- readonly type: "turn-fold";
+ readonly type: "run-fold";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId;
+ readonly runId: RunId;
readonly label: string;
readonly expanded: boolean;
}
| {
- /**
- * The turn's single live slot. Web keys its live tool row and its
- * "Thinking" row identically so the slot updates in place; here the
- * slot holds "Thinking" whenever no tool row is shimmering, so a tool
- * failing does not insert a row under the group it lives in.
- */
readonly type: "thinking";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
- }
- | {
- /**
- * One batch of spawned subagents. Rendered as its own card because a
- * single-line tool row has no room for what the agents are doing now,
- * which on a phone is the one thing worth showing.
- */
- readonly type: "agent-spawn";
- readonly id: string;
- readonly createdAt: string;
- readonly turnId: TurnId | null;
- readonly activity: ThreadFeedActivity;
- readonly expanded: boolean;
- readonly summary: AgentSpawnSummary;
+ readonly runId: RunId | null;
};
+export interface ThreadFeedLatestRun {
+ readonly runId: RunId;
+ readonly status: OrchestrationV2RunStatus;
+ readonly startedAt: string | null;
+ readonly completedAt: string | null;
+}
+
export interface AgentSpawnSummary {
- /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */
readonly title: string;
- /** Latest member activity while working, else the batch outcome. */
readonly status: string;
readonly tone: "working" | "completed" | "failed" | "stopped";
readonly members: ReadonlyArray<{
@@ -223,44 +186,66 @@ export interface AgentSpawnSummary {
}>;
}
-export type ThreadFeedLatestTurn = Pick<
- OrchestrationLatestTurn,
- "turnId" | "state" | "startedAt" | "completedAt"
->;
+function compactWorkEntryText(value: string): string {
+ return value.replace(/\s+/gu, " ").trim();
+}
+
+function stripShellWrapper(value: string): string {
+ const trimmed = value.trim();
+ const match = /^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/u.exec(trimmed);
+ return (match?.[1] ?? trimmed).trim();
+}
+
+/** Expanded work rows keep their detail while compact rows show a stable one-line label. */
+export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string {
+ const presentation = resolveWorkEntryToolPresentation(entry);
+ if (presentation) return presentation.displayName;
+ if (expanded && entry.command?.trim()) return "Command";
+ const preview =
+ entry.command ??
+ entry.detail ??
+ (entry.changedFiles?.length
+ ? entry.changedFiles.length === 1
+ ? entry.changedFiles[0]!
+ : `${entry.changedFiles[0]!} +${entry.changedFiles.length - 1} more`
+ : null);
+ if (expanded) return preview?.trim() || entry.label;
+ return preview ? compactWorkEntryText(stripShellWrapper(preview)) || entry.label : entry.label;
+}
type ThreadFeedActivityGroup = Extract;
-// These keys are immutable inputs. Weak caches release old histories with their source data.
-const activityEntriesCache = new WeakMap<
- ReadonlyArray,
- ReadonlyArray>
+// Immutable source rows let retained history keep its identities while the active item streams.
+const projectedEntriesCache = new WeakMap<
+ OrchestrationV2ProjectedTurnItem,
+ {
+ readonly attemptId: RunAttemptId | null;
+ readonly entry: RawThreadFeedEntry;
+ }
>();
-const messageEntriesCache = new WeakMap<
- OrchestrationThread["messages"][number],
+const localMessageEntriesCache = new WeakMap<
+ OrchestrationMessage,
Extract
>();
const activityGroupsCache = new WeakMap();
const presentedActivityGroupsCache = new WeakMap<
ThreadFeedActivityGroup,
{
- readonly unsettledTurnId: TurnId | null;
+ readonly activeRunId: RunId | null;
readonly isWorking: boolean;
readonly activeTail: boolean;
readonly rows: ReadonlyArray;
}
>();
-const turnFoldRowsCache = new WeakMap<
+const runFoldRowsCache = new WeakMap<
ThreadFeedEntry,
- Extract
+ Extract
>();
let cachedThinkingRow: Extract | null = null;
-export function isContextCompactionActivityGroup(
- entry: Extract,
-): boolean {
+export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return (
- entry.activities.length === 1 &&
- entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction"
+ entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "compaction"
);
}
@@ -269,15 +254,13 @@ function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
}
function normalizeDraftAnswer(value: string | undefined): string | null {
- if (typeof value !== "string") {
- return null;
- }
+ if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolvePendingUserInputOptionValue(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
value: string,
): string | null {
if (question.options.some((option) => option.value === value)) {
@@ -292,7 +275,7 @@ function resolvePendingUserInputOptionValue(
}
function normalizeSelectedOptionValues(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
value: ReadonlyArray | undefined,
): ReadonlyArray {
if (!Array.isArray(value)) {
@@ -309,7 +292,7 @@ function normalizeSelectedOptionValues(
}
function resolvePendingUserInputAnswer(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): string | ReadonlyArray | null {
if (draft?.attachmentsBlocked) return null;
@@ -333,696 +316,9 @@ function resolvePendingUserInputAnswer(
);
}
-/** Some providers settle agents through task.updated instead of task.completed. */
-const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet = new Set([
- "completed",
- "failed",
- "cancelled",
- "interrupted",
-]);
-
-function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
- if (activity.kind !== "task.updated") {
- return false;
- }
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return (
- typeof payload?.status === "string" &&
- (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) ||
- (payload.timelineBypass === true && payload.status === "idle"))
- );
-}
-
-/**
- * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
- * activity lives in the Agents sheet, not the work log. Agent lifecycle rows
- * pass even when bypassed or owned by another agent, because they fold into
- * their spawn batch rather than rendering on their own; that is how Codex
- * children (all bypassed) and Claude workflow members reach the batch row.
- * Terminal rows are kept regardless — with no Agents surface on mobile they
- * are the terminal signal.
- */
-function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- if (!payload) {
- return false;
- }
- const isTaskRow =
- activity.kind === "task.started" ||
- activity.kind === "task.progress" ||
- activity.kind === "task.updated" ||
- activity.kind === "task.completed";
- const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
- if (isTaskRow) {
- if (!ownedByAgent && payload.timelineBypass !== true) {
- return false;
- }
- // An agent's own shells stay internal; the agents themselves fold into
- // their batch. A bypassed batch marker keeps its terminal row.
- if (typeof payload.taskId === "string" && payload.agentKind === "agent") {
- return false;
- }
- if (ownedByAgent) {
- return true;
- }
- return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity));
- }
- return payload.timelineBypass === true || ownedByAgent;
-}
-
-/** Agent (non-background) task.started rows seed spawn batches. */
-function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return typeof payload?.taskId === "string" && payload.agentKind === "agent";
-}
-
-function deriveWorkLogEntries(
- activities: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const ordered = Arr.sort(activities, activityOrder);
- const entries: DerivedWorkLogEntry[] = [];
- for (const activity of foldUserInputActivities(ordered)) {
- // Mobile has no setup card, so a failed setup surfaces as an error row.
- if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
- if (activity.kind === "tool.started") continue;
- // Like web: an agent's task.started row anchors its batch. It has a fixed
- // id and timestamp, unlike progress ticks, whose stable per-task id is
- // rewritten with a new createdAt on every update (and would otherwise
- // make the batch row a "fresh" row again on each tick).
- if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
- if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
- if (activity.kind === "tool.progress") continue;
- if (activity.kind === "context-window.updated") continue;
- if (activity.summary === "Checkpoint captured") continue;
- if (isNoContentRuntimeWarning(activity)) continue;
- if (isPlanBoundaryToolActivity(activity)) continue;
- if (isAgentInternalActivity(activity)) continue;
- entries.push(toDerivedWorkLogEntry(activity));
- }
- return collapseDerivedWorkLogEntries(entries);
-}
-
-/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
- * commands_changed, ...) as runtime warnings. The suffix comes from
- * describeUnknownSdkMessage in the Claude adapter; a row with no displayable
- * text carries nothing a user can act on, so it does not render. */
-function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
- return (
- activity.kind === "runtime.warning" &&
- activity.summary.endsWith("(no displayable text content)")
- );
-}
-
-function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
- if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
- return false;
- }
-
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
-}
-
-const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
-
-function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- const commandPreview = extractToolCommand(payload);
- const changedFiles = extractChangedFiles(payload);
- const title = extractToolTitle(payload);
- const toolPresentation = extractToolActivityPresentation(payload);
- // Terminal task updates carry identity so they replace each child's progress row.
- const isTaskActivity =
- activity.kind === "task.started" ||
- activity.kind === "task.progress" ||
- activity.kind === "task.completed" ||
- activity.kind === "task.updated";
- const taskSummary =
- isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
- ? payload.summary
- : null;
- const taskDetailAsLabel =
- isTaskActivity &&
- !taskSummary &&
- !title &&
- typeof payload?.detail === "string" &&
- payload.detail.length > 0
- ? payload.detail
- : null;
- const taskLabel = taskSummary || taskDetailAsLabel;
- const taskId =
- isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
- ? payload.taskId
- : undefined;
- const entry: DerivedWorkLogEntry = {
- id: activity.id,
- createdAt: activity.createdAt,
- turnId: activity.turnId,
- ...(taskId ? { taskId } : {}),
- label: taskLabel || activity.summary,
- tone:
- activity.kind === "task.progress"
- ? "thinking"
- : activity.tone === "approval"
- ? "info"
- : activity.tone,
- sourceActivityKind: activity.kind,
- ...(() => {
- if (activity.kind !== "user-input.answer-submitted") return {};
- const answer = decodeQuestionAttachmentAnswer(activity.payload);
- return Option.isSome(answer) ? { questionAnswer: answer.value } : {};
- })(),
- };
- const toolCallId =
- asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
- if (toolCallId) {
- entry.toolCallId = toolCallId;
- }
- if (isTaskActivity && payload) {
- if (payload.agentKind !== "agent") {
- entry.isBackgroundTask = true;
- }
- const spawnToolCallId = asTrimmedString(payload.toolUseId);
- if (spawnToolCallId) {
- entry.agentSpawnToolCallId = spawnToolCallId;
- }
- if (
- payload.taskType === "local_workflow" ||
- (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
- ) {
- entry.isWorkflowCoordinator = true;
- }
- }
- const itemType = extractWorkLogItemType(payload);
- const requestKind = extractWorkLogRequestKind(payload);
- const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
- const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null;
- const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null;
- if (!taskDetailAsLabel && output) {
- entry.detail = output;
- } else if (!taskDetailAsLabel && typeof payload?.detail === "string") {
- const detail = stripTrailingExitCode(payload.detail).output;
- const data = asRecord(payload.data);
- const repeatsCommand =
- detail !== null &&
- commandDetailRepeatsCommand({
- detail,
- command: commandPreview.command,
- rawCommand: commandPreview.rawCommand,
- toolName: data?.toolName,
- data,
- });
- if (detail && detail !== title && !repeatsCommand) entry.detail = detail;
- }
- if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
- entry.detail = payload.error;
- }
- if (!entry.detail && (activity.kind === "runtime.error" || activity.kind === "runtime.warning")) {
- const message = asTrimmedString(payload?.message);
- if (message) entry.detail = message;
- }
- if (viewedImagePath) {
- entry.viewedImagePath = viewedImagePath;
- }
- if (commandPreview.command) {
- entry.command = commandPreview.command;
- }
- if (commandPreview.rawCommand) {
- entry.rawCommand = commandPreview.rawCommand;
- }
- if (changedFiles.length > 0) {
- entry.changedFiles = changedFiles;
- }
- if (title) {
- entry.toolTitle = title;
- }
- if (toolPresentation.toolSurface) {
- entry.toolSurface = toolPresentation.toolSurface;
- }
- if (toolPresentation.toolIcon) {
- entry.toolIcon = toolPresentation.toolIcon;
- }
- if (toolPresentation.toolSource) {
- entry.toolSource = toolPresentation.toolSource;
- }
- if (itemType === "mcp_tool_call") {
- const data = asRecord(payload?.data);
- const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
- if (toolData !== undefined) {
- entry.toolData = toolData;
- }
- }
- if (itemType) {
- entry.itemType = itemType;
- }
- if (requestKind) {
- entry.requestKind = requestKind;
- }
- let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
- if (
- !toolLifecycleStatus &&
- (activity.kind === "tool.completed" || activity.kind === "task.completed")
- ) {
- toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed";
- }
- // A Codex child that finishes its turn reports "idle" (resumable, not
- // terminal). For the batch row that is a finished member.
- if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") {
- toolLifecycleStatus = "completed";
- }
- if (toolLifecycleStatus) {
- entry.toolLifecycleStatus = toolLifecycleStatus;
- }
- const collapseKey = deriveToolLifecycleCollapseKey(entry);
- if (collapseKey) {
- entry.collapseKey = collapseKey;
- }
- return entry;
-}
-
-/**
- * Spawn-group key for a subagent lifecycle row. Workflow members and their
- * coordinator share the coordinator's group; direct spawns batch per turn.
- * Same keys as web's session-logic so both clients fold the same rows.
- */
-function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
- const taskId = entry.taskId ?? "";
- const workflowSlot = taskId.indexOf(":wf:");
- if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`;
- if (entry.isWorkflowCoordinator) return `wf:${taskId}`;
- return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
-}
-
-/**
- * The batch row keeps the group's anchor identity (id, createdAt, turnId,
- * label) so it renders where the run launched instead of drifting to the
- * newest progress tick, and gains each member's latest lifecycle state.
- */
-function agentSpawnRow(
- anchor: DerivedWorkLogEntry,
- workflowId: string | null,
- agentTaskIds: ReadonlyArray,
- members: NonNullable["agents"],
-): DerivedWorkLogEntry {
- // A finished coordinator settles members that never reported their own
- // end; Claude stops synthesizing member ticks once the workflow is done.
- const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)];
- const agents =
- coordinator?.status !== undefined && coordinator.status !== "inProgress"
- ? members.map((agent) =>
- agent.status === undefined || agent.status === "inProgress"
- ? { ...agent, status: coordinator.status }
- : agent,
- )
- : members;
- const agentSpawn = { workflowId, agentTaskIds, agents };
- // The batch row has no detail of its own: its body lists the members.
- const { detail: _detail, ...anchorWithoutDetail } = anchor;
- return {
- ...anchorWithoutDetail,
- // The row's own lifecycle is the batch's: live while any member is, then
- // the worst terminal state, so the group summary and shimmer follow it.
- toolLifecycleStatus: agentSpawnLifecycleStatus(agents),
- agentSpawn,
- };
-}
-
-function agentSpawnMember(
- entry: DerivedWorkLogEntry,
- previous?: NonNullable["agents"][number],
-) {
- return {
- title: entry.toolTitle ?? previous?.title ?? entry.label,
- status: entry.toolLifecycleStatus ?? previous?.status,
- detail: entry.detail ?? previous?.detail,
- updatedAt: entry.createdAt,
- };
-}
-
-function mergeAgentSpawnEntries(
- existing: DerivedWorkLogEntry,
- entry: DerivedWorkLogEntry,
-): DerivedWorkLogEntry {
- const spawn = existing.agentSpawn!;
- const taskId = entry.taskId ?? "";
- const memberIndex = spawn.agentTaskIds.indexOf(taskId);
- if (memberIndex === -1) {
- return agentSpawnRow(
- existing,
- spawn.workflowId,
- [...spawn.agentTaskIds, taskId],
- [...spawn.agents, agentSpawnMember(entry)],
- );
- }
- const agents = spawn.agents.map((agent, index) =>
- index === memberIndex ? agentSpawnMember(entry, agent) : agent,
- );
- return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents);
-}
-
-function agentSpawnLifecycleStatus(
- agents: NonNullable["agents"],
-): WorkLogToolLifecycleStatus {
- const statuses = agents.map((agent) => agent.status);
- if (statuses.some((status) => status === undefined || status === "inProgress")) {
- return "inProgress";
- }
- if (statuses.includes("failed")) return "failed";
- if (statuses.includes("declined")) return "declined";
- if (statuses.includes("stopped")) return "stopped";
- return "completed";
-}
-
-function collapseDerivedWorkLogEntries(
- entries: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const collapsed: DerivedWorkLogEntry[] = [];
- // Task rows collapse by identity, not adjacency (quiet-timeline guarantee;
- // mirrors web's session-logic). Background tasks keep one row per taskId;
- // agent spawns fold into one row per spawn group, decided at the FIRST row
- // seen for a taskId because later rows can arrive under synthetic turns.
- const taskRowIndex = new Map();
- const spawnRowIndex = new Map();
- const spawnGroupByTaskId = new Map();
- const toolLifecycleRowIndex = new Map();
- // Tool calls that launched an agent (Claude's Agent tool, ACP subagent
- // calls). The batch card is the whole story of that call, so its own
- // lifecycle row is dropped.
- const spawnToolCallIds = new Set(
- entries.flatMap((entry) =>
- entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [],
- ),
- );
- for (const entry of entries) {
- if (
- entry.toolCallId !== undefined &&
- entry.taskId === undefined &&
- spawnToolCallIds.has(entry.toolCallId)
- ) {
- continue;
- }
- const isTaskRow =
- entry.taskId !== undefined &&
- (entry.sourceActivityKind === "task.started" ||
- entry.sourceActivityKind === "task.progress" ||
- entry.sourceActivityKind === "task.completed" ||
- entry.sourceActivityKind === "task.updated");
- if (isTaskRow && entry.taskId !== undefined) {
- if (entry.isBackgroundTask) {
- const existingIndex = taskRowIndex.get(entry.taskId);
- if (existingIndex !== undefined) {
- collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
- continue;
- }
- taskRowIndex.set(entry.taskId, collapsed.length);
- collapsed.push(entry);
- continue;
- }
- const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry);
- spawnGroupByTaskId.set(entry.taskId, groupKey);
- const existingIndex = spawnRowIndex.get(groupKey);
- if (existingIndex !== undefined) {
- collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry);
- continue;
- }
- spawnRowIndex.set(groupKey, collapsed.length);
- collapsed.push(
- agentSpawnRow(
- entry,
- groupKey.startsWith("wf:") ? groupKey.slice(3) : null,
- [entry.taskId],
- [agentSpawnMember(entry)],
- ),
- );
- continue;
- }
- const lifecycleKey = toolLifecycleCollapseMapKey(entry);
- if (lifecycleKey !== undefined) {
- const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey);
- const matchingEntry = matchingIndex === undefined ? undefined : collapsed[matchingIndex];
- if (
- matchingIndex !== undefined &&
- matchingEntry &&
- shouldCollapseToolLifecycleEntries(matchingEntry, entry)
- ) {
- collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
- continue;
- }
- toolLifecycleRowIndex.delete(lifecycleKey);
- }
- const previous = collapsed.at(-1);
- if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
- const previousIndex = collapsed.length - 1;
- const previousKey = toolLifecycleCollapseMapKey(previous);
- if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
- const merged = mergeDerivedWorkLogEntries(previous, entry);
- collapsed[previousIndex] = merged;
- const mergedKey = toolLifecycleCollapseMapKey(merged);
- if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex);
- continue;
- }
- collapsed.push(entry);
- if (lifecycleKey !== undefined) {
- toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
- }
- }
- return collapsed;
-}
-
-function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
- if (
- entry.sourceActivityKind !== "tool.updated" &&
- entry.sourceActivityKind !== "tool.completed"
- ) {
- return undefined;
- }
- return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined;
-}
-
-function shouldCollapseToolLifecycleEntries(
- previous: DerivedWorkLogEntry,
- next: DerivedWorkLogEntry,
-): boolean {
- if (
- previous.sourceActivityKind !== "tool.updated" &&
- previous.sourceActivityKind !== "tool.completed"
- ) {
- return false;
- }
- if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") {
- return false;
- }
- if (previous.turnId !== next.turnId) {
- return false;
- }
- if (previous.sourceActivityKind === "tool.completed") {
- return false;
- }
- if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
- return true;
- }
- return (
- previous.toolCallId !== undefined &&
- next.toolCallId === undefined &&
- previous.itemType === next.itemType &&
- normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
- normalizeCompactToolLabel(next.toolTitle ?? next.label)
- );
-}
-
-function mergeDerivedWorkLogEntries(
- previous: DerivedWorkLogEntry,
- next: DerivedWorkLogEntry,
-): DerivedWorkLogEntry {
- const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
- const detail = next.detail ?? previous.detail;
- const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath;
- const command = next.command ?? previous.command;
- const rawCommand = next.rawCommand ?? previous.rawCommand;
- const toolTitle = next.toolTitle ?? previous.toolTitle;
- const toolSurface = next.toolSurface ?? previous.toolSurface;
- const toolIcon = next.toolIcon ?? previous.toolIcon;
- const toolSource = next.toolSource ?? previous.toolSource;
- const itemType = next.itemType ?? previous.itemType;
- const requestKind = next.requestKind ?? previous.requestKind;
- const collapseKey = next.collapseKey ?? previous.collapseKey;
- const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
- const toolCallId = next.toolCallId ?? previous.toolCallId;
- const toolData = next.toolData ?? previous.toolData;
- return {
- ...previous,
- ...next,
- id: previous.id,
- createdAt: previous.createdAt,
- ...(detail ? { detail } : {}),
- ...(viewedImagePath ? { viewedImagePath } : {}),
- ...(command ? { command } : {}),
- ...(rawCommand ? { rawCommand } : {}),
- ...(changedFiles.length > 0 ? { changedFiles } : {}),
- ...(toolTitle ? { toolTitle } : {}),
- ...(toolSurface ? { toolSurface } : {}),
- ...(toolIcon ? { toolIcon } : {}),
- ...(toolSource ? { toolSource } : {}),
- ...(itemType ? { itemType } : {}),
- ...(requestKind ? { requestKind } : {}),
- ...(collapseKey ? { collapseKey } : {}),
- ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
- ...(toolCallId ? { toolCallId } : {}),
- ...(toolData !== undefined ? { toolData } : {}),
- };
-}
-
-function mergeChangedFiles(
- previous: ReadonlyArray | undefined,
- next: ReadonlyArray | undefined,
-): string[] {
- const merged = [...(previous ?? []), ...(next ?? [])];
- if (merged.length === 0) {
- return [];
- }
- return [...new Set(merged)];
-}
-
-function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
- if (
- entry.sourceActivityKind !== "tool.updated" &&
- entry.sourceActivityKind !== "tool.completed"
- ) {
- return undefined;
- }
- if (entry.toolCallId) {
- return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
- }
- const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
- const detail = entry.detail?.trim() ?? "";
- const itemType = entry.itemType ?? "";
- if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
- return undefined;
- }
- return [itemType, normalizedLabel, detail].join("\u001f");
-}
-
-function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
- if (entry.agentSpawn) {
- switch (entry.toolLifecycleStatus) {
- case "failed":
- return "failure";
- case "completed":
- return "success";
- default:
- return "neutral";
- }
- }
- if (!workLogEntryIsToolLike(entry)) {
- return null;
- }
- if (workEntryIndicatesToolFailure(entry)) {
- return "failure";
- }
- if (workEntryIndicatesToolSuccess(entry)) {
- return "success";
- }
- return "neutral";
-}
-
-function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
- if (entry.agentSpawn) return "agent";
- if (
- entry.questionAnswer ||
- entry.sourceActivityKind === "user-input.requested" ||
- entry.sourceActivityKind === "user-input.resolved"
- ) {
- return "message";
- }
- if (entry.sourceActivityKind === "runtime.warning") return "warning";
- if (entry.toolSurface) return entry.toolSurface;
- if (entry.requestKind === "command") return "command";
- if (entry.requestKind === "file-read") return "eye";
- if (entry.requestKind === "file-change") return "edit";
- if (entry.itemType === "command_execution" || entry.command) return "command";
- if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
- if (entry.itemType === "web_search") return "globe";
- if (entry.itemType === "image_view") return "eye";
- if (entry.itemType === "mcp_tool_call") return "wrench";
- if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") {
- return "hammer";
- }
- if (entry.tone === "error") return "alert";
- if (entry.tone === "thinking") return "agent";
- if (entry.tone === "info") return "check";
- return "zap";
-}
-
-function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
- if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn);
- const blocks: string[] = [];
- const visibleLabel = workEntryRowLabel(entry, true).trim();
- const appendBlock = (value: string | null | undefined) => {
- const trimmed = value?.trim();
- if (trimmed && (entry.command || (trimmed !== visibleLabel && !blocks.includes(trimmed)))) {
- blocks.push(trimmed);
- }
- };
-
- if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
- appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
- }
- appendBlock(entry.rawCommand ?? entry.command);
- appendBlock(entry.detail);
- if ((entry.changedFiles?.length ?? 0) > 0) {
- appendBlock(entry.changedFiles!.join("\n"));
- }
-
- return blocks.length > 0 ? blocks.join("\n\n") : null;
-}
-
-/**
- * Even single-line details can be truncated by the available screen width.
- * Cheap field checks come first so large tool payloads are not serialized
- * for every row (see the deferred-expansion test).
- */
-function workEntryCanExpand(entry: WorkLogEntry): boolean {
- if (entry.questionAnswer) return true;
- if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
- if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
- if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
- return Boolean((entry.rawCommand ?? entry.command)?.trim() || entry.detail?.trim());
-}
-
-function collapseWhitespace(value: string): string {
- return value.replace(/\s+/g, " ").trim();
-}
-
-function stripShellWrapper(value: string): string {
+function capitalizePhrase(value: string): string {
const trimmed = value.trim();
- const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);
- return (match?.[1] ?? trimmed).trim();
-}
-
-/** Expanded rows retain detail formatting; commands stay in the separate body. */
-export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string {
- if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
- const presentation = resolveWorkEntryToolPresentation(entry);
- if (presentation) return presentation.displayName;
- if (expanded && entry.command?.trim()) return "Command";
- const preview = workEntryPreview(entry);
- if (expanded) return preview?.trim() || workEntryHeading(entry);
- const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview));
- return compactPreview || workEntryHeading(entry);
+ return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
function memoizeValue(build: () => T): () => T {
@@ -1037,488 +333,360 @@ function memoizeValue(build: () => T): () => T {
};
}
-function workEntryPreview(
- workEntry: Pick,
-): string | null {
- if (workEntry.command) return workEntry.command;
- if (workEntry.detail) return workEntry.detail;
- if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
- const [firstPath] = workEntry.changedFiles ?? [];
- if (!firstPath) return null;
- return workEntry.changedFiles!.length === 1
- ? firstPath
- : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
+function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {
+ return (
+ item.type === "reasoning" ||
+ item.type === "command_execution" ||
+ item.type === "file_change" ||
+ item.type === "file_search" ||
+ item.type === "web_search" ||
+ item.type === "approval_request" ||
+ item.type === "user_input_request" ||
+ item.type === "dynamic_tool" ||
+ item.type === "subagent"
+ );
}
-function capitalizePhrase(value: string): string {
- const trimmed = value.trim();
- if (trimmed.length === 0) {
- return value;
- }
- return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
+function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
+ return (
+ item.type === "fork" ||
+ item.type === "thread_created" ||
+ item.type === "subagent" ||
+ item.type === "system_notice"
+ );
}
-/**
- * Batch label for a spawn row, matching web's CTA wording. Web reads live
- * agent state from its Agents panel; mobile has only the lifecycle states
- * folded into the row, so "working" means a member has not reported a
- * terminal state yet.
- */
-export function agentSpawnLabel(spawn: NonNullable): string {
- const members = agentSpawnMembers(spawn);
- const count = Math.max(members.length, 1);
- const subjects = `${count} subagent${count === 1 ? "" : "s"}`;
- const working = members.filter(
- (agent) => agent.status === undefined || agent.status === "inProgress",
- ).length;
- const failed = members.filter((agent) => agent.status === "failed").length;
- const stopped = members.filter((agent) => agent.status === "stopped").length;
- if (working > 0) {
- return `Kicked off ${subjects} · ${working} working`;
+function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
+ if (item.type === "notification") return item.outcome === "failed" ? "failure" : null;
+ if (item.type === "error") {
+ if (item.status === "failed") return "failure";
+ return item.status === "completed" ? "success" : "neutral";
}
- const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed";
- return `Ran ${subjects} · ${status}`;
-}
-
-/** Workflow coordinators sit in their own batch but are not a member. */
-function agentSpawnMembers(spawn: NonNullable) {
- return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
+ if (!itemIsToolLike(item)) return null;
+ if (item.status === "failed") return "failure";
+ return item.status === "completed" ? "success" : "neutral";
}
-function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] {
- switch (status) {
- case undefined:
- case "inProgress":
- return "working";
+function itemLifecycleStatus(item: OrchestrationV2TurnItem): WorkLogToolLifecycleStatus {
+ switch (item.status) {
+ case "pending":
+ case "running":
+ case "waiting":
+ return "inProgress";
+ case "idle":
+ return "idle";
case "completed":
return "completed";
case "failed":
- case "declined":
return "failed";
- case "stopped":
+ case "cancelled":
+ case "interrupted":
return "stopped";
}
}
-/**
- * What the spawn card shows. While members work, the status line is the
- * newest member activity (its progress detail), so the card reads like the
- * live tool row does for a single call. Once every member settles, it is the
- * batch outcome in web's CTA wording.
- */
-export function agentSpawnSummary(
- spawn: NonNullable,
- batchStatus: WorkLogToolLifecycleStatus | undefined,
-): AgentSpawnSummary {
- const members = agentSpawnMembers(spawn).map((agent) => {
- const tone = agentSpawnTone(agent.status);
- return {
- title: agent.title,
- status: tone === "working" ? "working" : (agent.status ?? tone),
- tone,
- detail: agent.detail,
- updatedAt: agent.updatedAt,
- };
- });
- const tone = agentSpawnTone(batchStatus);
- // A workflow's coordinator is not a member; before any member reports the
- // batch has none.
- const title =
- members.length === 0
- ? "Subagents"
- : members.length === 1
- ? members[0]!.title
- : `${members.length} subagents`;
- if (tone === "working") {
- const working = members.filter((member) => member.tone === "working");
- const latest = working
- .filter((member) => member.detail !== undefined)
- .reduce<(typeof working)[number] | undefined>(
- (newest, member) =>
- newest === undefined || member.updatedAt > newest.updatedAt ? member : newest,
- undefined,
- );
- const status =
- latest?.detail ??
- (members.length > 1 ? `${working.length} of ${members.length} working` : "Working");
- return { title, status, tone, members };
- }
- // The batch tone covers a coordinator that failed or stopped on its own.
- const failed = members.filter((member) => member.tone === "failed").length;
- const stopped = members.filter((member) => member.tone === "stopped").length;
- const outcome =
- tone === "failed" || failed > 0
- ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed`
- : tone === "stopped" || stopped > 0
- ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped`
- : "completed";
- return { title, status: outcome, tone, members };
-}
-
-function agentSpawnExpandedBody(spawn: NonNullable): string | null {
- const lines = agentSpawnMembers(spawn).map((agent) => {
- const status =
- agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status;
- return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`;
- });
- return lines.length > 0 ? lines.join("\n") : null;
-}
-
-function workEntryHeading(workEntry: WorkLogEntry): string {
- if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn);
- const presentation = resolveWorkEntryToolPresentation(workEntry);
- if (presentation) return presentation.displayName;
- if (!workEntry.toolTitle) {
- return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
- }
- return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
-}
-
-function singleToolCallLabel(activity: ThreadFeedActivity): string {
- const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed");
- if (presentation) return presentation.displayName;
- const command = activity.workEntry.command?.trim();
- return command || activity.summary;
-}
-
-function asRecord(value: unknown): Record | null {
- return value && typeof value === "object" ? (value as Record) : null;
-}
-
-function asTrimmedString(value: unknown): string | null {
- if (typeof value !== "string") {
- return null;
- }
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : null;
-}
-
-function trimMatchingOuterQuotes(value: string): string {
- const trimmed = value.trim();
- if (
- (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
- (trimmed.startsWith('"') && trimmed.endsWith('"'))
- ) {
- const unquoted = trimmed.slice(1, -1).trim();
- return unquoted.length > 0 ? unquoted : trimmed;
- }
- return trimmed;
-}
-
-function executableBasename(value: string): string | null {
- const trimmed = trimMatchingOuterQuotes(value);
- if (trimmed.length === 0) {
- return null;
- }
- const normalized = trimmed.replace(/\\/g, "/");
- const segments = normalized.split("/");
- const last = segments.at(-1)?.trim() ?? "";
- return last.length > 0 ? last.toLowerCase() : null;
-}
-
-function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
- const trimmed = value.trim();
- if (trimmed.length === 0) {
+function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntry["tone"] {
+ if (item.type === "error") return "info";
+ if (item.type === "reasoning") return "thinking";
+ switch (item.type) {
+ case "command_execution":
+ case "file_change":
+ case "file_search":
+ case "web_search":
+ case "dynamic_tool":
+ case "subagent":
+ return "tool";
+ default:
+ return "info";
+ }
+}
+
+function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
+ if (item.type === "notification") return "zap";
+ switch (item.type) {
+ case "reasoning":
+ return "agent";
+ case "command_execution":
+ return "command";
+ case "file_change":
+ return "edit";
+ case "file_search":
+ return "eye";
+ case "web_search":
+ return "globe";
+ case "approval_request":
+ case "user_input_request":
+ case "user_message":
+ case "assistant_message":
+ return "message";
+ case "dynamic_tool":
+ return "wrench";
+ case "subagent":
+ return "hammer";
+ case "run_interrupt_request":
+ case "run_interrupt_result":
+ case "system_notice":
+ return "warning";
+ case "error":
+ return "alert";
+ case "checkpoint":
+ case "proposed_plan":
+ case "todo_list":
+ return "check";
+ case "compaction":
+ case "handoff":
+ case "fork":
+ case "thread_created":
+ return "zap";
+ }
+}
+
+function itemToolPresentation(item: OrchestrationV2TurnItem): T3McpToolPresentation | null {
+ if (item.type !== "dynamic_tool") {
return null;
}
-
- if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
- const quote = trimmed.charAt(0);
- const closeIndex = trimmed.indexOf(quote, 1);
- if (closeIndex <= 0) {
+ return resolveT3McpToolPresentation(item.toolName) ?? resolveT3McpToolPresentation(item.title);
+}
+
+function itemSummary(
+ item: OrchestrationV2TurnItem,
+ toolPresentation: T3McpToolPresentation | null = null,
+): string {
+ if (item.type === "notification") return item.summary;
+ if (item.type === "system_notice") return item.message;
+ if (item.type === "compaction") return contextCompactionLabel(item);
+ const title = item.title?.trim();
+ if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");
+ if (title) return toolPresentation?.displayName ?? capitalizePhrase(title);
+ switch (item.type) {
+ case "reasoning":
+ return "Thinking";
+ case "command_execution":
+ return "Command";
+ case "file_change":
+ return item.changes !== undefined && item.changes.length > 1
+ ? `Changed ${item.changes.length} files`
+ : `Changed ${item.fileName}`;
+ case "file_search":
+ return "Searched files";
+ case "web_search":
+ return "Searched the web";
+ case "approval_request":
+ return "Approval requested";
+ case "user_input_request":
+ return "Input requested";
+ case "checkpoint":
+ return "Checkpoint captured";
+ case "run_interrupt_request":
+ return "Interrupt requested";
+ case "run_interrupt_result":
+ return "Run interrupted";
+ case "error":
+ return "Provider error";
+ case "handoff":
+ return "Context handed off";
+ case "fork":
+ return "Thread forked";
+ case "thread_created":
+ return "Thread created";
+ case "dynamic_tool":
+ return toolPresentation?.displayName ?? item.toolName ?? "Tool call";
+ case "proposed_plan":
+ return "Proposed plan";
+ case "todo_list":
+ return "Plan updated";
+ case "user_message":
+ return "User message";
+ case "assistant_message":
+ return "Assistant message";
+ }
+}
+
+function itemPreview(item: OrchestrationV2TurnItem): string | null {
+ switch (item.type) {
+ case "reasoning":
+ return item.text || null;
+ case "command_execution":
+ return item.input || null;
+ case "file_change":
+ return item.fileName;
+ case "file_search":
+ return item.pattern ?? null;
+ case "web_search":
+ return item.patterns?.join(", ") ?? null;
+ case "approval_request":
+ return item.prompt ?? null;
+ case "user_input_request":
+ return item.questions.map((question) => question.question).join(" · ") || null;
+ case "checkpoint":
+ return item.files.length === 1
+ ? (item.files[0]?.path ?? null)
+ : `${item.files.length} changed files`;
+ case "run_interrupt_request":
+ case "run_interrupt_result":
+ case "system_notice":
+ return item.message || null;
+ case "error":
+ return item.failure.message;
+ case "compaction":
+ case "handoff":
+ return item.summary ?? null;
+ case "fork":
+ case "thread_created":
+ return item.targetThreadId;
+ case "subagent":
+ return item.result ?? item.progress ?? item.prompt;
+ case "dynamic_tool":
return null;
- }
- return {
- executable: trimmed.slice(0, closeIndex + 1),
- rest: trimmed.slice(closeIndex + 1).trim(),
- };
- }
-
- const firstWhitespace = trimmed.search(/\s/);
- if (firstWhitespace < 0) {
- return {
- executable: trimmed,
- rest: "",
- };
- }
-
- return {
- executable: trimmed.slice(0, firstWhitespace),
- rest: trimmed.slice(firstWhitespace).trim(),
- };
-}
-
-const SHELL_WRAPPER_SPECS = [
- {
- executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
- wrapperFlagPattern: /(?:^|\s)-command\s+/i,
- },
- {
- executables: ["cmd", "cmd.exe"],
- wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
- },
- {
- executables: ["bash", "sh", "zsh"],
- wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
- },
-] as const;
-
-function findShellWrapperSpec(shell: string) {
- return SHELL_WRAPPER_SPECS.find((spec) =>
- (spec.executables as ReadonlyArray).includes(shell),
+ case "notification":
+ return item.detail ?? null;
+ case "proposed_plan":
+ return item.markdown || null;
+ case "todo_list":
+ return `${item.steps.filter((step) => step.status === "completed").length}/${item.steps.length} completed`;
+ case "user_message":
+ case "assistant_message":
+ return item.text || null;
+ }
+}
+
+function toWorkLogEntry(
+ item: OrchestrationV2TurnItem,
+ createdAt: string,
+ summary: string,
+ detail: string | null,
+): WorkLogPresentationEntry {
+ const title = item.title?.trim() || null;
+ const common = {
+ ...extractToolActivityPresentation(item),
+ id: item.id,
+ createdAt,
+ label: summary,
+ tone: itemWorkLogTone(item),
+ itemType: item.type,
+ toolLifecycleStatus: itemLifecycleStatus(item),
+ structuredPayload: item,
+ ...(item.type === "user_input_request" && item.questionAnswer
+ ? { questionAnswer: item.questionAnswer }
+ : {}),
+ } as const;
+
+ switch (item.type) {
+ case "reasoning":
+ return { ...common, ...(item.text ? { detail: item.text } : {}) };
+ case "command_execution":
+ return {
+ ...common,
+ command: item.input,
+ rawCommand: item.input,
+ toolTitle: title ?? "Command",
+ toolData: item,
+ };
+ case "file_change":
+ return {
+ ...common,
+ changedFiles: [item.fileName],
+ toolTitle: title ?? "File change",
+ toolData: item,
+ };
+ case "file_search":
+ return {
+ ...common,
+ ...(item.pattern ? { detail: item.pattern } : {}),
+ toolTitle: title ?? "File search",
+ toolData: item,
+ };
+ case "web_search":
+ return {
+ ...common,
+ ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
+ toolTitle: title ?? "Web search",
+ toolData: item,
+ };
+ case "checkpoint":
+ return { ...common, changedFiles: item.files.map((file) => file.path), toolData: item };
+ case "approval_request":
+ return {
+ ...common,
+ ...(item.prompt ? { detail: item.prompt } : {}),
+ requestKind: item.requestKind,
+ toolData: item,
+ };
+ case "dynamic_tool":
+ return {
+ ...common,
+ toolTitle: title ?? item.toolName ?? "Tool",
+ toolData: { input: item.input, output: item.output },
+ };
+ default:
+ return { ...common, ...(detail ? { detail } : {}), toolData: item };
+ }
+}
+
+function toFeedActivity(
+ row: OrchestrationV2ProjectedTurnItem,
+ attemptId: RunAttemptId | null,
+): ThreadFeedActivity {
+ const item = row.item;
+ const toolPresentation = itemToolPresentation(item);
+ const summary = itemSummary(item, toolPresentation);
+ const detail = item.type === "notification" ? null : itemPreview(item);
+ const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
+ const workEntry = toWorkLogEntry(item, createdAt, summary, detail);
+ const getFullDetail = memoizeValue(() =>
+ JSON.stringify(
+ {
+ visibility: row.visibility,
+ sourceThreadId: row.sourceThreadId,
+ sourceItemId: row.sourceItemId,
+ item: toolItemForDisplay(item),
+ },
+ null,
+ 2,
+ ),
);
-}
-
-function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
- const match = wrapperFlagPattern.exec(value);
- if (!match) {
- return null;
- }
-
- const command = value.slice(match.index + match[0].length).trim();
- if (command.length === 0) {
- return null;
- }
-
- const openingQuote = command[0];
- if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) {
- return null;
- }
-
- const unwrapped = trimMatchingOuterQuotes(command);
- return unwrapped.length > 0 ? unwrapped : null;
-}
-
-function unwrapKnownShellCommandWrapper(value: string): string {
- const split = splitExecutableAndRest(value);
- if (!split || split.rest.length === 0) {
- return value;
- }
-
- const shell = executableBasename(split.executable);
- if (!shell) {
- return value;
- }
-
- const spec = findShellWrapperSpec(shell);
- if (!spec) {
- return value;
- }
-
- return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
-}
-
-function formatCommandArrayPart(value: string): string {
- return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
-}
-
-function formatCommandValue(value: unknown): string | null {
- const direct = asTrimmedString(value);
- if (direct) {
- return direct;
- }
- if (!Array.isArray(value)) {
- return null;
- }
- const parts: Array = [];
- for (const entry of value) {
- const part = asTrimmedString(entry);
- if (part !== null) {
- parts.push(part);
- }
- }
- if (parts.length === 0) {
- return null;
- }
- return parts.map((part) => formatCommandArrayPart(part)).join(" ");
-}
-
-function normalizeCommandValue(value: unknown): string | null {
- const formatted = formatCommandValue(value);
- return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
-}
-
-function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
- const formatted = formatCommandValue(value);
- if (!formatted || normalizedCommand === null) {
- return null;
- }
- return formatted === normalizedCommand ? null : formatted;
-}
-
-function extractToolCommand(payload: Record | null): {
- command: string | null;
- rawCommand: string | null;
-} {
- const data = asRecord(payload?.data);
- const item = asRecord(data?.item);
- const itemResult = asRecord(item?.result);
- const itemInput = asRecord(item?.input);
- const itemType = asTrimmedString(payload?.itemType);
- const detail = asTrimmedString(payload?.detail);
- const candidates: unknown[] = [
- item?.command,
- itemInput?.command,
- itemResult?.command,
- data?.command,
- itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
- ];
-
- for (const candidate of candidates) {
- const command = normalizeCommandValue(candidate);
- if (!command) {
- continue;
- }
- return {
- command,
- rawCommand: toRawToolCommand(candidate, command),
- };
- }
-
- return {
- command: null,
- rawCommand: null,
- };
-}
-
-function extractToolTitle(payload: Record | null): string | null {
- return asTrimmedString(payload?.title);
-}
-
-function stripTrailingExitCode(value: string): {
- output: string | null;
- exitCode?: number | undefined;
-} {
- const trimmed = value.trim();
- const match = /^(?