Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0381a571f2 | ||
|
|
8f3d47c49e |
@@ -190,9 +190,13 @@ choices, not daemon ones.
|
|||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
- The web client groups two or more tool rows after the same `User` row into
|
||||||
one collapsed turn activity block. Opening it reveals independently
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
expandable tool calls, including any raw command, input, output, and diff
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
content the adapter supplied.
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
|
outside as the prominent final answer. The header still reads
|
||||||
|
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
||||||
|
and intermediate text in chronological order, with the text rendered in a
|
||||||
|
subdued style.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
|
|||||||
@@ -413,6 +413,39 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 8,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
|
||||||
|
toolRow("tool:3", "three"),
|
||||||
|
toolRow("tool:4", "four"),
|
||||||
|
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
// Header counts only tool rows, not intermediate text.
|
||||||
|
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
|
||||||
|
// Intermediate text (agent:2) is inside the group.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// First text (agent:1) is outside — it precedes all tools.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
// Final text (agent:3) is outside — no tool follows it.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
|
|||||||
+58
-20
@@ -393,17 +393,53 @@ export function createStructuredConnection(
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
/** Group tool rows (and intermediate agent text between them) by turn.
|
||||||
const groups = new Map<string, TranscriptRow[]>();
|
* An agent_text/agent_thought row is "intermediate" when a tool row follows
|
||||||
|
* it in the same turn — it gets absorbed into the group so the collapsed
|
||||||
|
* block preserves chronological order. The *last* agent text with no
|
||||||
|
* subsequent tool stays standalone (the prominent final answer).
|
||||||
|
* Returns groups keyed by `turn:<userId>`, each with `toolCount` (for the
|
||||||
|
* header label) and `rows` (tools + intermediate text, in order). */
|
||||||
|
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||||||
|
// First: count tools per turn to know which turns qualify for grouping.
|
||||||
|
const toolCounts = new Map<string, number>();
|
||||||
let turnId = "before-first-message";
|
let turnId = "before-first-message";
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||||
const id = `turn:${turnId}`;
|
const gid = `turn:${turnId}`;
|
||||||
const group = groups.get(id) ?? [];
|
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||||
group.push(row);
|
|
||||||
groups.set(id, group);
|
|
||||||
}
|
}
|
||||||
|
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||||
|
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||||||
|
turnId = "before-first-message";
|
||||||
|
// Pending text rows that might be intermediate (not yet confirmed by a
|
||||||
|
// following tool). If no tool follows, they stay standalone.
|
||||||
|
let pendingText: TranscriptRow[] = [];
|
||||||
|
let currentGid = `turn:${turnId}`;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "user") {
|
||||||
|
turnId = row.id;
|
||||||
|
currentGid = `turn:${turnId}`;
|
||||||
|
pendingText = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const count = toolCounts.get(currentGid) ?? 0;
|
||||||
|
if (count < 2) continue;
|
||||||
|
if (row.kind === "agent_text" || row.kind === "agent_thought") {
|
||||||
|
pendingText.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||||
|
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||||
|
// Flush any pending text — a tool follows, so it's intermediate.
|
||||||
|
group.rows.push(...pendingText);
|
||||||
|
pendingText = [];
|
||||||
|
group.rows.push(row);
|
||||||
|
groups.set(currentGid, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pendingText at end of a turn = final text, stays outside (not flushed).
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,9 +449,9 @@ export function createStructuredConnection(
|
|||||||
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
||||||
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
||||||
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
||||||
const groups = toolGroupsByTurn();
|
const groups = activityGroupsByTurn();
|
||||||
const groupedRowIds = new Set<string>();
|
const groupedRowIds = new Set<string>();
|
||||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
const liveGroupIds = new Set([...groups.keys()]);
|
||||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||||
|
|
||||||
rowEls.clear();
|
rowEls.clear();
|
||||||
@@ -425,28 +461,30 @@ export function createStructuredConnection(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") turnId = row.id;
|
if (row.kind === "user") turnId = row.id;
|
||||||
const groupId = `turn:${turnId}`;
|
const groupId = `turn:${turnId}`;
|
||||||
const groupRows = groups.get(groupId) ?? [];
|
const group = groups.get(groupId);
|
||||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
if (group && groupedRowIds.has(row.id)) continue;
|
||||||
if (groupedRowIds.has(row.id)) continue;
|
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
|
||||||
const toolEls = groupRows.flatMap((toolRow) => {
|
// First ungrouped tool in this turn — render the whole activity group.
|
||||||
groupedRowIds.add(toolRow.id);
|
const memberEls = group.rows.flatMap((memberRow) => {
|
||||||
const toolEl = buildRowElement(toolRow);
|
groupedRowIds.add(memberRow.id);
|
||||||
if (!toolEl) return [];
|
const memberEl = buildRowElement(memberRow);
|
||||||
rowEls.set(toolRow.id, toolEl);
|
if (!memberEl) return [];
|
||||||
return [toolEl];
|
rowEls.set(memberRow.id, memberEl);
|
||||||
|
return [memberEl];
|
||||||
});
|
});
|
||||||
const groupEl = buildToolGroup(
|
const groupEl = buildToolGroup(
|
||||||
groupId,
|
groupId,
|
||||||
groupRows,
|
group.rows,
|
||||||
toolEls,
|
memberEls,
|
||||||
expandedGroupIds.has(groupId),
|
expandedGroupIds.has(groupId),
|
||||||
(expanded) => {
|
(expanded) => {
|
||||||
if (expanded) expandedGroupIds.add(groupId);
|
if (expanded) expandedGroupIds.add(groupId);
|
||||||
else expandedGroupIds.delete(groupId);
|
else expandedGroupIds.delete(groupId);
|
||||||
settleDisclosureLayout();
|
settleDisclosureLayout();
|
||||||
},
|
},
|
||||||
|
group.toolCount,
|
||||||
);
|
);
|
||||||
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||||
groupEl.classList.add("st-row-enter");
|
groupEl.classList.add("st-row-enter");
|
||||||
}
|
}
|
||||||
groupEls.set(groupId, groupEl);
|
groupEls.set(groupId, groupEl);
|
||||||
|
|||||||
@@ -581,7 +581,9 @@ export function buildToolGroup(
|
|||||||
children: HTMLElement[],
|
children: HTMLElement[],
|
||||||
expanded: boolean,
|
expanded: boolean,
|
||||||
onExpandedChange?: (expanded: boolean) => void,
|
onExpandedChange?: (expanded: boolean) => void,
|
||||||
|
toolCount?: number,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
|
const count = toolCount ?? rows.length;
|
||||||
const wrap = el("div", "st-tool-group");
|
const wrap = el("div", "st-tool-group");
|
||||||
wrap.dataset.toolGroupId = id;
|
wrap.dataset.toolGroupId = id;
|
||||||
const head = document.createElement("button");
|
const head = document.createElement("button");
|
||||||
@@ -593,7 +595,7 @@ export function buildToolGroup(
|
|||||||
glyph.textContent = "›";
|
glyph.textContent = "›";
|
||||||
glyph.setAttribute("aria-hidden", "true");
|
glyph.setAttribute("aria-hidden", "true");
|
||||||
const title = el("span", "st-tool-group-title");
|
const title = el("span", "st-tool-group-title");
|
||||||
title.textContent = `${rows.length} tool calls`;
|
title.textContent = `${count} tool calls`;
|
||||||
head.append(glyph, title);
|
head.append(glyph, title);
|
||||||
|
|
||||||
const body = el("div", "st-tool-group-body st-disclosure");
|
const body = el("div", "st-tool-group-body st-disclosure");
|
||||||
|
|||||||
@@ -2898,6 +2898,26 @@ input.leaf-rename:focus {
|
|||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* intermediate agent text inside a tool group — subdued so the final
|
||||||
|
answer outside the group stays the prominent one. */
|
||||||
|
.st-tool-group-body .st-row-agent {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-agent .st-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-group-body .st-row-thought {
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
.st-tool-group-body[hidden] {
|
.st-tool-group-body[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user