Skip to content
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
1,623 changes: 798 additions & 825 deletions package-lock.json

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions packages/chat-surface/src/activity/ToolRunGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,37 @@ describe("ToolRunGroup", () => {
);
});
});

describe("ToolRunGroup — the digest", () => {
const DIGEST = "Searched the web · Read 3 files";

it("names the work a settled group folds, keeping the 'Worked for' prefix", () => {
// `Worked for` is pinned by the TR-16 journey (`label.startswith`), so the
// digest replaces only the count after it.
render(g({ digest: DIGEST }));
expect(label()).toBe(`Worked for 8.3s · ${DIGEST}`);
});

it("names the work a FAILED group folds too", () => {
render(g({ state: "failed", digest: DIGEST }));
expect(label()).toBe(`Failed after 8.3s · ${DIGEST}`);
});

it("leaves the running label alone — an open group's cards already say it", () => {
render(g({ state: "running", done: 1, total: 6, digest: DIGEST }));
expect(label()).toBe("Working · 1 of 6");
});

it("falls back to the count rather than folding silently", () => {
render(g({ digest: " " }));
expect(label()).toBe("Worked for 8.3s · 3 steps");
});

it("keeps the full sentence recoverable when the label ellipsizes", () => {
render(g({ digest: DIGEST }));
expect(screen.getByTestId("tool-run-group-label")).toHaveAttribute(
"title",
`Worked for 8.3s · ${DIGEST}`,
);
});
});
34 changes: 30 additions & 4 deletions packages/chat-surface/src/activity/ToolRunGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export interface ToolRunGroupProps {
readonly retried?: number;
/** Pre-formatted elapsed (PRD-07's single formatter). `null` → omitted. */
readonly elapsed?: string | null;
/**
* WHAT the members did — "Searched the web · Read 3 files" — from
* `describeActivity`. Replaces the bare `N steps` in the SETTLED and FAILED
* labels; the running label keeps its `N of M`, because a running group is
* open and its cards are already saying this themselves.
*
* PRD-03's reference table shows the half this component originally dropped:
* Codex folds to `Worked for 26s ›`, but Claude Desktop keeps a noun —
* `Read depth.py ›`. A count tells the reader work is folded; only a noun
* tells them what it was, and a settled group is the one moment nothing else
* on screen can.
*/
readonly digest?: string | null;
/** The member cards. */
readonly children?: ReactNode;
/**
Expand All @@ -71,9 +84,15 @@ function summaryLabel(props: {
total: number;
elapsed: string | null;
compact: boolean;
digest: string | null;
}): string {
const { state, done, total, elapsed, compact } = props;
const steps = `${total} step${total === 1 ? "" : "s"}`;
const { state, done, total, elapsed, compact, digest } = props;
// The digest wins when there is one; the count is the fallback, so a caller
// that cannot describe its members still never folds them silently.
const steps =
digest !== null && digest.trim() !== ""
? digest
: `${total} step${total === 1 ? "" : "s"}`;
if (state === "running") {
return compact ? `${done}/${total}` : `Working · ${done} of ${total}`;
}
Expand All @@ -91,6 +110,7 @@ export function ToolRunGroup({
total,
retried = 0,
elapsed = null,
digest = null,
children,
compact = false,
id,
Expand Down Expand Up @@ -144,7 +164,7 @@ export function ToolRunGroup({
[],
);

const label = summaryLabel({ state, done, total, elapsed, compact });
const label = summaryLabel({ state, done, total, elapsed, compact, digest });

return (
<details
Expand All @@ -171,7 +191,13 @@ export function ToolRunGroup({
"⚙"
)}
</span>
<span style={labelStyle} data-testid="tool-run-group-label">
{/* `title` because the label ellipsizes in a narrow column, and a
sentence that names the work is worth recovering on hover. */}
<span
style={labelStyle}
data-testid="tool-run-group-label"
title={label}
>
{label}
</span>
{/* PRD-04 territory: the red lives inside the group, not on it. This is
Expand Down
54 changes: 54 additions & 0 deletions packages/chat-surface/src/messages/ThinkingShimmer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,57 @@ describe("ThinkingBlock · folded activity", () => {
expect(block().open).toBe(false);
});
});

describe("ThinkingBlock — the digest", () => {
const DIGEST = "Searched the web · Dispatched 1 subagent";
const steps = (): HTMLElement =>
screen.getByTestId("cs-thinking-block-steps");

it("names what the folded cards did instead of counting them", () => {
// The live capture behind this: a settled turn read
// "Thought process · 2 steps" and nothing recorded the search or the
// subagent. A number says work exists; it takes a noun to say what it was.
render(
<ThinkingBlock text="" running={false} stepCount={2} digest={DIGEST} />,
);
expect(steps()).toHaveTextContent(`· ${DIGEST}`);
expect(screen.getByTestId("cs-thinking-block")).toHaveAttribute(
"data-steps",
"2",
);
});

it("falls back to the count when the caller cannot describe its cards", () => {
render(<ThinkingBlock text="" running={false} stepCount={2} digest=" " />);
expect(steps()).toHaveTextContent("· 2 steps");
});

it("says nothing about steps when there are none, digest or not", () => {
render(
<ThinkingBlock text="" running={false} stepCount={0} digest={DIGEST} />,
);
expect(screen.queryByTestId("cs-thinking-block-steps")).toBeNull();
});

it("makes the digest the ONE item that yields in a narrow column", () => {
// jsdom runs no layout, so this asserts the shrink CONTRACT that decides
// clipping order. The digest is the only unbounded string in the header; if
// it were `flex: none`, a ~335px Studio column would push the chevron — the
// only affordance this control has — off the edge instead.
render(
<ThinkingBlock text="" running={false} stepCount={2} digest={DIGEST} />,
);
expect(steps()).toHaveStyle({
flex: "0 1 auto",
minWidth: "0",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
expect(steps()).toHaveAttribute("title", DIGEST);
const summary = screen
.getByTestId("cs-thinking-block")
.querySelector("summary") as HTMLElement;
expect(summary).toHaveStyle({ maxWidth: "100%" });
});
});
55 changes: 53 additions & 2 deletions packages/chat-surface/src/messages/ThinkingShimmer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ const summaryStyle: CSSProperties = {
margin: "0 -6px",
borderRadius: 6,
width: "fit-content",
// Bounded, because the header now carries one unbounded string — the digest.
// `fit-content` alone grows past the column, and the Studio chat column is
// ~335px: the row would push its own chevron (the only affordance this
// control has) off the edge.
maxWidth: "100%",
minWidth: 0,
boxSizing: "border-box",
};

const bodyStyle: CSSProperties = {
Expand Down Expand Up @@ -165,6 +172,8 @@ const activityListStyle: CSSProperties = {
const settledLabelStyle: CSSProperties = {
color: "var(--color-text-muted, #98989f)",
fontSize: CHROME_SIZE,
flex: "none",
whiteSpace: "nowrap",
};

/** The step count riding next to the label. Deliberately NOT inside the
Expand All @@ -174,6 +183,23 @@ const stepsStyle: CSSProperties = {
color: "var(--color-text-subtle, #64646d)",
fontSize: CHROME_SIZE,
whiteSpace: "nowrap",
// THE item that gives way. The label and the chevron stay `flex: none`; this
// span is the only arbitrary-length thing in the row, so it is the one that
// ellipsizes in a narrow column. The full sentence stays on `title`.
flex: "0 1 auto",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
};

/** The digest variant. The bare count is decoration and sits one rung quieter
* than the label; a sentence naming the work is CONTENT — after a run settles
* it is the only record on screen of what the agent did — so it gets the
* label's contrast. `--color-text-subtle` on the app background is ~3.3:1,
* under AA for text; `--color-text-muted` is ~7:1. */
const digestStyle: CSSProperties = {
...stepsStyle,
color: "var(--color-text-muted, #98989f)",
};

const failedStepsStyle: CSSProperties = {
Expand All @@ -197,6 +223,19 @@ export interface ThinkingBlockProps {
readonly activity?: ReactNode;
/** How many cards `activity` holds. Drives the header's `· N steps`. */
readonly stepCount?: number;
/**
* WHAT those cards did — "Searched the web · Dispatched 1 subagent" —
* supplied by `TcChat` from `describeActivity`. When present it REPLACES the
* bare count in the header.
*
* The count was written as the guard against this block hiding work ("the
* only thing that tells a reader work is folded under a collapsed row"), and
* a live capture showed it is not enough of one: a settled turn read
* "Thought process · 2 steps" and nothing on screen recorded that a search
* and a subagent had happened. A number says work exists; it takes a noun to
* say what it was. A plain string, so this module never learns about tools.
*/
readonly digest?: string | null;
/** How many of them failed. Drives the header's red `· N failed`. */
readonly failedCount?: number;
/**
Expand Down Expand Up @@ -227,6 +266,7 @@ export function ThinkingBlock({
children,
activity,
stepCount = 0,
digest = null,
failedCount = 0,
activityRunning = false,
}: ThinkingBlockProps): ReactElement {
Expand Down Expand Up @@ -275,8 +315,15 @@ export function ThinkingBlock({
[],
);

// The digest wins when there is one; the count stays as the fallback so a
// caller that cannot describe its cards still never folds them silently.
const hasDigest = digest !== null && digest.trim() !== "";
const steps =
stepCount > 0 ? `${stepCount} step${stepCount === 1 ? "" : "s"}` : null;
stepCount <= 0
? null
: hasDigest
? digest
: `${stepCount} step${stepCount === 1 ? "" : "s"}`;

return (
<details
Expand Down Expand Up @@ -307,7 +354,11 @@ export function ThinkingBlock({
reader work is folded under a collapsed row. Without it, absorbing
the tool cards would BE hiding them. */}
{steps !== null ? (
<span style={stepsStyle} data-testid="cs-thinking-block-steps">
<span
style={hasDigest ? digestStyle : stepsStyle}
data-testid="cs-thinking-block-steps"
title={steps}
>
· {steps}
</span>
) : null}
Expand Down
17 changes: 13 additions & 4 deletions packages/chat-surface/src/thread-canvas/TcChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2726,17 +2726,26 @@ describe("TcChat — tool calls folded into the thought", () => {
expect(screen.queryByTestId("tool-run-group")).not.toBeInTheDocument();
});

it("states how many steps it folded away", () => {
// The count is the honesty guarantee: the row is collapsed by default, so
// without it, folding the cards in would simply be hiding them.
it("NAMES the work it folded away, not just how much of it", () => {
// The row is collapsed by default, so without a statement of what is under
// it, folding the cards in would simply be hiding them. That statement used
// to be a count ("· 2 steps"), and a live capture showed the count is not
// enough: a settled turn that had searched the web and dispatched a
// subagent read "Thought process · 2 steps", and nothing on screen recorded
// that either had happened. The guarantee is now a NOUN.
renderTurn({
toolCalls: [
toolCall({ id: "call-1", runId: RUN, sequenceNo: 2 }),
toolCall({ id: "call-2", runId: RUN, sequenceNo: 3 }),
],
});
expect(screen.getByTestId("cs-thinking-block-steps")).toHaveTextContent(
"· 2 steps",
"· Searched the web ×2",
);
// The count survives as data for anything that needs the number.
expect(screen.getByTestId("cs-thinking-block")).toHaveAttribute(
"data-steps",
"2",
);
});

Expand Down
29 changes: 29 additions & 0 deletions packages/chat-surface/src/thread-canvas/TcChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { ToolRunGroup } from "../activity/ToolRunGroup";
// PRD-03 FR-3.10 — reuse the formatter that already exists rather than adding a
// third. PRD-07 renames/consolidates it; this call site moves with it.
import { formatSubagentDuration } from "../subagents/labels";
import { describeActivity, type ActivityDigestMember } from "./activityDigest";
import {
groupActivityStream,
summariseGroup,
Expand Down Expand Up @@ -1729,6 +1730,7 @@ function MessageListBody(props: MessageListBodyProps): ReactNode {
runFailed,
),
total: absorbed.length,
digest: describeActivity(absorbed.flatMap(toDigestMember)),
},
);
}
Expand Down Expand Up @@ -1877,6 +1879,7 @@ function MessageListBody(props: MessageListBodyProps): ReactNode {
? null
: formatSubagentDuration(summary.elapsedMs)
}
digest={describeActivity(members.flatMap(toDigestMember))}
compact={compact}
>
{members.map(renderItem)}
Expand Down Expand Up @@ -2043,6 +2046,7 @@ function renderPart(
: {
activity: activity.cards,
stepCount: activity.total,
digest: activity.digest,
failedCount: activity.summary.retried,
activityRunning: activity.summary.state === "running",
})}
Expand Down Expand Up @@ -2127,6 +2131,31 @@ interface ThoughtActivity {
readonly cards: readonly ReactNode[];
readonly summary: GroupSummary;
readonly total: number;
/** What the cards did, for the settled header — see `activityDigest.ts`. */
readonly digest: string | null;
}

/**
* One folded stream item, as the digest sees it.
*
* `flatMap`-shaped (an array of zero or one) because BOTH fold sites call it and
* a group's members are not only tools and fleets: anything else contributes no
* phrase rather than a wrong one.
*/
function toDigestMember(item: StreamItem): readonly ActivityDigestMember[] {
if (item.kind === "tool") {
return [
{
kind: "tool",
toolName: item.toolCall.toolName,
args: item.toolCall.args,
},
];
}
if (item.kind === "fleet") {
return [{ kind: "fleet", total: item.fleet.total }];
}
return [];
}

// PR-3.8 — reuse the hoisted `SubagentFleetCard` (Phase 1D) with the projected
Expand Down
Loading
Loading