From f7dbed9faf53ff58327ec4df38d038590db902ef Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:02:51 -0700 Subject: [PATCH 1/2] fix(clients): native subagent threads show when they are working Provider-native subagent threads (Claude Agent tool, Codex/Cursor/Grok/ OpenCode native subagents) never get app runs; their work is a runless root turn whose status follows the subagent. Every working signal on web and mobile came from runs, so an open child thread looked idle: no "Working for" timer, no live tool row, no Thinking slot. Derive the working start from the child's active runless root turn in client-runtime and feed it into the existing working state on web and mobile. Runless timeline entries count as the live response only while that root turn is active. Stop, queue, and steer stay run-only. A replay-fixture invariant pins the server contract for every recorded native subagent: the child hangs off the subagent node, has only runless root turns, is running before its first item, and its root turn follows the subagent's activity, including a Claude resume re-opening it. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.test.ts | 24 +++++ apps/mobile/src/lib/threadActivity.ts | 3 +- .../src/state/use-thread-composer-state.ts | 21 +++-- ...estratorReplayFixtures.integration.test.ts | 2 + .../testkit/fixtures/shared.ts | 61 +++++++++++++ apps/web/src/components/ChatView.tsx | 21 +++-- .../chat/MessagesTimeline.logic.test.ts | 90 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 20 +++-- .../src/components/chat/MessagesTimeline.tsx | 5 ++ .../src/state/threadExecution.test.ts | 45 ++++++++++ .../src/state/threadExecution.ts | 23 ++++- 11 files changed, 294 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e45641d54957..f8c11c2d9817 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -956,6 +956,30 @@ describe("buildThreadFeed", () => { }); }); + 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(); + const runningCommand: OrchestrationV2TurnItem = { + ...completedCommand, + runId: null, + status: "running", + completedAt: null, + output: "", + }; + const feed = buildThreadFeed([ + projected({ ...userMessage(), runId: null }, 0), + projected(runningCommand, 1), + ]); + + const presented = deriveThreadFeedPresentation(feed, null, new Set(), new Set(), startedAt); + expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ + summary: "Running vp", + live: true, + shimmer: true, + }); + expect(presented.some((entry) => entry.type === "thinking")).toBe(false); + }); + it("waits for workspace preparation before showing provider activity", () => { const startedAt = "2026-04-01T00:00:01.000Z"; const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5606f496cb9c..d2187dcef1df 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1037,9 +1037,10 @@ export function deriveThreadFeedPresentation( } const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { + // A provider-native subagent works without a run, so its null-run tail + // is live while it works (activeRunId is null then too). const isActiveTailGroup = isWorking && - activeRunId !== null && entry.type === "activity-group" && activeTailGroup?.type === "activity-group" && activeTailGroup.id === entry.id && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index c5115b576466..68525ae9ed9b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -2,6 +2,7 @@ import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import { + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, threadRuntimeHasInterruptibleRun, @@ -397,11 +398,21 @@ export function useThreadComposerState() { if (!selectedThreadShell) { return null; } - return resolveThreadWorkingStartedAt({ - latestRun: selectedThreadActivityRun, - runtime: selectedThreadRuntime, - }); - }, [selectedThreadActivityRun, selectedThreadRuntime, selectedThreadShell]); + return ( + resolveThreadWorkingStartedAt({ + latestRun: selectedThreadActivityRun, + runtime: selectedThreadRuntime, + }) ?? + (selectedThreadProjection + ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) + : null) + ); + }, [ + selectedThreadActivityRun, + selectedThreadProjection, + selectedThreadRuntime, + selectedThreadShell, + ]); // The run can start, or be cancelled from another client, while its message // is open in the composer. Leave edit mode rather than saving into a run the diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index ea5ee05ae33e..b74234d12209 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -17,6 +17,7 @@ import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { messageRestartInput } from "./fixtures/message_steering/input.ts"; import { + assertProviderNativeSubagentRootTurns, materializeFixtureInput, type OrchestratorFixtureInput, type ProviderOrchestratorReplayVariant, @@ -117,6 +118,7 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, ).pipe(provideDeterministicTestRuntime); input.driver.assertOutput(result, transcript); + assertProviderNativeSubagentRootTurns(result); const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; if (expectedAbsentWorkspacePaths !== undefined) { yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 96aee9e2d5e2..7fa9347d2de7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -2,6 +2,7 @@ import { assert } from "@effect/vitest"; import { type ChatAttachment, CommandId, + isOrchestrationV2WorkActive, MessageId, ProjectId, ThreadId, @@ -1044,6 +1045,66 @@ export function assertNoExtraAppRunsForProviderChildren(input: { ); } +/** + * Provider-native subagent threads have no runs; clients show them working + * from the child's runless root turn. Pin that contract for every recorded + * native subagent: the child hangs off the subagent node, every root turn is + * runless, the root turn is live before the child's first item, and its + * activity mirrors the subagent's (including a resume re-opening it). + */ +export function assertProviderNativeSubagentRootTurns(result: OrchestratorV2ScenarioResult) { + const activity = (statuses: ReadonlyArray) => + statuses + .map((status) => (isOrchestrationV2WorkActive(status) ? "active" : status)) + .filter((status, index, all) => status !== all[index - 1]); + for (const projection of result.projections.values()) { + for (const subagent of projection.subagents) { + if (subagent.origin !== "provider_native" || subagent.childThreadId === null) continue; + const childThreadId = subagent.childThreadId; + const child = result.projections.get(childThreadId); + assert.isDefined(child, `missing child thread for subagent ${subagent.id}`); + assert.equal(child.thread.creationSource, "provider"); + assert.deepEqual(child.thread.forkedFrom, { type: "node", nodeId: subagent.id }); + assert.lengthOf(child.runs, 0); + const roots = child.nodes.filter((node) => node.kind === "root_turn"); + assert.isNotEmpty(roots, `child ${childThreadId} must have a root turn`); + for (const root of roots) assert.isNull(root.runId); + + const rootEvents = result.domainEvents.flatMap((event, index) => + event.type === "node.updated" && + event.payload.threadId === childThreadId && + event.payload.kind === "root_turn" + ? [{ index, status: event.payload.status }] + : [], + ); + const firstItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.threadId === childThreadId && + event.payload.type !== "user_message", + ); + assert.equal(rootEvents[0]?.status, "running"); + if (firstItemIndex !== -1) { + assert.isBelow( + rootEvents[0]?.index ?? Infinity, + firstItemIndex, + `child ${childThreadId} must be working before its first item`, + ); + } + const subagentStatuses = result.domainEvents.flatMap((event) => + event.type === "subagent.updated" && event.payload.id === subagent.id + ? [event.payload.status] + : [], + ); + assert.deepEqual( + activity(rootEvents.map((event) => event.status)), + activity(subagentStatuses), + `child ${childThreadId} root turn must follow subagent ${subagent.id}`, + ); + } + } +} + export function assertExecutionNodeKinds( projection: OrchestrationV2ThreadProjection, expectedKinds: ReadonlyArray, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 75e35d3cac1d..b0b4247a86d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -81,6 +81,7 @@ import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { useThreadActions } from "../hooks/useThreadActions"; import { + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, @@ -2003,6 +2004,10 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)), [serverProjection], ); + const runlessWorkStartedAt = useMemo( + () => (serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection)), + [serverProjection], + ); const supportsProviderSwitchingViaHandoff = useMemo( () => threadSupportsProviderHandoff(serverProjection), [serverProjection], @@ -3414,7 +3419,12 @@ export default function ChatView(props: ChatViewProps) { compactRequestIsActive && !compactionSettled; const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; + phase === "running" || + isSendBusy || + isConnecting || + isRevertingCheckpoint || + isCompacting || + runlessWorkStartedAt !== null; const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot( @@ -3446,11 +3456,9 @@ export default function ChatView(props: ChatViewProps) { }), ]; }, [serverProjection]); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeActivityRun, - activeRuntime, - localDispatchStartedAt, - ); + const activeWorkStartedAt = + deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ?? + runlessWorkStartedAt; // Server-side workspace preparation: unlike the local-dispatch flag this // survives reloads and shows on remote viewers of the same thread. const activeRunPreparing = activeActivityRun?.status === "preparing"; @@ -10445,6 +10453,7 @@ export default function ChatView(props: ChatViewProps) { } : {})} isWorking={!paintOnlyDisplayedTimeline && isWorking} + runlessWorkActive={runlessWorkStartedAt !== null} activeTurnInProgress={ !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled) } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3f9bc37e9ba7..c9e0ac872742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1904,6 +1904,96 @@ describe("deriveMessagesTimelineRows", () => { }); }); + it("shows a provider-native subagent's runless tools as live work while it works", () => { + const entries = (commandStatus: "inProgress" | "completed") => [ + { + id: "task-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "task" as never, + role: "user" as const, + text: "Audit the adapters", + runId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "command-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "command", + createdAt: "2026-01-01T00:00:05Z", + runId: null, + label: "Running git", + command: "git diff --stat", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: commandStatus, + }, + }, + ]; + const rows = (input: { commandStatus: "inProgress" | "completed"; working: boolean }) => + deriveMessagesTimelineRows({ + timelineEntries: entries(input.commandStatus), + latestRun: null, + isWorking: input.working, + runlessWorkActive: input.working, + activeTurnStartedAt: input.working ? "2026-01-01T00:00:00Z" : null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + const running = rows({ commandStatus: "inProgress", working: true }); + expect(running.map((row) => row.kind)).toEqual(["message", "working", "work-live"]); + expect(running.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "command" }, + active: true, + }); + + // Once the subagent settles, the same entries read as finished history. + const settled = rows({ commandStatus: "completed", working: false }); + expect(settled.map((row) => row.kind)).toEqual(["message", "work"]); + }); + + it("does not treat runless entries as live work on a thread with runs", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "runless-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "runless-command", + createdAt: "2026-01-01T00:00:05Z", + runId: null, + label: "Running git", + command: "git status", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.some((row) => row.kind === "work-live")).toBe(false); + expect(rows.at(-1)?.kind).toBe("thinking"); + }); + it("renders a single completed tool call directly", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6f0f73a11584..29d86b2fe9ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1039,6 +1039,11 @@ export function deriveMessagesTimelineRows(input: { expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; + /** + * The live work has no app run (a provider-native subagent thread), so + * runless entries are the current response instead of settled history. + */ + runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; @@ -1098,11 +1103,13 @@ export function deriveMessagesTimelineRows(input: { } } } + const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; + const runIdIsActiveResponse = (runId: RunId | null | undefined) => + runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); const workEntryIsInActiveRun = (entry: WorkLogEntry) => input.isWorking && - unsettledRunId !== null && entry.toolLifecycleStatus === "inProgress" && - entry.runId === unsettledRunId; + (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId); // A steer continues the current turn. Keep its elapsed-time header below // the initiating prompt (or automatic wake), rather than moving it down. @@ -1115,7 +1122,7 @@ export function deriveMessagesTimelineRows(input: { // and once everything settles it keeps the latest tool in past tense // instead of vanishing (#8984). const activeToolEntries: Array> = []; - if (input.isWorking && unsettledRunId !== null) { + if (input.isWorking && (unsettledRunId !== null || runlessWorkActive)) { let tailAttemptId: string | null | undefined; for (let index = timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = timelineEntries[index]!; @@ -1125,8 +1132,7 @@ export function deriveMessagesTimelineRows(input: { entry.entry.sourceActivityKind === "runtime.error" || entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification" || - entry.entry.runId == null || - !activeVisualResponseRunIds.has(entry.entry.runId) || + !runIdIsActiveResponse(entry.entry.runId) || entry.entry.sourceActivityKind === "context-compaction" || collapsedEntryIds.has(entry.id) || collapsedSupersededEntryIds.has(entry.id) || @@ -1469,9 +1475,7 @@ export function deriveMessagesTimelineRows(input: { const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && - timelineEntry.message.runId !== null && - timelineEntry.message.runId !== undefined && - activeVisualResponseRunIds.has(timelineEntry.message.runId); + runIdIsActiveResponse(timelineEntry.message.runId); const durationStart = durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1ade3af63861..45420846d972 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -401,6 +401,8 @@ interface MessagesTimelineProps { sourceAnchor: AssistantCitationSourceAnchor, ) => boolean; isWorking: boolean; + /** The live work belongs to a runless root turn (a provider-native subagent). */ + runlessWorkActive?: boolean; activeTurnInProgress: boolean; activeTurnStartedAt?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; @@ -483,6 +485,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ citationHistoryLoading = false, onCiteAssistantText, isWorking, + runlessWorkActive = false, activeTurnInProgress, activeTurnStartedAt = null, worktreeSetup = null, @@ -741,6 +744,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, expandedWorkGroupIds, isWorking, + runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, @@ -763,6 +767,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, expandedWorkGroupIds, isWorking, + runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index c9d4bd9c0e26..186a799ccb1c 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -3,6 +3,7 @@ import { NodeId, MessageId, RunId, + type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -11,6 +12,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, threadRuntimeHasInterruptibleRun, @@ -167,6 +169,49 @@ describe("thread execution presentation", () => { }); }); +describe("deriveRunlessWorkStartedAt", () => { + const later = DateTime.makeUnsafe("2026-07-28T10:05:00.000Z"); + const rootTurn = ( + status: OrchestrationV2ExecutionNode["status"], + startedAt = now, + ): OrchestrationV2ExecutionNode => ({ + id: NodeId.make("child-root"), + threadId: v2Projection.thread.id, + runId: null, + parentNodeId: null, + rootNodeId: NodeId.make("child-root"), + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }); + + it("times a provider-native subagent from its runless root turn while it works", () => { + const projection = { ...v2Projection, nodes: [rootTurn("running", later)] }; + expect(deriveRunlessWorkStartedAt(projection)).toBe("2026-07-28T10:05:00.000Z"); + // The subagent has no run, so it stays unstoppable and unqueueable. + expect(deriveThreadRuntime(projection)).toBeNull(); + }); + + it.each(["completed", "cancelled", "failed", "interrupted", "idle"] as const)( + "is idle once the subagent is %s", + (status) => { + expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [rootTurn(status)] })).toBe(null); + }, + ); + + it("ignores root turns that belong to a run", () => { + const owned = { ...rootTurn("running"), runId: RunId.make("run-1") }; + expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [owned] })).toBeNull(); + }); +}); + describe("threadRuntimeCanArchive", () => { const runtime = ( status: ThreadRuntimeSummary["status"], diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 9a7012cc57d5..3b99c597923f 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -2,7 +2,10 @@ import { latestRootProviderFailure, threadErrorSummary, } from "@t3tools/shared/orchestrationV2ThreadError"; -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { + isOrchestrationV2WorkActive, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; @@ -67,6 +70,24 @@ export function deriveThreadActivityRun( return run === null ? null : summarizeThreadRun(projection, run); } +/** + * Provider-native subagent threads never get app runs: their work is a runless + * root turn whose status follows the subagent. Returns when that work started + * while it is still active, so clients can show the same working state (and + * timer) as a run. Stop, queue, and steer stay run-only. + */ +export function deriveRunlessWorkStartedAt( + projection: OrchestrationV2ThreadProjection, +): string | null { + const node = projection.nodes.findLast( + (candidate) => + candidate.kind === "root_turn" && + candidate.runId === null && + isOrchestrationV2WorkActive(candidate.status), + ); + return node?.startedAt == null ? null : DateTime.formatIso(node.startedAt); +} + export function deriveThreadRuntime( projection: OrchestrationV2ThreadProjection, ): ThreadRuntimeSummary | null { From 6a93fae7b26c530915d02fa12a08cb671f2be5aa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:12:37 -0700 Subject: [PATCH 2/2] fix(mobile): only runless subagent work makes a runless tail live Dropping the active-run check let any working thread without an unsettled run (the optimistic-send window, or a queued latest run) mark a runless tail group live with shimmer. Pass an explicit runlessWorkActive flag, as the web timeline does, and live-mark a runless tail only for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 3 ++ .../src/features/threads/ThreadFeed.tsx | 3 ++ .../features/threads/ThreadRouteScreen.tsx | 1 + apps/mobile/src/lib/threadActivity.test.ts | 34 ++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++-- .../src/state/use-thread-composer-state.ts | 21 ++++++------ 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 01bbf21e0265..31700c794aac 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -136,6 +136,8 @@ export interface ThreadDetailScreenProps { readonly selectedThreadFeed: ReadonlyArray; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; + /** The live work is a provider-native subagent's runless root turn. */ + readonly runlessWorkActive?: boolean; readonly isCompacting: boolean; /** * The server has not created this thread yet. "preparing" runs while the @@ -1031,6 +1033,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadTitle={props.selectedThread.title} latestRun={props.activityRun} activeWorkStartedAt={props.activeWorkStartedAt} + runlessWorkActive={props.runlessWorkActive ?? false} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 59940e4c9fe2..a927e68811c1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -277,6 +277,7 @@ export interface ThreadFeedProps { readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; + readonly runlessWorkActive?: boolean; readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; @@ -2606,6 +2607,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { .map(([groupId]) => groupId), ), props.activeWorkStartedAt, + props.runlessWorkActive ?? false, ), props.feed, props.queuedMessages, @@ -2615,6 +2617,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, + props.runlessWorkActive, props.feed, props.latestRun, ], diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f1b2eee2bc0b..4958cf9d05e9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1005,6 +1005,7 @@ function ThreadRouteContent( : composer.activeWorkStartedAt } isCompacting={composer.isCompacting} + runlessWorkActive={composer.runlessWorkActive} creationState={creationState} setupWorkingStartedAt={ composer.activeWorkStartedAt !== null && diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f8c11c2d9817..32034bdb1559 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -971,7 +971,14 @@ describe("buildThreadFeed", () => { projected(runningCommand, 1), ]); - const presented = deriveThreadFeedPresentation(feed, null, new Set(), new Set(), startedAt); + const presented = deriveThreadFeedPresentation( + feed, + null, + new Set(), + new Set(), + startedAt, + true, + ); expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ summary: "Running vp", live: true, @@ -980,6 +987,31 @@ 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", () => { + // 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. + const startedAt = "2026-06-20T00:00:05.000Z"; + const feed = buildThreadFeed([ + projected({ ...userMessage(), runId: null }, 0), + projected({ ...command(), runId: null }, 1), + ]); + for (const latestRun of [ + null, + { runId, status: "queued" as const, startedAt: null, completedAt: null }, + ]) { + const presented = deriveThreadFeedPresentation( + feed, + latestRun, + new Set(), + 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"); + } + }); + it("waits for workspace preparation before showing provider activity", () => { const startedAt = "2026-04-01T00:00:01.000Z"; const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d2187dcef1df..ff9b8731b9b5 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1019,6 +1019,8 @@ export function deriveThreadFeedPresentation( expandedRunIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), activeWorkStartedAt: string | null = null, + /** The live work is a provider-native subagent's runless root turn. */ + runlessWorkActive = false, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => @@ -1037,10 +1039,11 @@ export function deriveThreadFeedPresentation( } const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { - // A provider-native subagent works without a run, so its null-run tail - // is live while it works (activeRunId is null then too). + // A provider-native subagent works without a run: its null-run tail is + // live only while that runless work is active. const isActiveTailGroup = isWorking && + (activeRunId !== null || runlessWorkActive) && entry.type === "activity-group" && activeTailGroup?.type === "activity-group" && activeTailGroup.id === entry.id && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 68525ae9ed9b..623971c9531a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -394,6 +394,13 @@ export function useThreadComposerState() { selectedThreadVisibleTurnItems, ]); + const runlessWorkStartedAt = useMemo( + () => + selectedThreadProjection + ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) + : null, + [selectedThreadProjection], + ); const activeWorkStartedAt = useMemo(() => { if (!selectedThreadShell) { return null; @@ -402,17 +409,10 @@ export function useThreadComposerState() { resolveThreadWorkingStartedAt({ latestRun: selectedThreadActivityRun, runtime: selectedThreadRuntime, - }) ?? - (selectedThreadProjection - ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) - : null) + }) ?? runlessWorkStartedAt ); - }, [ - selectedThreadActivityRun, - selectedThreadProjection, - selectedThreadRuntime, - selectedThreadShell, - ]); + }, [selectedThreadActivityRun, runlessWorkStartedAt, selectedThreadRuntime, selectedThreadShell]); + const runlessWorkActive = runlessWorkStartedAt !== null; // The run can start, or be cancelled from another client, while its message // is open in the composer. Leave edit mode rather than saving into a run the @@ -1035,6 +1035,7 @@ export function useThreadComposerState() { selectedThreadQueuedMessages, dispatchingQueuedMessageId, activeWorkStartedAt, + runlessWorkActive, isCompacting, draftMessage, draftAttachments,