From 09cd966a6dd3cd1f8009200d824f003a783845ba Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 16:51:17 +0200 Subject: [PATCH 1/7] feat: show agent task progress in threads Agents already write task lists (Codex plan, Claude TodoWrite, ACP plan), but T3 Code only showed them as quiet timeline rows, so there was no way to see where an agent is without scrolling. Web: a closable progress card under the inline details card, and a composer strip that takes over when the card isn't showing it. The sidebar row shows a ring and step count while working or blocked, and the active row shows the current step. A blocked run (question or approval) is marked on the current step, and a run that ended early reads as stopped, not running. Mobile: the working pill becomes a progress pill (ring, current step, count, timer) that opens a native bottom sheet with every step. Thread list rows keep the step count. Server: thread shells carry `todoProgress` for runs in flight, behind a partial index so idle threads skip the lookup. The pure progress helpers now live in client-runtime and are shared by web and mobile. Written by Claude Sonnet 5.5 in Claude Code, running in T3 Code. --- apps/mobile/src/components/ProgressRing.tsx | 45 +++++ .../features/threads/ThreadDetailScreen.tsx | 40 +++++ .../features/threads/ThreadProgressSheet.tsx | 158 ++++++++++++++++++ .../threads/floating-working-control.tsx | 61 ++++++- .../threads/floating-working-status.ts | 13 ++ .../features/threads/thread-list-v2-items.tsx | 16 +- .../thread-progress-presentation.test.ts | 83 +++++++++ .../threads/thread-progress-presentation.ts | 110 ++++++++++++ .../features/threads/use-thread-progress.ts | 36 ++++ .../orchestration-v2/ProjectionStore.test.ts | 100 +++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 61 +++++++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/073_ThreadTodoListIndex.ts | 13 ++ .../reconcileV2PreviewMigration.test.ts | 2 + apps/web/src/components/ChatView.tsx | 18 +- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 11 ++ apps/web/src/components/chat/ProgressRing.tsx | 29 ++++ .../src/components/chat/ThreadDetailsCard.tsx | 6 +- .../components/chat/ThreadDetailsPanel.tsx | 12 ++ .../components/chat/ThreadProgressCard.tsx | 155 +++++++++++++++++ .../chat/ThreadProgressStrip.browser.test.tsx | 121 ++++++++++++++ .../components/chat/ThreadProgressStrip.tsx | 105 ++++++++++++ .../components/chat/threadProgressState.ts | 42 +++++ .../components/sidebar/SidebarThreadRow.tsx | 150 +++++++++++------ apps/web/src/session-logic.test.ts | 28 ---- apps/web/src/session-logic.ts | 24 +-- packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/models.ts | 3 + .../src/state/todoProgress.test.ts | 102 +++++++++++ .../client-runtime/src/state/todoProgress.ts | 62 +++++++ packages/contracts/src/orchestrationV2.ts | 11 ++ 32 files changed, 1516 insertions(+), 109 deletions(-) create mode 100644 apps/mobile/src/components/ProgressRing.tsx create mode 100644 apps/mobile/src/features/threads/ThreadProgressSheet.tsx create mode 100644 apps/mobile/src/features/threads/thread-progress-presentation.test.ts create mode 100644 apps/mobile/src/features/threads/thread-progress-presentation.ts create mode 100644 apps/mobile/src/features/threads/use-thread-progress.ts create mode 100644 apps/server/src/persistence/Migrations/073_ThreadTodoListIndex.ts create mode 100644 apps/web/src/components/chat/ProgressRing.tsx create mode 100644 apps/web/src/components/chat/ThreadProgressCard.tsx create mode 100644 apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx create mode 100644 apps/web/src/components/chat/ThreadProgressStrip.tsx create mode 100644 apps/web/src/components/chat/threadProgressState.ts create mode 100644 packages/client-runtime/src/state/todoProgress.test.ts create mode 100644 packages/client-runtime/src/state/todoProgress.ts diff --git a/apps/mobile/src/components/ProgressRing.tsx b/apps/mobile/src/components/ProgressRing.tsx new file mode 100644 index 0000000000000..a41b4ca8b4c24 --- /dev/null +++ b/apps/mobile/src/components/ProgressRing.tsx @@ -0,0 +1,45 @@ +import Svg, { Circle } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedCircle = withUniwind(Circle); + +const RADIUS = 8; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +/** Fills clockwise with `fraction` in the color `colorClassName` resolves to. */ +export function ProgressRing(props: { + readonly fraction: number; + readonly size: number; + readonly colorClassName: string; +}) { + const filled = Math.min(1, Math.max(0, props.fraction)); + return ( + + + {filled > 0 ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a23f06abef24b..060e293c6671e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -192,6 +192,9 @@ import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import { ThreadGuestShareBar } from "./ThreadGuestShareBar"; +import { ThreadProgressSheet } from "./ThreadProgressSheet"; +import { presentThreadProgress } from "./thread-progress-presentation"; +import { useThreadProgress } from "./use-thread-progress"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; @@ -609,6 +612,34 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { const pendingBackgroundWork = presentPendingBackgroundWork( props.selectedThread.pendingBackgroundTasks, ); + const [progressSheetOpen, setProgressSheetOpen] = useState(false); + const openProgressSheet = () => { + Keyboard.dismiss(); + setProgressSheetOpen(true); + }; + const threadProgress = useThreadProgress({ + threadRef: props.selectedThreadDetailRef, + thread: props.selectedThread, + hasPendingApproval: props.activePendingApproval !== null, + hasPendingUserInput: props.activePendingUserInput !== null, + }); + const progressStatus = ((): FloatingWorkingStatus | null => { + if (threadProgress === null || contentPresentationKind !== "ready") return null; + const progress = presentThreadProgress(threadProgress.steps, threadProgress.state); + const blocked = progress.note !== null; + if (props.activeWorkStartedAt === null && !blocked) return null; + return { + kind: "progress", + startedAt: props.activeWorkStartedAt, + title: progress.title, + count: progress.count, + waitingNote: blocked ? "Waiting" : null, + fraction: progress.fraction, + tone: progress.tone, + accessibilityLabel: `${progress.title}, ${progress.description}. Shows every step`, + onPress: openProgressSheet, + }; + })(); const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, @@ -650,6 +681,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } + if (progressStatus !== null) return progressStatus; if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } @@ -1383,6 +1415,14 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { className="w-full" > + {threadProgress !== null ? ( + setProgressSheetOpen(false)} + steps={threadProgress.steps} + state={threadProgress.state} + /> + ) : null} ; + +const SEGMENT_FILL_CLASSES = { + active: "bg-adaptive-sky-600-400", + waiting: "bg-warning-foreground", + done: "bg-adaptive-emerald-700-300", + muted: "bg-foreground-muted", +} as const satisfies Record; + +const ROW_TINT_CLASSES = { + active: "bg-adaptive-sky-600-400/10", + waiting: "bg-warning-foreground/10", + done: "", + muted: "", +} as const satisfies Record; + +function ProgressSegments(props: { + readonly segments: ReadonlyArray<{ + readonly id: string; + readonly status: OrchestrationV2PlanStep["status"]; + }> | null; + readonly fraction: number; + readonly tone: ThreadProgressTone; +}) { + const track = SEGMENT_TRACK_CLASSES[props.tone]; + const fill = SEGMENT_FILL_CLASSES[props.tone]; + if (props.segments === null) { + return ( + + + + ); + } + return ( + + {props.segments.map(({ id, status }) => ( + + {status === "completed" ? : null} + {status === "running" ? : null} + + ))} + + ); +} + +export function ThreadProgressSheet({ + open, + onClose, + steps, + state, +}: { + readonly open: boolean; + readonly onClose: () => void; + readonly steps: ReadonlyArray; + readonly state: TodoRunState; +}) { + const { height: windowHeight } = useWindowDimensions(); + const progress = presentThreadProgress(steps, state); + const background = StyleSheet.flatten(useResolveClassNames("bg-screen")); + return ( + + + + + + Progress + + {progress.summary} + + + + + + + {steps.map((step) => { + const row = presentThreadProgressStep(step, state); + const tone = THREAD_PROGRESS_TONE_CLASSES[row.tone]; + return ( + + + + + {step.text} + + {row.note ? ( + + {row.note} + + ) : null} + + + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 3709cf2affa86..d56313f608e5d 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -25,10 +25,12 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { ControlPill } from "../../components/ui/ControlPill"; +import { ProgressRing } from "../../components/ProgressRing"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; +import { THREAD_PROGRESS_TONE_CLASSES } from "./thread-progress-presentation"; import { ShimmeringWorkContent } from "./thread-work-log"; import { useVisibleSecondClock } from "./use-visible-second-clock"; @@ -160,6 +162,7 @@ export function FloatingWorkingControl(props: { const statusInteractive = props.status?.kind === "connection" || props.status?.kind === "child-input" || + props.status?.kind === "progress" || props.status?.kind === "background"; const capsuleInteractive = statusInteractive || action !== null || hasQueue || hasAgents || hasPreview; @@ -177,7 +180,9 @@ export function FloatingWorkingControl(props: { > ); } + if (props.status.kind === "progress") { + return ( + + + + {props.status.title} + + + + + + ); + } if (props.status.kind === "preparing") { return ( + {trailing === null ? props.count : `${props.count} · ${trailing}`} + + ); +} + function WorkingTimer(props: { readonly startedAt: string }) { const nowMs = useVisibleSecondClock(true); return ( diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 264d110f75f74..59cc4ff4e9976 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -1,5 +1,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { ThreadProgressTone } from "./thread-progress-presentation"; + export interface FloatingStatusAction { readonly label: string; readonly accessibilityLabel: string; @@ -9,6 +11,17 @@ export interface FloatingStatusAction { export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string } + | { + readonly kind: "progress"; + readonly startedAt: string | null; + readonly title: string; + readonly count: string; + readonly waitingNote: string | null; + readonly fraction: number; + readonly tone: ThreadProgressTone; + readonly accessibilityLabel: string; + readonly onPress: () => void; + } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } | { 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 b519cd7227612..6545fb3a4d8f5 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -74,6 +74,7 @@ import { type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; +import { withProgressCount } from "./thread-progress-presentation"; import { shouldRecedeThreadRow } from "./thread-row-emphasis"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; @@ -689,9 +690,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = - (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" + (workingLabel !== undefined && status === "working" && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } - : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + : workingLabel !== undefined + ? { + ...workingLabel, + label: withProgressCount(status, workingLabel.label, thread.todoProgress), + } + : undefined) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const timeLabel = props.timeLabel; @@ -1120,7 +1126,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { diff --git a/apps/mobile/src/features/threads/thread-progress-presentation.test.ts b/apps/mobile/src/features/threads/thread-progress-presentation.test.ts new file mode 100644 index 0000000000000..7e6a6b43ac8ed --- /dev/null +++ b/apps/mobile/src/features/threads/thread-progress-presentation.test.ts @@ -0,0 +1,83 @@ +import type { OrchestrationV2PlanStep } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + presentThreadProgress, + presentThreadProgressStep, + withProgressCount, +} from "./thread-progress-presentation"; + +const steps: ReadonlyArray = [ + { id: "a", text: "Read the code", status: "completed" }, + { id: "b", text: "Write the fix", status: "running" }, + { id: "c", text: "Run the tests", status: "pending" }, +]; + +describe("thread progress presentation", () => { + it("names the current step and says what a blocked run waits on", () => { + expect(presentThreadProgress(steps, "active")).toMatchObject({ + title: "Write the fix", + description: "1/3", + tone: "active", + }); + expect(presentThreadProgress(steps, "input")).toMatchObject({ + description: "1/3 · Waiting for your answer", + tone: "waiting", + }); + expect(presentThreadProgress(steps, "approval").description).toBe( + "1/3 · Waiting for your approval", + ); + expect(presentThreadProgress(steps, "stopped").summary).toBe("Stopped · 1 of 3"); + expect( + presentThreadProgress(steps, "active").segments?.map((segment) => segment.status), + ).toEqual(["completed", "running", "pending"]); + expect( + presentThreadProgress( + Array.from({ length: 13 }, (_, index) => ({ + id: String(index), + text: "Step", + status: "pending" as const, + })), + "active", + ).segments, + ).toBeNull(); + expect( + presentThreadProgress( + steps.map((s) => ({ ...s, status: "completed" })), + "done", + ), + ).toMatchObject({ + title: "All steps done", + tone: "done", + }); + }); + + it("shows the running step as blocked or stalled instead of moving", () => { + const running = steps[1]!; + expect(presentThreadProgressStep(running, "active")).toMatchObject({ + icon: "circle.inset.filled", + note: "In progress", + highlight: true, + }); + expect(presentThreadProgressStep(running, "input")).toMatchObject({ + icon: "questionmark.bubble", + note: "Waiting for your answer", + }); + expect(presentThreadProgressStep(running, "approval").icon).toBe("exclamationmark.shield"); + expect(presentThreadProgressStep(running, "stopped")).toMatchObject({ + icon: "circle.dashed", + tone: "muted", + highlight: false, + }); + expect(presentThreadProgressStep(steps[0]!, "active").strike).toBe(true); + }); + + it("keeps the step count on working and blocked rows only", () => { + const progress = { completed: 2, total: 5 }; + expect(withProgressCount("working", "Working", progress)).toBe("Working 2/5"); + expect(withProgressCount("input", "Input", progress)).toBe("Input 2/5"); + expect(withProgressCount("approval", "Approval", progress)).toBe("Approval 2/5"); + expect(withProgressCount("failed", "Failed", progress)).toBe("Failed"); + expect(withProgressCount("working", "Working", null)).toBe("Working"); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-progress-presentation.ts b/apps/mobile/src/features/threads/thread-progress-presentation.ts new file mode 100644 index 0000000000000..08d7d7f2e3c18 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-progress-presentation.ts @@ -0,0 +1,110 @@ +import type { OrchestrationV2PlanStep } from "@t3tools/contracts"; +import { summarizeTodoSteps, type TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; + +export type ThreadProgressTone = "active" | "waiting" | "done" | "muted"; + +const WAITING_NOTE = { + input: "Waiting for your answer", + approval: "Waiting for your approval", +} as const; + +const RING_TONE = { + active: "active", + input: "waiting", + approval: "waiting", + done: "done", + stopped: "muted", +} as const satisfies Record; + +const MAX_SEGMENTS = 12; + +export function presentThreadProgress( + steps: ReadonlyArray, + state: TodoRunState, +) { + const { completed, total, current } = summarizeTodoSteps(steps); + const count = `${completed}/${total}`; + const note = state === "input" || state === "approval" ? WAITING_NOTE[state] : null; + return { + fraction: completed / total, + title: current ?? "All steps done", + count, + note, + description: note === null ? count : `${count} · ${note}`, + tone: RING_TONE[state], + summary: `${state === "stopped" ? "Stopped · " : ""}${completed} of ${total}`, + segments: + total <= MAX_SEGMENTS ? steps.map((step) => ({ id: step.id, status: step.status })) : null, + }; +} + +export function presentThreadProgressStep(step: OrchestrationV2PlanStep, state: TodoRunState) { + if (step.status === "completed") { + return { + icon: "checkmark.circle.fill", + tone: "done", + note: null, + strike: true, + highlight: false, + } as const; + } + if (step.status === "pending") { + return { + icon: "circle.dashed", + tone: "muted", + note: null, + strike: false, + highlight: false, + } as const; + } + if (state === "input" || state === "approval") { + return { + icon: state === "input" ? "questionmark.bubble" : "exclamationmark.shield", + tone: "waiting", + note: WAITING_NOTE[state], + strike: false, + highlight: true, + } as const; + } + if (state === "stopped") { + return { + icon: "circle.dashed", + tone: "muted", + note: null, + strike: false, + highlight: false, + } as const; + } + return { + icon: "circle.inset.filled", + tone: "active", + note: "In progress", + strike: false, + highlight: true, + } as const; +} + +export const THREAD_PROGRESS_TONE_CLASSES = { + active: { + text: "text-adaptive-sky-600-400", + accent: "accent-adaptive-sky-600-400", + }, + waiting: { text: "text-warning-foreground", accent: "accent-warning-foreground" }, + done: { + text: "text-adaptive-emerald-700-300", + accent: "accent-adaptive-emerald-700-300", + }, + muted: { text: "text-foreground-muted", accent: "accent-foreground-muted" }, +} as const satisfies Record; + +/** The thread list keeps the step count on a row that is working or blocked on the user. */ +export function withProgressCount( + status: string, + label: string, + progress: { readonly completed: number; readonly total: number } | null | undefined, +) { + if (!progress || (status !== "working" && status !== "input" && status !== "approval")) { + return label; + } + return `${label} ${progress.completed}/${progress.total}`; +} diff --git a/apps/mobile/src/features/threads/use-thread-progress.ts b/apps/mobile/src/features/threads/use-thread-progress.ts new file mode 100644 index 0000000000000..0af5598c36004 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-progress.ts @@ -0,0 +1,36 @@ +import { + findLatestTodoSteps, + isLatestRunSettled, + resolveTodoRunState, + summarizeTodoSteps, +} from "@t3tools/client-runtime/state/todo-progress"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { OrchestrationV2PlanStep, ScopedThreadRef } from "@t3tools/contracts"; + +import { useQuietThreadStructure } from "../../state/use-thread-detail"; + +/** The latest run's task list while the run is live; null when there is nothing to show. */ +export function useThreadProgress(input: { + readonly threadRef: ScopedThreadRef | null; + readonly thread: Pick; + readonly hasPendingApproval: boolean; + readonly hasPendingUserInput: boolean; +}) { + const structure = useQuietThreadStructure(input.threadRef); + const steps: ReadonlyArray | null = findLatestTodoSteps( + structure === null ? null : structure.projection, + input.thread.latestRun?.runId ?? null, + ); + if (steps === null || isLatestRunSettled(input.thread.latestRun, input.thread.runtime)) { + return null; + } + const { completed, total } = summarizeTodoSteps(steps); + const state = resolveTodoRunState({ + settled: false, + completed, + total, + pendingApprovals: input.hasPendingApproval ? 1 : 0, + pendingUserInputs: input.hasPendingUserInput ? 1 : 0, + }); + return { steps, state }; +} diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 2af87ecf4feb7..7e06dbaa6b7be 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -404,6 +404,106 @@ it.effect("SQL shells flag only an active turn blocked solely on T3 thread waits }).pipe(Effect.provide(layerTest)), ); +it.effect("shells carry only the latest run's todo list progress", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("thread-todo-progress"); + const initial = yield* store.getThreadProjection(threadId); + const at = initial.updatedAt; + const previousCommand = initial.turnItems.find((item) => item.type === "command_execution")!; + const itemBase = { + ...previousCommand, + providerThreadId: previousCommand.providerThreadId ?? null, + providerTurnId: previousCommand.providerTurnId ?? null, + nativeItemRef: previousCommand.nativeItemRef ?? null, + parentItemId: previousCommand.parentItemId ?? null, + }; + yield* store.apply({ + id: EventId.make("event:todo-progress:previous-completed"), + type: "run.updated", + threadId, + occurredAt: at, + payload: { ...initial.runs[0]!, status: "completed", completedAt: at }, + }); + const run = { + ...initial.runs[0]!, + id: RunId.make("run:todo-progress:active"), + ordinal: 2, + status: "running" as const, + completedAt: null, + }; + yield* store.apply({ + id: EventId.make("event:todo-progress:active"), + type: "run.created", + threadId, + occurredAt: at, + payload: run, + }); + let sequence = 0; + const writeTodoList = ( + runId: RunId, + steps: ReadonlyArray<{ + id: string; + text: string; + status: "pending" | "running" | "completed"; + }>, + ) => + store.apply({ + id: EventId.make(`event:todo-progress:item:${sequence++}`), + type: "turn-item.updated", + threadId, + runId, + driver, + occurredAt: at, + payload: { + ...itemBase, + id: TurnItemId.make(`item:todo-progress:${runId}`), + runId, + type: "todo_list", + planId: PlanId.make(`plan:todo-progress:${runId}`), + status: "running", + steps, + }, + }); + const progressOf = Effect.fnUntraced(function* () { + const listed = (yield* store.getShellSnapshot()).threads.find((row) => row.id === threadId); + return [ + listed?.todoProgress ?? null, + (yield* store.getThreadShell(threadId))?.todoProgress ?? null, + ProjectionStore.threadShellFromProjection(yield* store.getThreadProjection(threadId)) + .todoProgress ?? null, + ]; + }); + + yield* writeTodoList(initial.runs[0]!.id, [{ id: "old", text: "Old run", status: "running" }]); + assert.deepEqual(yield* progressOf(), [null, null, null]); + + yield* writeTodoList(run.id, [ + { id: "a", text: "Read the code", status: "completed" }, + { id: "b", text: "Write the fix", status: "running" }, + { id: "c", text: "Run the tests", status: "pending" }, + ]); + const midway = { completed: 1, total: 3, current: "Write the fix" }; + assert.deepEqual(yield* progressOf(), [midway, midway, midway]); + + yield* writeTodoList(run.id, [ + { id: "a", text: "Read the code", status: "completed" }, + { id: "b", text: "Write the fix", status: "completed" }, + ]); + const finished = { completed: 2, total: 2 }; + assert.deepEqual(yield* progressOf(), [finished, finished, finished]); + + yield* store.apply({ + id: EventId.make("event:todo-progress:active-completed"), + type: "run.updated", + threadId, + occurredAt: at, + payload: { ...run, status: "completed", completedAt: at }, + }); + assert.deepEqual(yield* progressOf(), [null, null, null]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("SQL shells use the latest subagent owner for duplicate provider-thread ids", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2a21bfae4bd32..646c3ea3cdc7a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -942,6 +942,7 @@ type ShellThreadRow = { readonly pending_request_payload_json: string | null; readonly pending_secret_request_payload_json: string | null; readonly pending_link_request_payload_json: string | null; + readonly latest_todo_list_payload_json: string | null; readonly latest_user_message_at: string | null; readonly latest_user_authored_message_at: string | null; readonly has_actionable_proposed_plan: number; @@ -1517,6 +1518,11 @@ export function threadShellFromProjection( projection.providerThreads, projection.thread.activeProviderThreadId, ), + todoProgress: todoProgressForShell( + latestRun?.id ?? null, + latestRun?.status ?? "idle", + latestTodoListItem(projection.turnItems), + ), itemCount: activeLocalTurnItems(projection).length, visibleItemCount: projection.visibleTurnItems.length, createdAt: projection.thread.createdAt, @@ -1567,6 +1573,36 @@ function activeProviderGoalForShell( return providerThreads.find((thread) => thread.id === activeProviderThreadId)?.goal ?? null; } +function latestTodoListItem( + turnItems: ReadonlyArray, +) { + for (let index = turnItems.length - 1; index >= 0; index -= 1) { + const item = turnItems[index]; + if (item?.type === "todo_list") return item; + } + return null; +} + +// Progress only matters while a run is in flight, and a list from an earlier run says nothing +// about the run the row shows now. The shell SQL gates its todo subquery on the same statuses. +function todoProgressForShell( + latestRunId: RunId | null, + latestRunStatus: OrchestrationV2ShellThreadStatus, + item: ReturnType, +): OrchestrationV2ThreadShell["todoProgress"] { + if (!isInFlightShellStatus(latestRunStatus)) return null; + if (item === null || latestRunId === null || item.runId !== latestRunId) return null; + if (item.steps.length === 0) return null; + const current = + item.steps.find((step) => step.status === "running") ?? + item.steps.find((step) => step.status === "pending"); + return { + completed: item.steps.filter((step) => step.status === "completed").length, + total: item.steps.length, + ...(current === undefined ? {} : { current: current.text }), + }; +} + function isSilentRunForShell( run: OrchestrationV2ThreadProjection["runs"][number], projection: Pick, @@ -1586,6 +1622,12 @@ function isInterruptibleRunForShell(run: OrchestrationV2ThreadProjection["runs"] return run.status === "preparing" || run.status === "starting" || run.status === "running"; } +function isInFlightShellStatus(status: OrchestrationV2ShellThreadStatus): boolean { + return ( + status === "preparing" || status === "starting" || status === "running" || status === "waiting" + ); +} + type ShellActivityRunStatus = "preparing" | "running" | "starting" | "waiting"; function isActivityRunForShell( @@ -1619,6 +1661,7 @@ type ShellThreadState = { readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"]; readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"]; readonly goal: OrchestrationV2ThreadShell["goal"]; + readonly todoProgress: OrchestrationV2ThreadShell["todoProgress"]; readonly itemCount: number; readonly runlessItemCount: number; readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; @@ -1783,6 +1826,7 @@ function shellFromState(input: { pendingBackgroundTasks: input.state.pendingBackgroundTasks, providerInstanceHistory: input.state.providerInstanceHistory, goal: input.state.goal, + todoProgress: input.state.todoProgress, itemCount: input.state.itemCount, visibleItemCount: input.visibleItemCount, createdAt: input.state.thread.createdAt, @@ -5854,6 +5898,14 @@ export const layer: Layer.Layer = ORDER BY link.updated_at DESC, link.turn_item_id DESC LIMIT 1 ) AS pending_link_request_payload_json, + CASE WHEN presented.status IN ('preparing', 'starting', 'running', 'waiting') THEN ( + SELECT todo.payload_json + FROM orchestration_v2_projection_turn_items todo + INDEXED BY orchestration_v2_projection_turn_items_todo_list_idx + WHERE todo.thread_id = t.thread_id AND todo.type = 'todo_list' + ORDER BY todo.updated_at DESC, todo.turn_item_id DESC + LIMIT 1 + ) END AS latest_todo_list_payload_json, ( SELECT message.updated_at FROM orchestration_v2_projection_messages message @@ -6458,6 +6510,15 @@ export const layer: Layer.Layer = providerThreadsByThreadId.get(thread.id) ?? [], thread.activeProviderThreadId, ), + todoProgress: todoProgressForShell( + latestRunId, + latestRunStatus, + row.latest_todo_list_payload_json === null + ? null + : latestTodoListItem([ + yield* decodeTurnItemPayload(row.latest_todo_list_payload_json), + ]), + ), itemCount: shellItemCountsByThreadId.get(thread.id)?.itemCount ?? 0, runlessItemCount: shellItemCountsByThreadId.get(thread.id)?.runlessItemCount ?? 0, updatedAt: thread.updatedAt, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc395406eb502..60cb5ed91950b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,6 +75,7 @@ import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ThreadTodoListIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -150,6 +151,7 @@ const migrationEntries = [ [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ThreadTodoListIndex", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ThreadTodoListIndex.ts b/apps/server/src/persistence/Migrations/073_ThreadTodoListIndex.ts new file mode 100644 index 0000000000000..1c2ee0c309799 --- /dev/null +++ b/apps/server/src/persistence/Migrations/073_ThreadTodoListIndex.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep this predicate aligned with ProjectionStore's latest todo list shell query. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_todo_list_idx + ON orchestration_v2_projection_turn_items(thread_id, updated_at DESC, turn_item_id DESC) + WHERE type = 'todo_list' + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 91e40578091a7..e2223dc0bebcd 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -52,6 +52,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ThreadTodoListIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -147,6 +148,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ThreadTodoListIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253cc1ea2e84b..8681d1994bfe0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,8 +135,13 @@ import { findLatestProposedPlan, findRevertCheckpoint, hasActionableProposedPlan, - isLatestRunSettled, } from "../session-logic"; +import { + findLatestTodoSteps, + isLatestRunSettled, + resolveTodoRunState, + summarizeTodoSteps, +} from "@t3tools/client-runtime/state/todo-progress"; import { type LegendListRef } from "@legendapp/list/react"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; @@ -1314,6 +1319,13 @@ export default function ChatView(props: ChatViewProps) { } return findLatestProposedPlan(serverProjection, activeLatestRun?.runId ?? null); })(); + const todoSteps = findLatestTodoSteps(serverProjection, activeLatestRun?.runId ?? null); + const todoState = resolveTodoRunState({ + ...(todoSteps ? summarizeTodoSteps(todoSteps) : { completed: 0, total: 0 }), + settled: latestRunSettled, + pendingApprovals: pendingApprovals.length, + pendingUserInputs: pendingUserInputs.length, + }); const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({ pendingUserInputCount: pendingUserInputs.length, interactionMode, @@ -3433,6 +3445,8 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={composerBannerItems} + todoSteps={latestRunSettled ? null : todoSteps} + todoState={todoState} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3635,6 +3649,8 @@ export default function ChatView(props: ChatViewProps) { projectId={activeThread.projectId} worktreePath={activeThread.worktreePath} isServerThread={isServerThread} + todoSteps={todoSteps} + todoState={todoState} {...(draftId ? { draftId } : {})} activeProjectName={activeProject?.title} activeProjectScripts={activeProject ? activeProjectScripts : undefined} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..f0ab65a42abf5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/client-runtime/state/thread-sort"; import type { SidebarThreadSummary, Thread } from "../types"; import { normalizeSearchText } from "../lib/utils"; -import { isLatestRunSettled } from "../session-logic"; +import { isLatestRunSettled } from "@t3tools/client-runtime/state/todo-progress"; import { observeIntersection } from "../lib/observeIntersection"; import { deadlineAfter, deadlineStore } from "@t3tools/client-runtime/clock"; import { useDeadlinePassed } from "../hooks/useDeadline"; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57838e598161e..7ca6f2d21572e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import type { ChatFileAttachment, EnvironmentId, ModelSelection, + OrchestrationV2PlanStep, ProjectId, PreviewAnnotationPayload, ThreadContextRecord, @@ -116,6 +117,7 @@ import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; import { ComposerSurface } from "./ComposerSurface"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; +import { ThreadProgressStrip } from "./ThreadProgressStrip"; import { attachmentsToReleaseOnUploadCapabilityLoss, composerOtherFilesForPresentation, @@ -247,6 +249,7 @@ import type { PendingApproval, PendingUserInput, } from "../../session-logic"; +import type { TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; import { resolveComposerDispatchMode, type ComposerDispatchMode, @@ -378,6 +381,9 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; isCompacting: boolean; bannerItems: readonly ComposerBannerStackItem[]; + /** The agent's task list while its run is active. */ + todoSteps: ReadonlyArray | null; + todoState: TodoRunState; resumeCompactionTokens: number | null; keepFullHistory: boolean; onToggleKeepFullHistory: () => void; @@ -3140,6 +3146,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) > + {showComposerTopDrawer ? ( diff --git a/apps/web/src/components/chat/ProgressRing.tsx b/apps/web/src/components/chat/ProgressRing.tsx new file mode 100644 index 0000000000000..9bc7aac8053dc --- /dev/null +++ b/apps/web/src/components/chat/ProgressRing.tsx @@ -0,0 +1,29 @@ +import { cn } from "~/lib/utils"; + +/** Fills clockwise with `fraction` in the surrounding text color. */ +export function ProgressRing({ + fraction, + className, +}: { + readonly fraction: number; + readonly className?: string; +}) { + const filled = Math.min(1, Math.max(0, fraction)); + return ( + + + 0 ? "round" : "butt"} + pathLength={1} + strokeDasharray="1 1" + strokeDashoffset={1 - filled} + className="stroke-current transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none" + /> + + ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4add56b80eb08..fa75f2c91e7be 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -17,11 +17,14 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + footer, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** A card under the details card, only while the card sits inline. */ + footer?: ReactNode; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const detailsArea = workspaceAreaProps("details", 3, "Thread details"); @@ -140,7 +143,7 @@ export function ThreadDetailsCard({ data-workspace-focus-key={`details:${threadRef.environmentId}:${threadRef.threadId}`} // Skip the return transition to avoid overlapping the rising preview. className={cn( - "absolute z-20", + "absolute z-20 flex flex-col gap-2", topInset > 0 && "transition-[top] duration-150 ease-out motion-reduce:transition-none", )} style={{ @@ -153,6 +156,7 @@ export function ThreadDetailsCard({ data-thread-details-panel="inline" > {card} + {footer} ) : null} {/* Rendered in both modes: base-ui keeps a popup that unmounts mid-open diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 1463c97f9ff6f..1cb4bbe1fbf40 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -1,4 +1,5 @@ import type { + OrchestrationV2PlanStep, EditorId, EnvironmentId, ProjectId, @@ -17,12 +18,14 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { memo, type ComponentProps } from "react"; +import type { TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; +import { ThreadProgressCard } from "./ThreadProgressCard"; const GitActionsControl = memo(BaseGitActionsControl); @@ -36,6 +39,8 @@ interface ThreadDetailsPanelProps extends Pick< projectId: ProjectId; worktreePath: string | null; isServerThread: boolean; + todoSteps: ReadonlyArray | null; + todoState: TodoRunState; draftId?: DraftId; activeProjectName: string | undefined; activeProjectScripts: ReadonlyArray | undefined; @@ -106,6 +111,13 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + footer={ + + } > {(density) => ( <> diff --git a/apps/web/src/components/chat/ThreadProgressCard.tsx b/apps/web/src/components/chat/ThreadProgressCard.tsx new file mode 100644 index 0000000000000..6d313c72f3335 --- /dev/null +++ b/apps/web/src/components/chat/ThreadProgressCard.tsx @@ -0,0 +1,155 @@ +import type { OrchestrationV2PlanStep, ScopedThreadRef } from "@t3tools/contracts"; +import { + CircleCheckIcon, + CircleDashedIcon, + CircleDotIcon, + MessageCircleQuestionMarkIcon, + ShieldQuestionIcon, + XIcon, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { summarizeTodoSteps, type TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { ProgressRing } from "./ProgressRing"; +import { useThreadProgressCardVisible, useThreadProgressStore } from "./threadProgressState"; + +const STEP_ICONS = { + completed: CircleCheckIcon, + running: CircleDotIcon, + pending: CircleDashedIcon, +} as const; + +export const RING_TONE = { + active: "text-info-foreground", + input: "text-warning-foreground", + approval: "text-warning-foreground", + done: "text-success-foreground", + stopped: "text-muted-foreground", +} as const satisfies Record; + +// The viewport keeps the current step in sight once a long plan scrolls. +function keepInView(node: HTMLLIElement | null) { + node?.scrollIntoView({ block: "nearest" }); +} + +export const WAITING_NOTE = { + input: "Waiting for your answer", + approval: "Waiting for your approval", +} as const; + +const BLOCKED_ICONS = { + input: MessageCircleQuestionMarkIcon, + approval: ShieldQuestionIcon, +} as const; + +export function TodoStepList({ + steps, + state = "active", +}: { + readonly steps: ReadonlyArray; + readonly state?: TodoRunState; +}) { + return ( +
    + {steps.map((step) => { + const isCurrent = step.status === "running"; + const blocked = isCurrent && (state === "input" || state === "approval") ? state : null; + const stalled = isCurrent && state === "stopped"; + const Icon = blocked + ? BLOCKED_ICONS[blocked] + : stalled + ? CircleDashedIcon + : STEP_ICONS[step.status]; + return ( +
  1. + + + + {step.text} + + {blocked ? ( + {WAITING_NOTE[blocked]} + ) : null} + +
  2. + ); + })} +
+ ); +} + +/** Renders under the details card while the agent's task list is on screen there. */ +export function ThreadProgressCard({ + threadRef, + steps, + state, +}: { + readonly threadRef: ScopedThreadRef; + readonly steps: ReadonlyArray | null; + readonly state: TodoRunState; +}) { + const visible = useThreadProgressCardVisible(threadRef); + if (steps === null || !visible) return null; + const { completed, total } = summarizeTodoSteps(steps); + return ( +
+
+ +

Progress

+ + {completed}/{total} + {state === "stopped" ? " · Stopped" : ""} + + + {completed} of {total} steps done{state === "stopped" ? ", stopped" : ""} + + + + +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx b/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx new file mode 100644 index 0000000000000..57f7717e27d36 --- /dev/null +++ b/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx @@ -0,0 +1,121 @@ +import { EnvironmentId, ThreadId, type OrchestrationV2PlanStep } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { ComponentProps } from "react"; +import { expect, it } from "vite-plus/test"; + +import "~/index.css"; + +import { render } from "../../../test/render"; +import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore"; +import { ChatCanvasContext } from "./ChatCanvasContext"; +import { ThreadProgressCard } from "./ThreadProgressCard"; +import { ThreadProgressStrip } from "./ThreadProgressStrip"; +import { useThreadProgressStore } from "./threadProgressState"; + +const steps: ReadonlyArray = [ + { id: "a", text: "Read the code", status: "completed" }, + { id: "b", text: "Write the fix", status: "running" }, + { id: "c", text: "Run the tests", status: "pending" }, +]; + +function setup() { + const threadRef = scopeThreadRef(EnvironmentId.make("env"), ThreadId.make("progress-thread")); + useThreadProgressStore.getState().setClosed(threadRef, false); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", false); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", false); + return threadRef; +} + +// Only `detailsCardPlacement` is read by the progress components. +const inlineCanvas = { + detailsCardPlacement: {}, +} as unknown as NonNullable["value"]>; + +it("shows the current step and expands to the full list", async () => { + const threadRef = setup(); + await using screen = await render( + , + { + router: false, // no routing involved + }, + ); + + await expect.element(screen.getByText("Write the fix")).toBeVisible(); + await expect.element(screen.getByText("1/3")).toBeVisible(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 2 of 3: Write the fix"); + await expect.element(screen.getByText("Run the tests")).not.toBeInTheDocument(); + + await screen.getByRole("button", { name: "Show steps" }).click(); + await expect.element(screen.getByText("Run the tests")).toBeVisible(); +}); + +it("hands off between the details card and the strip", async () => { + const threadRef = setup(); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true); + await using screen = await render( + + + + , + { router: false }, // no routing involved + ); + + await expect.element(screen.getByRole("region", { name: "Progress" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Show steps" })).not.toBeInTheDocument(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 2 of 3: Write the fix"); + + await screen.getByRole("button", { name: "Close progress" }).click(); + await expect.element(screen.getByRole("region", { name: "Progress" })).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Show steps" })).toBeVisible(); + + await screen.getByRole("button", { name: "Show progress in details" }).click(); + await expect.element(screen.getByRole("region", { name: "Progress" })).toBeVisible(); + expect( + selectThreadPanelOpen( + useRightPanelStore.getState().threadPanelVisibilityByThreadKey, + threadRef, + "inline", + ), + ).toBe(true); +}); + +it("keeps the strip to one line while the agent waits on an answer", async () => { + const threadRef = setup(); + await using screen = await render( + , + { router: false }, // no routing involved + ); + + await expect.element(screen.getByText("1/3 · Waiting for your answer")).toBeVisible(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Waiting for your answer"); + await expect.element(screen.getByRole("button", { name: "Show steps" })).not.toBeInTheDocument(); +}); + +it("marks the current step as stopped, not running, once the run ended early", async () => { + const threadRef = setup(); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true); + await using screen = await render( + + + , + { router: false }, // no routing involved + ); + + await expect.element(screen.getByText("1/3 · Stopped")).toBeVisible(); + await expect.element(screen.getByText("1 of 3 steps done, stopped")).toBeInTheDocument(); + await expect.element(screen.getByText("Write the fix")).not.toHaveClass("font-medium"); +}); + +it("takes over when the details card is closed", async () => { + const threadRef = setup(); + await using screen = await render( + + + + , + { router: false }, // no routing involved + ); + + await expect.element(screen.getByRole("region", { name: "Progress" })).not.toBeInTheDocument(); + await expect.element(screen.getByText("Write the fix")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ThreadProgressStrip.tsx b/apps/web/src/components/chat/ThreadProgressStrip.tsx new file mode 100644 index 0000000000000..754c10a7e9ff7 --- /dev/null +++ b/apps/web/src/components/chat/ThreadProgressStrip.tsx @@ -0,0 +1,105 @@ +import type { OrchestrationV2PlanStep, ScopedThreadRef } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronUpIcon, PanelRightIcon } from "lucide-react"; +import { useState } from "react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +import { cn } from "~/lib/utils"; +import { summarizeTodoSteps, type TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; +import { useChatCanvas } from "./ChatCanvasContext"; +import { ProgressRing } from "./ProgressRing"; +import { RING_TONE, TodoStepList, WAITING_NOTE } from "./ThreadProgressCard"; +import { showThreadProgressInDetails, useThreadProgressCardVisible } from "./threadProgressState"; + +function describeProgress(steps: ReadonlyArray, state: TodoRunState) { + if (state === "input" || state === "approval") return WAITING_NOTE[state]; + const { completed, total, current, position } = summarizeTodoSteps(steps); + if (state === "done") return "All steps done"; + if (state === "stopped") return `Stopped after ${completed} of ${total} steps`; + return current + ? `Step ${position} of ${total}: ${current}` + : `${completed} of ${total} steps done`; +} + +/** The agent's progress above the composer while the details card isn't showing it. */ +export function ThreadProgressStrip({ + threadRef, + steps, + state, +}: { + readonly threadRef: ScopedThreadRef; + readonly steps: ReadonlyArray | null; + readonly state: TodoRunState; +}) { + const cardVisible = useThreadProgressCardVisible(threadRef); + const inline = useChatCanvas()?.detailsCardPlacement != null; + const [expanded, setExpanded] = useState(false); + if (steps === null) return null; + // The composer stays mounted while the card and strip trade places, so one region serves both. + // Screen readers speak only when its text changes; the key keeps a thread switch silent. + const announcement = ( + + {describeProgress(steps, state)} + + ); + if (cardVisible) return <>{announcement}; + const { completed, total, current } = summarizeTodoSteps(steps); + // While a question or approval is open, its own panel owns the composer; the strip stays one line. + const blocked = state === "input" || state === "approval" ? state : null; + const showList = expanded && blocked === null; + const item: ComposerBannerStackItem = { + id: "thread-progress", + variant: "info", + icon: , + title: current ?? "All steps done", + description: blocked + ? `${completed}/${total} · ${WAITING_NOTE[blocked]}` + : `${completed}/${total}`, + actions: ( + <> + {blocked === null ? ( + + setExpanded((open) => !open)} + /> + } + > + {expanded ? : } + + {expanded ? "Hide steps" : "Show steps"} + + ) : null} + + showThreadProgressInDetails(threadRef, inline)} + /> + } + > + + + Show progress in details + + + ), + ...(showList ? { children: } : {}), + }; + return ( + <> + {announcement} + + + ); +} diff --git a/apps/web/src/components/chat/threadProgressState.ts b/apps/web/src/components/chat/threadProgressState.ts new file mode 100644 index 0000000000000..8a854006f673a --- /dev/null +++ b/apps/web/src/components/chat/threadProgressState.ts @@ -0,0 +1,42 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { create } from "zustand"; + +import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore"; +import { useChatCanvas } from "./ChatCanvasContext"; + +interface ThreadProgressStore { + readonly closedByThreadKey: Readonly>; + readonly setClosed: (threadRef: ScopedThreadRef, closed: boolean) => void; +} + +export const useThreadProgressStore = create()((set) => ({ + closedByThreadKey: {}, + setClosed: (threadRef, closed) => + set((state) => { + const key = scopedThreadKey(threadRef); + if ((state.closedByThreadKey[key] === true) === closed) return state; + const { [key]: _removed, ...rest } = state.closedByThreadKey; + return { closedByThreadKey: closed ? { ...rest, [key]: true } : rest }; + }), +})); + +/** + * The progress card hangs under the inline details card. In popover mode, or once the + * details card or the progress card is closed, the strip above the composer takes over. + */ +export function useThreadProgressCardVisible(threadRef: ScopedThreadRef): boolean { + const inline = useChatCanvas()?.detailsCardPlacement != null; + const detailsOpen = useRightPanelStore((state) => + selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), + ); + const closed = useThreadProgressStore( + (state) => state.closedByThreadKey[scopedThreadKey(threadRef)] === true, + ); + return inline && detailsOpen && !closed; +} + +export function showThreadProgressInDetails(threadRef: ScopedThreadRef, inline: boolean) { + useThreadProgressStore.getState().setClosed(threadRef, false); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, inline ? "inline" : "popover", true); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a111126342f4e..a2ec5499f964a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -105,6 +105,7 @@ import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "../ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { ProgressRing } from "../chat/ProgressRing"; import { getTriggerDisplayModelName } from "../chat/providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { useThreadRunningTerminalIds } from "../../state/terminalSessions"; @@ -526,7 +527,14 @@ const TOP_STATUS_ICONS = { woke: AlarmClockIcon, } as const; -function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: string }) { +function TopStatusIcon(props: { + icon: keyof typeof TOP_STATUS_ICONS; + className: string; + progress?: number | undefined; +}) { + if (props.progress !== undefined) { + return ; + } const Icon = TOP_STATUS_ICONS[props.icon]; return ; } @@ -720,10 +728,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, isSelected, }); + const todoProgress = + status === "working" && thread.goal?.status !== "active" ? (thread.todoProgress ?? null) : null; + const blockedProgress = + (status === "input" || status === "approval") && thread.todoProgress + ? ` ${thread.todoProgress.completed}/${thread.todoProgress.total}` + : ""; const topStatus = status === "working" ? { - label: thread.goal?.status === "active" ? "Goal" : "Working", + label: + thread.goal?.status === "active" + ? "Goal" + : todoProgress + ? `${todoProgress.completed}/${todoProgress.total}` + : "Working", + accessibleLabel: todoProgress + ? `Working, ${todoProgress.completed} of ${todoProgress.total} steps done` + : undefined, + progress: todoProgress ? todoProgress.completed / todoProgress.total : undefined, icon: "working" as const, className: "text-info-foreground", } @@ -735,13 +758,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } : status === "approval" ? { - label: "Approval", + label: `Approval${blockedProgress}`, icon: "approval" as const, className: "text-warning-foreground", } : status === "input" ? { - label: "Input", + label: `Input${blockedProgress}`, icon: "input" as const, className: "text-indigo-600 dark:text-indigo-300", } @@ -777,6 +800,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } : null; const isWokeStatus = topStatus?.icon === "woke"; + // The row being read swaps its branch line for the current step, so its height never changes. + const currentStepLine = + todoProgress?.current && props.isActive ? ( +
+ {todoProgress.current} +
+ ) : null; const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", @@ -997,7 +1027,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: topStatus?.accessibleLabel ?? topStatus?.label ?? null, projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -1131,55 +1161,61 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP diff === null && !isRemote && runningTerminalIds.length === 0; - const metaLine = metaLineEmpty ? null : ( -
- {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + + +{formatDiffCount(diff.insertions)} + {" "} + + −{formatDiffCount(diff.deletions)} + + + ) : null} + - {props.scratchMachineLabel} - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{formatDiffCount(diff.insertions)}{" "} - −{formatDiffCount(diff.deletions)} - - ) : null} - - {isRemote && !showsScratchMachine ? ( - - + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + - ) : null} - - -
- ); + + )); const titleLineProviderStack = metaLineEmpty ? ( - + {topStatus.label} ) : ( @@ -1482,7 +1522,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > - + {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341a..79aedd74dac0f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -28,7 +28,6 @@ import { deriveTimelineEntriesFromVisibleTurnItemsWithState, deriveRevertTurnCountByUserMessageId, derivePhase, - isLatestRunSettled, selectHandoffImageResources, selectMessageImageResources, createMessageAttachmentPreviewProjector, @@ -42,33 +41,6 @@ import { makeStreamingTimelineFixture, makeThreadProjectionFixture } from "./tes import type { ChatMessage } from "./types"; describe("V2 session presentation", () => { - it("uses run status as the settlement boundary", () => { - const runId = RunId.make("run-1"); - expect( - isLatestRunSettled( - { - runId, - status: "completed", - startedAt: "2026-06-20T00:00:00.000Z", - completedAt: "2026-06-20T00:01:00.000Z", - }, - null, - ), - ).toBe(true); - expect( - isLatestRunSettled( - { runId, status: "running", startedAt: null, completedAt: null }, - { status: "running", activeRunId: runId }, - ), - ).toBe(false); - expect( - isLatestRunSettled( - { runId, status: "queued", startedAt: null, completedAt: null }, - { status: "running", activeRunId: RunId.make("run-active") }, - ), - ).toBe(false); - }); - it("offers Stop while a run is preparing/starting, not just once it's running (#13392)", () => { const runtimeWithStatus = ( status: ThreadRuntimeSummary["status"], diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 37d7ad27be5a7..bef686f3c6088 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -37,6 +37,7 @@ import type { ThreadPendingUserInput, } from "@t3tools/client-runtime/state/thread-requests"; import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell"; +import { summarizeTodoSteps } from "@t3tools/client-runtime/state/todo-progress"; import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; @@ -199,22 +200,6 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea ); } -export function isLatestRunSettled( - latestRun: Pick | null, - runtime: Pick | null, -): boolean { - if (latestRun === null) return false; - if ( - latestRun.status === "preparing" || - latestRun.status === "queued" || - latestRun.status === "starting" || - latestRun.status === "running" || - latestRun.status === "waiting" - ) - return false; - return runtime?.activeRunId !== latestRun.runId; -} - export function deriveActiveWorkStartedAt( latestRun: Pick< ThreadRunSummary, @@ -516,13 +501,10 @@ function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry } case "todo_list": { // Each todo update is a quiet row where the agent wrote it; it opens to every step. - const done = item.steps.filter((step) => step.status === "completed").length; - const current = - item.steps.find((step) => step.status === "running") ?? - item.steps.find((step) => step.status === "pending"); + const { completed, total, current } = summarizeTodoSteps(item.steps); return { ...common, - label: `Tasks ${done}/${item.steps.length}${current ? ` · ${current.text}` : ""}`, + label: `Tasks ${completed}/${total}${current ? ` · ${current}` : ""}`, }; } case "approval_request": diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 66e79c7492ae3..b29df95181ea2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -315,6 +315,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/todo-progress": { + "types": "./src/state/todoProgress.ts", + "default": "./src/state/todoProgress.ts" + }, "./state/thread-inbox": { "types": "./src/state/threadInbox.ts", "default": "./src/state/threadInbox.ts" diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ac40471a853ba..bf5f713e0a569 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -6,6 +6,7 @@ import type { MessageId, OrchestrationProjectShell, OrchestrationV2ProviderGoal, + OrchestrationV2TodoProgress, OrchestrationV2RunStatus, OrchestrationV2ProviderFailureClass, OrchestrationV2ThreadProjection, @@ -167,6 +168,7 @@ export interface EnvironmentThreadShell { >; readonly providerInstanceHistory: ReadonlyArray; readonly goal: OrchestrationV2ProviderGoal | null; + readonly todoProgress?: OrchestrationV2TodoProgress | null; readonly itemCount: number; readonly visibleItemCount: number; readonly createdAt: string; @@ -324,6 +326,7 @@ export function presentThreadShell( pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], providerInstanceHistory: thread.providerInstanceHistory ?? [], goal: thread.goal ?? null, + todoProgress: thread.todoProgress ?? null, itemCount: thread.itemCount, visibleItemCount: thread.visibleItemCount, createdAt: iso(thread.createdAt), diff --git a/packages/client-runtime/src/state/todoProgress.test.ts b/packages/client-runtime/src/state/todoProgress.test.ts new file mode 100644 index 0000000000000..32958201aaac0 --- /dev/null +++ b/packages/client-runtime/src/state/todoProgress.test.ts @@ -0,0 +1,102 @@ +import { NodeId, PlanId, RunId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findLatestTodoSteps, + isLatestRunSettled, + resolveTodoRunState, + summarizeTodoSteps, +} from "./todoProgress.ts"; + +describe("todo progress", () => { + it("uses run status as the settlement boundary", () => { + const runId = RunId.make("run-1"); + expect( + isLatestRunSettled( + { + runId, + status: "completed", + startedAt: "2026-06-20T00:00:00.000Z", + completedAt: "2026-06-20T00:01:00.000Z", + }, + null, + ), + ).toBe(true); + expect( + isLatestRunSettled( + { runId, status: "running", startedAt: null, completedAt: null }, + { status: "running", activeRunId: runId }, + ), + ).toBe(false); + expect( + isLatestRunSettled( + { runId, status: "queued", startedAt: null, completedAt: null }, + { status: "running", activeRunId: RunId.make("run-active") }, + ), + ).toBe(false); + }); + + it("reads progress from the newest todo list of the latest run only", () => { + const now = DateTime.makeUnsafe("2026-09-04T00:00:00.000Z"); + const todoItem = (id: string, runId: string, ordinal: number, texts: ReadonlyArray) => + ({ + id: TurnItemId.make(id), + threadId: ThreadId.make("thread-todo"), + runId: RunId.make(runId), + nodeId: NodeId.make(`node-${id}`), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "todo_list" as const, + planId: PlanId.make(`plan-${id}`), + steps: texts.map((text, index) => ({ + id: `step-${index + 1}`, + text, + status: index === 0 ? ("completed" as const) : ("pending" as const), + })), + }) satisfies OrchestrationV2TurnItem; + const projection = { + turnItems: [ + todoItem("old-run", "run-old", 0, ["Old"]), + todoItem("first", "run-now", 1, ["A", "B"]), + todoItem("latest", "run-now", 2, ["A", "B", "C"]), + todoItem("empty", "run-empty", 3, []), + ], + }; + + expect(findLatestTodoSteps(projection, "run-now")?.map((step) => step.text)).toEqual([ + "A", + "B", + "C", + ]); + expect(findLatestTodoSteps(projection, "run-other")).toBeNull(); + expect(findLatestTodoSteps(projection, "run-empty")).toBeNull(); + expect(findLatestTodoSteps(projection, null)).toBeNull(); + expect(summarizeTodoSteps(findLatestTodoSteps(projection, "run-now") ?? [])).toEqual({ + completed: 1, + total: 3, + current: "B", + position: 2, + }); + }); + + it("tells a stopped task list from a finished or blocked one", () => { + const run = { completed: 1, total: 3, pendingApprovals: 0, pendingUserInputs: 0 }; + expect(resolveTodoRunState({ ...run, settled: false })).toBe("active"); + expect(resolveTodoRunState({ ...run, settled: false, pendingUserInputs: 1 })).toBe("input"); + expect( + resolveTodoRunState({ ...run, settled: false, pendingUserInputs: 1, pendingApprovals: 1 }), + ).toBe("approval"); + expect(resolveTodoRunState({ ...run, settled: true, pendingUserInputs: 1 })).toBe("stopped"); + expect(resolveTodoRunState({ ...run, settled: true, completed: 3 })).toBe("done"); + }); +}); diff --git a/packages/client-runtime/src/state/todoProgress.ts b/packages/client-runtime/src/state/todoProgress.ts new file mode 100644 index 0000000000000..06835a7c2bf98 --- /dev/null +++ b/packages/client-runtime/src/state/todoProgress.ts @@ -0,0 +1,62 @@ +import type { OrchestrationV2PlanStep, RunId } from "@t3tools/contracts"; + +import type { ThreadStructureTurnItem } from "./models.ts"; +import type { ThreadRunSummary, ThreadRuntimeSummary } from "./shell.ts"; + +export function isLatestRunSettled( + latestRun: Pick | null, + runtime: Pick | null, +): boolean { + if (latestRun === null) return false; + if ( + latestRun.status === "preparing" || + latestRun.status === "queued" || + latestRun.status === "starting" || + latestRun.status === "running" || + latestRun.status === "waiting" + ) + return false; + return runtime?.activeRunId !== latestRun.runId; +} + +export function summarizeTodoSteps(steps: ReadonlyArray) { + const current = + steps.find((step) => step.status === "running") ?? + steps.find((step) => step.status === "pending"); + return { + completed: steps.filter((step) => step.status === "completed").length, + total: steps.length, + current: current?.text, + position: current ? steps.indexOf(current) + 1 : null, + }; +} + +export type TodoRunState = "active" | "input" | "approval" | "done" | "stopped"; + +export function resolveTodoRunState(input: { + readonly settled: boolean; + readonly completed: number; + readonly total: number; + readonly pendingApprovals: number; + readonly pendingUserInputs: number; +}): TodoRunState { + if (input.settled) return input.completed === input.total ? "done" : "stopped"; + if (input.pendingApprovals > 0) return "approval"; + if (input.pendingUserInputs > 0) return "input"; + return "active"; +} + +/** The agent's newest task list in the latest run; an earlier run's list says nothing about now. */ +export function findLatestTodoSteps( + projection: { readonly turnItems: ReadonlyArray } | null, + latestRunId: RunId | string | null | undefined, +): ReadonlyArray | null { + if (projection === null || !latestRunId) return null; + for (let index = projection.turnItems.length - 1; index >= 0; index -= 1) { + const item = projection.turnItems[index]; + if (item?.type === "todo_list" && item.runId === latestRunId) { + return item.steps.length > 0 ? item.steps : null; + } + } + return null; +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 56d910b752deb..13fd68de7f88a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1296,6 +1296,15 @@ export const OrchestrationV2PlanStep = Schema.Struct({ }); export type OrchestrationV2PlanStep = typeof OrchestrationV2PlanStep.Type; +/** The agent's latest task list boiled down to what a sidebar row shows. */ +export const OrchestrationV2TodoProgress = Schema.Struct({ + completed: NonNegativeInt, + total: PositiveInt, + /** The step in progress, or the next pending one. Absent once every step is done. */ + current: Schema.optional(TrimmedNonEmptyString), +}); +export type OrchestrationV2TodoProgress = typeof OrchestrationV2TodoProgress.Type; + export const OrchestrationV2UserInputQuestion = Schema.Struct({ id: TrimmedNonEmptyString, header: TrimmedNonEmptyString, @@ -2069,6 +2078,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)), + /** The latest run's task list, when the agent wrote one. Absent from older servers. */ + todoProgress: Schema.optional(Schema.NullOr(OrchestrationV2TodoProgress)), itemCount: NonNegativeInt, visibleItemCount: NonNegativeInt, createdAt: Schema.DateTimeUtc, From 2d81031aef199667eb7dd69d8785af9bb208aab3 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 18:49:10 +0200 Subject: [PATCH 2/7] fix(web): preserve upstream sidebar controls with task progress Integrated by GPT-6.1 Sol through the Codex harness in T3 Code. --- .../components/sidebar/SidebarThreadRow.tsx | 102 +++++++++--------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a2ec5499f964a..1789149c6178f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1161,61 +1161,55 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP diff === null && !isRemote && runningTerminalIds.length === 0; - const metaLine = metaLineEmpty - ? null - : (currentStepLine ?? ( -
- {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - {props.scratchMachineLabel} - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + - {isRemote && !showsScratchMachine ? ( - - - - ) : null} - + {props.scratchMachineLabel} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{formatDiffCount(diff.insertions)}{" "} + −{formatDiffCount(diff.deletions)} + + ) : null} + + {isRemote && !showsScratchMachine ? ( + + -
- )); + ) : null} + + + + ); const titleLineProviderStack = metaLineEmpty ? ( - {multiline ? metaLine : null} + {multiline && !metaLineEmpty ? (currentStepLine ?? metaLine) : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1615,7 +1609,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} {titleLineProviderStack} - {metaLine} + {metaLineEmpty ? null : (currentStepLine ?? metaLine)} {props.jumpLabel ? : null} From 77ea3c43b8c1007a9bd3a65129be87d0b30013e8 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 21:27:22 +0200 Subject: [PATCH 3/7] fix: address task progress review findings Keep sidebar controls with the current step, show progress in popover details, clear the mobile sheet state, and scope shell progress to the presented run. Written by GPT-6.1 Sol through the Codex harness in T3 Code. --- .../src/features/threads/ThreadDetailScreen.tsx | 1 + .../orchestration-v2/ProjectionStore.test.ts | 7 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 1 + .../src/components/chat/ThreadDetailsCard.tsx | 7 ++++++- .../chat/ThreadProgressStrip.browser.test.tsx | 16 ++++++++++++++++ .../src/components/chat/threadProgressState.ts | 12 ++++++------ .../src/components/sidebar/SidebarThreadRow.tsx | 17 +++++++---------- 7 files changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 060e293c6671e..3c09ff9be9129 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -623,6 +623,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { hasPendingApproval: props.activePendingApproval !== null, hasPendingUserInput: props.activePendingUserInput !== null, }); + if (threadProgress === null && progressSheetOpen) setProgressSheetOpen(false); const progressStatus = ((): FloatingWorkingStatus | null => { if (threadProgress === null || contentPresentationKind !== "ready") return null; const progress = presentThreadProgress(threadProgress.steps, threadProgress.state); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 7e06dbaa6b7be..6df0edc0ebf8c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -447,6 +447,7 @@ it.effect("shells carry only the latest run's todo list progress", () => text: string; status: "pending" | "running" | "completed"; }>, + updatedAt = at, ) => store.apply({ id: EventId.make(`event:todo-progress:item:${sequence++}`), @@ -457,6 +458,7 @@ it.effect("shells carry only the latest run's todo list progress", () => occurredAt: at, payload: { ...itemBase, + updatedAt, id: TurnItemId.make(`item:todo-progress:${runId}`), runId, type: "todo_list", @@ -483,6 +485,11 @@ it.effect("shells carry only the latest run's todo list progress", () => { id: "b", text: "Write the fix", status: "running" }, { id: "c", text: "Run the tests", status: "pending" }, ]); + yield* writeTodoList( + initial.runs[0]!.id, + [{ id: "old", text: "Late update from the old run", status: "running" }], + DateTime.add(at, { seconds: 1 }), + ); const midway = { completed: 1, total: 3, current: "Write the fix" }; assert.deepEqual(yield* progressOf(), [midway, midway, midway]); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 646c3ea3cdc7a..b3f43e7d6d036 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5903,6 +5903,7 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_turn_items todo INDEXED BY orchestration_v2_projection_turn_items_todo_list_idx WHERE todo.thread_id = t.thread_id AND todo.type = 'todo_list' + AND todo.run_id = presented.run_id ORDER BY todo.updated_at DESC, todo.turn_item_id DESC LIMIT 1 ) END AS latest_todo_list_payload_json, diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index fa75f2c91e7be..67fc4a69f28f4 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -181,8 +181,13 @@ export function ThreadDetailsCard({ padding="none" > {mode === "popover" ? ( -
+
{card} + {footer}
) : null} diff --git a/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx b/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx index 57f7717e27d36..5831c4e76b4dd 100644 --- a/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx @@ -119,3 +119,19 @@ it("takes over when the details card is closed", async () => { await expect.element(screen.getByRole("region", { name: "Progress" })).not.toBeInTheDocument(); await expect.element(screen.getByText("Write the fix")).toBeVisible(); }); + +it("opens progress from the strip when details use a popover", async () => { + const threadRef = setup(); + await using screen = await render( + <> + + + , + { router: false }, // no routing involved + ); + + await expect.element(screen.getByRole("region", { name: "Progress" })).not.toBeInTheDocument(); + await screen.getByRole("button", { name: "Show progress in details" }).click(); + await expect.element(screen.getByRole("region", { name: "Progress" })).toBeVisible(); + await expect.element(screen.getByText("Run the tests")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/threadProgressState.ts b/apps/web/src/components/chat/threadProgressState.ts index 8a854006f673a..1f50dbe3819cf 100644 --- a/apps/web/src/components/chat/threadProgressState.ts +++ b/apps/web/src/components/chat/threadProgressState.ts @@ -21,19 +21,19 @@ export const useThreadProgressStore = create()((set) => ({ }), })); -/** - * The progress card hangs under the inline details card. In popover mode, or once the - * details card or the progress card is closed, the strip above the composer takes over. - */ export function useThreadProgressCardVisible(threadRef: ScopedThreadRef): boolean { const inline = useChatCanvas()?.detailsCardPlacement != null; const detailsOpen = useRightPanelStore((state) => - selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), + selectThreadPanelOpen( + state.threadPanelVisibilityByThreadKey, + threadRef, + inline ? "inline" : "popover", + ), ); const closed = useThreadProgressStore( (state) => state.closedByThreadKey[scopedThreadKey(threadRef)] === true, ); - return inline && detailsOpen && !closed; + return detailsOpen && !closed; } export function showThreadProgressInDetails(threadRef: ScopedThreadRef, inline: boolean) { diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 1789149c6178f..f86c51d2a0406 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -800,13 +800,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } : null; const isWokeStatus = topStatus?.icon === "woke"; - // The row being read swaps its branch line for the current step, so its height never changes. - const currentStepLine = - todoProgress?.current && props.isActive ? ( -
- {todoProgress.current} -
- ) : null; + const currentStep = props.isActive ? todoProgress?.current : undefined; const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", @@ -1155,6 +1149,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const diff = latestRunDiff(thread); // Only the provider icon would remain, so the row drops the line instead of leaving it blank. const metaLineEmpty = + !currentStep && !thread.branch && !showsScratchMachine && prBadge === null && @@ -1163,7 +1158,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP runningTerminalIds.length === 0; const metaLine = metaLineEmpty ? null : (
- {thread.branch ? ( + {currentStep ? ( + {currentStep} + ) : thread.branch ? ( <> @@ -1405,7 +1402,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {props.sweepAction !== null ? dragDestination : null}
- {multiline && !metaLineEmpty ? (currentStepLine ?? metaLine) : null} + {multiline ? metaLine : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1609,7 +1606,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} {titleLineProviderStack}
- {metaLineEmpty ? null : (currentStepLine ?? metaLine)} + {metaLine} {props.jumpLabel ? : null} From 557e85fd5271c64655d4d2302b812d51427e927c Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 21:54:33 +0200 Subject: [PATCH 4/7] fix: retain incomplete task progress after a run stops Preserve stopped progress in the web composer and mobile progress control while keeping completed mobile progress hidden. Written by GPT-6.1 Sol through the Codex harness in T3 Code. --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 3 ++- apps/mobile/src/features/threads/use-thread-progress.ts | 7 +++---- apps/web/src/components/ChatView.tsx | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3c09ff9be9129..0fb2fcf3c3053 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -628,7 +628,8 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { if (threadProgress === null || contentPresentationKind !== "ready") return null; const progress = presentThreadProgress(threadProgress.steps, threadProgress.state); const blocked = progress.note !== null; - if (props.activeWorkStartedAt === null && !blocked) return null; + if (props.activeWorkStartedAt === null && !blocked && threadProgress.state !== "stopped") + return null; return { kind: "progress", startedAt: props.activeWorkStartedAt, diff --git a/apps/mobile/src/features/threads/use-thread-progress.ts b/apps/mobile/src/features/threads/use-thread-progress.ts index 0af5598c36004..75aa514ddc102 100644 --- a/apps/mobile/src/features/threads/use-thread-progress.ts +++ b/apps/mobile/src/features/threads/use-thread-progress.ts @@ -9,7 +9,6 @@ import type { OrchestrationV2PlanStep, ScopedThreadRef } from "@t3tools/contract import { useQuietThreadStructure } from "../../state/use-thread-detail"; -/** The latest run's task list while the run is live; null when there is nothing to show. */ export function useThreadProgress(input: { readonly threadRef: ScopedThreadRef | null; readonly thread: Pick; @@ -21,16 +20,16 @@ export function useThreadProgress(input: { structure === null ? null : structure.projection, input.thread.latestRun?.runId ?? null, ); - if (steps === null || isLatestRunSettled(input.thread.latestRun, input.thread.runtime)) { + if (steps === null) { return null; } const { completed, total } = summarizeTodoSteps(steps); const state = resolveTodoRunState({ - settled: false, + settled: isLatestRunSettled(input.thread.latestRun, input.thread.runtime), completed, total, pendingApprovals: input.hasPendingApproval ? 1 : 0, pendingUserInputs: input.hasPendingUserInput ? 1 : 0, }); - return { steps, state }; + return state === "done" ? null : { steps, state }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8681d1994bfe0..677aa0ba02630 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3445,7 +3445,9 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={composerBannerItems} - todoSteps={latestRunSettled ? null : todoSteps} + todoSteps={ + latestRunSettled && todoState !== "stopped" ? null : todoSteps + } todoState={todoState} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} From 767633365a2dc2fb83cb2b36f60bae33f4d34b05 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 22:05:04 +0200 Subject: [PATCH 5/7] fix(mobile): keep actions available with stopped progress Treat stopped progress as a fallback status and label it in the pill and accessibility description. Written by GPT-6.1 Sol through the Codex harness in T3 Code. --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 6 +++--- .../features/threads/thread-progress-presentation.test.ts | 7 ++++++- .../src/features/threads/thread-progress-presentation.ts | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0fb2fcf3c3053..0b129a14e2ecb 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -635,7 +635,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { startedAt: props.activeWorkStartedAt, title: progress.title, count: progress.count, - waitingNote: blocked ? "Waiting" : null, + waitingNote: blocked ? "Waiting" : threadProgress.state === "stopped" ? "Stopped" : null, fraction: progress.fraction, tone: progress.tone, accessibilityLabel: `${progress.title}, ${progress.description}. Shows every step`, @@ -683,7 +683,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } - if (progressStatus !== null) return progressStatus; + if (progressStatus !== null && threadProgress?.state !== "stopped") return progressStatus; if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } @@ -745,7 +745,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { accessibilityLabel: `${goal.title}: ${goal.objective}`, }; } - return null; + return progressStatus; })(); const showWorkingControl = floatingStatus !== null; const showFloatingStatus = diff --git a/apps/mobile/src/features/threads/thread-progress-presentation.test.ts b/apps/mobile/src/features/threads/thread-progress-presentation.test.ts index 7e6a6b43ac8ed..4b3b161e82200 100644 --- a/apps/mobile/src/features/threads/thread-progress-presentation.test.ts +++ b/apps/mobile/src/features/threads/thread-progress-presentation.test.ts @@ -27,7 +27,12 @@ describe("thread progress presentation", () => { expect(presentThreadProgress(steps, "approval").description).toBe( "1/3 · Waiting for your approval", ); - expect(presentThreadProgress(steps, "stopped").summary).toBe("Stopped · 1 of 3"); + expect(presentThreadProgress(steps, "stopped")).toMatchObject({ + title: "Write the fix", + count: "1/3", + description: "1/3 · Stopped", + summary: "Stopped · 1 of 3", + }); expect( presentThreadProgress(steps, "active").segments?.map((segment) => segment.status), ).toEqual(["completed", "running", "pending"]); diff --git a/apps/mobile/src/features/threads/thread-progress-presentation.ts b/apps/mobile/src/features/threads/thread-progress-presentation.ts index 08d7d7f2e3c18..fe421e3c42a79 100644 --- a/apps/mobile/src/features/threads/thread-progress-presentation.ts +++ b/apps/mobile/src/features/threads/thread-progress-presentation.ts @@ -30,7 +30,8 @@ export function presentThreadProgress( title: current ?? "All steps done", count, note, - description: note === null ? count : `${count} · ${note}`, + description: + state === "stopped" ? `${count} · Stopped` : note === null ? count : `${count} · ${note}`, tone: RING_TONE[state], summary: `${state === "stopped" ? "Stopped · " : ""}${completed} of ${total}`, segments: From aed7506052d83c1d433f3d0b4bfb77b7540182d5 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 22:19:16 +0200 Subject: [PATCH 6/7] fix(mobile): keep stopped task details reachable beside status actions Add a compact task-progress opener when another floating status takes priority, preserving its label and actions. Written by GPT-6.1 Sol through the Codex harness in T3 Code. --- .../features/threads/ThreadDetailScreen.tsx | 8 +++++ .../threads/floating-working-control.tsx | 31 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0b129a14e2ecb..8167bf873f03f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1428,6 +1428,14 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { 0 diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index d56313f608e5d..ab892464ce283 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -70,6 +70,11 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTRO export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly status: FloatingWorkingStatus | null; + readonly progress: { + readonly fraction: number; + readonly accessibilityLabel: string; + readonly onPress: () => void; + } | null; readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null; readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null; readonly showScrollToEnd: boolean; @@ -99,6 +104,7 @@ export function FloatingWorkingControl(props: { Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 32 - + (props.progress !== null ? 44 : 0) - (action !== null ? actionWidth : 0) - (hasQueue ? queueWidth : 0) - (hasAgents ? agentsWidth : 0) - @@ -135,7 +141,7 @@ export function FloatingWorkingControl(props: { capsuleWidth.set(first ? width : withTiming(width, CONTROL_TIMING)); }; const hasStatus = props.status !== null; - const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview; + const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview || props.progress !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the Reanimated capsule width value and measured width when the status goes away useEffect(() => { if (!hasStatus) { @@ -165,7 +171,12 @@ export function FloatingWorkingControl(props: { props.status?.kind === "progress" || props.status?.kind === "background"; const capsuleInteractive = - statusInteractive || action !== null || hasQueue || hasAgents || hasPreview; + statusInteractive || + action !== null || + hasQueue || + hasAgents || + hasPreview || + props.progress !== null; const statusContent = props.status !== null ? ( {statusContent} + {props.progress !== null ? ( + + {hasStatus ? : null} + + + ) : null} {action !== null ? ( setActionWidth(settleWidth(event.nativeEvent.layout.width))} From bef318ce59aa1236c64ee54875a9c21d2921aeba Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Fri, 30 Oct 2026 12:34:58 +0200 Subject: [PATCH 7/7] feat(web): put task progress behind Labs and the thread details menu Task progress is now a Labs toggle (off by default) that gates the composer strip, the thread details card, and the sidebar ring. The thread details menu gains a Task progress item, listed only while the Labs toggle is on; unchecking it keeps progress above the composer. The strip's show-in-details button turns the item back on. Opus 5.5 via Claude Code (T3 Code) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 5 ++++- .../chat/ThreadDetailsItemsMenu.tsx | 5 ++++- .../src/components/chat/threadDetailsItems.ts | 1 + .../components/chat/threadProgressState.ts | 21 ++++++++++++++++++- .../components/settings/LabsSettingsPanel.tsx | 19 +++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++++++ .../components/sidebar/SidebarThreadRow.tsx | 10 ++++++--- docs/user/thread-sidebar.md | 6 ++++++ packages/contracts/src/settings.ts | 3 +++ 10 files changed, 71 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ec5062f9105bf..41ead1aff6329 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + taskProgressEnabled: false, threadDetailsUsageEnabled: false, threadDetailsItems: {}, legacySidebarEnabled: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c4c396922138..42ae770615f4f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1357,7 +1357,10 @@ export default function ChatView(props: ChatViewProps) { } return findLatestProposedPlan(serverProjection, activeLatestRun?.runId ?? null); })(); - const todoSteps = findLatestTodoSteps(serverProjection, activeLatestRun?.runId ?? null); + const taskProgressEnabled = useClientSettings((settings) => settings.taskProgressEnabled); + const todoSteps = taskProgressEnabled + ? findLatestTodoSteps(serverProjection, activeLatestRun?.runId ?? null) + : null; const todoState = resolveTodoRunState({ ...(todoSteps ? summarizeTodoSteps(todoSteps) : { completed: 0, total: 0 }), settled: latestRunSettled, diff --git a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx index d84c13809eaf6..f315e8623a4dd 100644 --- a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx +++ b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx @@ -15,11 +15,14 @@ import { /** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */ export function ThreadDetailsItemsMenuItems() { const visibility = useClientSettings(resolveThreadDetailsVisibility); + const taskProgressEnabled = useClientSettings((settings) => settings.taskProgressEnabled); return ( <> Show in thread details - {THREAD_DETAILS_ITEMS.map((item) => ( + {THREAD_DETAILS_ITEMS.filter( + (item) => item.id !== "taskProgress" || taskProgressEnabled, + ).map((item) => ( >; @@ -33,10 +43,19 @@ export function useThreadProgressCardVisible(threadRef: ScopedThreadRef): boolea const closed = useThreadProgressStore( (state) => state.closedByThreadKey[scopedThreadKey(threadRef)] === true, ); - return detailsOpen && !closed; + const shownInDetails = useClientSettings( + (settings) => resolveThreadDetailsVisibility(settings).taskProgress, + ); + return detailsOpen && shownInDetails && !closed; } export function showThreadProgressInDetails(threadRef: ScopedThreadRef, inline: boolean) { + const settings = getClientSettings(); + if (!resolveThreadDetailsVisibility(settings).taskProgress) { + const item = THREAD_DETAILS_ITEMS.find((candidate) => candidate.id === "taskProgress"); + if (item) + updateClientSettings({ threadDetailsItems: setThreadDetailsItem(settings, item, true) }); + } useThreadProgressStore.getState().setClosed(threadRef, false); useRightPanelStore.getState().setThreadPanelOpen(threadRef, inline ? "inline" : "popover", true); } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index ce3f68eac49d0..f257644fc8dd6 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -18,6 +18,7 @@ export function LabsSettingsPanel() { + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -69,6 +70,24 @@ function BoringSidebarMultilineRow() { ); } +function TaskProgressRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + updateSettings({ taskProgressEnabled: Boolean(checked) })} + aria-label="Task progress" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aabfd0300eda5..c873c45e35541 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -455,6 +455,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], }, + { + id: "task-progress", + title: "Task progress", + to: "/settings/labs", + searchTerms: ["beta todo plan steps checklist agent progress ring"], + }, { id: "guest-links", title: "Guest links", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index f86c51d2a0406..0363605cbe4de 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -4,6 +4,7 @@ import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { useDeadlinePassed } from "~/hooks/useDeadline"; +import { useClientSettings } from "~/hooks/useSettings"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -728,11 +729,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, isSelected, }); + const taskProgress = useClientSettings((settings) => + settings.taskProgressEnabled ? (thread.todoProgress ?? null) : null, + ); const todoProgress = - status === "working" && thread.goal?.status !== "active" ? (thread.todoProgress ?? null) : null; + status === "working" && thread.goal?.status !== "active" ? taskProgress : null; const blockedProgress = - (status === "input" || status === "approval") && thread.todoProgress - ? ` ${thread.todoProgress.completed}/${thread.todoProgress.total}` + (status === "input" || status === "approval") && taskProgress + ? ` ${taskProgress.completed}/${taskProgress.total}` : ""; const topStatus = status === "working" diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fb5e12b08372b..48e64feaf0407 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -282,6 +282,12 @@ To choose what the thread details card shows, right-click the card or open shortcuts, such as Open in editor and project scripts. **Reset to default** brings back the standard rows. +To follow the agent's task list while it works, turn on **Settings → Labs → Task +progress** on web and desktop. The current step shows above the composer, under +the thread details card when it is open, and as a ring beside working threads in +the sidebar. Uncheck **Task progress** in the thread details menu to keep it above +the composer only. + ## Snooze until later To change the choices in the snooze menu, open **Settings → Threads → Snooze options**. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 643d27296f34b..454386484cacb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -478,6 +478,8 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + // Labs: the agent's task list in the composer, thread details, and sidebar. + taskProgressEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Retired Labs toggle. Still read as the default for the Usage item in threadDetailsItems. threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Sparse show/hide overrides for thread details card items. Missing ids use the client's @@ -1773,6 +1775,7 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + taskProgressEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),