diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..bc87f08ba294 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -13,6 +13,7 @@ import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, + buildBackgroundWorkBannerCopy, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, @@ -308,6 +309,79 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("buildBackgroundWorkBannerCopy", () => { + const watch = (label: string) => ({ kind: "watch" as const, label }); + const agent = (label: string) => ({ kind: "agent" as const, label }); + + it("names the watch loops it can see", () => { + expect( + buildBackgroundWorkBannerCopy({ + liveness: "monitoring", + liveAgentCount: 0, + tasks: [watch("Watch CI on PR #18")], + }), + ).toEqual({ + title: "1 watch loop running in the background", + description: "Watch CI on PR #18", + }); + }); + + it("counts the work that does not fit on the line", () => { + expect( + buildBackgroundWorkBannerCopy({ + liveness: "monitoring", + liveAgentCount: 0, + tasks: [watch("Watch CI"), watch("Tail logs"), watch("Poll deploy")], + }), + ).toEqual({ + title: "3 watch loops running in the background", + description: "Watch CI, Tail logs, and 1 more", + }); + }); + + it("keeps the old copy when retention left no task detail", () => { + expect( + buildBackgroundWorkBannerCopy({ liveness: "monitoring", liveAgentCount: 0, tasks: [] }), + ).toEqual({ title: "Monitoring in the background", description: null }); + expect( + buildBackgroundWorkBannerCopy({ liveness: "working", liveAgentCount: 0, tasks: [] }), + ).toEqual({ title: "Background work running", description: null }); + }); + + it("prefers the agent roster's count while agents are working", () => { + expect( + buildBackgroundWorkBannerCopy({ + liveness: "working", + liveAgentCount: 2, + tasks: [agent("Review the diff"), agent("Write tests")], + }), + ).toEqual({ + title: "2 agents working in the background", + description: "Review the diff and Write tests", + }); + }); + + it("falls back to the folded task count when the roster is empty", () => { + expect( + buildBackgroundWorkBannerCopy({ + liveness: "working", + liveAgentCount: 0, + tasks: [agent("Orphaned run")], + }), + ).toEqual({ title: "1 task running in the background", description: "Orphaned run" }); + }); + + it("describes only the watch loops when monitoring", () => { + expect( + buildBackgroundWorkBannerCopy({ + liveness: "monitoring", + liveAgentCount: 0, + tasks: [agent("Stale agent row"), watch("Watch CI")], + }).description, + ).toBe("Watch CI"); + }); +}); + describe("getStartedThreadModelChangeBlockReason", () => { const providers = [ { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..c88a2670003e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -149,6 +149,72 @@ export function buildThreadTurnInterruptInput(thread: Pick, + limit = BACKGROUND_WORK_LABEL_LIMIT, +): string | null { + const shown = labels.slice(0, Math.max(limit, 1)); + const hidden = labels.length - shown.length; + if (hidden > 0) { + return `${shown.join(", ")}, and ${hidden} more`; + } + if (shown.length > 1) { + return `${shown.slice(0, -1).join(", ")} and ${shown.at(-1)}`; + } + return shown[0] ?? null; +} + +/** + * Banner copy for background work that outlived the turn. The server's + * liveness state is authoritative for working-vs-monitoring and for whether + * anything is live at all; the folded task list only adds detail and is empty + * whenever start rows aged out or the server restarted, so every branch reads + * without it. + */ +export function buildBackgroundWorkBannerCopy(input: { + liveness: "working" | "monitoring"; + liveAgentCount: number; + tasks: ReadonlyArray<{ readonly kind: "watch" | "agent"; readonly label: string }>; +}): { title: string; description: string | null } { + const watchTasks = input.tasks.filter((task) => task.kind === "watch"); + const described = + input.liveness === "monitoring" && watchTasks.length > 0 ? watchTasks : input.tasks; + const description = formatBackgroundWorkLabels(described.map((task) => task.label)); + + if (input.liveness === "monitoring") { + return { + title: + watchTasks.length > 0 + ? `${pluralize(watchTasks.length, "watch loop", "watch loops")} running in the background` + : "Monitoring in the background", + description, + }; + } + if (input.liveAgentCount > 0) { + return { + title: `${pluralize(input.liveAgentCount, "agent", "agents")} working in the background`, + description, + }; + } + return { + title: + input.tasks.length > 0 + ? `${pluralize(input.tasks.length, "task", "tasks")} running in the background` + : "Background work running", + description, + }; +} + export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 674542f3ee0e..d29208dfd7a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,6 +156,10 @@ import { deriveAgentPanelModel, foldSubagentActivities, } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + foldLiveBackgroundTasks, + type LiveBackgroundTask, +} from "@t3tools/client-runtime/state/background-work"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -293,6 +297,7 @@ import { import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, + buildBackgroundWorkBannerCopy, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, @@ -341,6 +346,7 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { BackgroundWorkDetailsPopover } from "./chat/BackgroundWorkDetailsPopover"; import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, @@ -358,6 +364,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_LIVE_BACKGROUND_TASKS: ReadonlyArray = []; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -4391,6 +4398,16 @@ function ChatViewContent(props: ChatViewProps) { // interrupting, and works by session, so no active turn is needed. const activeBackgroundLiveness = !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; + // The liveness state names a mode, not the work, so the banner also folds + // the thread's task rows for per-task detail. Gated on the liveness so a + // thread with nothing running never pays for the fold. + const liveBackgroundTasks = useMemo( + () => + activeBackgroundLiveness === null + ? EMPTY_LIVE_BACKGROUND_TASKS + : foldLiveBackgroundTasks(threadActivities), + [activeBackgroundLiveness, threadActivities], + ); const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); useEffect(() => { // "Stopping..." holds until the liveness clears; the interrupt command @@ -4430,7 +4447,11 @@ function ChatViewContent(props: ChatViewProps) { return null; } const working = activeBackgroundLiveness === "working"; - const liveCount = agentPanelModel.liveCount; + const copy = buildBackgroundWorkBannerCopy({ + liveness: working ? "working" : "monitoring", + liveAgentCount: agentPanelModel.liveCount, + tasks: liveBackgroundTasks, + }); return { id: `background-liveness:${activeThread.id}`, variant: "default", @@ -4440,20 +4461,33 @@ function ChatViewContent(props: ChatViewProps) { aria-hidden="true" /> ), - title: working - ? liveCount > 0 - ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background` - : "Background work running" - : "Monitoring in the background", + title: copy.title, + // One line, always: a provider-written task title can run long and the + // banner must not grow to fit it. The popover has the full text. + ...(copy.description + ? { description: {copy.description} } + : {}), actions: ( - + <> + {liveBackgroundTasks.length > 0 ? ( + + ) : null} + + void handleStopBackgroundWork()} + /> + } + > + {isStoppingBackgroundWork ? "Stopping..." : "Stop"} + + Ends all background work in this thread at once + + ), }; }, [ @@ -4462,6 +4496,7 @@ function ChatViewContent(props: ChatViewProps) { agentPanelModel.liveCount, handleStopBackgroundWork, isStoppingBackgroundWork, + liveBackgroundTasks, ]); // A woken thread announces itself in the open view, not just the sidebar // pill. Dismissing marks the wake as seen (same acknowledgment as the diff --git a/apps/web/src/components/chat/BackgroundWorkDetailsPopover.tsx b/apps/web/src/components/chat/BackgroundWorkDetailsPopover.tsx new file mode 100644 index 000000000000..10e21932c792 --- /dev/null +++ b/apps/web/src/components/chat/BackgroundWorkDetailsPopover.tsx @@ -0,0 +1,57 @@ +import type { LiveBackgroundTask } from "@t3tools/client-runtime/state/background-work"; + +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; + +/** + * Names the live background work behind the composer banner. Timestamps are + * formatted at open time rather than ticking: the popup is built fresh on every + * open, so a repainting clock would cost frames for nothing. + */ +export function BackgroundWorkDetailsPopover({ + tasks, +}: { + readonly tasks: ReadonlyArray; +}) { + return ( + + }>Details + +
+
    + {tasks.map((task) => ( +
  • +
    + + {taskFlavorLabel(task)} + + + {formatRelativeTimeLabel(task.updatedAt)} + +
    + {task.label} + {task.progress ? ( + {task.progress} + ) : null} +
  • + ))} +
+

+ Stop ends everything listed here at once and interrupts the session. There is no way to + stop a single item. +

+
+
+
+ ); +} + +function taskFlavorLabel(task: LiveBackgroundTask): string { + if (task.kind === "agent") { + return "Agent"; + } + return task.taskType === "shell" || task.taskType === "local_bash" + ? "Background shell" + : "Monitor"; +} diff --git a/docs/fork/0011-follow-background-work.md b/docs/fork/0011-follow-background-work.md new file mode 100644 index 000000000000..a96a7353657b --- /dev/null +++ b/docs/fork/0011-follow-background-work.md @@ -0,0 +1,42 @@ +# 0011: Follow the background work a thread left running + +- PR: [TrogonStack/t3code#20](https://github.com/TrogonStack/t3code/pull/20) +- Status: active + +## What you can do now + +- Read what is actually running when a thread keeps working after its turn + ends. The composer banner names the work instead of only naming a mode, so + "Monitoring in the background" becomes the watch loops by name. +- Open Details on that banner for the full list: what each item is, its latest + progress line, and when it last reported. +- Know what Stop does before pressing it. The banner and the list both say it + ends every listed item at once, which is the only granularity there is. + +## Why + +A thread can hold background work open for hours, and the banner was the only +place that admitted it. It reported a state and offered a single destructive +button, which left two questions unanswered at the moment they matter: what is +still running, and what am I about to kill. People who could not answer either +one stopped everything to find out, which is the opposite of what the feature +is for. + +Monitoring was the worse of the two states. It is by definition the state with +no live agents, so the roster people would otherwise check is empty exactly when +the banner is up, and a watch loop that is waiting on something looks identical +to one that has quietly wedged. Its progress line is the difference, and it was +already being recorded, just never shown. + +## Upstream considerations + +This is a presentation change over data the server already persists, with no +contract, wire, or server change, so it should go upstream as a feature. Submit +it and delete this entry once it merges. + +While it is carried, the chat view is the part a sync will notice, since it +moves often upstream. The rest is additive: one shared derivation and one +self-contained popover. + +Mobile is deliberately untouched: it has no background-liveness banner to +improve. Desktop wraps the web client and picks this up with it. diff --git a/docs/fork/README.md b/docs/fork/README.md index 6de813b11c7d..8099275ad826 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -33,3 +33,4 @@ Each entry uses these sections: | 0006 | [Fork schema on its own migration ledger](./0006-fork-migration-ledger.md) | [#13](https://github.com/TrogonStack/t3code/pull/13), [#16](https://github.com/TrogonStack/t3code/pull/16) | active | | 0007 | [API-key Codex installs are not reported as broken](./0007-codex-api-key-auth-is-supported.md) | [#15](https://github.com/TrogonStack/t3code/pull/15) | active | | 0008 | [Drop a folder on the sidebar to add a project](./0008-drop-a-folder-to-add-a-project.md) | [#17](https://github.com/TrogonStack/t3code/pull/17) | active | +| 0011 | [Follow the background work a thread left running](./0011-follow-background-work.md) | [#20](https://github.com/TrogonStack/t3code/pull/20) | active | diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b0..463a3abfeca9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,15 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +## Background work + +When a turn ends while work is still running, a banner sits above the composer and names that +work: the agents still going, or the watch loops and background shells waiting on something. + +Open **Details** on the banner for the full list, with each item's latest progress line and when +it last reported. A watch loop that is waiting and one that has stopped making progress look the +same in the banner, and the progress line is what tells them apart. + +**Stop** ends every item in that list at once and interrupts the session. Items cannot be stopped +one at a time. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 01600af4699f..c505c566df07 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -47,6 +47,10 @@ "types": "./src/state/assets.ts", "default": "./src/state/assets.ts" }, + "./state/background-work": { + "types": "./src/state/backgroundWork.ts", + "default": "./src/state/backgroundWork.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" diff --git a/packages/client-runtime/src/state/backgroundWork.test.ts b/packages/client-runtime/src/state/backgroundWork.test.ts new file mode 100644 index 000000000000..f9c030f88b02 --- /dev/null +++ b/packages/client-runtime/src/state/backgroundWork.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { foldLiveBackgroundTasks } from "./backgroundWork.ts"; + +let sequence = 0; +function activity( + kind: string, + payload: Record, + at = `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`, +): OrchestrationThreadActivity { + sequence += 1; + return { + id: `activity-${sequence}`, + tone: "info", + kind, + summary: kind, + payload, + turnId: null, + createdAt: at, + } as unknown as OrchestrationThreadActivity; +} + +describe("foldLiveBackgroundTasks", () => { + it("names a live watch loop and keeps its latest progress line", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { + taskId: "t1", + taskType: "monitor", + title: "Watch CI on PR #18", + }), + activity("task.progress", { taskId: "t1", summary: "3 checks pending" }), + activity("task.progress", { taskId: "t1", summary: "2 checks pending" }), + ]); + + expect(live).toEqual([ + { + taskId: "t1", + kind: "watch", + taskType: "monitor", + label: "Watch CI on PR #18", + progress: "2 checks pending", + firstSeenAt: expect.any(String), + updatedAt: expect.any(String), + }, + ]); + }); + + it("separates watch loops from agent work", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "monitor", title: "Tail logs" }), + activity("task.started", { taskId: "t2", taskType: "subagent", title: "Review the diff" }), + ]); + + expect(live.map((task) => [task.taskId, task.kind])).toEqual([ + ["t1", "watch"], + ["t2", "agent"], + ]); + }); + + it("drops a task once it completes, fails, is stopped, or goes idle", () => { + const settled = ["failed", "stopped", "cancelled", "interrupted", "idle"]; + for (const status of settled) { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "monitor", title: "Watch" }), + activity("task.updated", { taskId: "t1", status }), + ]); + expect(live, `status ${status}`).toEqual([]); + } + + expect( + foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "monitor", title: "Watch" }), + activity("task.completed", { taskId: "t1" }), + ]), + ).toEqual([]); + }); + + it("brings a task back when a later row reports it running again", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "shell", title: "Tail logs" }), + activity("task.updated", { taskId: "t1", status: "idle" }), + activity("task.progress", { taskId: "t1", taskType: "shell", status: "running" }), + ]); + + expect(live.map((task) => task.taskId)).toEqual(["t1"]); + }); + + it("ignores plan-mode bookkeeping, which is neither agent nor watch loop", () => { + expect( + foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "plan", title: "Plan mode" }), + activity("task.started", { taskId: "t2", taskType: "dream", title: "Dreaming" }), + ]), + ).toEqual([]); + }); + + it("ignores a subagent's own watch loops, which its owner already covers", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { + taskId: "t1", + taskType: "shell", + agentId: "agent-1", + title: "Agent's own shell", + }), + activity("task.started", { + taskId: "t2", + agentId: "agent-1", + title: "Untyped agent-internal work", + }), + activity("task.started", { + taskId: "t3", + taskType: "subagent", + agentId: "agent-1", + title: "Nested agent", + }), + ]); + + expect(live.map((task) => task.taskId)).toEqual(["t3"]); + }); + + it("reclassifies when the task type only shows up on a later row", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", title: "Something" }), + activity("task.progress", { taskId: "t1", taskType: "monitor" }), + ]); + + expect(live.map((task) => task.kind)).toEqual(["watch"]); + }); + + it("keeps a watch loop's flavor when a thinner later row omits the task type", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "monitor", title: "Watch CI" }), + activity("task.progress", { taskId: "t1", summary: "still waiting" }), + ]); + + expect(live.map((task) => task.kind)).toEqual(["watch"]); + }); + + it("keeps the label when a thinner later row omits it", () => { + const live = foldLiveBackgroundTasks([ + activity("task.started", { taskId: "t1", taskType: "monitor", title: "Watch CI" }), + activity("task.progress", { taskId: "t1" }), + ]); + + expect(live[0]?.label).toBe("Watch CI"); + }); + + it("falls back to the task id when no row carried a label", () => { + const live = foldLiveBackgroundTasks([ + activity("task.progress", { taskId: "t1", taskType: "monitor" }), + ]); + + expect(live[0]?.label).toBe("t1"); + }); + + it("folds out-of-order rows by sequence, so a late start cannot reopen a task", () => { + const started = { + ...activity("task.started", { taskId: "t1", taskType: "monitor" }), + sequence: 1, + }; + const completed = { ...activity("task.completed", { taskId: "t1" }), sequence: 2 }; + + expect(foldLiveBackgroundTasks([completed, started])).toEqual([]); + }); + + it("skips rows without a task id or payload", () => { + expect( + foldLiveBackgroundTasks([ + activity("task.started", { taskType: "monitor" }), + activity("message.appended", { taskId: "t1", taskType: "monitor" }), + { ...activity("task.started", {}), payload: null } as OrchestrationThreadActivity, + ]), + ).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/backgroundWork.ts b/packages/client-runtime/src/state/backgroundWork.ts new file mode 100644 index 000000000000..e927f15fb8da --- /dev/null +++ b/packages/client-runtime/src/state/backgroundWork.ts @@ -0,0 +1,174 @@ +/** + * Per-task detail for a thread's live background work, folded from the same + * persisted task.* rows the server's liveness registry is fed. + * + * The registry answers one question for the whole thread ("working", + * "monitoring", or nothing) and keeps no per-task detail, so a client that + * only reads it can name a state but never the work. This fold recovers the + * individual tasks so the shell can say what is running and what stopping it + * would end. + * + * It mirrors ThreadBackgroundLiveness rather than inventing a second opinion: + * the same contracts classification sets, the same drop rules (inert types, + * agent-internal watch loops, terminal and idle statuses), and the same + * per-transition classification, so this list can never claim work the + * registry does not hold live. Metadata is the one sticky part, because a + * task's title usually arrives only on its start row while terminal rows + * commonly carry taskId and status alone. + * + * Retention is why callers must read an empty list as "no detail available" + * rather than "nothing running": start rows age out, and after a server + * restart the registry is empty while old rows survive. The registry stays + * the authority on whether background work exists at all. + */ +import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +/** Watch loops (Monitor tasks, background shells) vs everything else live. */ +export type LiveBackgroundTaskKind = "watch" | "agent"; + +export interface LiveBackgroundTask { + readonly taskId: string; + readonly kind: LiveBackgroundTaskKind; + /** SDK task_type when a row carried one, for callers that label by flavor. */ + readonly taskType: string | null; + /** Best available human label, falling back to the task id. */ + readonly label: string; + /** Latest progress line, which is what "following" a watch loop means. */ + readonly progress: string | null; + readonly firstSeenAt: string; + readonly updatedAt: string; +} + +const TASK_LIFECYCLE_KINDS: ReadonlySet = new Set([ + "task.started", + "task.progress", + "task.updated", + "task.completed", +]); + +// Registry copy, including idle as not-live: a resting (resumable) child is +// not doing anything. +const SETTLED_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "stopped", + "cancelled", + "interrupted", + "idle", +]); + +interface TaskState { + kind: LiveBackgroundTaskKind | null; + taskType: string | null; + label: string | null; + progress: string | null; + firstSeenAt: string; + updatedAt: string; +} + +// Same bound the subagent fold uses: task text is provider-supplied and a +// banner or popover line is not the place to discover it was a page long. +const TEXT_CHAR_LIMIT = 180; + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** Display text: identity fields keep asString, since truncating an id lies. */ +function asText(value: unknown): string | undefined { + const text = asString(value); + if (text === undefined) { + return undefined; + } + return text.length <= TEXT_CHAR_LIMIT ? text : `${text.slice(0, TEXT_CHAR_LIMIT - 1)}…`; +} + +function compareByOrder( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if ( + left.sequence !== undefined && + right.sequence !== undefined && + left.sequence !== right.sequence + ) { + return left.sequence - right.sequence; + } + const byCreatedAt = left.createdAt.localeCompare(right.createdAt); + return byCreatedAt !== 0 ? byCreatedAt : left.id.localeCompare(right.id); +} + +/** + * Folds task rows into the background work still live, oldest first. Pure — + * memoize by activity-list identity at the atom layer. + */ +export function foldLiveBackgroundTasks( + activities: ReadonlyArray, +): ReadonlyArray { + const tasks = new Map(); + + for (const activity of [...activities].toSorted(compareByOrder)) { + if (!TASK_LIFECYCLE_KINDS.has(activity.kind)) continue; + if (typeof activity.payload !== "object" || activity.payload === null) continue; + const payload = activity.payload as Record; + const taskId = asString(payload.taskId); + if (!taskId) continue; + + const taskType = asString(payload.taskType); + const agentId = asString(payload.agentId); + const status = asString(payload.status); + const at = activity.createdAt; + const existing = tasks.get(taskId); + const state: TaskState = { + kind: null, + taskType: taskType ?? existing?.taskType ?? null, + // An explicit title always wins; otherwise keep what we know rather + // than letting a thinner later row downgrade the label. + label: + asText(payload.title) ?? + existing?.label ?? + asText(payload.description) ?? + asText(payload.detail) ?? + null, + progress: asText(payload.summary) ?? existing?.progress ?? null, + firstSeenAt: existing?.firstSeenAt ?? at, + updatedAt: at, + }; + tasks.set(taskId, state); + + // Drop rules in the registry's order. Classification is per-transition, + // not sticky: a task whose taskType only shows up on a later row must be + // reclassified rather than pinned by its first row. + if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) continue; + // A subagent's own watch loops are covered by the owning agent's + // liveness. A nested agent falls through: it can outlive its parent. + if (agentId !== undefined && (taskType === undefined || MONITOR_TASK_TYPES.has(taskType))) { + continue; + } + if (activity.kind === "task.completed") continue; + if (status !== undefined && SETTLED_STATUSES.has(status)) continue; + + // Whether a task is live mirrors the registry exactly (the rules above + // read this row's own fields). Which flavor it is reads the sticky type: + // losing taskType on a thinner later row is not news about the work, and + // flavor only decides wording, never presence. + state.kind = + state.taskType !== null && MONITOR_TASK_TYPES.has(state.taskType) ? "watch" : "agent"; + } + + const live: LiveBackgroundTask[] = []; + for (const [taskId, state] of tasks) { + if (state.kind === null) continue; + live.push({ + taskId, + kind: state.kind, + taskType: state.taskType, + label: state.label ?? taskId, + progress: state.progress, + firstSeenAt: state.firstSeenAt, + updatedAt: state.updatedAt, + }); + } + return live.toSorted((left, right) => left.firstSeenAt.localeCompare(right.firstSeenAt)); +}