From f99259f085e7548ab3503e536c5d4d60b943afaa Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Wed, 28 Oct 2026 16:51:17 +0200 Subject: [PATCH] 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 | 154 ++++++++++------- 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(+), 113 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 5bca01a57fe99..96efbe22351e3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -403,6 +403,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 1e9176d1bed69..8a950af48d572 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -928,6 +928,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; @@ -1503,6 +1504,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, @@ -1553,6 +1559,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, @@ -1572,6 +1608,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( @@ -1605,6 +1647,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"]; @@ -1769,6 +1812,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, @@ -5708,6 +5752,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 @@ -6312,6 +6364,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 6af016c18ce91..15280a72970ad 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"; @@ -1323,6 +1328,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, @@ -3486,6 +3498,8 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={composerBannerItems} + todoSteps={latestRunSettled ? null : todoSteps} + todoState={todoState} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3700,6 +3714,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 7f7705573673c..706fffc7a12eb 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 57a3a8aa41909..d225dc29c6fc9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,6 +18,7 @@ import type { ChatFileAttachment, EnvironmentId, ModelSelection, + OrchestrationV2PlanStep, ProjectId, PreviewAnnotationPayload, ThreadContextRecord, @@ -112,6 +113,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, @@ -255,6 +257,7 @@ import type { PendingApproval, PendingUserInput, } from "../../session-logic"; +import type { TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; import { resolveComposerDispatchMode, type ComposerDispatchMode, @@ -388,6 +391,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; @@ -3438,6 +3444,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null} + {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 eac7c8591fa1c..b7e6bfdba27de 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 ; } @@ -719,10 +727,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", } @@ -734,13 +757,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", } @@ -776,6 +799,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", @@ -996,7 +1026,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, }); @@ -1228,7 +1258,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > - + {topStatus.label} ) : ( @@ -1415,7 +1449,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. */} @@ -1511,59 +1549,61 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} - {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} - - -
- )} + + ))} {props.jumpLabel ? : null} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de213de2f3c1c..f766d0547ecb4 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 362d9fb9451a4..ff939f4d07506 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 993fb62453d59..e126c4a1d4bda 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, @@ -2061,6 +2070,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,