From 02adefa39a9e19ef9ca8ec858907db9099e2ec03 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 14:41:09 -0700 Subject: [PATCH] feat(ui): show the live plan checklist inside the chat thread (#109343) The run-scoped plan snapshot already fed the sticky composer bar; it now also renders as an always-expanded card inside the active run's work group, so plan progress is visible in scroll context. The bar and card share one extracted renderer (variant: bar | card) instead of duplicating the template, and the card clears with the run through existing plan lifecycle. --- ui/src/lib/chat/chat-types.ts | 3 +- ui/src/pages/chat/chat-thread.test.ts | 18 ++++ ui/src/pages/chat/chat-thread.ts | 20 ++++- ui/src/pages/chat/chat-view.ts | 1 + ui/src/pages/chat/components/chat-composer.ts | 68 ++------------ .../chat/components/chat-message.test.ts | 28 ++++++ ui/src/pages/chat/components/chat-message.ts | 40 ++++++--- .../components/chat-plan-checklist.test.ts | 51 +++++++++++ .../chat/components/chat-plan-checklist.ts | 89 +++++++++++++++++++ ui/src/pages/chat/components/chat-thread.ts | 7 ++ ui/src/styles/chat/plan-checklist.css | 21 ++++- 11 files changed, 264 insertions(+), 82 deletions(-) create mode 100644 ui/src/pages/chat/components/chat-plan-checklist.test.ts create mode 100644 ui/src/pages/chat/components/chat-plan-checklist.ts diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 0236533a2137..ac8c5b942281 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -55,7 +55,8 @@ export type ChatItem = timestamp: number; } | { kind: "stream"; key: string; text: string; startedAt: number; isStreaming: boolean } - | { kind: "reading-indicator"; key: string; startedAt: number }; + | { kind: "reading-indicator"; key: string; startedAt: number } + | { kind: "plan"; key: string }; export type ChatStreamSegment = { text: string; diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 45316c212cba..86f58043c316 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -556,6 +556,24 @@ describe("buildCachedChatItems working spark", () => { expect(hasReadingIndicator({ runWorking: true })).toBe(true); }); + it("adds the plan to the active stream run and removes it when the run stops", () => { + const planStatus = { + steps: [{ step: "Inspect the route", status: "in_progress" as const }], + }; + const activeItems = buildCachedChatItems( + createProps({ runActive: true, runWorking: true, planStatus }), + ); + + expect(coalesceStreamRuns(activeItems)).toMatchObject([ + { kind: "stream-run", parts: [{ kind: "reading-indicator" }, { kind: "plan" }] }, + ]); + + const idleItems = buildCachedChatItems( + createProps({ runActive: false, runWorking: false, planStatus }), + ); + expect(idleItems.some((item) => item.kind === "plan")).toBe(false); + }); + it("keeps the run start time on the working indicator", () => { const indicator = buildCachedChatItems( createProps({ runWorking: true, streamStartedAt: 42_000 }), diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index c201e77a2a68..c34d04914a1b 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -43,6 +43,7 @@ import { shouldRenderQueuedSendInThread, } from "./chat-progress.ts"; import { getOrCreateSessionCacheValue } from "./session-cache.ts"; +import type { PlanStatus } from "./tool-stream.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; type BuildChatItemsProps = { @@ -60,6 +61,9 @@ type BuildChatItemsProps = { showToolCalls: boolean; /** True while the agent is visibly working (isChatRunWorking). */ runWorking?: boolean; + /** True while the current session has an abortable live run. */ + runActive?: boolean; + planStatus?: PlanStatus | null; /** True while chat history is loading (initial load or background reload). */ loading?: boolean; searchOpen?: boolean; @@ -77,7 +81,9 @@ type RenderChatItem = ReturnType[number]; type StreamRunRenderItem = { kind: "stream-run"; key: string; - parts: Array>; + parts: Array< + Extract + >; }; const chatItemsByPane = new Map>(); @@ -1095,6 +1101,7 @@ function chatItemTimestamp(item: ChatItem): number | null { case "stream": return item.startedAt; case "reading-indicator": + case "plan": return null; } return null; @@ -1469,6 +1476,9 @@ function buildChatItems(props: BuildChatItemsProps): Array 0) { + items.push({ kind: "plan", key: `plan:${props.sessionKey}:active` }); + } return annotateToolTurnOutcome( groupMessages( @@ -1532,6 +1542,8 @@ function sameChatItem(previous: RenderChatItem, next: RenderChatItem): boolean { ); case "reading-indicator": return previous.kind === "reading-indicator" && previous.startedAt === next.startedAt; + case "plan": + return previous.kind === "plan"; } return false; } @@ -1632,6 +1644,8 @@ function sameChatItemsStructuralInput( previous.queue === next.queue && previous.showToolCalls === next.showToolCalls && previous.runWorking === next.runWorking && + previous.runActive === next.runActive && + Boolean(previous.planStatus?.steps.length) === Boolean(next.planStatus?.steps.length) && previous.loading === next.loading && previous.searchOpen === next.searchOpen && previous.searchQuery === next.searchQuery @@ -1732,7 +1746,7 @@ export function coalesceStreamRuns( ): Array { const result: Array = []; let run: StreamRunRenderItem["parts"] = []; - // Contiguous in-flight stream and reading-indicator items render under one + // Contiguous in-flight stream, plan, and reading-indicator items render under one // assistant avatar; messages, groups, and dividers intentionally break the run. const flush = () => { const [first] = run; @@ -1742,7 +1756,7 @@ export function coalesceStreamRuns( } }; for (const item of items) { - if (item.kind === "stream" || item.kind === "reading-indicator") { + if (item.kind === "stream" || item.kind === "reading-indicator" || item.kind === "plan") { run.push(item); continue; } diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 689db842db2e..a54dcdab6286 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -280,6 +280,7 @@ export function renderChat(props: ChatProps) { showToolCalls: props.showToolCalls, runActive: Boolean(props.canAbort), runWorking: isChatRunWorking(props), + planStatus: props.planStatus, sessions: props.sessions, sessionHost: props.sessionHost, assistantName: props.assistantName, diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index d3858c8fce6f..560bdb42f494 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -56,6 +56,7 @@ import { renderChatAttachmentInputs, renderChatAttachmentMenu, } from "./chat-attachments.ts"; +import { renderChatPlanChecklist } from "./chat-plan-checklist.ts"; import { renderChatVoiceError, renderMicrophoneActivity, @@ -1160,68 +1161,6 @@ function renderFallbackIndicator(status: FallbackStatus | null | undefined) { `; } -function renderPlanChecklist(status: PlanStatus | null | undefined, active: boolean) { - if (!active || !status || status.steps.length === 0) { - return nothing; - } - const completed = status.steps.filter((step) => step.status === "completed").length; - let current = status.steps.find((step) => step.status === "in_progress"); - if (!current) { - for (let index = status.steps.length - 1; index >= 0; index -= 1) { - const step = status.steps[index]; - if (step?.status === "completed") { - current = step; - break; - } - } - } - current ??= status.steps[0]; - if (!current) { - return nothing; - } - const statusLabels: Record = { - completed: "completed", - in_progress: "in progress", - pending: "pending", - }; - return html` -
- - - ${current.step} - ${completed}/${status.steps.length} - -
- ${status.explanation - ? html`
${status.explanation}
` - : nothing} -
    - ${status.steps.map( - (step) => html` -
  1. - - ${step.step} -
  2. - `, - )} -
-
-
- `; -} - type ContextNoticeOptions = { compactBusy?: boolean; compactDisabled?: boolean; @@ -2256,7 +2195,10 @@ export function renderChatComposer(props: ChatComposerProps) { ` : nothing}
- ${renderPlanChecklist(props.planStatus, showAbortableUi)} + ${renderChatPlanChecklist(props.planStatus, { + active: showAbortableUi, + variant: "bar", + })} ${renderFallbackIndicator(props.fallbackStatus)} ${renderCompactionIndicator(props.compactionStatus)} ${renderChatGoal(state, activeSession?.goal, { diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index c4ecf0b6626c..d109c577e0c3 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1074,6 +1074,34 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-group-footer")).toBeNull(); }); + it("renders the active plan card inside the working stream group", () => { + const container = document.createElement("div"); + + render( + renderStreamGroup( + [ + { kind: "reading-indicator", key: "reading", startedAt: 1_000 }, + { kind: "plan", key: "plan:main:active" }, + ], + { + planActive: true, + planStatus: { + explanation: "Keep the change focused", + steps: [ + { step: "Inspect", status: "completed" }, + { step: "Implement", status: "in_progress" }, + ], + }, + }, + ), + container, + ); + + expect(container.querySelector(".chat-group--working .plan-checklist--card")).not.toBeNull(); + expect(container.querySelectorAll(".plan-checklist__step")).toHaveLength(2); + expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(0); + }); + it("keeps the avatar once a stream part joins the reading indicator", () => { const container = document.createElement("div"); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 3c022a723715..0c7c10b75bc8 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -51,6 +51,8 @@ import { stripThinkingTags } from "../../../lib/strip-thinking-tags.ts"; import { detectTextDirection } from "../../../lib/text-direction.ts"; import { getSafeLocalStorage } from "../../../local-storage.ts"; import { renderChatAvatar } from "../chat-avatar.ts"; +import type { PlanStatus } from "../tool-stream.ts"; +import { renderChatPlanChecklist } from "./chat-plan-checklist.ts"; import type { SidebarContent } from "./chat-sidebar.ts"; import { isRunningToolCard, @@ -556,13 +558,18 @@ function extractTranscriptAttachments(message: unknown): AttachmentItem[] { } /** A contiguous run of in-flight streaming items rendered under one assistant group. */ -type StreamGroupPart = Extract; +type StreamGroupPart = Extract< + ChatItem, + { kind: "stream" } | { kind: "reading-indicator" } | { kind: "plan" } +>; type StreamGroupOptions = { onOpenSidebar?: (content: SidebarContent) => void; assistant?: AssistantIdentity; basePath?: string; authToken?: string | null; + planStatus?: PlanStatus | null; + planActive?: boolean; }; // One assistant group per contiguous run of streaming items: a reply that @@ -578,14 +585,14 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt // While the agent works with nothing streamed yet the run is pure claw: no // avatar next to it - the punching pincer is the whole signal. The avatar // arrives with the first stream part. - const indicatorOnly = parts.every((part) => part.kind === "reading-indicator"); - const avatar = indicatorOnly + const workingOnly = parts.every((part) => part.kind !== "stream"); + const avatar = workingOnly ? nothing : renderChatAvatar("assistant", assistant, undefined, basePath, authToken); return html`
${avatar} @@ -593,16 +600,21 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt ${parts.map((part) => part.kind === "reading-indicator" ? renderChatWorkingIndicator(part) - : renderGroupedMessage( - { - role: "assistant", - content: [{ type: "text", text: part.text }], - timestamp: part.startedAt, - }, - part.key, - { isStreaming: part.isStreaming, showReasoning: false }, - onOpenSidebar, - ), + : part.kind === "plan" + ? renderChatPlanChecklist(opts.planStatus, { + active: opts.planActive === true, + variant: "card", + }) + : renderGroupedMessage( + { + role: "assistant", + content: [{ type: "text", text: part.text }], + timestamp: part.startedAt, + }, + part.key, + { isStreaming: part.isStreaming, showReasoning: false }, + onOpenSidebar, + ), )} ${footerStartedAt !== null ? html` diff --git a/ui/src/pages/chat/components/chat-plan-checklist.test.ts b/ui/src/pages/chat/components/chat-plan-checklist.test.ts new file mode 100644 index 000000000000..7697bac5981d --- /dev/null +++ b/ui/src/pages/chat/components/chat-plan-checklist.test.ts @@ -0,0 +1,51 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import type { PlanStatus } from "../tool-stream.ts"; +import { renderChatPlanChecklist } from "./chat-plan-checklist.ts"; + +const planStatus: PlanStatus = { + explanation: "Keep the change focused", + steps: [ + { step: "Inspect the route", status: "completed" }, + { step: "Wire the checklist", status: "in_progress" }, + { step: "Run focused tests", status: "pending" }, + ], +}; + +function renderChecklist(status: PlanStatus | null, active: boolean) { + const container = document.createElement("div"); + render(renderChatPlanChecklist(status, { active, variant: "card" }), container); + return container; +} + +describe("renderChatPlanChecklist", () => { + it("renders the full card plan with explanation and step statuses", () => { + const container = renderChecklist(planStatus, true); + const card = container.querySelector(".plan-checklist--card"); + + expect(card).not.toBeNull(); + expect(card).not.toBeInstanceOf(HTMLDetailsElement); + expect(card?.querySelector(".plan-checklist__current")?.textContent).toBe("Wire the checklist"); + expect(card?.querySelector(".plan-checklist__count")?.textContent).toBe("1/3"); + expect(card?.querySelector(".plan-checklist__explanation")?.textContent).toBe( + "Keep the change focused", + ); + expect( + [...(card?.querySelectorAll(".plan-checklist__step") ?? [])].map((step) => ({ + label: step.getAttribute("aria-label"), + status: [...step.classList].find((name) => name.startsWith("plan-checklist__step--")), + })), + ).toEqual([ + { label: "Inspect the route, completed", status: "plan-checklist__step--completed" }, + { label: "Wire the checklist, in progress", status: "plan-checklist__step--in_progress" }, + { label: "Run focused tests, pending", status: "plan-checklist__step--pending" }, + ]); + }); + + it("hides the checklist without a plan or active run", () => { + expect(renderChecklist(null, true).querySelector(".plan-checklist")).toBeNull(); + expect(renderChecklist(planStatus, false).querySelector(".plan-checklist")).toBeNull(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-plan-checklist.ts b/ui/src/pages/chat/components/chat-plan-checklist.ts new file mode 100644 index 000000000000..44e068280089 --- /dev/null +++ b/ui/src/pages/chat/components/chat-plan-checklist.ts @@ -0,0 +1,89 @@ +import { html, nothing } from "lit"; +import type { PlanStatus } from "../tool-stream.ts"; + +type ChatPlanChecklistVariant = "bar" | "card"; + +type ChatPlanChecklistOptions = { + active: boolean; + variant: ChatPlanChecklistVariant; +}; + +function renderPlanChecklistBody(status: PlanStatus) { + const statusLabels: Record = { + completed: "completed", + in_progress: "in progress", + pending: "pending", + }; + return html` +
+ ${status.explanation + ? html`
${status.explanation}
` + : nothing} +
    + ${status.steps.map( + (step) => html` +
  1. + + ${step.step} +
  2. + `, + )} +
+
+ `; +} + +export function renderChatPlanChecklist( + status: PlanStatus | null | undefined, + options: ChatPlanChecklistOptions, +) { + if (!options.active || !status || status.steps.length === 0) { + return nothing; + } + const completed = status.steps.filter((step) => step.status === "completed").length; + let current = status.steps.find((step) => step.status === "in_progress"); + if (!current) { + for (let index = status.steps.length - 1; index >= 0; index -= 1) { + const step = status.steps[index]; + if (step?.status === "completed") { + current = step; + break; + } + } + } + current ??= status.steps[0]; + if (!current) { + return nothing; + } + const label = `Plan: ${current.step}. ${completed} of ${status.steps.length} completed`; + const summary = html` + + ${current.step} + ${completed}/${status.steps.length} + `; + const body = renderPlanChecklistBody(status); + + if (options.variant === "card") { + return html` +
+
${summary}
+ ${body} +
+ `; + } + return html` +
+ ${summary} + ${body} +
+ `; +} diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 52e731b8cc6a..912c533c36aa 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -56,6 +56,7 @@ import { DeletedMessages } from "../deleted-messages.ts"; import { PinnedMessages } from "../pinned-messages.ts"; import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; import { getOrCreateSessionCacheValue } from "../session-cache.ts"; +import type { PlanStatus } from "../tool-stream.ts"; import { getToolTitlesVersion } from "../tool-titles.ts"; import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; import type { BackgroundTasksProps } from "./chat-background-tasks.ts"; @@ -108,6 +109,7 @@ type ChatThreadProps = { runActive?: boolean; /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ runWorking?: boolean; + planStatus?: PlanStatus | null; sessions: SessionsListResult | null; /** Host context resolving global-alias session keys (scope=global fleets). */ /** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */ @@ -972,6 +974,8 @@ function renderChatThreadContents( queue: props.queue, showToolCalls: props.showToolCalls, runWorking: Boolean(props.runWorking), + runActive: Boolean(props.runActive), + planStatus: props.planStatus, loading: props.loading, searchOpen: state.searchOpen, searchQuery: state.searchQuery, @@ -1050,6 +1054,8 @@ function renderChatThreadContents( } if (item.kind === "stream-run") { return renderStreamGroup(item.parts, { + planStatus: props.planStatus, + planActive: Boolean(props.runActive), onOpenSidebar: props.onOpenSidebar, assistant: assistantIdentity, basePath: props.basePath, @@ -1117,6 +1123,7 @@ function renderChatThreadContents( props.showToolCalls, Boolean(props.runActive), Boolean(props.runWorking), + props.planStatus, Boolean(props.autoExpandToolCalls), props.assistantName, assistantIdentity.avatar, diff --git a/ui/src/styles/chat/plan-checklist.css b/ui/src/styles/chat/plan-checklist.css index 9397f963bf5b..0ac0f85a0d6e 100644 --- a/ui/src/styles/chat/plan-checklist.css +++ b/ui/src/styles/chat/plan-checklist.css @@ -1,4 +1,5 @@ .plan-checklist { + box-sizing: border-box; flex: 1 1 100%; min-width: 0; overflow: hidden; @@ -9,6 +10,24 @@ animation: fade-in 0.2s var(--ease-out); } +.plan-checklist--card { + flex: none; + width: min(760px, 100%); + margin-top: 4px; + border-color: color-mix(in srgb, var(--border) 82%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card, var(--bg)) 55%, transparent); +} + +.plan-checklist--card .plan-checklist__summary { + cursor: default; + user-select: text; +} + +.plan-checklist--card .plan-checklist__body { + border-top-color: color-mix(in srgb, var(--border) 62%, transparent); +} + .agent-chat__composer-status-stack:has(.plan-checklist) { display: flex; } @@ -49,7 +68,7 @@ transition: transform var(--duration-fast) ease; } -.plan-checklist[open] .plan-checklist__current-marker { +.plan-checklist--bar[open] .plan-checklist__current-marker { transform: rotate(90deg); }