Skip to content
This repository was archived by the owner on Sep 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/screens/workbench/ProjectsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 41 additions & 7 deletions src/stores/flatWorkCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/flatWorkCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading