From 6c1c2e1d0b1b60395a55e7eb5f62b98df3c8227a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sat, 25 Jul 2026 23:53:53 -0300 Subject: [PATCH] feat(sidebar): make the work card's brief the plan's active step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wired to it, but `taskBriefText` has no writer outside fixtures, so the middle of every real card — the part that says what the agent is doing — was empty. The brief is now the plan's `inProgress` step, per the decision recorded on the issue. That needs no new plumbing: `PlanItemStatus` already carries `inProgress` and `latestPlanForChat` is the same accessor `cardPlanProgress` already reads for `plan M/N`. So the card says *step 2 of 5* in its footer and *what step 2 is* on this line, from one source — they cannot drift. `taskBriefText` becomes the fallback rather than the only source, lighting up when #210's one-liner producer lands. Resolved sub-decisions: - **Truncation**: capped in the store at 120 chars, and newlines flattened. The line is single-line-ellipsis in CSS anyway, so anything past that is invisible — a persisted value has no reason to be longer than what can be read. - **Precedence**: plan step wins over a cached one-liner, made explicit in the resolver so it cannot drift. - **All-complete plan** (M === N, no `inProgress`): falls through to the one-liner, then to nothing. Showing the last completed step would claim work that is finished. An `inProgress` step with only whitespace is ignored rather than rendered as a blank line, matching the locked no-placeholder rule. Verified in the rendered app: the fixture card showing `plan 2/5` now reads "Wire the swarm survey lanes into the footer", and the card with no plan still falls through to its one-liner. Refs #361 --- docs/releases/UNRELEASED.md | 6 +++ src/screens/workbench/ProjectsPane.tsx | 2 +- src/stores/flatWorkCard.ts | 48 ++++++++++++++--- tests/unit/flatWorkCard.test.ts | 73 ++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 9fb3b508..c85c594c 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -119,6 +119,12 @@ reset this file. blocked from dead. The elapsed clock comes from the SDK's own heartbeat, not a client-side timer, so it cannot drift; compacting and requesting are surfaced too. +- Sidebar work cards now show what the agent is actually doing (#361 PR 2, + behind the default-off `flatChatList` flag): the brief line is the plan's + active step, so a card reading `plan 2/5` also says what step 2 is. Both come + from one source, so they cannot disagree. A chat with no plan falls back to a + model-written one-liner, and a finished plan shows nothing rather than a + stale final step. - The default-off `pikitLanes` panel no longer re-reads and re-parses every run on disk every four seconds, or grow without bound (#363 PR 2). It shows every ACTIVE run plus the five most recent ended ones, with the rest behind a diff --git a/src/screens/workbench/ProjectsPane.tsx b/src/screens/workbench/ProjectsPane.tsx index d119e289..0e4100ee 100644 --- a/src/screens/workbench/ProjectsPane.tsx +++ b/src/screens/workbench/ProjectsPane.tsx @@ -1508,7 +1508,7 @@ const FlatWorkCard = (props: { ctrl: ProjectsPaneController; chat: Chat; state: const plan = () => cardPlanProgress(id, latestPlanForChat); const lanes = () => cardLanes(cardSwarmRun(id, root, swarmRuns)); const cost = () => cardCost(id, agentChat); - const brief = () => cardBrief(props.chat); + const brief = () => cardBrief(props.chat, latestPlanForChat); const edge = () => (props.state === "justFinished" ? null : cardContextEdge(id, props.state, agentChat)); const renaming = () => ctrl.renaming() === id; // Shared between the button (normal) and plain-div (renaming) variants diff --git a/src/stores/flatWorkCard.ts b/src/stores/flatWorkCard.ts index 46d65cc5..e547c694 100644 --- a/src/stores/flatWorkCard.ts +++ b/src/stores/flatWorkCard.ts @@ -109,13 +109,47 @@ export function cardContextEdge( return { fraction, color: state === "working" ? "ember" : "amber" }; } -/** Task brief line — present only when the chat carries real brief text (no - * placeholder copy for a chat that hasn't been given one; taskBriefText has - * no writer yet as of #306 PR2, so this is always null today and lights up - * once one lands). */ -export function cardBrief(chat: Chat): string | null { +/** How long a brief may be before it is cut. The line is single-line-ellipsis + * in CSS anyway, so anything past this is invisible — capping here keeps a + * persisted value from being longer than anything that can ever be read. */ +const BRIEF_MAX_CHARS = 120; + +/** Task brief line — the active plan step, falling back to a model-written + * one-liner, then to nothing (decided on #361, 2026-07-25). + * + * The plan step wins on purpose: the card says *step 2 of 5* in its footer and + * *what step 2 is* on this line, both read from `latestPlanForChat`, so the + * two can never disagree. `taskBriefText` is the fallback slot — it has no + * writer outside fixtures today and lights up when #210's producer lands. + * + * No placeholder when there is nothing to say, per the locked footer rule. */ +export function cardBrief( + chat: Chat, + latestPlanOf?: (id: string) => CardPlanLike | null, +): string | null { + const step = activePlanStep(chat, latestPlanOf); + if (step) return step; const text = chat.taskBriefText?.trim(); - return text ? text : null; + return text ? clipBrief(text) : null; +} + +function activePlanStep( + chat: Chat, + latestPlanOf?: (id: string) => CardPlanLike | null, +): string | null { + if (!latestPlanOf) return null; + const plan = latestPlanOf(chat.chatId); + if (!plan) return null; + // An all-complete plan has no `inProgress` item, so it falls through to the + // one-liner and then to nothing rather than showing a stale final step. + const active = plan.items.find((item) => item.status === "inProgress"); + const text = active?.text?.trim(); + return text ? clipBrief(text) : null; +} + +function clipBrief(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > BRIEF_MAX_CHARS ? `${flat.slice(0, BRIEF_MAX_CHARS - 1)}…` : flat; } /** Footer branch item (#306 PR3) — present only when the chat's project root @@ -136,7 +170,7 @@ export function cardBranch( * importing agentChat.ts's own timeline type for it alone (same reasoning * as `CardAgentChatLike` above). */ export interface CardPlanLike { - items: readonly { status: PlanItemStatus }[]; + items: readonly { status: PlanItemStatus; text?: string }[]; } export interface CardPlanProgress { diff --git a/tests/unit/flatWorkCard.test.ts b/tests/unit/flatWorkCard.test.ts index f48d53b7..026a3c6f 100644 --- a/tests/unit/flatWorkCard.test.ts +++ b/tests/unit/flatWorkCard.test.ts @@ -208,6 +208,79 @@ describe("cardBrief — present only with real task-brief text", () => { }); }); +describe("cardBrief — the active plan step (#361 PR2)", () => { + const planOf = (items: { text: string; status: PlanItemStatus }[]) => () => ({ items }); + + it("shows the in-progress step, so the brief and `plan M/N` read one source", () => { + const brief = cardBrief( + chat({ taskBriefText: null }), + planOf([ + { text: "Read the parser", status: "completed" }, + { text: "Lift the Bash-only guard", status: "inProgress" }, + { text: "Write the fixture tests", status: "pending" }, + ]), + ); + expect(brief).toBe("Lift the Bash-only guard"); + }); + + it("prefers the plan step over a cached one-liner, so the two cannot drift", () => { + const brief = cardBrief( + chat({ taskBriefText: "Some older summary" }), + planOf([{ text: "Lift the Bash-only guard", status: "inProgress" }]), + ); + expect(brief).toBe("Lift the Bash-only guard"); + }); + + it("falls through to the one-liner when the plan is all complete", () => { + // M === N leaves no inProgress item; showing the last step would be a lie. + const brief = cardBrief( + chat({ taskBriefText: "Wrapping up" }), + planOf([ + { text: "Read the parser", status: "completed" }, + { text: "Ship it", status: "completed" }, + ]), + ); + expect(brief).toBe("Wrapping up"); + }); + + it("shows nothing when a finished plan has no one-liner either", () => { + const brief = cardBrief( + chat({ taskBriefText: null }), + planOf([{ text: "Ship it", status: "completed" }]), + ); + expect(brief).toBeNull(); + }); + + it("falls through when there is no plan at all", () => { + expect(cardBrief(chat({ taskBriefText: "One-liner" }), () => null)).toBe("One-liner"); + expect(cardBrief(chat({ taskBriefText: null }), () => null)).toBeNull(); + }); + + it("ignores an in-progress step with no text rather than showing a blank line", () => { + const brief = cardBrief( + chat({ taskBriefText: null }), + planOf([{ text: " ", status: "inProgress" }]), + ); + expect(brief).toBeNull(); + }); + + it("flattens and caps a long step — the line is single-line-ellipsis anyway", () => { + const long = `${"x".repeat(200)}`; + const brief = cardBrief( + chat({ taskBriefText: null }), + planOf([{ text: `multi\n line\n ${long}`, status: "inProgress" }]), + ); + expect(brief).not.toBeNull(); + expect(brief!.length).toBeLessThanOrEqual(120); + expect(brief).not.toContain("\n"); + expect(brief!.endsWith("…")).toBe(true); + }); + + it("still works with no plan accessor at all (call sites that have none)", () => { + expect(cardBrief(chat({ taskBriefText: "Just text" }))).toBe("Just text"); + }); +}); + describe("cardBranch — footer principle: present only for a worktree with a resolvable branch (#306 PR3)", () => { it("returns the branch when the project root's cache holds one", () => { const branchOf = (root: string) => (root === "/proj/a" ? "feat/flat-card-plumbing" : undefined);