()((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,