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
29 changes: 29 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,35 @@ connected — never a generic verb applied to a state where nothing has
been set up yet, and never invented synonyms for the same action across
screens. One action, one verb, everywhere that action appears.

## Tool Activity in the Conversation

What an agent did between question and answer renders as sentences, never
as the material it was made from. A tool call is described by what it
accomplished — "Searched the web for 'pricing'", "Wrote a file —
report.md", "Posted a message in Slack #general" — never by its
identifier, its namespace, or a humanised spelling of either. A result is
plain text: the prose the tool returned, or a count when it returned a
list. Raw JSON never reaches a reader, expanded or not; the only
exception is code the user actually asked for, which is prose, not
machinery.

Tense follows state: a call still running speaks in the present
("Searching…"), a settled one in the past ("Searched…"). The same rows
render mid-turn and in the persisted transcript, so nothing restyles
itself the moment a turn ends.

Consecutive calls fold into one round on a single line — the step
currently working while the turn is open, then a count of steps once it
settles. Rounds stay collapsed by default, including while running: the
answer is what the reader is waiting for, and a transcript that expands
its own machinery pushes the answer off screen. Detail opens on demand,
one click, and a row with nothing to show offers no disclosure at all.

A failure says so plainly, in words, and opens itself — it is the one
state where the detail is the point. A tool that failed without saying
why still says that much. Failure is the only state in this strip that
earns colour; everything else is quiet chrome.

## State Pills

Status indicators (ok / warn / error / running) use semantic colors that
Expand Down
60 changes: 1 addition & 59 deletions packages/chat-ui/src/agent-part-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,5 @@
import { describe, expect, test } from "bun:test";
import { toReactUiToolTrace, toReactUiReasoning } from "./agent-part-adapter";

describe("toReactUiToolTrace (CL-6318: chat's 4 statuses onto react-ui's 6)", () => {
const base = {
kind: "tool-trace" as const,
name: "slack__post",
input: { a: 1 },
};

test("success is react-ui's output-available, the state that carries a result", () => {
const adapted = toReactUiToolTrace({
...base,
status: "success",
output: "ok",
});
expect(adapted.status).toBe("output-available");
expect(adapted.output).toBe("ok");
});

test("pending, running and error carry across unchanged", () => {
for (const status of ["pending", "running", "error"] as const) {
expect(toReactUiToolTrace({ ...base, status }).status).toBe(status);
}
});

test("name and input ride along so the expanded detail can show them", () => {
const adapted = toReactUiToolTrace({ ...base, status: "running" });
expect(adapted.name).toBe("slack__post");
expect(adapted.input).toEqual({ a: 1 });
});

test("the caller's message-scoped key becomes the toolCallId", () => {
// react-ui keys tool calls by id; chat's wire has no such field, so a
// key the caller already guarantees unique stands in rather than an
// invented id that could collide across messages.
const adapted = toReactUiToolTrace(
{ ...base, status: "running" },
"msg_1-3",
);
expect(adapted.toolCallId).toBe("msg_1-3");
});

test("output stays absent when the wire carried none", () => {
expect("output" in toReactUiToolTrace({ ...base, status: "running" })).toBe(
false,
);
});

// The two approval states have no `@corbits/chat` equivalent — approvals
// ride as a `block` part bound to the real approvals flow, not as a tool
// status. Nothing should silently map onto them.
test("no chat status maps onto the approval states", () => {
const reachable = (["pending", "running", "success", "error"] as const).map(
(status) => toReactUiToolTrace({ ...base, status }).status,
);
expect(reachable).not.toContain("approval-requested");
expect(reachable).not.toContain("output-denied");
});
});
import { toReactUiReasoning } from "./agent-part-adapter";

