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/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 6695e7963eff0..cc30712aad035 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -195,6 +195,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"; @@ -659,6 +662,36 @@ 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, + }); + if (threadProgress === null && progressSheetOpen) setProgressSheetOpen(false); + 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 && threadProgress.state !== "stopped") + return null; + return { + kind: "progress", + startedAt: props.activeWorkStartedAt, + title: progress.title, + count: progress.count, + waitingNote: blocked ? "Waiting" : threadProgress.state === "stopped" ? "Stopped" : 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, @@ -726,6 +759,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } + if (progressStatus !== null && threadProgress?.state !== "stopped") return progressStatus; if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } @@ -787,7 +821,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { accessibilityLabel: `${goal.title}: ${goal.objective}`, }; } - return null; + return progressStatus; })(); const showWorkingControl = floatingStatus !== null; const showFloatingStatus = @@ -1459,9 +1493,25 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { className="w-full" > + {threadProgress !== null ? ( + setProgressSheetOpen(false)} + steps={threadProgress.steps} + state={threadProgress.state} + /> + ) : null} 0 diff --git a/apps/mobile/src/features/threads/ThreadProgressSheet.tsx b/apps/mobile/src/features/threads/ThreadProgressSheet.tsx new file mode 100644 index 0000000000000..c90264fb6fafe --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadProgressSheet.tsx @@ -0,0 +1,158 @@ +import type { OrchestrationV2PlanStep } from "@t3tools/contracts"; +import type { TodoRunState } from "@t3tools/client-runtime/state/todo-progress"; +import BottomSheet, { BottomSheetView } from "@expo/ui/community/bottom-sheet"; +import { ScrollView, StyleSheet, useWindowDimensions, View } from "react-native"; +import { useResolveClassNames } from "uniwind"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { cn } from "../../lib/cn"; +import { + presentThreadProgress, + presentThreadProgressStep, + THREAD_PROGRESS_TONE_CLASSES, + type ThreadProgressTone, +} from "./thread-progress-presentation"; + +const SEGMENT_TRACK_CLASSES = { + active: "bg-adaptive-sky-600-400/20", + waiting: "bg-warning-foreground/20", + done: "bg-adaptive-emerald-700-300/20", + muted: "bg-foreground-muted/20", +} as const satisfies Record; + +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 c1440535e9cad..a71b4dc496526 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"; @@ -68,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 +106,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 +143,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) { @@ -162,9 +170,15 @@ 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; + 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))} @@ -512,6 +544,40 @@ function FloatingStatusLabel(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 2892b4aafa51b..407e4a465d141 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 5b0c1fd2f5284..64d70e2f36e4e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -76,6 +76,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"; @@ -691,9 +692,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; @@ -1139,7 +1145,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..4b3b161e82200 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-progress-presentation.test.ts @@ -0,0 +1,88 @@ +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")).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"]); + 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..fe421e3c42a79 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-progress-presentation.ts @@ -0,0 +1,111 @@ +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: + state === "stopped" ? `${count} · Stopped` : 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..75aa514ddc102 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-progress.ts @@ -0,0 +1,35 @@ +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"; + +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) { + return null; + } + const { completed, total } = summarizeTodoSteps(steps); + const state = resolveTodoRunState({ + settled: isLatestRunSettled(input.thread.latestRun, input.thread.runtime), + completed, + total, + pendingApprovals: input.hasPendingApproval ? 1 : 0, + pendingUserInputs: input.hasPendingUserInput ? 1 : 0, + }); + return state === "done" ? null : { steps, state }; +} diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1dc7c912e7765..da425603d99e5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -433,6 +433,113 @@ 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"; + }>, + updatedAt = at, + ) => + store.apply({ + id: EventId.make(`event:todo-progress:item:${sequence++}`), + type: "turn-item.updated", + threadId, + runId, + driver, + occurredAt: at, + payload: { + ...itemBase, + updatedAt, + 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" }, + ]); + 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]); + + 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 88a9073117dd6..fe968e97dab68 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -961,6 +961,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; @@ -1536,6 +1537,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, @@ -1586,6 +1592,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, @@ -1605,6 +1641,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( @@ -1638,6 +1680,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"]; @@ -1802,6 +1845,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, @@ -5884,6 +5928,15 @@ 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' + 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, ( SELECT message.updated_at FROM orchestration_v2_projection_messages message @@ -6488,6 +6541,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 8143a86b15034..ea5ec2922f31e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -76,6 +76,7 @@ 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_ActiveTurnItemIndexes.ts"; +import Migration0074 from "./Migrations/074_ThreadTodoListIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -152,6 +153,7 @@ const migrationEntries = [ [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], [73, "ActiveTurnItemIndexes", Migration0073], + [74, "ThreadTodoListIndex", Migration0074], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/074_ThreadTodoListIndex.ts b/apps/server/src/persistence/Migrations/074_ThreadTodoListIndex.ts new file mode 100644 index 0000000000000..1c2ee0c309799 --- /dev/null +++ b/apps/server/src/persistence/Migrations/074_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 c08062c09d1ad..7853aa5aa634b 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -53,6 +53,7 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "ThreadTodoListIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -149,6 +150,7 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "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 cf48ddcb842dd..42ae770615f4f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -136,8 +136,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"; @@ -1352,6 +1357,16 @@ export default function ChatView(props: ChatViewProps) { } return findLatestProposedPlan(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, + pendingApprovals: pendingApprovals.length, + pendingUserInputs: pendingUserInputs.length, + }); const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({ pendingUserInputCount: pendingUserInputs.length, interactionMode, @@ -3526,6 +3541,10 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={isDraftHeroState ? [] : composerBannerItems} + todoSteps={ + latestRunSettled && todoState !== "stopped" ? null : todoSteps + } + todoState={todoState} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3734,6 +3753,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 d7878cc29a165..a637e095eef85 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 a5df136f14615..414257443564e 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -23,6 +23,7 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + footer, contentKey, onContextMenu, children, @@ -30,6 +31,8 @@ export function ThreadDetailsCard({ threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** A card under the details card, only while the card sits inline. */ + footer?: ReactNode; /** Changes when the set of rendered items changes, so cached heights are measured again. */ contentKey: string; onContextMenu?: MouseEventHandler; @@ -152,7 +155,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={{ @@ -165,6 +168,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 @@ -189,8 +193,13 @@ export function ThreadDetailsCard({ padding="none" > {mode === "popover" ? ( -
+
{card} + {footer}
) : null} 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) => ( | null; + todoState: TodoRunState; draftId?: DraftId; activeProjectName: string | undefined; activeProjectScripts: ReadonlyArray | undefined; @@ -121,6 +126,13 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + footer={ + + } contentKey={THREAD_DETAILS_ITEMS.filter((item) => visible[item.id]) .map((item) => item.id) .join(",")} 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..5831c4e76b4dd --- /dev/null +++ b/apps/web/src/components/chat/ThreadProgressStrip.browser.test.tsx @@ -0,0 +1,137 @@ +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(); +}); + +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/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/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts index 7502a4c0b1dce..c948845f4be71 100644 --- a/apps/web/src/components/chat/threadDetailsItems.ts +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -12,6 +12,7 @@ export const THREAD_DETAILS_ITEMS = [ { id: "automations", label: "Automations", defaultVisible: true }, { id: "lineage", label: "Lineage", defaultVisible: true }, { id: "usage", label: "Usage", defaultVisible: false }, + { id: "taskProgress", label: "Task progress", defaultVisible: true }, ] as const; export type ThreadDetailsItemId = (typeof THREAD_DETAILS_ITEMS)[number]["id"]; diff --git a/apps/web/src/components/chat/threadProgressState.ts b/apps/web/src/components/chat/threadProgressState.ts new file mode 100644 index 0000000000000..c351b70af6dca --- /dev/null +++ b/apps/web/src/components/chat/threadProgressState.ts @@ -0,0 +1,61 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { create } from "zustand"; + +import { + getClientSettings, + updateClientSettings, + useClientSettings, +} from "../../hooks/useSettings"; +import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore"; +import { useChatCanvas } from "./ChatCanvasContext"; +import { + resolveThreadDetailsVisibility, + setThreadDetailsItem, + THREAD_DETAILS_ITEMS, +} from "./threadDetailsItems"; + +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 }; + }), +})); + +export function useThreadProgressCardVisible(threadRef: ScopedThreadRef): boolean { + const inline = useChatCanvas()?.detailsCardPlacement != null; + const detailsOpen = useRightPanelStore((state) => + selectThreadPanelOpen( + state.threadPanelVisibilityByThreadKey, + threadRef, + inline ? "inline" : "popover", + ), + ); + const closed = useThreadProgressStore( + (state) => state.closedByThreadKey[scopedThreadKey(threadRef)] === true, + ); + 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 a111126342f4e..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"; @@ -105,6 +106,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 +528,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 +729,28 @@ 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" ? taskProgress : null; + const blockedProgress = + (status === "input" || status === "approval") && taskProgress + ? ` ${taskProgress.completed}/${taskProgress.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 +762,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 +804,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } : null; const isWokeStatus = topStatus?.icon === "woke"; + const currentStep = props.isActive ? todoProgress?.current : undefined; const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", @@ -997,7 +1025,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, }); @@ -1125,6 +1153,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 && @@ -1133,7 +1162,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP runningTerminalIds.length === 0; const metaLine = metaLineEmpty ? null : (
- {thread.branch ? ( + {currentStep ? ( + {currentStep} + ) : thread.branch ? ( <> @@ -1303,7 +1334,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > - + {topStatus.label} ) : ( @@ -1482,7 +1517,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 df3eb85c3db38..3f30b8656768c 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 6cf2761d3d6d3..7086a8d39b39b 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/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/client-runtime/package.json b/packages/client-runtime/package.json index 9600ad7ad356d..7e0d1abac6efb 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -319,6 +319,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 d20da5780255b..3bf756ce93690 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1298,6 +1298,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, @@ -2083,6 +2092,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, 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),