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