Refine tool call summaries and scrolling (#2069)

* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
This commit is contained in:
Mohamed Boudra
2026-07-14 21:16:29 +02:00
committed by GitHub
parent d781b68711
commit 1b8af2e58e
55 changed files with 1484 additions and 2488 deletions
@@ -0,0 +1,501 @@
import { describe, expect, it } from "vitest";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "./projection";
import { resolveOverviewHeader } from "./overview/model";
type AssistantMessageItem = Extract<StreamItem, { kind: "assistant_message" }>;
function toolCall(
id: string,
detail: ToolCallDetail,
options: {
name?: string;
status?: "running" | "completed" | "failed" | "canceled";
} = {},
): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`),
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: options.name ?? detail.type,
status: options.status ?? "completed",
error: options.status === "failed" ? "boom" : null,
detail,
},
},
};
}
function assistant(id: string): AssistantMessageItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-01-01T00:01:00.000Z"),
};
}
function project(input: {
level: ToolCallDetailLevel;
tail?: StreamItem[];
head?: StreamItem[];
isTurnActive?: boolean;
preparedHistory?: PreparedToolCallHistory | null;
}) {
const tail = input.tail ?? [];
return projectToolCallDetailLevel({
level: input.level,
tail,
head: input.head ?? [],
preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail),
isTurnActive: input.isTurnActive ?? false,
});
}
describe("tool call detail-level projection", () => {
it("passes detailed timelines through without grouping work", () => {
const tail = [toolCall("1", { type: "shell", command: "one" })];
const head = [toolCall("2", { type: "shell", command: "two" })];
const prepared = prepareToolCallHistory("detailed", tail);
const result = project({ level: "detailed", tail, head, preparedHistory: prepared });
expect(prepared).toBeNull();
expect(result.tail).toBe(tail);
expect(result.head).toBe(head);
expect(result.groupsByHostId.size).toBe(0);
});
it("keeps one stable overview host as a run grows", () => {
const firstCall = toolCall("1", { type: "shell", command: "one" });
const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" });
const prepared = prepareToolCallHistory("overview", []);
const single = project({
level: "overview",
head: [firstCall],
isTurnActive: true,
preparedHistory: prepared,
});
expect(single.head).toEqual([firstCall]);
expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
calls: [firstCall],
latest: firstCall,
isSealed: false,
});
const grouped = project({
level: "overview",
head: [firstCall, secondCall],
isTurnActive: true,
preparedHistory: prepared,
});
expect(grouped.head).toEqual([
expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }),
]);
expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
calls: [firstCall, secondCall],
latest: secondCall,
isSealed: false,
});
});
it("shows the latest call while collapsed and the summary while expanded", () => {
const calls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }),
];
const result = project({
level: "overview",
head: calls,
isTurnActive: true,
});
const group = result.groupsByHostId.get("1");
if (!group) {
throw new Error("Expected an overview group");
}
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] });
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" });
});
it("keeps a parallel group loading while any call is still running", () => {
const calls = [
toolCall("1", { type: "shell", command: "slow" }, { status: "running" }),
toolCall("2", { type: "shell", command: "done" }),
];
const result = project({ level: "overview", head: calls, isTurnActive: true });
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
});
it("keeps a one-call run presented as its tool call", () => {
const call = toolCall("1", { type: "shell", command: "one" });
const result = project({ level: "overview", head: [call] });
const group = result.groupsByHostId.get(call.id);
if (!group) {
throw new Error("Expected an overview group");
}
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call });
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call });
});
it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
const calls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
toolCall("3", { type: "read", filePath: "/repo/b.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }),
];
const prepared = prepareToolCallHistory("overview", []);
const active = project({
level: "overview",
head: calls,
isTurnActive: true,
preparedHistory: prepared,
});
const activeGroup = active.groupsByHostId.get("1");
expect(activeGroup).toMatchObject({
mode: "overview",
run: { id: "1", latest: calls[3], isSealed: false },
});
expect(resolveOverviewHeader(activeGroup!, false)).toEqual({
kind: "latest",
call: calls[3],
});
const boundary = assistant("answer");
const sealed = project({
level: "overview",
head: [...calls, boundary],
isTurnActive: true,
preparedHistory: prepared,
});
expect(sealed.groupsByHostId.get("1")).toMatchObject({
mode: "overview",
run: { latest: calls[3], isSealed: true },
summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 },
});
expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({
kind: "summary",
});
});
it("keeps a running overview header live before the agent lifecycle catches up", () => {
const calls = ["1", "2", "3", "4"].map((id) =>
toolCall(id, { type: "shell", command: id }, { status: "running" }),
);
const result = project({
level: "overview",
tail: calls,
isTurnActive: false,
});
expect(result.groupsByHostId.get("1")).toMatchObject({
run: { latest: calls[3], isSealed: false },
});
expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: calls[3],
});
});
it("seals the trailing overview group only when the turn ends", () => {
const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
const prepared = prepareToolCallHistory("overview", []);
const betweenCalls = project({
level: "overview",
head: calls,
isTurnActive: true,
preparedHistory: prepared,
});
const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" });
const continued = project({
level: "overview",
head: [...calls, nextCall],
isTurnActive: true,
preparedHistory: prepared,
});
const ended = project({
level: "overview",
head: [...calls, nextCall],
isTurnActive: false,
preparedHistory: prepared,
});
expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false);
expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: calls[3],
});
expect(continued.groupsByHostId.get("1")?.run).toMatchObject({
latest: nextCall,
isSealed: false,
});
expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: nextCall,
});
expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true);
expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({
kind: "summary",
});
});
it("builds overview summaries without category-specific presentation data", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }),
toolCall("3", { type: "shell", command: "npm test" }),
toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }),
];
const overview = project({ level: "overview", head: calls });
expect(overview.groupsByHostId.get("1")).toEqual({
mode: "overview",
run: expect.any(Object),
failedCount: 1,
isLoading: false,
summary: {
editedFileCount: 1,
commandCount: 1,
readFileCount: 2,
searchCount: 0,
otherToolCount: 0,
paseoCallCount: 0,
},
});
});
it("distinguishes reads, searches, and other tools in overview", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }),
toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }),
toolCall(
"4",
{ type: "search", query: "paseo", toolName: "web_search" },
{ status: "failed" },
),
toolCall("5", { type: "fetch", url: "not a url" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
failedCount: 1,
summary: {
editedFileCount: 0,
commandCount: 0,
readFileCount: 2,
searchCount: 1,
otherToolCount: 2,
},
});
});
it("counts unique edited files and every shell command in overview", () => {
const calls = [
toolCall("1", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("2", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("3", { type: "write", filePath: "/repo/b.ts" }),
toolCall("4", { type: "shell", command: "npm test" }),
toolCall("5", { type: "shell", command: "npm run lint" }),
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: {
editedFileCount: 2,
commandCount: 2,
readFileCount: 1,
otherToolCount: 0,
},
});
});
it("counts Paseo calls separately from other tools", () => {
const calls = [
toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }),
toolCall(
"2",
{ type: "unknown", input: null, output: null },
{ name: "mcp__paseo__list_worktrees" },
),
toolCall("3", { type: "fetch", url: "https://paseo.sh" }),
toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { otherToolCount: 2, paseoCallCount: 2 },
});
});
it("classifies direct Brave search and Paseo runtime tool names", () => {
const unknownDetail = { type: "unknown" as const, input: null, output: null };
const calls = [
toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }),
toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }),
toolCall("3", unknownDetail, { name: "paseo_list_providers" }),
toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 },
});
});
it("reuses prepared history and sealed group models across live-head updates", () => {
const historicalCalls = ["1", "2", "3", "4"].map((id) =>
toolCall(id, { type: "shell", command: id }),
);
const tail = [...historicalCalls, assistant("boundary")];
const prepared = prepareToolCallHistory("overview", tail);
if (!prepared) {
throw new Error("Overview history must be prepared");
}
expect(prepared.grouped.tail).toEqual([
expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }),
tail[4],
]);
const first = project({
level: "overview",
tail,
head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })],
isTurnActive: true,
preparedHistory: prepared,
});
const second = project({
level: "overview",
tail,
head: [
toolCall("5", { type: "read", filePath: "/repo/a.ts" }),
toolCall("6", { type: "read", filePath: "/repo/b.ts" }),
],
isTurnActive: true,
preparedHistory: prepared,
});
expect(first.tail).toBe(prepared.grouped.tail);
expect(second.tail).toBe(prepared.grouped.tail);
expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2);
});
it("preserves projected history identity during assistant-only head updates", () => {
const trailingCalls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
];
const tail = [assistant("before"), ...trailingCalls];
const prepared = prepareToolCallHistory("overview", tail);
if (!prepared) {
throw new Error("Overview history must be prepared");
}
const firstHead = [assistant("answer")];
const secondHead = [{ ...firstHead[0], text: "answer grows" }];
const first = project({
level: "overview",
tail,
head: firstHead,
isTurnActive: true,
preparedHistory: prepared,
});
const second = project({
level: "overview",
tail,
head: secondHead,
isTurnActive: true,
preparedHistory: prepared,
});
expect(first.tail).toBe(prepared.grouped.tail);
expect(second.tail).toBe(prepared.grouped.tail);
expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
});
it("forms one group across the retained-history and live-head boundary", () => {
const tail = [
assistant("before"),
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "shell", command: "two" }),
];
const head = [
toolCall("3", { type: "read", filePath: "/repo/a.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }),
];
const result = project({ level: "overview", tail, head, isTurnActive: true });
expect(result.tail).toEqual([
tail[0],
expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }),
]);
expect(result.head).toEqual([]);
expect(result.groupsByHostId.get("1")?.run).toMatchObject({
calls: [...tail.slice(1), ...head],
latest: head[1],
isSealed: false,
});
expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1"));
});
it("keeps a trailing history-only group in the retained segment", () => {
const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
const result = project({ level: "overview", tail, isTurnActive: false });
expect(result.tail).toEqual([
expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }),
]);
expect(result.head).toEqual([]);
expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true);
});
it("hosts single calls while leaving plans and spoken messages ungrouped", () => {
const singleCall = toolCall("1", { type: "shell", command: "one" });
const plan = toolCall("2", { type: "plan", text: "Plan" });
const speak = toolCall(
"3",
{ type: "unknown", input: "Hello", output: null },
{ name: "speak" },
);
const result = project({ level: "overview", head: [singleCall, plan, speak] });
expect(result.head).toEqual([singleCall, plan, speak]);
expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]);
expect(result.groupsByHostId.size).toBe(1);
});
});