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 7fee5f57a83c..44ba3dff0947 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,8 @@ on:
push:
branches:
- main
+ # Keep v2 checked while its pull request has conflicts with main.
+ - t3code/codex-turn-mapping
permissions:
contents: read
@@ -52,7 +54,9 @@ jobs:
run: vpr typecheck
- name: Install browser secret helper build libraries
- run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
+ run: |
+ sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
+ sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
- name: Build desktop pipeline
run: vp run build:desktop
@@ -78,6 +82,17 @@ 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 sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
+ 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/README.md b/README.md
index 27b5dc491693..fbb2a2c96574 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,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 89cc592831a7..0c0d23242aeb 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
@@ -101,6 +101,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)");
}),
);
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
index 0b48adc308c3..4f53a7e6f54a 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 0d9ddc8fde91..e896e85620e9 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -48,6 +48,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 cd28b446ccf1..07598602520f 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -175,7 +175,7 @@ const screenshot = await getImage({

diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css
index 8ba542165f18..580b3a7ad6b7 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%);
@@ -190,6 +204,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);
@@ -200,6 +216,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);
@@ -223,6 +240,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%);
@@ -235,6 +254,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%);
@@ -315,6 +336,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);
@@ -325,6 +348,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);
@@ -348,6 +372,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%);
@@ -360,6 +386,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%);
@@ -440,6 +468,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);
@@ -450,6 +480,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);
@@ -473,6 +504,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%);
@@ -485,6 +518,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%);
@@ -565,6 +600,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);
@@ -575,6 +612,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);
@@ -598,6 +636,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%);
@@ -610,6 +650,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%);
@@ -690,6 +732,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);
@@ -700,6 +744,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);
@@ -723,6 +768,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%);
@@ -735,6 +782,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%);
@@ -815,6 +864,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);
@@ -825,6 +876,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);
@@ -848,6 +900,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%);
@@ -860,6 +914,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%);
@@ -940,6 +996,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);
@@ -950,6 +1008,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);
@@ -973,6 +1032,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%);
@@ -985,6 +1046,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%);
@@ -1065,6 +1128,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);
@@ -1075,6 +1140,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);
@@ -1098,6 +1164,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%);
@@ -1110,6 +1178,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%);
@@ -1190,6 +1260,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);
@@ -1200,6 +1272,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);
@@ -1223,6 +1296,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%);
@@ -1235,6 +1310,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%);
@@ -1315,6 +1392,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);
@@ -1325,6 +1404,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);
@@ -1348,6 +1428,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%);
@@ -1360,6 +1442,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/package.json b/apps/mobile/package.json
index b6f445d7eab0..945105d0771d 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -93,7 +93,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/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts
index aa3d9b0bfb03..08eb6264ae8f 100644
--- a/apps/mobile/scripts/generate-uniwind-themes.mts
+++ b/apps/mobile/scripts/generate-uniwind-themes.mts
@@ -57,6 +57,8 @@ const ADAPTIVE_COLORS = {
"--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)],
"--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)],
"--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)],
+ "--color-adaptive-amber-500-a10-400-a10": [color("amber", 500, 0.1), color("amber", 400, 0.1)],
+ "--color-adaptive-amber-500-a25-400-a25": [color("amber", 500, 0.25), color("amber", 400, 0.25)],
"--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)],
"--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)],
"--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)],
@@ -73,6 +75,10 @@ const ADAPTIVE_COLORS = {
color("black", undefined, 0.15),
color("black", undefined, 0.35),
],
+ "--color-adaptive-black-a2p5-white-a2p5": [
+ color("black", undefined, 0.025),
+ color("white", undefined, 0.025),
+ ],
"--color-adaptive-emerald-500-a12-a16": [
color("emerald", 500, 0.12),
color("emerald", 500, 0.16),
@@ -114,6 +120,14 @@ const ADAPTIVE_COLORS = {
"--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)],
"--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)],
"--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)],
+ "--color-adaptive-neutral-950-a5-white-a5": [
+ color("neutral", 950, 0.05),
+ color("white", undefined, 0.05),
+ ],
+ "--color-adaptive-neutral-950-a10-white-a10": [
+ color("neutral", 950, 0.1),
+ color("white", undefined, 0.1),
+ ],
"--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)],
"--color-adaptive-red-200-800": [color("red", 200), color("red", 800)],
"--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)],
@@ -126,6 +140,8 @@ const ADAPTIVE_COLORS = {
"--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)],
"--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)],
"--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)],
+ "--color-adaptive-sky-500-a10-400-a10": [color("sky", 500, 0.1), color("sky", 400, 0.1)],
+ "--color-adaptive-sky-500-a25-400-a25": [color("sky", 500, 0.25), color("sky", 400, 0.25)],
"--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)],
"--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)],
"--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)],
diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts
new file mode 100644
index 000000000000..e7a059496af6
--- /dev/null
+++ b/apps/mobile/scripts/notification-center-manager.test.ts
@@ -0,0 +1,65 @@
+// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
+
+// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler.
+describe.skipIf(NodeOS.platform() !== "darwin")(
+ "NotificationCenterManager native concurrency",
+ () => {
+ 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/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 (
();
+ const schemaVersions = new Map();
const removed: Array = [];
const database = MobileDatabase.of({
loadCache: (environmentId, kind, cacheKey) =>
Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
- saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
+ saveCache: (environmentId, kind, cacheKey, schemaVersion, payload) =>
Effect.sync(() => {
- values.set(cacheId(environmentId, kind, cacheKey), payload);
+ const id = cacheId(environmentId, kind, cacheKey);
+ values.set(id, payload);
+ schemaVersions.set(id, schemaVersion);
}),
removeCache: (environmentId, kind, cacheKey) =>
Effect.sync(() => {
@@ -59,10 +191,40 @@ function makeDatabase() {
loadPreferencesJson: Effect.succeed(Option.none()),
savePreferencesJson: () => Effect.void,
});
- return { database, removed, values };
+ return { database, removed, schemaVersions, values };
}
describe("mobile SQLite environment cache store", () => {
+ it.effect("round-trips V2 shell and thread DateTime fields with the shared cache schema", () =>
+ Effect.gen(function* () {
+ const memory = makeDatabase();
+ const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
+
+ yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT);
+ yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT);
+
+ const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
+ const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));
+
+ expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z");
+ expect(DateTime.formatIso(shell.threads[0]!.latestUserMessageAt!)).toBe(
+ "2026-07-29T12:00:00.000Z",
+ );
+ expect(DateTime.formatIso(shell.threads[0]!.titleRegeneration!.startedAt)).toBe(
+ "2026-07-29T12:00:00.000Z",
+ );
+ expect(DateTime.formatIso(thread.projection.thread.updatedAt)).toBe(
+ "2026-07-29T12:00:00.000Z",
+ );
+ expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "shell", "snapshot"))).toBe(
+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
+ );
+ expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "thread", THREAD_ID))).toBe(
+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
+ );
+ }),
+ );
+
it.effect("round-trips schema-validated VCS refs", () =>
Effect.gen(function* () {
const memory = makeDatabase();
diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts
index ad5ef13b62d5..1942cb7cc35d 100644
--- a/apps/mobile/src/connection/environment-cache-store.ts
+++ b/apps/mobile/src/connection/environment-cache-store.ts
@@ -1,14 +1,11 @@
import {
ConnectionPersistenceError,
EnvironmentCacheStore,
+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
+ StoredOrchestrationShellSnapshot,
+ StoredOrchestrationThreadSnapshot,
} from "@t3tools/client-runtime/platform";
-import {
- type EnvironmentId,
- OrchestrationShellSnapshot,
- OrchestrationThreadDetailSnapshot,
- ServerConfig,
- VcsListRefsResult,
-} from "@t3tools/contracts";
+import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
@@ -16,25 +13,9 @@ import * as Schema from "effect/Schema";
import * as MobileDatabase from "../persistence/mobile-database";
-const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
-// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
-// makes pre-pagination clients discard the record instead of decoding a
-// partial thread as complete (rollback safety).
-const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
-const StoredShellSnapshot = Schema.Struct({
- schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
- environmentId: Schema.String,
- snapshot: OrchestrationShellSnapshot,
-});
-const StoredThreadSnapshot = Schema.Struct({
- schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
- environmentId: Schema.String,
- threadId: Schema.String,
- snapshot: OrchestrationThreadDetailSnapshot,
-});
const StoredServerConfig = Schema.Struct({
schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION),
environmentId: Schema.String,
@@ -48,13 +29,17 @@ const StoredVcsRefs = Schema.Struct({
});
const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(
- Schema.fromJsonString(StoredShellSnapshot),
+ Schema.fromJsonString(StoredOrchestrationShellSnapshot),
+);
+const encodeStoredShellSnapshot = Schema.encodeEffect(
+ Schema.fromJsonString(StoredOrchestrationShellSnapshot),
);
-const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot));
const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
- Schema.fromJsonString(StoredThreadSnapshot),
+ Schema.fromJsonString(StoredOrchestrationThreadSnapshot),
+);
+const encodeStoredThreadSnapshot = Schema.encodeEffect(
+ Schema.fromJsonString(StoredOrchestrationThreadSnapshot),
);
-const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot));
const decodeStoredServerConfig = Schema.decodeUnknownEffect(
Schema.fromJsonString(StoredServerConfig),
);
@@ -130,12 +115,12 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
const payload = yield* encodeStoredShellSnapshot({
- schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
- .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
+ .saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
}),
loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) =>
@@ -153,15 +138,15 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
}),
),
saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) {
- const threadId = snapshot.thread.id;
+ const threadId = snapshot.projection.thread.id;
const payload = yield* encodeStoredThreadSnapshot({
- schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
threadId,
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
yield* database
- .saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
+ .saveCache(environmentId, "thread", threadId, ORCHESTRATION_CACHE_SCHEMA_VERSION, payload)
.pipe(Effect.mapError(mapDatabaseError("save-thread")));
}),
removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) =>
diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts
index 662a4dcdf70c..61ce2c386af8 100644
--- a/apps/mobile/src/connection/runtime.ts
+++ b/apps/mobile/src/connection/runtime.ts
@@ -1,6 +1,9 @@
import { Connection } from "@t3tools/client-runtime/connection";
import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
-import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
+import {
+ boundedThreadSnapshotLoaderLayer,
+ threadHistoryControllerLayer,
+} from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";
@@ -20,7 +23,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
Layer.provide(runtimeContextLayer),
);
-const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
+const snapshotLoaderLayer = Layer.mergeAll(
+ boundedThreadSnapshotLoaderLayer,
+ shellSnapshotLoaderLayer,
+ threadHistoryControllerLayer,
+);
type ConnectionLayerSource =
| typeof Connection.layer
diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts
index e844181673df..b043a3b414bd 100644
--- a/apps/mobile/src/connection/storage.ts
+++ b/apps/mobile/src/connection/storage.ts
@@ -17,6 +17,7 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+
import * as CatalogStore from "./catalog-store";
function targetPersistenceError(
diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts
index 697d13e7c472..77e45963b831 100644
--- a/apps/mobile/src/features/archive/archivedThreadList.test.ts
+++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts
@@ -1,9 +1,11 @@
import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
-import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts";
-import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts";
+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
+import * as DateTime from "effect/DateTime";
import { buildArchivedThreadGroups } from "./archivedThreadList";
+import { makeRawThreadShell } from "../../test-fixtures";
const environmentId = EnvironmentId.make("environment-1");
@@ -22,42 +24,30 @@ function makeProject(
}
function makeThread(
- input: Partial &
- Pick,
-): OrchestrationThreadShell {
- return {
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- latestTurn: null,
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- archivedAt: "2026-06-02T00:00:00.000Z",
- session: null,
- latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
- hasActionableProposedPlan: false,
+ input: Pick & {
+ readonly branch?: string | null;
+ readonly archivedAt?: string | null;
+ },
+): OrchestrationV2ThreadShell {
+ const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt;
+ return makeRawThreadShell({
...input,
- settledOverride: input.settledOverride ?? null,
- settledAt: input.settledAt ?? null,
- };
+ archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt),
+ });
}
function makeSnapshot(
projects: ReadonlyArray,
- threads: ReadonlyArray,
+ threads: ReadonlyArray,
targetEnvironmentId = environmentId,
): ArchivedSnapshotEntry {
return {
environmentId: targetEnvironmentId,
snapshot: {
+ schemaVersion: 1,
snapshotSequence: 1,
projects,
threads,
- updatedAt: "2026-06-04T00:00:00.000Z",
},
};
}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 4c41ce2bf150..ce5cf9afc9a4 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -193,7 +193,7 @@ function deriveEmptyState(props: {
return {
title: "No threads yet",
- detail: "Create a task to start a new coding session in one of your connected projects.",
+ detail: "Create a task to start a new coding runtime in one of your connected projects.",
loading: false,
};
}
@@ -813,7 +813,7 @@ export function HomeScreen(props: HomeScreenProps) {
?.providers.find(
(provider) =>
provider.instanceId ===
- (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
+ (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
)?.driver ?? null
}
environmentLabel={
@@ -1106,7 +1106,7 @@ export function HomeScreen(props: HomeScreenProps) {
detail="Choose another environment or create a new task."
/>
) : (
-
+
)
) : null;
// Use the v2 project scope for its empty state. Snoozed threads need no
diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts
index c5a9f2c6bbcb..89727851a921 100644
--- a/apps/mobile/src/features/home/homeListItems.test.ts
+++ b/apps/mobile/src/features/home/homeListItems.test.ts
@@ -1,8 +1,10 @@
-import type {
- EnvironmentProject,
- EnvironmentThreadShell,
+import {
+ presentThreadShell,
+ type EnvironmentProject,
+ type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";
import {
@@ -32,29 +34,41 @@ function makeProject(id: string, title: string): EnvironmentProject {
};
}
+const threadTimestamp = DateTime.makeUnsafe("2026-06-01T00:00:00.000Z");
+
function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
- return {
- environmentId,
- id: ThreadId.make(id),
+ const threadId = ThreadId.make(id);
+ return presentThreadShell(environmentId, {
+ id: threadId,
projectId,
title: `Thread ${id}`,
+ providerInstanceId: ProviderInstanceId.make("codex"),
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
- latestTurn: null,
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- archivedAt: null,
+ activeProviderThreadId: null,
+ lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
+ forkedFrom: null,
+ createdBy: "user",
+ creationSource: "mobile",
+ latestRunId: null,
+ activeRunId: null,
+ status: "idle",
+ pendingRuntimeRequest: null,
+ latestVisibleMessage: null,
settledOverride: null,
settledAt: null,
- session: null,
latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
hasActionableProposedPlan: false,
- };
+ itemCount: 0,
+ visibleItemCount: 0,
+ createdAt: threadTimestamp,
+ updatedAt: threadTimestamp,
+ archivedAt: null,
+ deletedAt: null,
+ });
}
function makeGroup(key: string, threadCount: number): HomeThreadGroup {
diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts
index 60d3ab2c867a..6ed8f01982ed 100644
--- a/apps/mobile/src/features/home/homeThreadList.test.ts
+++ b/apps/mobile/src/features/home/homeThreadList.test.ts
@@ -11,6 +11,7 @@ import {
buildHomeThreadGroups,
sortHomeProjectScopes,
} from "./homeThreadList";
+import { makeThreadShellFixture } from "../../test-fixtures";
function makeProject(
input: Partial & Pick,
@@ -30,25 +31,21 @@ function makeThread(
input: Partial &
Pick,
): EnvironmentThreadShell {
- return {
+ return makeThreadShellFixture({
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
- latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
archivedAt: null,
- session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
- settledOverride: input.settledOverride ?? null,
- settledAt: input.settledAt ?? null,
- };
+ });
}
const NOW = Date.parse("2026-06-29T00:00:00.000Z");
diff --git a/apps/mobile/src/features/home/threadArchive.test.ts b/apps/mobile/src/features/home/threadArchive.test.ts
new file mode 100644
index 000000000000..5f1320b76240
--- /dev/null
+++ b/apps/mobile/src/features/home/threadArchive.test.ts
@@ -0,0 +1,40 @@
+import { ProviderInstanceId, RunId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/models";
+import { threadCanArchive } from "./threadArchive";
+
+function runtime(
+ status: ThreadRuntimeSummary["status"],
+ activeRunId: ThreadRuntimeSummary["activeRunId"],
+): ThreadRuntimeSummary {
+ return {
+ status,
+ activeRunId,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "codex",
+ lastError: null,
+ updatedAt: "2026-07-28T10:00:00.000Z",
+ };
+}
+
+describe("threadCanArchive", () => {
+ it("blocks provider-active work", () => {
+ const activeRunId = RunId.make("run-live");
+ expect(threadCanArchive(runtime("preparing", activeRunId))).toBe(false);
+ expect(threadCanArchive(runtime("starting", activeRunId))).toBe(false);
+ expect(threadCanArchive(runtime("running", activeRunId))).toBe(false);
+ });
+
+ it("only allows queued work when no provider run remains active", () => {
+ const activeRunId = RunId.make("run-live");
+ expect(threadCanArchive(runtime("queued", null))).toBe(true);
+ expect(threadCanArchive(runtime("queued", activeRunId))).toBe(false);
+ });
+
+ it("allows post-provider waiting work despite a retained active run id", () => {
+ const staleActiveRunId = RunId.make("run-finished");
+ expect(threadCanArchive(runtime("waiting", null))).toBe(true);
+ expect(threadCanArchive(runtime("waiting", staleActiveRunId))).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/features/home/threadArchive.ts b/apps/mobile/src/features/home/threadArchive.ts
new file mode 100644
index 000000000000..8eaf9fa541d5
--- /dev/null
+++ b/apps/mobile/src/features/home/threadArchive.ts
@@ -0,0 +1,16 @@
+import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/models";
+
+/**
+ * Archiving may discard queued work, but it must not detach a provider while
+ * that provider is still executing a turn.
+ */
+export function threadCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
+ if (runtime?.status === "queued") {
+ return runtime.activeRunId === null;
+ }
+ return (
+ runtime?.status !== "preparing" &&
+ runtime?.status !== "starting" &&
+ runtime?.status !== "running"
+ );
+}
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index dae6c46a89dd..d3167e579b5b 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -1,3 +1,4 @@
+import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
import * as Cause from "effect/Cause";
@@ -17,6 +18,7 @@ import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
+import { threadCanArchive } from "./threadArchive";
/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
@@ -120,11 +122,7 @@ function useThreadActionExecutor(
}
// Archive keeps its original, narrower guard: never interrupt a
// thread mid-turn.
- if (
- action === "archive" &&
- thread.session?.status === "running" &&
- thread.session.activeTurnId != null
- ) {
+ if (action === "archive" && threadRuntimeIsActive(thread.runtime)) {
Alert.alert(
actionFailureTitle(action),
"This thread is working. Interrupt it first, then try again.",
diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
index fa1c953849f9..2a24c7cb8160 100644
--- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
+++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
@@ -56,7 +56,10 @@ export function subscribeToHardwareKeyboardCommandRegistrations(listener: () =>
export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
const commandHandlers = handlers.get(command);
if (!commandHandlers) return false;
- for (const handler of [...commandHandlers].toReversed()) {
+ const handlersInRegistrationOrder = Array.from(commandHandlers);
+ for (let index = handlersInRegistrationOrder.length - 1; index >= 0; index -= 1) {
+ const handler = handlersInRegistrationOrder[index];
+ if (!handler) continue;
if (handler() !== false) return true;
}
return false;
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index cc1e8f4e5799..a553fe85e8e5 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -613,7 +613,6 @@ function useCreateProject(environment: EnvironmentOption | null) {
commandId: CommandId.make(uuidv4()),
projectId,
workspaceRoot,
- createdAt: new Date().toISOString(),
});
const result = await createProject({
environmentId: environment.environmentId,
diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts
index 3390afd9ff27..770a6561360b 100644
--- a/apps/mobile/src/features/review/reviewModel.test.ts
+++ b/apps/mobile/src/features/review/reviewModel.test.ts
@@ -1,11 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
-import {
- MessageId,
- TurnId,
- type OrchestrationCheckpointSummary,
- type ReviewDiffPreviewSource,
-} from "@t3tools/contracts";
+import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts";
+import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import {
buildReviewListItems,
@@ -18,9 +14,9 @@ import {
} from "./reviewModel";
function makeCheckpoint(
- input: Partial &
- Pick,
-): OrchestrationCheckpointSummary {
+ input: Partial &
+ Pick,
+): ThreadCheckpointSummary {
return {
checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
status: "ready",
@@ -52,12 +48,12 @@ describe("buildReviewSectionItems", () => {
it("keeps one chip per checkpoint and appends git sources", () => {
const checkpoints = [
makeCheckpoint({
- turnId: TurnId.make("turn-1"),
+ runId: RunId.make("run-1"),
checkpointTurnCount: 1,
completedAt: "2026-04-01T00:00:00.000Z",
}),
makeCheckpoint({
- turnId: TurnId.make("turn-2"),
+ runId: RunId.make("run-2"),
checkpointTurnCount: 2,
completedAt: "2026-04-02T00:00:00.000Z",
}),
diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts
index 9459d41872d1..5e1ae6bac5a2 100644
--- a/apps/mobile/src/features/review/reviewModel.ts
+++ b/apps/mobile/src/features/review/reviewModel.ts
@@ -1,6 +1,7 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
-import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
+import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
+import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
import * as Order from "effect/Order";
@@ -128,11 +129,11 @@ export type ReviewParsedDiff =
readonly notice: string | null;
};
-function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string {
+function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
return `Turn ${checkpoint.checkpointTurnCount}`;
}
-function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string {
+function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
const fileCount = checkpoint.files.length;
if (checkpoint.status !== "ready") {
return `Diff ${checkpoint.status}`;
@@ -141,8 +142,8 @@ function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string
}
function compareCheckpointTurnCountDescending(
- left: OrchestrationCheckpointSummary,
- right: OrchestrationCheckpointSummary,
+ left: ThreadCheckpointSummary,
+ right: ThreadCheckpointSummary,
): -1 | 0 | 1 {
if (left.checkpointTurnCount === right.checkpointTurnCount) {
return 0;
@@ -151,7 +152,7 @@ function compareCheckpointTurnCountDescending(
return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;
}
-const readyCheckpointOrder = Order.make(
+const readyCheckpointOrder = Order.make(
compareCheckpointTurnCountDescending,
);
@@ -510,14 +511,14 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
}
export function getReviewSectionIdForCheckpoint(
- checkpoint: Pick,
+ checkpoint: Pick,
): string {
return `turn:${checkpoint.checkpointTurnCount}`;
}
export function getReadyReviewCheckpoints(
- checkpoints: ReadonlyArray,
-): ReadonlyArray {
+ checkpoints: ReadonlyArray,
+): ReadonlyArray {
return pipe(
checkpoints,
Arr.filter((checkpoint) => checkpoint.status === "ready"),
@@ -526,7 +527,7 @@ export function getReadyReviewCheckpoints(
}
export function buildReviewSectionItems(input: {
- readonly checkpoints: ReadonlyArray;
+ readonly checkpoints: ReadonlyArray;
readonly gitSections: ReadonlyArray;
readonly turnDiffById: Readonly>;
readonly loadingTurnIds: Readonly>;
diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts
index 87325490990c..b8a002801975 100644
--- a/apps/mobile/src/features/review/useReviewSections.ts
+++ b/apps/mobile/src/features/review/useReviewSections.ts
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo } from "react";
-import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
+import {
+ deriveThreadCheckpointSummaries,
+ type ThreadCheckpointSummary,
+} from "@t3tools/client-runtime/state/thread-checkpoints";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useCheckpointDiff } from "../../state/queries";
import { useEnvironmentQuery } from "../../state/query";
import { reviewEnvironment } from "../../state/review";
-import { useSelectedThreadDetail } from "../../state/use-thread-detail";
+import { useSelectedThreadProjection } from "../../state/use-thread-detail";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import {
buildReviewSectionItems,
@@ -30,7 +34,7 @@ export function useReviewSections(input: {
}) {
const { environmentId, reviewCache, threadId } = input;
const enabled = input.enabled ?? true;
- const selectedThread = useSelectedThreadDetail();
+ const selectedThread = useSelectedThreadProjection();
const { selectedThreadCwd } = useSelectedThreadWorktree();
const diffPreview = useEnvironmentQuery(
enabled && environmentId !== undefined && selectedThreadCwd !== null
@@ -49,8 +53,11 @@ export function useReviewSections(input: {
}, [diffPreview.data, reviewCache.threadKey]);
const readyCheckpoints = useMemo(
- () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []),
- [selectedThread?.checkpoints],
+ () =>
+ getReadyReviewCheckpoints(
+ selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection),
+ ),
+ [selectedThread],
);
const checkpointBySectionId = useMemo(
() =>
@@ -59,7 +66,7 @@ export function useReviewSections(input: {
getReviewSectionIdForCheckpoint(checkpoint),
checkpoint,
]),
- ) as Record,
+ ) as Record,
[readyCheckpoints],
);
const reviewSections = useMemo(
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index 351082580d63..3f83d6aae286 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -43,7 +43,7 @@ import {
useKnownTerminalSessions,
} from "../../state/use-terminal-session";
import { useThreadSelection } from "../../state/use-thread-selection";
-import { useSelectedThreadDetail } from "../../state/use-thread-detail";
+import { useSelectedThreadProjection } from "../../state/use-thread-detail";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { TerminalSurface } from "./NativeTerminalSurface";
@@ -166,7 +166,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const params = props.route.params;
const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
useThreadSelection();
- const selectedThreadDetail = useSelectedThreadDetail();
+ const selectedThreadDetail = useSelectedThreadProjection();
+ const selectedThreadDetailWorktreePath =
+ selectedThreadDetail?.projection.thread.worktreePath ?? null;
const routeEnvironmentIdRaw = firstRouteParam(params.environmentId);
const routeThreadIdRaw = firstRouteParam(params.threadId);
const routeEnvironmentId = routeEnvironmentIdRaw
@@ -279,13 +281,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
activeSessionLocation: activeKnownSession?.state.summary ?? null,
workspaceRoot: selectedThreadProject.workspaceRoot,
threadShellWorktreePath: selectedThread.worktreePath ?? null,
- threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
+ threadDetailWorktreePath: selectedThreadDetailWorktreePath,
});
}, [
activeKnownSession?.state.summary,
pendingLaunch,
selectedThread,
- selectedThreadDetail?.worktreePath,
+ selectedThreadDetailWorktreePath,
selectedThreadProject?.workspaceRoot,
]);
const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index e1cc7405bde2..8f87c0aff3b0 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -51,6 +51,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ShimmeringWorkContent } from "./thread-work-log";
+import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { useComposerCommandMenu } from "./use-composer-command-menu";
import {
@@ -89,7 +90,6 @@ import {
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { resolveProviderInteractionMode } from "./legacy-plan-mode";
-import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
@@ -970,7 +970,10 @@ export function NewTaskDraftScreen(props: {
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: selectedProject.environmentId,
- threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
+ threadTitle: deriveThreadTitleSeed({
+ text: initialMessageText,
+ attachments: draft.attachments,
+ }),
projectTitle: selectedProject.title,
});
const creationBranch = resolveProjectThreadCreationBranch({
diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
index a94f321a4ad8..e01ad75fc3b3 100644
--- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx
+++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
@@ -1,7 +1,7 @@
import type {
- ApprovalRequestId,
ProviderApprovalDecision,
ProviderApprovalOption,
+ RuntimeRequestId,
} from "@t3tools/contracts";
import { Pressable, View } from "react-native";
@@ -10,9 +10,9 @@ import type { PendingApproval } from "../../lib/threadActivity";
export interface PendingApprovalCardProps {
readonly approval: PendingApproval;
- readonly respondingApprovalId: ApprovalRequestId | null;
+ readonly respondingApprovalId: RuntimeRequestId | null;
readonly onRespond: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
decision: ProviderApprovalDecision,
) => Promise;
}
@@ -29,6 +29,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
const warning = options.find((option) => option.warning)?.warning;
// Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
// behind this card, so a translucent surface bleeds messages through it.
+ const canRespond = props.approval.responseCapability === "live";
+ const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
return (
@@ -42,6 +44,12 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
{props.approval.detail}
) : null}
+ {!canRespond ? (
+
+ The provider process for this request is no longer available. Interrupt or restart the run
+ to continue.
+
+ ) : null}
{warning ? (
{warning}
@@ -58,7 +66,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
? "bg-adaptive-rose-100-500-a18"
: "bg-adaptive-neutral-200-800"
}`}
- disabled={props.respondingApprovalId === props.approval.requestId}
+ disabled={disabled}
onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
>
void;
readonly drafts: Record;
readonly answers: Record> | null;
- readonly respondingUserInputId: ApprovalRequestId | null;
+ readonly respondingUserInputId: RuntimeRequestId | null;
readonly onSelectOption: (
- requestId: ApprovalRequestId,
- question: UserInputQuestion,
+ requestId: RuntimeRequestId,
+ question: ThreadUserInputQuestion,
value: string,
) => void;
readonly onChangeCustomAnswer: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
questionId: string,
customAnswer: string,
) => void;
@@ -87,6 +88,8 @@ const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);
export function PendingUserInputCard(props: PendingUserInputCardProps) {
const questionCount = props.pendingUserInput.questions.length;
+ // Message responses start a new run and remain available after the provider exits.
+ const canRespond = props.pendingUserInput.responseCapability !== "not_resumable";
const cardCoverage = props.cardCoverage;
const barHeightRef = useRef(0);
@@ -255,6 +258,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
showsVerticalScrollIndicator
style={{ flexShrink: 1 }}
>
+ {!canRespond ? (
+
+ The provider process for this request is no longer available. Interrupt or restart the
+ run to continue.
+
+ ) : null}
{props.pendingUserInput.questions.map((question) => {
const draft = props.drafts[question.id];
return (
@@ -274,6 +283,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
return (
{question.allowCustomAnswer !== false ? (
props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
@@ -331,7 +342,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
props.answers ? "bg-blue-500" : "bg-adaptive-neutral-200-700-a60",
)}
disabled={
- props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId
+ !canRespond ||
+ props.answers === null ||
+ props.respondingUserInputId === props.pendingUserInput.requestId
}
onPress={() => void props.onSubmit()}
>
diff --git a/apps/mobile/src/features/threads/ThreadActivityInspector.tsx b/apps/mobile/src/features/threads/ThreadActivityInspector.tsx
new file mode 100644
index 000000000000..642e26a495f8
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadActivityInspector.tsx
@@ -0,0 +1,203 @@
+import * as Haptics from "expo-haptics";
+import { SymbolView } from "expo-symbols";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { useNavigation } from "@react-navigation/native";
+import { useMemo, useState } from "react";
+import { Linking, Pressable, ScrollView, type ColorValue, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import type { ThreadFeedActivity } from "../../lib/threadActivity";
+import { buildThreadActivityInspector } from "../../lib/threadActivityInspector";
+import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useV2ItemSupport } from "../../state/v2-item-support";
+import { buildThreadActivityFileParams } from "./threadActivityFileNavigation";
+
+export function ThreadActivityInspector(props: {
+ readonly activity: ThreadFeedActivity;
+ readonly currentThreadId: ThreadId;
+ readonly environmentId: EnvironmentId;
+ readonly iconColor: ColorValue;
+ readonly workspaceRoot?: string | null;
+}) {
+ const navigation = useNavigation();
+ const row = props.activity.projectedItem;
+ const support = useV2ItemSupport({
+ environmentId: props.environmentId,
+ sourceThreadId: row.sourceThreadId,
+ sourceItemId: row.sourceItemId,
+ });
+ const model = useMemo(
+ () => buildThreadActivityInspector(props.activity, support, props.currentThreadId),
+ [props.activity, props.currentThreadId, support],
+ );
+ const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
+ label: "checkpoint rollback",
+ reportFailure: true,
+ });
+ const [rollingBack, setRollingBack] = useState(false);
+
+ return (
+
+
+ {model.fields.map((field) => (
+
+
+ {field.label}
+
+
+ {field.value}
+
+
+ ))}
+
+
+ {model.blocks.map((block) => (
+
+
+ {block.label}
+
+
+
+ {block.value}
+
+
+
+ ))}
+
+ {model.fileLinks.length > 0 ? (
+
+
+ Files
+
+ {model.fileLinks.map((link) => {
+ const relativePath =
+ resolveWorkspaceRelativeFilePath(props.workspaceRoot, link.path) ??
+ (link.path.startsWith("/") ? null : link.path);
+ return (
+ {
+ if (!relativePath) return;
+ void Haptics.selectionAsync();
+ navigation.navigate(
+ "ThreadFile",
+ buildThreadActivityFileParams({
+ environmentId: props.environmentId,
+ currentThreadId: props.currentThreadId,
+ activitySourceThreadId: row.sourceThreadId,
+ relativePath,
+ line: link.line,
+ }),
+ );
+ }}
+ className="min-h-9 flex-row items-center gap-2 rounded-md border border-adaptive-neutral-300-a60-white-a12 px-2.5 py-1.5"
+ >
+
+
+ {link.label}
+
+
+ );
+ })}
+
+ ) : null}
+
+ {model.webLinks.length > 0 ? (
+
+
+ Sources
+
+ {model.webLinks.map((link) => (
+ void Linking.openURL(link.url)}
+ className="min-h-9 flex-row items-center gap-2 rounded-md border border-adaptive-neutral-300-a60-white-a12 px-2.5 py-1.5"
+ >
+
+
+ {link.label}
+
+
+ ))}
+
+ ) : null}
+
+ {model.rollbackTarget ? (
+ {
+ setRollingBack(true);
+ void Haptics.selectionAsync();
+ void revertCheckpoint({
+ environmentId: props.environmentId,
+ input: {
+ threadId: model.rollbackTarget!.threadId,
+ checkpointId: model.rollbackTarget!.checkpointId,
+ scopeId: model.rollbackTarget!.scopeId,
+ },
+ }).finally(() => setRollingBack(false));
+ }}
+ className="min-h-10 flex-row items-center justify-center gap-2 rounded-lg border border-adaptive-neutral-300-a60-white-a12 px-3 py-2"
+ >
+
+
+ {rollingBack ? "Rolling back…" : "Roll back to checkpoint"}
+
+
+ ) : null}
+
+
+
+ Structured details
+
+
+
+ {model.structuredDetails}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index af3359ec8c79..80cb8b7f0570 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -1,9 +1,9 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { useAtomValue } from "@effect/atom-react";
import type {
EnvironmentId,
MessageId,
ModelSelection,
- OrchestrationThreadShell,
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
@@ -110,10 +110,18 @@ export interface ThreadComposerProps {
readonly connectionState: RemoteClientConnectionState;
readonly connectionError: string | null;
readonly environmentLabel: string | null;
- readonly selectedThread: OrchestrationThreadShell;
+ /**
+ * Message sync phase for the selected thread (drives the status pill):
+ * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
+ * are on screen while they reconcile with the server.
+ */
+ readonly threadSyncPhase?: "loading" | "syncing" | null;
+ readonly selectedThread: EnvironmentThreadShell;
readonly hasCompactableConversation: boolean;
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
+ readonly activeThreadBusy: boolean;
+ readonly canStopThread: boolean;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly editorRef?: RefObject;
@@ -310,10 +318,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const [previewFile, setPreviewFile] = useState(null);
const [previewVideo, setPreviewVideo] = useState(null);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
- const showStopAction =
- !hasContent &&
- (props.selectedThread.session?.status === "running" ||
- props.selectedThread.session?.status === "starting");
+ const showStopAction = !hasContent && props.canStopThread;
const sendLabel =
props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index d0e553ebdcf9..9ee3fa2e8ec9 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -1,26 +1,26 @@
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
-import {
- appendCodexArtifactTemplateUsePrompt,
- type CodexArtifactTemplate,
-} from "@t3tools/client-runtime/codex-artifact-templates";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
import type {
- ApprovalRequestId,
EnvironmentId,
MessageId,
ModelSelection,
- OrchestrationThreadShell,
ProviderApprovalDecision,
ProviderInteractionMode,
RuntimeMode,
+ RuntimeRequestId,
ServerConfig as T3ServerConfig,
ThreadId,
- UserInputQuestion,
} from "@t3tools/contracts";
+import {
+ appendCodexArtifactTemplateUsePrompt,
+ type CodexArtifactTemplate,
+} from "@t3tools/client-runtime/codex-artifact-templates";
+import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
import * as Haptics from "expo-haptics";
import {
memo,
@@ -61,13 +61,15 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
-import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { scopedThreadKey } from "../../lib/scopedEntities";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
import type {
PendingApproval,
PendingUserInput,
PendingUserInputDraftAnswer,
ThreadFeedEntry,
+ ThreadFeedLatestRun,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
@@ -88,32 +90,37 @@ import {
COMPOSER_TRANSITION_DURATION_MS,
ThreadComposer,
} from "./ThreadComposer";
-import { ThreadFeed } from "./ThreadFeed";
+import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
+import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
+import { ThreadQueueControl } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
export interface ThreadDetailScreenProps {
- readonly selectedThread: OrchestrationThreadShell;
+ readonly selectedThread: EnvironmentThreadShell;
readonly contentPresentation: ThreadContentPresentation;
readonly screenTone: StatusTone;
readonly connectionError: string | null;
readonly environmentLabel: string | null;
readonly selectedThreadFeed: ReadonlyArray;
+ readonly activityRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
readonly isCompacting: boolean;
readonly activePendingApproval: PendingApproval | null;
- readonly respondingApprovalId: ApprovalRequestId | null;
+ readonly respondingApprovalId: RuntimeRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
readonly activePendingUserInputDrafts: Record;
readonly activePendingUserInputAnswers: Record> | null;
- readonly respondingUserInputId: ApprovalRequestId | null;
+ readonly respondingUserInputId: RuntimeRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray;
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
- /** Non-null when older turns exist beyond the loaded window. */
- readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
+ /** Progressive history controls for oversized mobile thread opens. */
+ readonly historyControls?: ThreadFeedHistoryControls;
+ readonly activeThreadBusy: boolean;
+ readonly canStopThread: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
@@ -135,16 +142,16 @@ export interface ThreadDetailScreenProps {
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
readonly onRespondToApproval: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
decision: ProviderApprovalDecision,
) => Promise;
readonly onSelectUserInputOption: (
- requestId: ApprovalRequestId,
- question: UserInputQuestion,
+ requestId: RuntimeRequestId,
+ question: ThreadUserInputQuestion,
value: string,
) => void;
readonly onChangeUserInputCustomAnswer: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
questionId: string,
customAnswer: string,
) => void;
@@ -263,7 +270,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}
}, []);
const windowHeight = useWindowDimensions().height;
- const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT;
+ const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const composerEditorRef = useRef(null);
@@ -347,7 +354,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
((entry.message.attachments?.length ?? 0) > 0 ||
entry.message.text.trim().toLowerCase() !== "/compact"),
) ||
- (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null);
+ (props.historyControls?.hasMoreHistory === true &&
+ props.selectedThread.latestUserMessageAt !== null);
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
// While a user-input request is pending, the questionnaire owns the
@@ -357,7 +365,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// keyboard animations coherent. Collapse state is keyed by request id so a
// new request re-expands automatically.
const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
- useState(null);
+ useState(null);
const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
const userInputCollapsed =
activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;
@@ -551,6 +559,39 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
setComposerFocused(false);
}, [selectedThreadKey, showContent]);
+ const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
+ const lastDispatchedVisitRef = useRef(null);
+ const selectedThreadId = props.selectedThread.id;
+ const selectedThreadUpdatedAt = props.selectedThread.updatedAt;
+ const selectedThreadLastVisitedAt = props.selectedThread.lastVisitedAt;
+ useEffect(() => {
+ // Records the server-side visited watermark while the thread is on
+ // screen (mirror of web ChatView), so the "Done" marker clears on every
+ // device. Field absent → the server predates visited tracking.
+ if (selectedThreadLastVisitedAt === undefined) return;
+ const threadUpdatedAtMs = Date.parse(selectedThreadUpdatedAt);
+ if (Number.isNaN(threadUpdatedAtMs)) return;
+ const lastVisitedAtMs = selectedThreadLastVisitedAt
+ ? Date.parse(selectedThreadLastVisitedAt)
+ : NaN;
+ if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= threadUpdatedAtMs) return;
+ // Dedupe per watermark — the effect re-runs before the command echo lands.
+ const dispatchKey = `${selectedThreadKey}:${selectedThreadUpdatedAt}`;
+ if (lastDispatchedVisitRef.current === dispatchKey) return;
+ lastDispatchedVisitRef.current = dispatchKey;
+ void visitThread({
+ environmentId: props.environmentId,
+ input: { threadId: selectedThreadId, visitedAt: selectedThreadUpdatedAt },
+ });
+ }, [
+ props.environmentId,
+ selectedThreadId,
+ selectedThreadKey,
+ selectedThreadLastVisitedAt,
+ selectedThreadUpdatedAt,
+ visitThread,
+ ]);
+
useEffect(() => {
setAnchorMessageId(null);
setSubmittedMessageId(null);
@@ -628,7 +669,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
resolveThreadFeedSubmissionAnchor({
currentAnchorMessageId: anchorMessageId,
submittedMessageId: messageId,
- hasStartedTurn: props.selectedThread.latestTurn !== null,
+ hasStartedTurn: props.selectedThread.latestRun !== null,
hasUserMessage,
queuedMessageCount: props.selectedThreadQueueCount,
}),
@@ -638,7 +679,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, [
anchorMessageId,
props.onSendMessage,
- props.selectedThread.latestTurn,
+ props.selectedThread.latestRun,
props.selectedThreadQueueCount,
selectedThreadFeed,
selectedThreadKey,
@@ -723,7 +764,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
feed={props.selectedThreadFeed}
contentPresentation={props.contentPresentation}
agentLabel={agentLabel}
- latestTurn={props.selectedThread.latestTurn}
+ threadTitle={props.selectedThread.title}
+ latestRun={props.activityRun}
activeWorkStartedAt={props.activeWorkStartedAt}
listRef={listRef}
freeze={freeze}
@@ -735,13 +777,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
}
contentMaxWidth={contentMaxWidth}
+ historyControls={props.historyControls}
+ topAccessory={
+
+ }
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
onUseArtifactTemplate={handleUseArtifactTemplate}
- loadEarlier={props.loadEarlier ?? null}
/>
) : (
@@ -778,6 +826,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onScrollToEnd={handleScrollToEnd}
/>
+
+
{props.activePendingApproval || props.activePendingUserInput ? (
void;
+}
+
export interface ThreadFeedProps {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
+ readonly threadTitle: string;
readonly workspaceRoot?: string | null;
readonly feed: ReadonlyArray;
readonly contentPresentation: ThreadContentPresentation;
readonly agentLabel: string;
- readonly latestTurn: ThreadFeedLatestTurn | null;
+ readonly latestRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
readonly listRef: RefObject;
readonly freeze: SharedValue;
@@ -236,6 +254,8 @@ export interface ThreadFeedProps {
readonly contentInsetEndAdjustment: SharedValue;
readonly contentTopInset?: number;
readonly contentBottomInset?: number;
+ readonly historyControls?: ThreadFeedHistoryControls;
+ readonly topAccessory?: ReactNode;
readonly contentMaxWidth?: number;
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
@@ -243,11 +263,90 @@ export interface ThreadFeedProps {
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly skills?: ReadonlyArray;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
- /** Non-null when older turns exist beyond the loaded window. */
- readonly loadEarlier?: {
- readonly loading: boolean;
- readonly onLoadEarlier: () => void;
- } | null;
+}
+
+async function waitForThreadShell(
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+): Promise {
+ const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId));
+ return waitForThreadShellReady({
+ read: () => appAtomRegistry.get(atom) !== null,
+ });
+}
+
+function AssistantForkButton(props: {
+ readonly environmentId: EnvironmentId;
+ readonly iconColor: ColorValue;
+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
+ readonly sourceTitle: string;
+}) {
+ const support = useV2ItemSupport({
+ environmentId: props.environmentId,
+ sourceThreadId: props.projectedItem.sourceThreadId,
+ sourceItemId: props.projectedItem.sourceItemId,
+ });
+ const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response");
+ const navigation = useNavigation();
+ const [busy, setBusy] = useState(false);
+ const canFork = canForkProjectedAssistantItem({
+ projectedItem: props.projectedItem,
+ capabilities: support.providerSession?.capabilities,
+ });
+ const runId = props.projectedItem.item.runId;
+
+ if (!canFork || runId === null) return null;
+
+ return (
+ {
+ const targetThreadId = ThreadId.make(uuidv4());
+ setBusy(true);
+ void Haptics.selectionAsync();
+ void forkFromRun({
+ environmentId: props.environmentId,
+ input: {
+ sourceThreadId: props.projectedItem.sourceThreadId,
+ targetThreadId,
+ runId,
+ title: `${props.sourceTitle} fork`,
+ creationSource: "mobile",
+ },
+ })
+ .then(async (result) => {
+ if (result._tag !== "Success") return;
+ const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId);
+ if (!targetThreadReady) {
+ Alert.alert(
+ "Fork created",
+ "Its thread data did not reach this client. Reconnect and try opening it from the thread list.",
+ );
+ return;
+ }
+ navigation.navigate("Thread", {
+ environmentId: props.environmentId,
+ threadId: targetThreadId,
+ });
+ })
+ .finally(() => setBusy(false));
+ }}
+ className="h-7 w-7 items-center justify-center disabled:opacity-40"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ );
}
function MessageAttachmentImage(props: {
@@ -1298,8 +1397,8 @@ function useMarkdownStyles(
markdownUserInlineCodeText,
nativeMarkdownTypography,
onLinkPress,
- regularFontFamily,
renderImage,
+ regularFontFamily,
themeMode,
userBubbleForegroundMuted,
userBubbleSkillForeground,
@@ -1308,17 +1407,20 @@ function useMarkdownStyles(
function renderFeedEntry(
info: { item: ThreadFeedEntry; index: number },
- props: Pick & {
+ props: Pick<
+ ThreadFeedProps,
+ "environmentId" | "skills" | "threadId" | "workspaceRoot" | "onUseArtifactTemplate"
+ > & {
readonly copiedRowId: string | null;
readonly expandedWorkRows: Record;
readonly workRowSizing: ReturnType;
readonly workGroupScrollPositions: Map;
readonly terminalAssistantMessageIds: ReadonlySet;
- readonly unsettledTurnId: TurnId | null;
+ readonly unsettledTurnId: RunId | null;
readonly onCopyWorkRow: (rowId: string, value: string) => void;
- readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
- readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
- readonly onToggleTurnFold: (turnId: TurnId) => void;
+ readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
+ readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
+ readonly onToggleTurnFold: (runId: RunId) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
@@ -1331,17 +1433,18 @@ function renderFeedEntry(
readonly reviewCommentBubbleWidth: number;
readonly themeAppearance: "light" | "dark";
readonly userBubbleMaxWidth: number;
+ readonly threadTitle: string;
},
) {
const entry = info.item;
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
- if (entry.type === "turn-fold") {
+ if (entry.type === "run-fold") {
return (
props.onToggleTurnFold(entry.turnId)}
+ onPress={() => props.onToggleTurnFold(entry.runId)}
hitSlop={4}
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
style={{
@@ -1414,7 +1517,9 @@ function renderFeedEntry(
const renderedText = renderAssistantCitationsAsText(message.text);
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
- const attachments = message.attachments ?? [];
+ const attachments = (message.attachments ?? []).filter(
+ (attachment) => attachment.type === "image",
+ );
const hasReviewCommentContext = message.text.includes("
+ {message.createdBy === "agent" ? (
+
+ Sent by another agent
+
+ ) : null}
+ {intentBadge ? (
+
+
+ {intentBadge.label}
+
+
+ ) : null}
{timestampLabel}
@@ -1550,6 +1685,14 @@ function renderFeedEntry(
})}
{showAssistantMeta ? (
+ {message.projectedItem ? (
+
+ ) : null}
= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
}
+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}
+
+ );
+}
+
function ThreadFeedPlaceholder(props: {
readonly bottomInset: number;
readonly detail: string;
@@ -1864,9 +2043,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef(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(
@@ -1888,11 +2067,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
@@ -1920,7 +2099,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record;
readonly expandedWorkRows: Record;
- readonly expandedTurnIds: ReadonlySet;
+ readonly expandedTurnIds: ReadonlySet;
}>({
copiedRowId: null,
expandedWorkGroups: {},
@@ -2173,6 +2352,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const listAppearanceData = useMemo(
() => ({
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
workRowSizing,
iconSubtleColor,
@@ -2184,6 +2364,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}),
[
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
workRowSizing,
iconSubtleColor,
@@ -2278,13 +2459,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.
@@ -2295,11 +2469,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.
@@ -2316,36 +2485,39 @@ 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(
() =>
deriveThreadFeedPresentation(
props.feed,
- props.latestTurn,
+ props.latestRun,
expandedTurnIds,
- expandedWorkGroupIds,
+ new Set(
+ Object.entries(expandedWorkGroups)
+ .filter(([, expanded]) => expanded)
+ .map(([groupId]) => groupId),
+ ),
props.activeWorkStartedAt,
),
- [
- expandedTurnIds,
- expandedWorkGroupIds,
- props.activeWorkStartedAt,
- props.feed,
- props.latestTurn,
- ],
+ [expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, props.feed, props.latestRun],
);
- // The empty↔filled key below remounts the list and resets its imperative
- // content-inset override. Seed the fresh instance synchronously with the
- // current overlay height before the scroll integration's next reaction;
- // on Android the declarative contentInset floor covers this same window.
+ // The empty↔filled key below remounts the list, which resets its imperative
+ // content-inset override — and useKeyboardChatComposerInset (mounted above
+ // the remount boundary) deduplicates by height, so it never re-reports the
+ // composer inset to the fresh instance. Without this, the remounted list's
+ // initial scroll-to-end computes with a zero end inset and rests one
+ // composer-height short of the end. Layout effect: it must land before the
+ // list's first positioning tick or the one-shot initial scroll misses it.
const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
useLayoutEffect(() => {
const bottom = props.contentInsetEndAdjustment.value;
@@ -2359,35 +2531,31 @@ 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]);
- const unsettledTurnId =
- props.latestTurn &&
- (props.latestTurn.completedAt === null || props.latestTurn.state === "running")
- ? props.latestTurn.turnId
- : null;
+ const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
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),
@@ -2396,14 +2564,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 () => {
@@ -2498,8 +2666,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: {
@@ -2512,8 +2680,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: {
@@ -2526,14 +2694,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 };
});
@@ -2560,24 +2728,28 @@ 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 "turn-fold":
- return TURN_FOLD_HEIGHT;
+ case "run-fold":
+ return resolveThreadFeedFixedItemSize(entry.type);
case "work-toggle":
- return WORK_GROUP_TOGGLE_HEIGHT;
+ return resolveThreadFeedFixedItemSize(entry.type);
case "activity-group":
if (isContextCompactionActivityGroup(entry)) {
return undefined;
}
// 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:
@@ -2598,6 +2770,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
{renderFeedEntry(info, {
environmentId: props.environmentId,
+ onUseArtifactTemplate: props.onUseArtifactTemplate,
+ threadId: props.threadId,
copiedRowId,
expandedWorkRows,
workRowSizing,
@@ -2620,8 +2794,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
reviewCommentBubbleWidth,
themeAppearance,
userBubbleMaxWidth,
+ threadTitle: props.threadTitle,
skills: props.skills,
- onUseArtifactTemplate: props.onUseArtifactTemplate,
+ workspaceRoot: props.workspaceRoot,
})}
@@ -2650,7 +2825,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleWorkRow,
props.environmentId,
props.onUseArtifactTemplate,
+ props.threadId,
+ props.threadTitle,
props.skills,
+ props.workspaceRoot,
renderMarkdownImage,
renderViewedImage,
],
@@ -2701,7 +2879,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.
@@ -2713,16 +2891,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
@@ -2796,17 +2967,10 @@ 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}
+ {props.topAccessory}
>
}
contentContainerStyle={{
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 07357a1b7524..01b8671bb351 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -870,7 +870,7 @@ function ThreadNavigationSidebarPane(
?.providers.find(
(provider) =>
provider.instanceId ===
- (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
+ (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
)?.driver ?? null
}
environmentLabel={
diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
new file mode 100644
index 000000000000..e70d71083988
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
@@ -0,0 +1,150 @@
+import * as Haptics from "expo-haptics";
+import { SymbolView } from "expo-symbols";
+import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
+import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
+import { useMemo, useState } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useThreadProjection } from "../../state/use-thread-detail";
+import {
+ buildCancelQueuedRunCommand,
+ resolveThreadQueueRowControls,
+} from "./threadQueueControlPresentation";
+
+export function ThreadQueueControl(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+}) {
+ const scoped = useThreadProjection(props);
+ const workflow = useMemo(
+ () => (scoped ? deriveThreadQueueWorkflowState(scoped.projection) : null),
+ [scoped],
+ );
+ const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message");
+ const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message");
+ const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "cancel queued message");
+ const [busyRunId, setBusyRunId] = useState(null);
+ const theme = useUniwindTheme();
+ const iconColor = theme["--color-icon-subtle"];
+
+ if (!workflow || workflow.queuedRuns.length === 0) return null;
+
+ const move = async (runId: RunId, beforeRunId: RunId | null) => {
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ await reorder({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId, runId, beforeRunId },
+ });
+ setBusyRunId(null);
+ };
+
+ const steer = async (queuedRunId: RunId) => {
+ if (!workflow.activeRun || !workflow.canPromoteToSteer) return;
+ setBusyRunId(queuedRunId);
+ void Haptics.selectionAsync();
+ await promote({
+ environmentId: props.environmentId,
+ input: {
+ threadId: props.threadId,
+ queuedRunId,
+ targetRunId: workflow.activeRun.id,
+ },
+ });
+ setBusyRunId(null);
+ };
+
+ const dismiss = async (runId: RunId) => {
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ await cancel(
+ buildCancelQueuedRunCommand({
+ environmentId: props.environmentId,
+ runId,
+ threadId: props.threadId,
+ }),
+ );
+ setBusyRunId(null);
+ };
+
+ return (
+
+
+
+ Queue
+
+ {workflow.queuedRuns.length}
+
+
+
+ {workflow.queuedRuns.map(({ run, text }, index) => {
+ const controls = resolveThreadQueueRowControls({
+ busy: busyRunId !== null,
+ canPromoteToSteer: workflow.canPromoteToSteer,
+ canReorder: workflow.canReorder,
+ index,
+ queuedCount: workflow.queuedRuns.length,
+ text,
+ });
+
+ return (
+
+
+ {index + 1}
+
+
+ {controls.displayText}
+
+ void move(run.id, workflow.queuedRuns[index - 1]?.run.id ?? null)}
+ className="h-9 w-9 items-center justify-center disabled:opacity-30"
+ >
+
+
+ void move(run.id, workflow.queuedRuns[index + 2]?.run.id ?? null)}
+ className="h-9 w-9 items-center justify-center disabled:opacity-30"
+ >
+
+
+ void steer(run.id)}
+ className="min-h-8 flex-row items-center gap-1 rounded-lg border border-adaptive-neutral-300-a60-white-a12 px-2 disabled:opacity-30"
+ >
+
+ Steer
+
+ void dismiss(run.id)}
+ className="h-9 w-9 items-center justify-center disabled:opacity-30"
+ >
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx
new file mode 100644
index 000000000000..902a83791fc8
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx
@@ -0,0 +1,307 @@
+import * as Haptics from "expo-haptics";
+import { SymbolView, type SFSymbol } from "expo-symbols";
+import {
+ deriveThreadRelationshipGraph,
+ immediateThreadRelationships,
+ resolveMergeBackTargetThreadId,
+ type ThreadRelationshipEdge,
+} from "@t3tools/client-runtime/state/thread-relationships";
+import {
+ canDetachThreadProviderSession,
+ resolveLatestMergeBackRun,
+} from "@t3tools/client-runtime/state/thread-workflows";
+import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
+import { copySorted } from "@t3tools/shared/Array";
+import { useNavigation } from "@react-navigation/native";
+import { useMemo, useState } from "react";
+import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../components/AppText";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { useThreadShells } from "../../state/entities";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useThreadProjection } from "../../state/use-thread-detail";
+import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
+
+function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId): string {
+ if (edge.kind === "transfer") {
+ return edge.sourceThreadId === currentThreadId ? "Context sent to" : "Context received from";
+ }
+ if (edge.kind === "subagent") {
+ return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent";
+ }
+ return edge.sourceThreadId === currentThreadId ? "Fork" : "Forked from";
+}
+
+function relationshipSymbol(edge: ThreadRelationshipEdge): SFSymbol {
+ if (edge.kind === "subagent") return "person.2";
+ if (edge.kind === "transfer") return "arrow.left.arrow.right";
+ return "arrow.triangle.branch";
+}
+
+function threadAvailability(
+ thread: OrchestrationV2ThreadShell | null,
+ missing: boolean,
+): string | null {
+ if (missing) return "Unavailable";
+ if (thread?.deletedAt !== null && thread?.deletedAt !== undefined) return "Deleted";
+ if (thread?.archivedAt !== null && thread?.archivedAt !== undefined) return "Archived";
+ return null;
+}
+
+const EMPTY_THREAD_SHELLS: ReadonlyArray = [];
+
+export function ThreadRelationshipsBanner(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+}) {
+ const scopedProjection = useThreadProjection(props);
+ const projection = scopedProjection?.projection ?? null;
+ const threadShells = useThreadShells();
+ const environmentIds = useMemo(() => [props.environmentId], [props.environmentId]);
+ const archived = useArchivedThreadSnapshots(environmentIds);
+ const archivedShells =
+ archived.snapshots.find((entry) => entry.environmentId === props.environmentId)?.snapshot
+ .threads ?? EMPTY_THREAD_SHELLS;
+ const shells = useMemo>(() => {
+ const environmentShells: OrchestrationV2ThreadShell[] = [];
+ for (const thread of threadShells) {
+ if (thread.environmentId === props.environmentId) {
+ environmentShells.push(thread.source);
+ }
+ }
+ environmentShells.push(...archivedShells);
+ return environmentShells;
+ }, [archivedShells, props.environmentId, threadShells]);
+ const graph = useMemo(
+ () => deriveThreadRelationshipGraph({ threads: shells, projection }),
+ [projection, shells],
+ );
+ const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
+ const rows = useMemo(
+ () =>
+ copySorted(
+ immediateThreadRelationships(graph, props.threadId),
+ (left, right) =>
+ Number(right.threadId === mergeTargetThreadId) -
+ Number(left.threadId === mergeTargetThreadId),
+ ),
+ [graph, mergeTargetThreadId, props.threadId],
+ );
+ const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
+ const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
+ const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
+ const [visible, setVisible] = useState(false);
+ const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
+ const navigation = useNavigation();
+ const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
+ const stopSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
+ const insets = useSafeAreaInsets();
+ const theme = useUniwindTheme();
+ const backdropColor = theme["--color-backdrop"];
+ const sheetColor = theme["--color-sheet"];
+ const iconColor = theme["--color-icon-subtle"];
+
+ if (rows.length === 0 && !canDetach) return null;
+
+ const primaryParent = rows.find(({ edge }) => edge.targetThreadId === props.threadId) ?? rows[0];
+ const primaryNode = primaryParent ? graph.nodes.get(primaryParent.threadId) : null;
+ const summary = primaryParent
+ ? `${relationshipLabel(primaryParent.edge, props.threadId)}: ${primaryNode?.thread?.title ?? "related thread"}`
+ : "Agent session connected";
+
+ const openThread = (threadId: ThreadId, archivedThread: boolean) => {
+ setVisible(false);
+ void Haptics.selectionAsync();
+ if (archivedThread) {
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "SettingsArchive" },
+ });
+ return;
+ }
+ navigation.navigate("Thread", {
+ environmentId: props.environmentId,
+ threadId,
+ });
+ };
+
+ const merge = async () => {
+ if (!canMerge || mergeTargetThreadId === null || latestMergeBackRun === null) return;
+ setBusyAction("merge");
+ const result = await mergeBack({
+ environmentId: props.environmentId,
+ input: {
+ sourceThreadId: props.threadId,
+ targetThreadId: mergeTargetThreadId,
+ runId: latestMergeBackRun.id,
+ creationSource: "mobile",
+ },
+ });
+ setBusyAction(null);
+ if (result._tag === "Success") openThread(mergeTargetThreadId, false);
+ };
+
+ const detach = async () => {
+ if (!canDetach) return;
+ setBusyAction("detach");
+ await stopSession({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId },
+ });
+ setBusyAction(null);
+ };
+
+ return (
+ <>
+ {
+ void Haptics.selectionAsync();
+ setVisible(true);
+ }}
+ className="mb-4 min-h-11 flex-row items-center gap-2 rounded-xl border border-adaptive-neutral-300-a60-white-a12 bg-card px-3 py-2.5"
+ >
+
+
+ {summary}
+
+ {rows.length > 1 ? (
+ +{rows.length - 1}
+ ) : null}
+
+
+
+ setVisible(false)}
+ >
+
+
+ setVisible(false)} />
+
+
+
+
+ Thread lineage
+
+ {rows.length} related {rows.length === 1 ? "thread" : "threads"}
+
+
+ setVisible(false)}
+ className="h-10 w-10 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8"
+ >
+
+
+
+
+
+ {rows.map(({ threadId, edge }) => {
+ const node = graph.nodes.get(threadId);
+ const availability = threadAvailability(
+ node?.thread ?? null,
+ node?.missing ?? true,
+ );
+ const archivedThread = availability === "Archived";
+ const disabled = availability === "Unavailable" || availability === "Deleted";
+ return (
+ openThread(threadId, archivedThread)}
+ className="min-h-14 flex-row items-center gap-3 rounded-2xl border border-adaptive-neutral-300-a60-white-a12 bg-card px-3 py-2.5"
+ >
+
+
+
+
+
+ {relationshipLabel(edge, props.threadId)}
+
+
+ {node?.thread?.title ?? threadId}
+
+
+ {availability ? (
+ {availability}
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ {canMerge || canDetach ? (
+
+ {canMerge ? (
+ void merge()}
+ className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl bg-primary px-3"
+ >
+ {busyAction === "merge" ? (
+
+ ) : (
+
+ )}
+
+ Merge back to source
+
+
+ ) : null}
+ {canDetach ? (
+ void detach()}
+ className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl border border-adaptive-neutral-300-a60-white-a12 px-3"
+ >
+ {busyAction === "detach" ? : null}
+
+ Disconnect agent session
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index df9486e8556a..00f4b8e3ff40 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -8,10 +8,6 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import * as Option from "effect/Option";
import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
-import {
- requestOlderThreadTurns,
- threadHasOlderTurns,
-} from "@t3tools/client-runtime/state/threads";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -195,26 +191,36 @@ 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 params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
@@ -326,7 +332,7 @@ function ThreadRouteContent(
}),
[knownTerminalSessions, selectedThreadProject?.workspaceRoot],
);
- const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;
+ const selectedThreadDetailWorktreePath = selectedThreadDetail?.thread.worktreePath ?? null;
const handleReconnectEnvironment = useCallback(() => {
if (!environmentId) {
return;
@@ -480,23 +486,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) => {
@@ -774,6 +774,7 @@ function ThreadRouteContent(
connectionError={routeConnectionError}
environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
selectedThreadFeed={composer.selectedThreadFeed}
+ activityRun={composer.selectedThreadActivityRun}
activeWorkStartedAt={composer.activeWorkStartedAt}
isCompacting={composer.isCompacting}
activePendingApproval={requests.activePendingApproval}
@@ -786,7 +787,9 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
- loadEarlier={loadEarlierTurns}
+ historyControls={historyControls}
+ activeThreadBusy={composer.activeThreadBusy}
+ canStopThread={composer.interruptibleRunId !== null}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
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 97c13de56aab..5df253292f75 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -26,8 +26,9 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
- resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
+ resolveThreadListV2SnoozeMenuSelection,
+ threadHasUnseenCompletion,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
type ThreadListV2Status,
@@ -419,7 +420,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const selected = props.selected === true;
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.
@@ -733,7 +740,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
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index a2e19b9a8c4f..8527defeb3c8 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -701,7 +701,8 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary;
const displayText =
!toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText;
- 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);
@@ -770,9 +771,9 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
"min-w-0 flex-1 text-sm text-foreground-muted",
iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400",
)}
- numberOfLines={1}
+ numberOfLines={isSystemNotice ? undefined : 1}
>
- {displayText}
+ {isSystemNotice ? row.summary : displayText}
>
)}
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/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 33ae27cc0638..4d3f8c186147 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -7,12 +7,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 {
buildThreadListV2Items,
buildThreadListV2ListItems,
@@ -29,30 +30,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,
- 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",
@@ -132,18 +117,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,
},
@@ -151,6 +134,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",
@@ -266,19 +269,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("buildThreadListV2Items", () => {
@@ -373,6 +363,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: [
@@ -712,10 +756,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,
@@ -723,7 +768,7 @@ describe("buildThreadListV2Items", () => {
}),
],
environmentId: null,
- projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
+ projectRefs: [{ environmentId, projectId }],
searchQuery: "",
now: NOW,
});
@@ -733,19 +778,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,
@@ -768,9 +815,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`,
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 2b44851f9309..84b5df29a757 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -23,11 +23,13 @@ export { snoozeWakeLabel };
* 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: {
@@ -88,7 +90,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 {
@@ -125,8 +127,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";
@@ -134,10 +157,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";
diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts
index 59cf108a01dd..4e21f4ec99b4 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..5e4593557b36
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ buildCancelQueuedRunCommand,
+ resolveThreadQueueRowControls,
+} 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("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",
+ },
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
new file mode 100644
index 000000000000..def490455d9e
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
@@ -0,0 +1,52 @@
+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 canMoveDown: boolean;
+ readonly canMoveUp: boolean;
+ readonly canSteer: boolean;
+ readonly dismissAccessibilityLabel: string;
+ readonly displayText: string;
+}
+
+export function resolveThreadQueueRowControls(input: {
+ readonly busy: boolean;
+ readonly canPromoteToSteer: boolean;
+ readonly canReorder: boolean;
+ readonly index: number;
+ readonly queuedCount: number;
+ readonly text: string;
+}): ThreadQueueRowControls {
+ const mutationEnabled = !input.busy;
+
+ return {
+ canDismiss: !input.busy,
+ canMoveDown: mutationEnabled && input.canReorder && input.index < input.queuedCount - 1,
+ canMoveUp: mutationEnabled && input.canReorder && input.index > 0,
+ canSteer: mutationEnabled && input.canPromoteToSteer,
+ dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ displayText: input.text,
+ };
+}
+
+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,
+ },
+ };
+}
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/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index 4e8295733141..f2c16ad52547 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -1,3 +1,4 @@
+import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
ModelSelection,
@@ -254,3 +255,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 bd7918e52d79..e19e1bf2a94d 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 37190780c5ac..18db0b1977d2 100644
--- a/apps/mobile/src/lib/projectThreadStartTurn.ts
+++ b/apps/mobile/src/lib/projectThreadStartTurn.ts
@@ -7,20 +7,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;
@@ -48,10 +39,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),
@@ -89,3 +81,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 df3dd3197d0d..de557d3f29ee 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -1,2026 +1,808 @@
-import { describe, expect, it } from "vite-plus/test";
-import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
-
import {
- EventId,
MessageId,
- ProjectId,
+ NodeId,
+ PlanId,
ProviderInstanceId,
+ ProviderDriverKind,
+ ProviderThreadId,
+ RunId,
+ RunAttemptId,
ThreadId,
- TurnId,
- type OrchestrationThread,
- type OrchestrationThreadActivity,
+ TurnItemId,
+ type OrchestrationV2RunAttempt,
+ type OrchestrationV2ProjectedTurnItem,
+ type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
import {
- buildPendingUserInputAnswers,
buildThreadFeed,
- derivePendingApprovals,
- derivePendingUserInputs,
deriveThreadFeedPresentation,
- isPendingUserInputOptionSelected,
- setPendingUserInputCustomAnswer,
- togglePendingUserInputOptionSelection,
+ threadFeedActivityIsVisible,
+ threadFeedRunIsUnsettled,
type ThreadFeedActivity,
type ThreadFeedEntry,
+ togglePendingUserInputOptionSelection,
+ setPendingUserInputCustomAnswer,
+ isPendingUserInputOptionSelected,
+ buildPendingUserInputAnswers,
} from "./threadActivity";
-describe("Codex feedback pseudo-messages", () => {
- it("keeps pending and completed feedback messages in the mobile thread body", () => {
- const pending = {
- id: MessageId.make("feedback-command"),
- command: "/feedback The agent stopped early.",
- createdAt: "2026-08-23T00:00:00.000Z",
- status: "uploading" as const,
- };
- const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map(
- (message) => ({
- type: "message" as const,
- id: message.id,
- createdAt: message.createdAt,
- message,
- }),
- );
+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");
+});
- expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries);
- expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI...");
+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 completed = codexFeedbackMessage(
- { ...pending, status: "sent", feedbackId: "codex-thread-1" },
- "assistant",
- );
- expect(completed.text).toContain("codex-thread-1");
- });
-});
+function projected(
+ item: OrchestrationV2TurnItem,
+ position: number,
+ visibility: OrchestrationV2ProjectedTurnItem["visibility"] = "local",
+): OrchestrationV2ProjectedTurnItem {
+ return {
+ position,
+ visibility,
+ sourceThreadId: visibility === "local" ? threadId : sourceThreadId,
+ sourceItemId: item.id,
+ item,
+ };
+}
-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 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: [],
+ };
+}
-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 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,
+ };
+}
-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 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,
+ };
+}
-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 = derivePendingUserInputs([requested])[0]?.questions;
- expect(questions).toEqual([question]);
- expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({
- "0": "Example",
+describe("buildThreadFeed", () => {
+ 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",
+ },
+ ],
});
- });
- 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],
+ expect(feed).toHaveLength(1);
+ expect(feed[0]).toMatchObject({
+ type: "message",
+ message: {
+ id: "feedback-local",
+ role: "assistant",
+ text: expect.stringContaining("codex-thread-1"),
},
});
+ });
- expect(derivePendingUserInputs([requested])).toEqual([
+ 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),
+ ],
{
- requestId: "interaction_1",
- createdAt: requested.createdAt,
- questions: [nativeQuestion, singleSelectQuestion],
+ 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(
+ messages
+ .filter((entry) => entry.id.startsWith("feedback-"))
+ .every((entry) => entry.message.projectedItem === undefined),
+ ).toBe(true);
});
- it("replaces single-select options and toggles multi-select options", () => {
+ it("keeps prominent activity visible while it is running", () => {
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(ordersAndListings).toEqual({
- customAnswer: "",
- selectedOptionValues: ["Orders", "Listings"],
- });
+ threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }),
+ ).toBe(true);
expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
- ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
+ threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }),
+ ).toBe(false);
+ });
- const paddedOrders = togglePendingUserInputOptionSelection(
- multiSelectQuestion,
- undefined,
- " Orders ",
+ 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(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
- expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
- ).toEqual({ customAnswer: "" });
+ 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);
});
- 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("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(
+ {
+ ...retryBase,
+ status: "running",
+ title: "Provider retry",
+ completedAt: null,
+ },
+ 0,
+ ),
+ ]);
+ const recoveredFeed = buildThreadFeed([
+ projected(
+ {
+ ...retryBase,
+ status: "completed",
+ title: "Provider recovered",
+ },
+ 0,
+ ),
+ ]);
+ 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.");
+ }
+
+ expect(runningActivity).toMatchObject({
+ summary: "Provider retry",
+ status: "neutral",
+ toolLike: false,
+ });
+ expect(threadFeedActivityIsVisible(runningActivity)).toBe(true);
+ expect(recoveredActivity).toMatchObject({
+ summary: "Provider recovered",
+ status: "success",
+ toolLike: false,
});
+ 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(
+ failedPresentation[0]?.type === "activity-group"
+ ? failedPresentation[0].activities[0]?.summary
+ : null,
+ ).toBe("Provider retry failed");
});
- it("clears selected options while a custom answer is active", () => {
- expect(
- setPendingUserInputCustomAnswer(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders", "Listings"] },
- "Orders first",
- ),
- ).toEqual({ customAnswer: "Orders first" });
+ 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]),
+ );
+ },
+ );
+
+ it("hides synthetic workspace preparation activity", () => {
+ const workspacePreparation = projected(
+ {
+ ...command(),
+ title: "Workspace ready",
+ input: "Preparing workspace",
+ output: "Workspace preparation completed.",
+ },
+ 0,
+ );
+
+ expect(buildThreadFeed([workspacePreparation])).toEqual([]);
});
- it("matches selected options against normalized legacy labels", () => {
+ it("does not treat a queued-only run as live feed activity", () => {
expect(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"] },
- " Orders ",
- ),
+ 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(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
- " Orders ",
- ),
- ).toBe(false);
- });
-
- it("keeps custom answers enabled for legacy questions", () => {
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
expect(
- buildPendingUserInputAnswers([singleSelectQuestion], {
- runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " },
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "waiting",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
}),
- ).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",
- });
+ ).toBe(true);
});
- 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"],
- });
+ 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 second = togglePendingUserInputOptionSelection(question, both, " choice ");
- expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({
- choice: ["choice"],
- });
+ const promotedEntries = buildThreadFeed([
+ projected(
+ {
+ ...userMessage(),
+ id: TurnItemId.make("item-dispatched-queued"),
+ runId: dispatchedRunId,
+ messageId: dispatchedMessageId,
+ inputIntent: "turn_start",
+ },
+ 0,
+ ),
+ ]);
+ expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]);
+ expect(
+ promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined,
+ ).toBe("turn_start");
});
- 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("hides the interruption request and keeps the terminal result", () => {
+ const request = projected(
+ {
+ ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1),
+ type: "run_interrupt_request",
+ message: "Interrupt requested",
+ },
+ 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",
+ },
+ 1,
+ );
- 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();
- });
-});
+ const activities = buildThreadFeed([request, result]).flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
-describe("pending approvals", () => {
- it.each([{}, { requestType: "unknown" }])(
- "exposes legacy OpenCode approvals without a known request kind: %j",
- (legacyPayload) => {
- const requested = makeActivity({
- id: EventId.make("approval-legacy"),
- kind: "approval.requested",
- summary: "Approval requested",
- createdAt: "2026-08-24T00:00:00.000Z",
- payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
- });
-
- expect(derivePendingApprovals([requested])).toEqual([
+ 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]),
{
- requestId: "per-legacy",
- requestKind: "command",
- createdAt: requested.createdAt,
- detail: "*",
+ runId,
+ status: "interrupted",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
},
- ]);
- },
- );
-
- it.each(["tool_user_input", "auth_tokens_refresh"])(
- "does not turn %s into an approval",
- (requestType) => {
- const activity = makeActivity({
- id: EventId.make("approval-non-approval"),
- kind: "approval.requested",
- summary: "Approval requested",
- createdAt: "2026-08-24T00:00:00.000Z",
- payload: { requestId: "not-an-approval", requestType },
- });
-
- expect(derivePendingApprovals([activity])).toEqual([]);
- },
- );
+ new Set(),
+ ).some((entry) => entry.type === "run-fold"),
+ ).toBe(false);
+ });
- it.each(["approval.resolved", "provider.approval.respond.failed"])(
- "removes legacy approvals after %s",
- (kind) => {
- const requested = makeActivity({
- id: EventId.make("approval-legacy-open"),
- kind: "approval.requested",
- summary: "Approval requested",
- createdAt: "2026-08-24T00:00:00.000Z",
- payload: { requestId: "per-legacy", requestType: "unknown" },
- });
- const resolved = makeActivity({
- id: EventId.make("approval-legacy-resolved"),
- kind,
- summary: "Approval resolved",
- createdAt: "2026-08-24T00:00:01.000Z",
- payload: {
- requestId: "per-legacy",
- detail: "Unknown pending permission request: per-legacy",
- },
- });
+ 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(derivePendingApprovals([requested, resolved])).toEqual([]);
- },
- );
+ 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("keeps app access approvals and persistence choices from remote environments", () => {
- const options = [
- { decision: "decline", label: "Decline" },
- { decision: "acceptAlways", label: "Always allow Safari" },
- { decision: "accept", label: "Approve" },
- ];
- const activity = makeActivity({
- id: EventId.make("approval-safari"),
- kind: "approval.requested",
- summary: "App access approval requested",
- createdAt: "2026-08-24T00:00:00.000Z",
- payload: {
- requestId: "req-safari",
- requestType: "mcp_elicitation_approval",
- detail: "Allow ChatGPT to use Safari?",
- appName: "Safari",
- options,
+ 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 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"),
},
- });
-
- expect(derivePendingApprovals([activity])).toEqual([
{
- requestId: "req-safari",
- requestKind: "mcp-elicitation",
- createdAt: "2026-08-24T00:00:00.000Z",
- detail: "Allow ChatGPT to use Safari?",
- appName: "Safari",
- options,
+ 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;
- it("removes an app access approval after a remote client rejects it", () => {
- const requested = makeActivity({
- id: EventId.make("approval-safari-open"),
- kind: "approval.requested",
- summary: "App access approval requested",
- createdAt: "2026-08-24T00:00:00.000Z",
- payload: { requestId: "req-safari", requestKind: "mcp-elicitation" },
- });
- const resolved = makeActivity({
- id: EventId.make("approval-safari-resolved"),
- kind: "approval.resolved",
- summary: "Approval resolved",
- createdAt: "2026-08-24T00:00:01.000Z",
- payload: { requestId: "req-safari", decision: "decline" },
+ const feed = buildThreadFeed([projected(firstCommand, 0), projected(secondCommand, 1)], {
+ attempts,
});
- expect(derivePendingApprovals([requested, resolved])).toEqual([]);
+ expect(feed).toHaveLength(2);
+ expect(
+ feed.map((entry) =>
+ entry.type === "activity-group" ? entry.activities[0]?.attemptId : null,
+ ),
+ ).toEqual(["attempt-1", "attempt-2"]);
});
-});
-function makeActivity(
- input: Partial &
- Pick,
-): OrchestrationThreadActivity {
- return {
- tone: "info",
- payload: {},
- turnId: null,
- ...input,
- };
-}
+ 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(
+ {
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId: sourceThreadId, runId },
+ targetThreadId: threadId,
+ },
+ 1,
+ "synthetic",
+ );
-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,
- 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,
- };
-}
+ 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);
+ });
-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: [
- {
- 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",
- },
+ 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(
{
- 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",
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
},
- ],
- 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`,
- }),
+ 2,
),
- });
- 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 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" }],
- });
- 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(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",
+ projected(assistantMessage(), 3),
]);
- expect(prepended.at(-1)).toBe(page.at(-1));
- });
- 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"),
- }),
- ],
- });
-
- const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set());
- expect(presented).toMatchObject([
+ const collapsed = deriveThreadFeedPresentation(
+ feed,
{
- type: "activity-group",
- id: "context-compaction",
- activities: [{ summary: "Compacted context 899K → 19K tokens" }],
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
},
- ]);
- });
-
- 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 },
- },
- }),
- ],
- });
-
- 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("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 } },
- },
- }),
- ],
- });
-
- 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}`);
- });
-
- 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,
- };
+ new Set(),
+ );
- for (const currentTurn of [null, latestTurn]) {
- const currentThread = { ...thread, latestTurn: currentTurn };
- const feed = buildThreadFeed(currentThread);
- expect(feed).toMatchObject([
- {
- type: "activity-group",
- activities: [{ id: "setup-failed", status: "failure" }],
- },
- ]);
- 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");
- }
+ 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.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 older local feedback before newer messages returned by the server", () => {
- const submission = {
- id: MessageId.make("feedback-command-ordering"),
- command: "/feedback The agent stopped early.",
- createdAt: "2026-08-23T00:00:01.000Z",
- status: "sent" as const,
- feedbackId: "codex-thread-1",
+ 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 laterMessage = {
- id: MessageId.make("later-server-message"),
- role: "assistant" as const,
- text: "Newer server response",
- turnId: null,
- createdAt: "2026-08-23T00:00:02.000Z",
- updatedAt: "2026-08-23T00:00:02.000Z",
- streaming: false,
+ 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 thread = makeThread({
- id: ThreadId.make("thread-feedback-ordering"),
- projectId: ProjectId.make("project-1"),
- title: "Feedback ordering",
- messages: [laterMessage],
- });
-
- const feed = buildThreadFeed(thread, {
- localMessages: [
- codexFeedbackMessage(submission),
- codexFeedbackMessage(submission, "assistant"),
- ],
- });
-
- expect(feed.map((entry) => entry.id)).toEqual([
- "feedback-command-ordering",
- "feedback-command-ordering:feedback",
- "later-server-message",
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(opening, 1),
+ projected(command(), 2),
+ projected(middle, 3),
+ projected(assistantMessage(), 4),
]);
- });
-
- 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 latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ };
- 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" }],
- },
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ expect(collapsed.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "run-fold:run-1",
+ "message-assistant",
]);
- });
-
- 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([
- {
- type: "activity-group",
- activities: [{ id: "activity-signal" }],
- },
+ 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 } });
});
- 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,
- },
- 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'",
- );
- });
-
- 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 group = buildThreadFeed(thread)[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [
+ it("does not fold a response that only has opening and final messages", () => {
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(
{
- workEntry: {
- itemType: "image_view",
- viewedImagePath: imagePath,
- },
+ ...assistantMessage("2026-06-20T00:00:02.000Z"),
+ id: TurnItemId.make("item-opening"),
+ messageId: MessageId.make("message-opening"),
+ text: "The result is ready.",
},
- ],
- });
- });
-
- 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;
- }
+ 1,
+ ),
+ projected(assistantMessage(), 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(group.activities[0]?.getFullDetail()).toContain("repository.search");
+ const presented = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(presented.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "message-assistant",
+ ]);
});
- 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" },
- };
- 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,
+ 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,
+ );
+ 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",
},
- 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,
- },
- }),
- ],
- });
-
- 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" },
- },
- ],
- });
- 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(
- deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
- ),
- ).toMatchObject([
{
- type: "work-toggle",
- summary: "Clicking in the preview browser",
- summaryToolIcon: "browser",
- live: true,
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
},
- ]);
- });
-
- 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,
- shimmer: false,
- },
- {
- type: "activity-group",
- id: `work-details:${groupId}`,
- activities: [
- {
- id: "preview-click-started",
- summary: displayName,
- lifecycleStatus: status,
- live: false,
- },
- ],
- },
- ]);
- 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) };
- },
- },
- },
- },
- }),
- );
- 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;
- }
-
- expect(group.activities).toHaveLength(5_000);
- const expanded = deriveThreadFeedPresentation(
- feed,
- null,
- 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);
- });
-
- 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"),
+ {
+ ...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",
},
- 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",
- },
- }),
- ],
- });
-
- 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",
+ ] 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),
]);
- 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 collapsed = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(collapsed.map((entry) => entry.type)).toEqual([
+ "message",
+ "activity-group",
+ "activity-group",
+ "run-fold",
+ "activity-group",
+ "message",
]);
-
- const interrupted = deriveThreadFeedPresentation(
- feed,
- { ...thread.latestTurn!, state: "interrupted", completedAt: "2026-04-01T00:00:20.000Z" },
- new Set(),
- );
- expect(interrupted[1]).toMatchObject({
- type: "turn-fold",
- label: "You stopped after 19s",
- expanded: false,
+ 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("does not append synthetic timeline work 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(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(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 },
- ],
- });
+ expect(presented).toEqual([]);
});
it("keeps expanded work in one group with stable row identities", () => {
@@ -2028,73 +810,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" },
- },
+ summary: "Ran 3 commands",
});
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",
@@ -2108,548 +884,408 @@ 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: false },
- { 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,
- };
+ 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 rows = deriveThreadFeedPresentation(
- feed,
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- );
- expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
- ["work-toggle:work-group:activity-1", "work-toggle"],
- ["activity-2", "activity-group"],
- ["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 });
-
- const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
- expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { live: false, shimmer: false },
+ 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("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(
{
- live: false,
- shimmer: false,
- summary: lifecycleStatus === "inProgress" ? "printf done" : command,
+ ...command("2026-06-20T00:00:06.000Z"),
+ id: TurnItemId.make("item-command-2"),
+ ordinal: 6,
},
- ]);
+ 4,
+ ),
+ ];
- 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 },
- ]);
- },
- );
+ const presented = deriveThreadFeedPresentation(
+ buildThreadFeed(rows),
+ { runId, status: "running", startedAt: null, completedAt: null },
+ new Set(),
+ );
- 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,
+ 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("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,
- },
- 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,
+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 latestRun = {
+ runId,
+ status: "running" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ };
+ const before = buildThreadFeed(rows);
+ const beforePresentation = deriveThreadFeedPresentation(
+ before,
+ latestRun,
+ new Set(),
+ new Set(),
+ latestRun.startedAt,
+ );
+ 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(),
- thread.latestTurn!.startedAt,
+ latestRun.startedAt,
);
- expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer);
+ 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]);
});
- it("does not revive cached in-progress tools after work stops", () => {
- const turnId = TurnId.make("turn-stale-tool");
- const feed: ThreadFeedEntry[] = [
+ it("keeps a standalone compaction visible and folds it with other completed work", () => {
+ const compact = projected(
{
- 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",
- },
- },
- ],
+ ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
+ type: "compaction",
+ driver: null,
+ summary: "Shorter context",
},
- ];
- 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,
+ 1,
+ );
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:04.000Z",
};
-
- expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]);
+ 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(
+ deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type),
+ ).toEqual(["message", "run-fold", "message"]);
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
expect(
- deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt),
- ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]);
+ expanded.find(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities[0]?.projectedItem.item.type === "compaction",
+ ),
+ ).toMatchObject({ activities: [{ summary: "Context compacted" }] });
});
- 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,
+ 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] } },
+ ]);
+ });
+
+ 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,
},
- });
- const thread = makeThread({
- id: ThreadId.make("thread-parallel-tools"),
- projectId: ProjectId.make("project-1"),
- title: "Parallel tools",
+ index,
+ ),
+ );
+ 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(presented[0]).toMatchObject({
+ type: "work-toggle",
+ summary: "Used Editor",
+ toolSurface: "computer",
+ toolIcon: icon,
+ });
+ expect(presented[1]).toMatchObject({
+ type: "activity-group",
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),
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
],
});
+ });
+
+ 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,
+ ),
+ ]);
+ const rows = deriveThreadFeedPresentation(
+ feed,
+ { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
+ new Set(),
+ new Set(),
+ "2026-06-20T00:00:01.000Z",
+ );
+ expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false });
+ },
+ );
- const feed = buildThreadFeed(thread);
- const activityGroup = feed.find((entry) => entry.type === "activity-group");
- expect(activityGroup).toMatchObject({
+ 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(rows[0]).toMatchObject({
type: "activity-group",
- activities: [
- { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" },
- { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
- ],
+ activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: true }],
});
+ expect(deriveThreadFeedPresentation(rows, null, new Set())).toMatchObject([
+ { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] },
+ ]);
+ });
+});
+
+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;
+
+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(
- deriveThreadFeedPresentation(feed, null, new Set([turnId])).find(
- (entry) => entry.type === "work-toggle",
+ togglePendingUserInputOptionSelection(
+ singleSelectQuestion,
+ { selectedOptionValues: ["Go"] },
+ "Node.js",
),
- ).toMatchObject({
- type: "work-toggle",
- hiddenCount: 2,
- summary: "Ran 2 commands",
- live: false,
- });
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
- 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 },
- new Set(),
- new Set([groupId]),
- startedAt,
+ const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
+ const ordersAndListings = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ orders,
+ "Listings",
);
- 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 },
- ],
+ expect(ordersAndListings).toEqual({
+ customAnswer: "",
+ selectedOptionValues: ["Orders", "Listings"],
});
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
- const completedRows = deriveThreadFeedPresentation(
- feed,
- null,
- new Set([turnId]),
- new Set([groupId]),
+ const paddedOrders = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ undefined,
+ " Orders ",
);
- 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 },
- ],
- });
+ expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
+ ).toEqual({ customAnswer: "" });
+ });
- 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]),
- );
- expect(correctedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: "call-a-1",
- activities: [{ status: "failure", workEntry: { tone: "error" } }],
+ 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"],
});
});
-});
-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 : [],
- );
- expect(rows).toMatchObject([
- {
- lifecycleStatus: "inProgress",
- summary: "Reviewer",
- workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" },
- },
- ]);
- },
- );
+ it("clears selected options while a custom answer is active", () => {
+ expect(
+ setPendingUserInputCustomAnswer(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders", "Listings"] },
+ "Orders first",
+ ),
+ ).toEqual({ customAnswer: "Orders first" });
+ });
- 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 : [],
- );
- expect(rows).toHaveLength(2);
- expect(rows[0]).toMatchObject({
- lifecycleStatus: status === "failed" ? "failed" : "stopped",
- detail,
- workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" },
- });
- expect(rows[1]).toMatchObject({
- lifecycleStatus: "inProgress",
- workEntry: { taskId: "trajectory:5" },
- });
- },
- );
+ it("matches selected chips against normalized option labels", () => {
+ expect(
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"] },
+ " Orders ",
+ ),
+ ).toBe(true);
+ expect(
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
+ " Orders ",
+ ),
+ ).toBe(false);
+ });
+});
- 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" },
- }),
- ],
- });
+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("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: "activity-group", id: "nested-done" },
- ]);
+ 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" });
});
});
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index ef75fce56af4..56136d8118c3 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1,55 +1,48 @@
-import {
- ApprovalRequestId,
- isToolLifecycleItemType,
- ProviderApprovalOption,
- ProviderRequestKind,
-} 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 { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
+import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
import {
- commandDetailRepeatsCommand,
- extractCommandOutputText,
- isWorktreeSetupActivity,
+ workEntryDisplayIndicatesToolFailure,
liveActivityToolStatus,
- normalizeCompactToolLabel,
- omitSupersededLifecycleMarkers,
+ toolGroupAction,
resolveWorkEntryToolPresentation,
summarizeToolGroup,
- toolGroupAction,
toolGroupSummaryKind,
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 * as Schema from "effect/Schema";
-
-export interface PendingApproval {
- readonly requestId: ApprovalRequestId;
- readonly requestKind: ProviderRequestKind;
- readonly createdAt: string;
- readonly detail?: string;
- readonly appName?: string;
- readonly options?: ReadonlyArray;
-}
-
-const isProviderRequestKind = Schema.is(ProviderRequestKind);
-const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
+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,
+} from "@t3tools/contracts";
+import { ThreadId } from "@t3tools/contracts";
+import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+import * as DateTime from "effect/DateTime";
-export interface PendingUserInput {
- readonly requestId: ApprovalRequestId;
- readonly createdAt: string;
- readonly questions: ReadonlyArray;
-}
+export type PendingApproval = ThreadPendingApproval;
+export type PendingUserInput = ThreadPendingUserInput;
export interface PendingUserInputDraftAnswer {
readonly selectedOptionValues?: ReadonlyArray;
@@ -59,7 +52,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;
@@ -69,9 +63,9 @@ export interface ThreadFeedActivity {
| "agent"
| "alert"
| "browser"
+ | "computer"
| "check"
| "command"
- | "computer"
| "edit"
| "eye"
| "globe"
@@ -80,45 +74,32 @@ 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;
-}
-
-type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
-
-export interface WorkLogEntry {
- 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;
- agentSpawn?: boolean;
- toolData?: unknown;
-}
-
-interface DerivedWorkLogEntry extends WorkLogEntry {
- sourceActivityKind: OrchestrationThreadActivity["kind"];
- collapseKey?: string;
- /** Grouping key for subagent lifecycle rows (one row per agent). */
- taskId?: string;
+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
+}
+
+export interface ThreadFeedMessage {
+ 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 visibility: OrchestrationV2ProjectedTurnItem["visibility"];
+ readonly sourceThreadId: ThreadId;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
}
type RawThreadFeedEntry =
@@ -126,13 +107,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;
};
@@ -142,173 +123,85 @@ 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" | "t3-code";
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;
};
-export type ThreadFeedLatestTurn = Pick<
- OrchestrationLatestTurn,
- "turnId" | "state" | "startedAt" | "completedAt"
->;
+export interface ThreadFeedLatestRun {
+ readonly runId: RunId;
+ readonly status: OrchestrationV2RunStatus;
+ readonly startedAt: string | null;
+ readonly completedAt: string | null;
+}
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
>();
-export function isContextCompactionActivityGroup(
- entry: Extract,
-): boolean {
- return (
- entry.activities.length === 1 &&
- entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction"
- );
-}
-
-function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
- switch (requestType) {
- case "command_execution_approval":
- case "exec_command_approval":
- return "command";
- case "file_read_approval":
- return "file-read";
- case "file_change_approval":
- case "apply_patch_approval":
- return "file-change";
- case "mcp_elicitation_approval":
- return "mcp-elicitation";
- default:
- return null;
- }
-}
-
-function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
- const normalized = detail?.toLowerCase();
- if (!normalized) {
- return false;
- }
+export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return (
- normalized.includes("stale pending approval request") ||
- normalized.includes("stale pending user-input request") ||
- normalized.includes("unknown pending approval request") ||
- normalized.includes("unknown pending permission request") ||
- normalized.includes("unknown pending user-input request")
+ entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "compaction"
);
}
-function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
- return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
-}
-
-function parseUserInputQuestions(
- payload: Record | null,
-): ReadonlyArray | null {
- const questions = payload?.questions;
- if (!Array.isArray(questions)) {
- return null;
- }
-
- const parsed = questions
- .map((entry) => {
- if (!entry || typeof entry !== "object") return null;
- const question = entry as Record;
- if (
- typeof question.id !== "string" ||
- typeof question.header !== "string" ||
- typeof question.question !== "string" ||
- !Array.isArray(question.options)
- ) {
- return null;
- }
- const options = question.options
- .map((option) => {
- if (!option || typeof option !== "object") return null;
- const record = option as Record;
- if (typeof record.label !== "string" || typeof record.description !== "string") {
- return null;
- }
- return {
- label: record.label,
- description: record.description,
- ...(typeof record.value === "string" ? { value: record.value } : {}),
- };
- })
- .filter((option): option is UserInputQuestion["options"][number] => option !== null);
- if (options.length === 0 && question.allowCustomAnswer === false) {
- return null;
- }
- return {
- id: question.id,
- header: question.header,
- question: question.question,
- options,
- multiSelect: question.multiSelect === true,
- ...(typeof question.allowCustomAnswer === "boolean"
- ? { allowCustomAnswer: question.allowCustomAnswer }
- : {}),
- };
- })
- .filter((question): question is UserInputQuestion => question !== null);
-
- return parsed.length > 0 ? parsed : null;
-}
-
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)) {
@@ -323,7 +216,7 @@ function resolvePendingUserInputOptionValue(
}
function normalizeSelectedOptionValues(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
value: ReadonlyArray | undefined,
): ReadonlyArray {
if (!Array.isArray(value)) {
@@ -340,7 +233,7 @@ function normalizeSelectedOptionValues(
}
function resolvePendingUserInputAnswer(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): string | ReadonlyArray | null {
const customAnswer =
@@ -356,528 +249,9 @@ function resolvePendingUserInputAnswer(
return selectedOptionValues[0] ?? null;
}
-/** 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. Terminal rows are
- * kept — with no Agents surface on mobile they are the terminal signal
- * (a surface that hides rows must keep its own terminal signal). That means
- * task.completed and terminal task.updated, including Antigravity cancellation.
- */
-function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- if (!payload) {
- return false;
- }
- const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity);
- if (payload.timelineBypass === true && !isTerminalTaskRow) {
- return true;
- }
- // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
- // the only signal mobile gets (no Agents sheet), so it stays. Only an
- // agent's own background work (stamped "background") is internal — same
- // rule as web (review finding: hiding on agentId alone dropped nested
- // completions with no replacement UI).
- const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
- if (!ownedByAgent) {
- return false;
- }
- return !(isTerminalTaskRow && payload.agentKind === "agent");
-}
-
-function deriveWorkLogEntries(
- activities: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const ordered = Arr.sort(activities, activityOrder);
- const entries: DerivedWorkLogEntry[] = [];
- for (const activity of ordered) {
- if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
- if (activity.kind === "tool.started") continue;
- if (activity.kind === "task.started") 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:");
-}
-
-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.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,
- };
- const toolCallId =
- asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
- if (toolCallId) {
- entry.toolCallId = toolCallId;
- }
- if (isTaskActivity && payload?.agentKind === "agent") {
- entry.agentSpawn = 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 (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") {
- toolLifecycleStatus = "completed";
- }
- if (toolLifecycleStatus) {
- entry.toolLifecycleStatus = toolLifecycleStatus;
- }
- const collapseKey = deriveToolLifecycleCollapseKey(entry);
- if (collapseKey) {
- entry.collapseKey = collapseKey;
- }
- return entry;
-}
-
-function collapseDerivedWorkLogEntries(
- entries: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const collapsed: DerivedWorkLogEntry[] = [];
- // Subagent rows collapse by identity, not adjacency (quiet-timeline
- // guarantee; mirrors web's session-logic).
- const taskRowIndex = new Map();
- const toolLifecycleRowIndex = new Map();
- for (const entry of entries) {
- const isTaskRow =
- entry.taskId !== undefined &&
- (entry.sourceActivityKind === "task.progress" ||
- entry.sourceActivityKind === "task.completed" ||
- entry.sourceActivityKind === "task.updated");
- if (isTaskRow && entry.taskId !== undefined) {
- 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 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 workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
- if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
- return true;
- }
- if (entry.command !== undefined && entry.command.trim().length > 0) {
- return true;
- }
- if (entry.requestKind !== undefined) {
- return true;
- }
- return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
-}
-
-function toolDetailTextLooksLikeFailure(text: string): boolean {
- const normalized = text.toLowerCase();
- return (
- normalized.includes("file not found") ||
- normalized.includes("no files found") ||
- normalized.includes("enoent") ||
- normalized.includes("no such file or directory") ||
- normalized.includes("no such file") ||
- normalized.includes("commandnotfoundexception") ||
- normalized.includes("command not found") ||
- (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) ||
- (normalized.includes("is not recognized") && normalized.includes("the term '")) ||
- normalized.includes("is not recognized as the name of a cmdlet") ||
- normalized.includes("a parameter cannot be found that matches parameter name") ||
- //i.test(text) ||
- /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
- /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)
- );
-}
-
-function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
- if (entry.tone === "error") {
- return true;
- }
- if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") {
- return true;
- }
- if (!workLogEntryIsToolLike(entry)) {
- return false;
- }
- return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n"));
-}
-
-function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
- if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) {
- return false;
- }
- if (entry.tone === "thinking") {
- return false;
- }
- return (
- entry.toolLifecycleStatus !== "inProgress" &&
- entry.toolLifecycleStatus !== "stopped" &&
- entry.toolLifecycleStatus !== "failed" &&
- entry.toolLifecycleStatus !== "declined"
- );
-}
-
-function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
- 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.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 {
- const blocks: string[] = [];
- const appendBlock = (value: string | null | undefined) => {
- const trimmed = value?.trim();
- if (trimmed && (entry.command || !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;
-}
-
-function workEntryHasExpandedBody(entry: WorkLogEntry): boolean {
- return (
- (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) ||
- Boolean((entry.rawCommand ?? entry.command)?.trim()) ||
- Boolean(entry.detail?.trim()) ||
- (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false)
- );
+function capitalizePhrase(value: string): string {
+ const trimmed = value.trim();
+ return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
function memoizeValue(build: () => T): () => T {
@@ -892,402 +266,354 @@ 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 capitalizePhrase(value: string): string {
- const trimmed = value.trim();
- if (trimmed.length === 0) {
- return value;
- }
- return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
-}
-
-function workEntryHeading(workEntry: WorkLogEntry): string {
- 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 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 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 itemIsProminent(item: OrchestrationV2TurnItem): boolean {
+ return (
+ item.type === "fork" ||
+ item.type === "thread_created" ||
+ item.type === "subagent" ||
+ item.type === "system_notice"
+ );
}
-function executableBasename(value: string): string | null {
- const trimmed = trimMatchingOuterQuotes(value);
- if (trimmed.length === 0) {
- return null;
+function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
+ if (item.type === "error") {
+ if (item.status === "failed") return "failure";
+ return item.status === "completed" ? "success" : "neutral";
+ }
+ if (!itemIsToolLike(item)) return null;
+ if (item.status === "failed") return "failure";
+ return item.status === "completed" ? "success" : "neutral";
+}
+
+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":
+ return "failed";
+ case "cancelled":
+ case "interrupted":
+ return "stopped";
+ }
+}
+
+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";
}
- 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 itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
+ 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 === "system_notice") return item.message;
+ const title = item.title?.trim();
+ if (title) return toolPresentation?.displayName ?? capitalizePhrase(title);
+ switch (item.type) {
+ case "reasoning":
+ return "Thinking";
+ case "command_execution":
+ return "Command";
+ case "file_change":
+ return `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 "compaction":
+ return "Context compacted";
+ case "handoff":
+ return "Context handed off";
+ case "fork":
+ return "Thread forked";
+ case "thread_created":
+ return "Thread created";
+ case "subagent":
+ return "Subagent";
+ 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 "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,
+ } 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,
+ ...(item.output ? { detail: item.output } : {}),
+ toolTitle: title ?? "Command",
+ toolData: item,
+ };
+ case "file_change":
+ return {
+ ...common,
+ changedFiles: [item.fileName],
+ ...((item.diffStr ?? item.newStr) ? { detail: item.diffStr ?? item.newStr } : {}),
+ 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 = 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,
+ },
+ 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 extractWorkLogToolLifecycleStatus(
- payload: Record | null,
-): WorkLogToolLifecycleStatus | undefined {
- const status = payload?.status;
- // The parent turn ended, so batch tracking is inactive. The detail explains
- // that child status is unavailable; do not retain the earlier running marker.
- if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped";
- if (status === "pending" || status === "running" || status === "waiting") return "inProgress";
- if (status === "cancelled" || status === "interrupted") return "stopped";
- if (
- status === "inProgress" ||
- status === "completed" ||
- status === "failed" ||
- status === "declined" ||
- status === "stopped"
- ) {
- return status;
- }
- return undefined;
-}
-
-function stripTrailingExitCode(value: string): {
- output: string | null;
- exitCode?: number | undefined;
-} {
- const trimmed = value.trim();
- const match = /^(?