diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 119bc6e2a..1468eee9e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -481,11 +481,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { - kind: "waiting", + kind: "background", label: pendingBackgroundWork.title, accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items .map((item) => item.label) .join(", ")}`, + waiting: pendingBackgroundWork.waiting, }; } return null; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 06ff435e0..ed2b48e6c 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -27,7 +27,7 @@ import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import type { FloatingWorkingStatus } from "./floating-working-status"; +import { backgroundStatusSymbol, type FloatingWorkingStatus } from "./floating-working-status"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { DevicePreviewButton } from "../devices/device-preview-button"; @@ -424,16 +424,16 @@ function FloatingStatusLabel(props: { ); } - if (props.status.kind === "waiting") { + if (props.status.kind === "background") { return ( [0]["connectionState"], @@ -64,3 +64,10 @@ describe("connectionFloatingStatus", () => { expect(onReconnect).toHaveBeenCalledOnce(); }); }); + +describe("backgroundStatusSymbol", () => { + it("shows the bolt only for work that will wake the agent", () => { + expect(backgroundStatusSymbol(true)).toEqual({ ios: "bolt", android: "bolt" }); + expect(backgroundStatusSymbol(false)).toBe("terminal"); + }); +}); diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 17fa3074f..8b0e4436a 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -10,8 +10,14 @@ export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string; readonly step?: string } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } - // The turn settled while background work it started still runs. - | { readonly kind: "waiting"; readonly label: string; readonly accessibilityLabel: string } + // The turn settled while background work it started still runs. `waiting` + // is false when only commands remain, such as a dev server: the agent is done. + | { + readonly kind: "background"; + readonly label: string; + readonly accessibilityLabel: string; + readonly waiting: boolean; + } // A task whose thread the server has not created yet: the worktree may // still be checking out, so there is no turn to time. | { readonly kind: "preparing"; readonly label: string } @@ -22,6 +28,14 @@ export type FloatingWorkingStatus = readonly onPress: () => void; }; +/** + * The symbol for settled background work. A dev server can run for hours after + * the agent is done, so only work that will wake the agent gets the bolt. + */ +export function backgroundStatusSymbol(waiting: boolean) { + return waiting ? ({ ios: "bolt", android: "bolt" } as const) : ("terminal" as const); +} + /** * The pill's connection variant, or null once the environment is connected and * the pill is free to report sync and working state instead. diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index df07535f2..0af60d95f 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,3 +1,5 @@ +import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import * as DateTime from "effect/DateTime"; import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; import { createPendingThreadOrder, @@ -23,7 +25,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { makeThreadShellFixture } from "../../test-fixtures"; +import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; import { buildThreadListV2Items, @@ -37,6 +39,7 @@ import { resolveThreadListV2SwipeActions, sortThreadsForListV2, threadListV2ListItemsAreEqual, + threadHasUnseenCompletion, type ThreadListV2ListItem, } from "./threadListV2"; @@ -184,9 +187,7 @@ describe("resolveThreadListV2Status", () => { makeThread({ id: ThreadId.make("t"), title: "t", - pendingBackgroundTasks: [ - { taskId: "bg-1", description: "Run Codex review", kind: "command" }, - ], + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], runtime: { status: "idle", activeRunId: null, @@ -200,6 +201,28 @@ describe("resolveThreadListV2Status", () => { ).toBe("waiting"); }); + it.each([ + { kind: "command", status: "ready" }, + { kind: "monitor", status: "waiting" }, + ] as const)( + "presents an unseen completion with a $kind roster as $status", + ({ kind, status }) => { + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestRunId: RunId.make("run-background-completion"), + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe(NOW), + lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), + pendingBackgroundTasks: [{ taskId: "background-work", kind }], + }), + ); + + expect(resolveThreadListV2Status(thread)).toBe(status); + expect(threadHasUnseenCompletion(thread)).toBe(true); + }, + ); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 8a2cb5177..bf4e44d4f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -63,7 +63,8 @@ export function resolveThreadListV2ProviderDrivers( * 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. + * post-settlement background roster holds the run's completion (subagents, + * monitors); commands left running, such as a dev server, read as ready. */ export type ThreadListV2Status = | "approval" diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f0c140ac..16fd407f5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const imported = (item: T, id: string) => ({ + ...item, + id: TurnItemId.make(id), + runId: null, + }); + const presented = (start: OrchestrationV2TurnItem) => + deriveThreadFeedPresentation( + buildThreadFeed( + [ + imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), + imported( + { + ...assistantMessage("2026-06-20T00:00:02.000Z"), + messageId: MessageId.make("update"), + }, + "imported-update", + ), + imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), + imported( + { + ...assistantMessage("2026-06-20T00:00:08.000Z"), + messageId: MessageId.make("answer"), + }, + "imported-answer", + ), + start, + ].map((item, position) => projected(item, position)), + ), + { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, + new Set(), + new Set(), + "2026-06-20T00:01:00.000Z", + ) + .slice(0, 4) + .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); + + // A sent prompt and an automatic wake both start V2 work below the import. + expect( + presented({ + ...userMessage("2026-06-20T00:01:00.000Z"), + id: TurnItemId.make("new-prompt"), + messageId: MessageId.make("new-prompt"), + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + expect( + presented({ + ...base("wake", "2026-06-20T00:01:00.000Z", 4), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const { exitCode: _exitCode, ...completedCommand } = command(); @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); - it("keeps a runless tail settled while a normal thread waits for its sent run", () => { + it("keeps a runless tail folded while a normal thread waits for its sent run", () => { // Right after a send the local clock runs before the server creates the - // run, and the latest run may still be queued: neither is runless work. + // run, and the latest run may still be queued: neither is runless work, + // so the settled tail must not reopen and shift the feed. const startedAt = "2026-06-20T00:00:05.000Z"; const feed = buildThreadFeed([ projected({ ...userMessage(), runId: null }, 0), @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { new Set(), startedAt, ); - const toggle = presented.find((entry) => entry.type === "work-toggle"); - expect(toggle).toMatchObject({ live: false, shimmer: false }); - expect(presented.at(-1)?.type).toBe("thinking"); + expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); } }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c453ebf23..211d0f5f5 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -972,13 +972,14 @@ export function failedFeedRunIds( } /** - * A thread without runs (a provider-native subagent) folds each prompt's - * response like a run; `isWorking` keeps its latest response open. + * A prompt without a run (a provider-native subagent, or a turn imported from + * V1) folds its response like a run. `runlessWorkActive` keeps the latest + * runless response open; V2 work must not reopen imported turns. */ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, - isWorking: boolean, + runlessWorkActive: boolean, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } >(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( for (const [runId, group] of groupsByRunId) { if ( runId === activeRunId || - (isWorking && runId === runlessKey) || + (runlessWorkActive && runId === runlessKey) || interruptedRunIds.has(runId) || failedRunIds.has(runId) || group.entries.some((entry) => entry.type === "message" && entry.message.streaming) @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( const activeTailGroup = sourceFeed.at(-1); const activeRunId = unsettledRunId(latestRun); const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; - const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); + const foldsByAnchorId = deriveThreadFeedRunFolds( + sourceFeed, + latestRun, + isWorking && runlessWorkActive, + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedRunIds.has(fold.runId)) { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a9f81fb82..f5139da95 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -684,6 +684,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("holds a first send while the thread shell still reports a preparing run", () => { + // The draft had no run. The server thread's shell shows the new run before + // the detail projection behind `phase` loads. + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const preparingRun = { + ...completedTurn, + status: "preparing" as const, + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "disconnected", + latestRun: preparingRun, + runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0bd1f39cc..6b5120a7c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -58,7 +58,7 @@ import { filterTerminalContextsWithText, type TerminalContextDraft } from "../li import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; -import type { TimelineEntry } from "../session-logic"; +import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; @@ -1242,7 +1242,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } - if (input.phase === "connecting") { + // The thread shell can report a preparing or starting run before the detail + // projection behind `phase` loads, so either source still connecting holds + // the send. + if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ac0d731f0..9b51c5bab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6496,6 +6496,9 @@ export default function ChatView(props: ChatViewProps) { optimisticUserMessages, ]); + // Keyed on the thread, not the draft: a draft's promotion to its server + // route keeps this instance and drops `draftId`, and the send it is still + // dispatching must survive that swap. useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -6505,7 +6508,7 @@ export default function ChatView(props: ChatViewProps) { }); resetLocalDispatch(); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -6933,7 +6936,7 @@ export default function ChatView(props: ChatViewProps) { // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. - // Background work (subagent fleets, workflow runs, watch loops) can outlive + // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive // the turn; once it settles, the composer stop button is gone, so this // banner is the only visible stop affordance. The interrupt path also // accepts a completed run while its provider still has background work. @@ -6981,9 +6984,14 @@ export default function ChatView(props: ChatViewProps) { id: `background-work:${activeThread.id}`, variant: "default", priority: "activity", + // A dev server can run for hours after the agent is done, so only work + // that will wake the agent pulses. icon: (