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.
This commit is contained in:
Peter Steinberger
2026-07-16 14:41:09 -07:00
committed by GitHub
parent d6e9cbd775
commit 02adefa39a
11 changed files with 264 additions and 82 deletions

View File

@@ -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;

View File

@@ -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 }),

View File

@@ -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<typeof buildChatItems>[number];
type StreamRunRenderItem = {
kind: "stream-run";
key: string;
parts: Array<Extract<ChatItem, { kind: "stream" } | { kind: "reading-indicator" }>>;
parts: Array<
Extract<ChatItem, { kind: "stream" } | { kind: "reading-indicator" } | { kind: "plan" }>
>;
};
const chatItemsByPane = new Map<string, Map<string, CachedChatItems>>();
@@ -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<ChatItem | MessageGro
items.push({ kind: "reading-indicator", key, startedAt });
}
}
if (props.runActive === true && props.planStatus && props.planStatus.steps.length > 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<RenderChatItem | StreamRunRenderItem> {
const result: Array<RenderChatItem | StreamRunRenderItem> = [];
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;
}

View File

@@ -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,

View File

@@ -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<PlanStatus["steps"][number]["status"], string> = {
completed: "completed",
in_progress: "in progress",
pending: "pending",
};
return html`
<details class="plan-checklist">
<summary
class="plan-checklist__summary"
aria-label=${`Plan: ${current.step}. ${completed} of ${status.steps.length} completed`}
>
<span class="plan-checklist__current-marker" aria-hidden="true">▸</span>
<span class="plan-checklist__current">${current.step}</span>
<span class="plan-checklist__count">${completed}/${status.steps.length}</span>
</summary>
<div class="plan-checklist__body">
${status.explanation
? html`<div class="plan-checklist__explanation">${status.explanation}</div>`
: nothing}
<ol class="plan-checklist__steps">
${status.steps.map(
(step) => html`
<li
class=${`plan-checklist__step plan-checklist__step--${step.status}`}
aria-label=${`${step.step}, ${statusLabels[step.status]}`}
>
<span class="plan-checklist__step-marker" aria-hidden="true"
>${step.status === "completed"
? "✓"
: step.status === "in_progress"
? "▸"
: "▢"}</span
>
<span class="plan-checklist__step-text">${step.step}</span>
</li>
`,
)}
</ol>
</div>
</details>
`;
}
type ContextNoticeOptions = {
compactBusy?: boolean;
compactDisabled?: boolean;
@@ -2256,7 +2195,10 @@ export function renderChatComposer(props: ChatComposerProps) {
`
: nothing}
<div class="agent-chat__composer-status-stack">
${renderPlanChecklist(props.planStatus, showAbortableUi)}
${renderChatPlanChecklist(props.planStatus, {
active: showAbortableUi,
variant: "bar",
})}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderChatGoal(state, activeSession?.goal, {

View File

@@ -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");

View File

@@ -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<ChatItem, { kind: "stream" } | { kind: "reading-indicator" }>;
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`
<div
class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}"
class="chat-group assistant ${workingOnly ? "chat-group--working" : ""}"
data-chat-row-key=${parts[0]?.key ?? nothing}
>
${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`

View File

@@ -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();
});
});

View File

@@ -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<PlanStatus["steps"][number]["status"], string> = {
completed: "completed",
in_progress: "in progress",
pending: "pending",
};
return html`
<div class="plan-checklist__body">
${status.explanation
? html`<div class="plan-checklist__explanation">${status.explanation}</div>`
: nothing}
<ol class="plan-checklist__steps">
${status.steps.map(
(step) => html`
<li
class=${`plan-checklist__step plan-checklist__step--${step.status}`}
aria-label=${`${step.step}, ${statusLabels[step.status]}`}
>
<span class="plan-checklist__step-marker" aria-hidden="true"
>${step.status === "completed"
? "✓"
: step.status === "in_progress"
? "▸"
: "▢"}</span
>
<span class="plan-checklist__step-text">${step.step}</span>
</li>
`,
)}
</ol>
</div>
`;
}
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`
<span class="plan-checklist__current-marker" aria-hidden="true">▸</span>
<span class="plan-checklist__current">${current.step}</span>
<span class="plan-checklist__count">${completed}/${status.steps.length}</span>
`;
const body = renderPlanChecklistBody(status);
if (options.variant === "card") {
return html`
<section class="plan-checklist plan-checklist--card" aria-label=${label}>
<div class="plan-checklist__summary">${summary}</div>
${body}
</section>
`;
}
return html`
<details class="plan-checklist plan-checklist--bar">
<summary class="plan-checklist__summary" aria-label=${label}>${summary}</summary>
${body}
</details>
`;
}

View File

@@ -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,

View File

@@ -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);
}