describe("toReactUiReasoning", () => {
test("carries the text through", () => {
Expand Down
42 changes: 7 additions & 35 deletions packages/chat-ui/src/agent-part-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,14 @@
// Adapting `@corbits/chat`'s parts to the shapes `@corbits/react-ui`
// renders (CL-6318).
//
// The two unions agree on their kinds — text, reasoning, tool-trace,
// block, file — because react-ui's was built against this wire. They do
// not agree on the tool-call status enum: chat's has four states,
// react-ui's has six, adding `approval-requested` and `output-denied`.
// Those two have no chat equivalent today (an approval rides as a `block`
// part bound to the real approvals flow, not as a tool status), so nothing
// here maps onto them — a gap to close upstream-first if the wire ever
// grows them, never by inventing a status the server never sent.
// Only reasoning crosses this boundary now. Tool calls used to as well,
// but the conversation renders them through `tool-activity.tsx` instead:
// react-ui's `ToolBlock` shows one call at a time with its arguments and
// its result as `JSON.stringify` output, and the chat surface groups a
// turn's calls into rounds and never shows a reader JSON at all.

import type {
PartReasoning,
PartToolTrace,
ToolTraceStatus,
} from "@corbits/react-ui";
import type { ReasoningPart, ToolTracePart } from "@corbits/chat/parts";

/** chat's `success` is react-ui's `output-available` — the same state
* under the name that says what it carries. The rest are identical. */
export function toReactUiToolTrace(
part: ToolTracePart,
/** react-ui keys tool calls by id; chat's wire carries none, so the
* caller passes its own message-scoped key rather than an invented id
* that could collide across messages. */
toolCallId = "",
): PartToolTrace {
const status: ToolTraceStatus =
part.status === "success" ? "output-available" : part.status;
const base = {
kind: "tool-trace" as const,
toolCallId,
name: part.name,
status,
input: part.input,
};
return part.output !== undefined ? { ...base, output: part.output } : base;
}
import type { PartReasoning } from "@corbits/react-ui";
import type { ReasoningPart } from "@corbits/chat/parts";

/** chat's reasoning carries no duration; react-ui's is optional, so it
* stays absent rather than being fabricated. */
Expand Down
115 changes: 100 additions & 15 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1959,53 +1959,138 @@
row, and a retry note, shown above the composer while a turn streams.
Square, zero-radius, hairline borders — matches `.chat-block`'s
generative-UI language rather than the rounded typing-dot pill. */
.chat-turn-activity {
.chat-tool-activity {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.15rem 0;
font-size: 0.72rem;
color: var(--muted-foreground);
}

.chat-tool-activity-live {
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}

.chat-turn-activity-row {
.chat-tool-activity-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}

.chat-tool-activity-row[data-indented="true"] {
padding-left: 0.85rem;
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
margin-left: 0.18rem;
}

/* A row with no detail is inert text, not a control: it lines up with the
triggers above and below it but never invites a click. */
.chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)) {
flex-direction: row;
align-items: center;
gap: 0.45rem;
font-size: 0.72rem;
color: var(--muted-foreground);
padding-block: 0.15rem;
}

.chat-tool-activity-trigger {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 0.15rem 0;
border: 0;
border-radius: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}

.chat-tool-activity-trigger:hover {
color: var(--foreground);
}

.chat-turn-activity-marker {
.chat-tool-activity-trigger:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}

.chat-tool-activity-marker {
width: 0.4rem;
height: 0.4rem;
flex: none;
border-radius: 0;
background: var(--primary);
background: var(--muted-foreground);
opacity: 0.6;
}

.chat-turn-activity-row[data-status="running"] .chat-turn-activity-marker {
.chat-tool-activity-marker[data-status="running"],
.chat-tool-activity-marker[data-status="pending"] {
background: var(--primary);
opacity: 1;
animation: chat-block-pulse 1.6s ease infinite;
}

.chat-turn-activity-row[data-status="done"] .chat-turn-activity-marker {
background: var(--muted-foreground);
opacity: 0.6;
/* A failure is the one state that earns colour here — everything else in
this strip is deliberately quiet chrome. */
.chat-tool-activity-marker[data-status="failed"] {
background: var(--destructive, #b42318);
opacity: 1;
}

.chat-tool-activity-row[data-status="failed"] .chat-tool-activity-phrase {
color: var(--destructive, #b42318);
}

.chat-turn-activity-label {
.chat-tool-activity-phrase {
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
}

.chat-turn-activity-elapsed {
margin-left: auto;
.chat-tool-activity-meta {
flex: none;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}

.chat-tool-activity-caret {
flex: none;
width: 0.75rem;
height: 0.75rem;
transition: transform 0.15s ease-out;
}

.chat-tool-activity-caret[data-open="true"] {
transform: rotate(90deg);
}

.chat-tool-activity-detail {
margin: 0 0 0.2rem 0.85rem;
padding-left: 0.7rem;
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
max-height: 12rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--muted-foreground);
}

.chat-tool-activity-rows {
display: flex;
flex-direction: column;
gap: 0.15rem;
}

.chat-turn-activity-thinking {
.chat-tool-activity-thinking {
font-style: italic;
}

.chat-turn-activity-retry {
.chat-tool-activity-retry {
color: var(--warn, #b7791f);
}

Expand Down
34 changes: 14 additions & 20 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ import {
Button,
EmptyState,
PartsRenderer,
ToolBlock,
toast,
toolTraceToBlockState,
} from "@corbits/react-ui";
import { toReactUiReasoning, toReactUiToolTrace } from "./agent-part-adapter";
import { toReactUiReasoning } from "./agent-part-adapter";
import { groupTimelineParts } from "./tool-activity";
import { ToolActivityGroup } from "./tool-activity-view";
import {
ArrowBendUpLeft,
ChatCircle,
Expand Down Expand Up @@ -1234,8 +1234,12 @@ function MessageParts({
>
{showDayDivider && <DayDivider createdAt={item.createdAt} />}
<div className="chat-message-row">
{item.parts.map((part, index) => {
const key = `${groupKey}-${index}`;
{groupTimelineParts(item.parts, groupKey).map((group) => {
const key = group.key;
if (group.kind === "tool-activity") {
return <ToolActivityGroup key={key} rows={group.rows} />;
}
const part = group.part;
if (part.kind === "text" && part.turnFailed === true) {
return (
<FailedTurnStrip
Expand Down Expand Up @@ -1293,26 +1297,16 @@ function MessageParts({
/>
);
}
// The agent's own thinking and its tool calls (CL-6318). Both
// render through react-ui, which already owns this presentation —
// a reasoning disclosure and the tool-call lifecycle — so the
// workbench carries no second version of either.
// The agent's own thinking still renders through react-ui, which
// owns the reasoning disclosure. Tool calls no longer do: they
// arrive here already folded into rounds by `groupTimelineParts`
// above, and react-ui's `ToolBlock` renders one call at a time
// with its arguments and result as `JSON.stringify` output.
if (part.kind === "reasoning") {
return (
<PartsRenderer key={key} parts={[toReactUiReasoning(part)]} />
);
}
if (part.kind === "tool-trace") {
const trace = toReactUiToolTrace(part, key);
return (
<ToolBlock
key={key}
name={trace.name}
state={toolTraceToBlockState(trace)}
input={trace.input}
/>
);
}
if (part.kind === "block") {
return (
<BlockPartView
Expand Down
Loading
Loading