fix(ui): restore tool activity interaction polish (#125790)

* fix(ui): restore tool activity interaction polish

* fix(ui): restore final tool activity visuals

* fix(ui): keep running tool activity compact

* fix(ui): preserve tool disclosure state ownership

* fix(ui): preserve disclosure scroll after resize

---------

Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
ClawSweeper
2026-08-18 03:56:22 -07:00
committed by GitHub
parent cd05e3cd46
commit 415ffc010b
12 changed files with 211 additions and 40 deletions
+8
View File
@@ -161,6 +161,14 @@ export const toolIcons = {
<path d="M6 21V9a9 9 0 0 0 9 9" />`),
terminal: strokeIcon(svg` <polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />`),
squareTerminal: strokeIcon(svg` <path d="m7 11 2-2-2-2" />
<path d="M11 13h4" />
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />`),
listTree: strokeIcon(svg` <path d="M21 12h-8" />
<path d="M21 6H8" />
<path d="M21 18h-8" />
<path d="M3 6v4c0 1.1.9 2 2 2h3" />
<path d="M3 10v6c0 1.1.9 2 2 2h3" />`),
claw: strokeIcon(svg` <path
d="M8.2 10 A5.2 5.2 0 1 0 8.2 20.4 A5.2 5.2 0 0 0 8.2 10 Z M10.2 20 C14.5 20.8 19 18.6 22.3 13.2 C21 12.9 19.7 12.7 18.4 12.8 L17.5 14.6 L16 12.9 L14.3 14.5 L13.5 13 L11.5 14.2 Z"
/>
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { chatThreadDistanceFromBottom, waitForChatScrollIdle } from "./chat-flow.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
@@ -60,6 +61,84 @@ async function expandCompletedWorkGroups(page: import("playwright").Page) {
}
suite.define(() => {
it("keeps the final activity row anchored while its disclosure opens", async () => {
const context = await suite.browser.newContext({ viewport: { height: 600, width: 900 } });
const page = await context.newPage();
const transcriptPrefix = Array.from({ length: 12 }, (_, index) => [
{
role: "user",
content: `Earlier prompt ${index + 1}: keep enough transcript above the active row to make the pane scroll.`,
timestamp: index * 2 + 1,
},
{
role: "assistant",
content: [{ type: "text", text: `Earlier response ${index + 1}.` }],
timestamp: index * 2 + 2,
},
]).flat();
await installMockGateway(page, {
historyMessages: [
...transcriptPrefix,
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-anchor",
name: "bash",
arguments: { command: "pnpm test ui/src/pages/chat" },
},
{
type: "toolCall",
id: "call-anchor-read",
name: "read",
arguments: { path: "ui/src/pages/chat/components/chat-tool-cards.ts" },
},
],
timestamp: 100,
},
{
role: "toolResult",
toolCallId: "call-anchor",
toolName: "bash",
content: [{ type: "text", text: "All focused tests passed." }],
timestamp: 101,
},
{
role: "toolResult",
toolCallId: "call-anchor-read",
toolName: "read",
content: [{ type: "text", text: "export function renderToolCard() {}" }],
timestamp: 102,
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
const activity = page.locator(".chat-group--activity .chat-activity-group__summary");
await activity.waitFor();
await waitForChatScrollIdle(page);
expect(Math.abs(await chatThreadDistanceFromBottom(page))).toBeLessThanOrEqual(2);
const virtualRow = page.locator(".chat-virtual-row").filter({ has: activity });
const rowTop = async () =>
virtualRow.evaluate((row) => {
const thread = row.closest<HTMLElement>(".chat-thread");
if (!thread) {
throw new Error("Expected activity row inside the chat thread");
}
return row.getBoundingClientRect().top - thread.getBoundingClientRect().top;
});
const topBefore = await rowTop();
await activity.click();
await page.locator(".chat-activity-group__body:not([hidden])").waitFor();
await waitForChatScrollIdle(page);
expect(Math.abs((await rowTop()) - topBefore)).toBeLessThanOrEqual(2);
await captureToolActivityProof(page, "activity-disclosure-scroll-anchor");
await context.close();
});
it("keeps an earlier autonomous failure visible after a later turn recovers", async () => {
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const page = await context.newPage();
@@ -202,7 +202,7 @@ function activityAlignmentHtml() {
return `
<div class="chat-thread" role="log">
<div class="chat-thread-inner">
<div class="chat-group tool chat-group--activity">
<div class="chat-group tool chat-group--activity chat-group--with-footer">
<div class="chat-group-messages">
<div class="chat-activity-group is-open">
<button class="chat-inline-disclosure chat-activity-group__summary" type="button" aria-expanded="true">
@@ -1197,8 +1197,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const activityGroup = await getRect(page, ".chat-activity-group");
const activitySummary = await getRect(page, ".chat-activity-group__summary");
const failedSummary = await getRect(page, "[data-failed-call-row]");
const thread = await getRect(page, ".chat-thread-inner");
expect(activitySummary.width).toBeLessThan(activityGroup.width);
expect(failedSummary.width).toBeLessThan(activityGroup.width);
expect(activityGroup.left - thread.left).toBeCloseTo(51, 0);
const styles = await page.evaluate(() => {
const activity = document.querySelector<HTMLElement>(".chat-activity-group__summary")!;
const label = activity.querySelector<HTMLElement>(".chat-activity-group__label")!;
@@ -1604,6 +1606,32 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
}
});
it("keeps tool-card header actions visible without hover", async () => {
const page = await openBrowserPage(430, 720);
try {
await page.setContent(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
<div class="chat-tool-card">
<div class="chat-tool-card__header">
<span>ui/src/styles/chat/tool-cards.css</span>
<div class="chat-tool-card__actions">
<button class="chat-tool-card__action-btn" type="button">${iconSvg()}</button>
</div>
</div>
</div>
</body></html>`,
);
expect(
await page
.locator(".chat-tool-card__header > .chat-tool-card__actions")
.evaluate((node) => getComputedStyle(node).opacity),
).toBe("1");
} finally {
await closeBrowserPage(page);
}
});
it(
"remeasures a populated composer when the viewport width changes",
FULL_APP_TEST_OPTIONS,
@@ -65,10 +65,10 @@ import {
renderToolCard,
renderToolOutcome,
renderToolPreview,
isRunningToolCard,
resolveCollapsedToolDetail,
shouldToggleSelectableDisclosure,
syncToolDisclosureOverflow,
toggleToolDisclosureKeepingScroll,
} from "./chat-tool-cards.ts";
function renderChatIcon(name: string) {
@@ -84,7 +84,7 @@ function renderInlineToolCards(
onOpenSidebar?: (content: SidebarContent) => void;
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onToggleToolExpanded?: (toolCardId: string, expanded?: boolean) => void;
runActive?: boolean;
canvasPluginSurfaceUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
@@ -93,12 +93,14 @@ function renderInlineToolCards(
) {
return html`
<div class="chat-tools-inline">
${toolCards.map((card, index) =>
renderToolCard(card, {
expanded: opts.isToolExpanded?.(`${opts.messageKey}:toolcard:${index}`) ?? false,
${toolCards.map((card, index) => {
const disclosureId = `${opts.messageKey}:toolcard:${index}`;
const expanded = opts.isToolExpanded?.(disclosureId) ?? false;
return renderToolCard(card, {
expanded,
runActive: opts.runActive,
onToggleExpanded: opts.onToggleToolExpanded
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
? () => opts.onToggleToolExpanded?.(disclosureId, expanded)
: () => undefined,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
@@ -107,8 +109,8 @@ function renderInlineToolCards(
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
}),
)}
});
})}
</div>
`;
}
@@ -228,7 +230,7 @@ export function renderGroupedMessage(
assistantMessageDisclosure?: AssistantMessageDisclosure;
actionMarkdown?: string;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onToggleToolExpanded?: (toolCardId: string, expanded?: boolean) => void;
onRequestUpdate?: () => void;
canvasPluginSurfaceUrl?: string | null;
basePath?: string;
@@ -330,9 +332,7 @@ export function renderGroupedMessage(
}
const toolMessageDisclosureId = `toolmsg:${messageKey}`;
const toolMessageExpanded =
toolCards.some((card) => isRunningToolCard(card, opts.runActive)) ||
(opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false);
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const singleToolCard = toolCards.length === 1 ? toolCards[0] : null;
// One expanded card already closes with its own outcome line; every other
@@ -488,7 +488,12 @@ export function renderGroupedMessage(
@focus=${syncToolDisclosureOverflow}
@click=${(event: MouseEvent) => {
if (shouldToggleSelectableDisclosure(event)) {
opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId);
toggleToolDisclosureKeepingScroll(event, () =>
opts.onToggleToolMessageExpanded?.(
toolMessageDisclosureId,
toolMessageExpanded,
),
);
}
}}
>
@@ -44,6 +44,7 @@ import {
resolveToolRowText,
shouldToggleSelectableDisclosure,
syncToolDisclosureOverflow,
toggleToolDisclosureKeepingScroll,
} from "./chat-tool-cards.ts";
import { renderTurnRecapRow } from "./chat-working-indicator.ts";
@@ -72,7 +73,7 @@ type RenderMessageGroupOptions = {
getAssistantMessageExpansion?: (messageId: string) => AssistantMessageExpansionState | undefined;
onToggleAssistantMessageExpanded?: (messageId: string) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onToggleToolExpanded?: (toolCardId: string, expanded?: boolean) => void;
onRequestUpdate?: () => void;
onAssistantAttachmentLoaded?: () => void;
onRequestOpenImage?: () => number;
@@ -247,8 +248,7 @@ export function renderActivityGroup(
: summarizeToolGroup(cards.map((card) => ({ name: card.name, args: card.args })));
const activityDisclosureId = `activity:${firstGroup.key}`;
const activityBodyId = `activity-body-${fnv1aUtf16(firstGroup.key).toString(16)}`;
const activityExpanded =
Boolean(runningCard) || (opts.isToolMessageExpanded?.(activityDisclosureId) ?? false);
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? false;
return html`
<div
class="chat-group tool chat-group--activity chat-group--with-footer"
@@ -265,11 +265,13 @@ export function renderActivityGroup(
@focus=${syncToolDisclosureOverflow}
@click=${(event: MouseEvent) => {
if (shouldToggleSelectableDisclosure(event)) {
opts.onToggleToolMessageExpanded?.(activityDisclosureId, activityExpanded);
toggleToolDisclosureKeepingScroll(event, () =>
opts.onToggleToolMessageExpanded?.(activityDisclosureId, activityExpanded),
);
}
}}
>
<span class="chat-activity-group__icon">${icons.activity}</span>
<span class="chat-activity-group__icon">${icons.listTree}</span>
<span class="chat-tool-disclosure__content">
<span class="chat-activity-group__label" title=${groupSummaryLabel}
>${groupSummaryLabel}</span
@@ -11,7 +11,11 @@ import { renderGroupedMessage } from "./chat-message-bubble.ts";
import { renderChatTimestamp } from "./chat-message-timestamp.ts";
import { renderChatQuestionSummary } from "./chat-question-card.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
import { shouldToggleSelectableDisclosure, syncToolDisclosureOverflow } from "./chat-tool-cards.ts";
import {
shouldToggleSelectableDisclosure,
syncToolDisclosureOverflow,
toggleToolDisclosureKeepingScroll,
} from "./chat-tool-cards.ts";
import { renderChatWorkingIndicator } from "./chat-working-indicator.ts";
/** A contiguous run of in-flight streaming items rendered under one assistant group. */
@@ -166,7 +170,7 @@ export function renderWorkGroupSummary(
@focus=${syncToolDisclosureOverflow}
@click=${(event: MouseEvent) => {
if (shouldToggleSelectableDisclosure(event)) {
opts.onToggle();
toggleToolDisclosureKeepingScroll(event, opts.onToggle);
}
}}
>
@@ -2428,8 +2428,8 @@ describe("grouped chat rendering", () => {
".chat-activity-group__summary",
HTMLButtonElement,
);
expect(container.querySelector(".chat-activity-group.is-open")).not.toBeNull();
expect(activitySummary.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector(".chat-activity-group.is-open")).toBeNull();
expect(activitySummary.getAttribute("aria-expanded")).toBe("false");
expect(activitySummary.getAttribute("aria-label")).toBeNull();
expect(activitySummary.classList.contains("chat-activity-group__summary--error")).toBe(false);
expect(container.querySelector(".chat-activity-group__label")?.textContent).toBe(
@@ -134,6 +134,27 @@ describe("tool-cards", () => {
container.remove();
});
it("keeps a running card closed by default", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:running",
name: "bash",
args: { command: "pnpm test" },
live: true,
},
{ expanded: false, runActive: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
expect(container.querySelector(".chat-tool-msg-summary")?.getAttribute("aria-expanded")).toBe(
"false",
);
});
it("renders expanded cards with key-value args and an output section", () => {
const container = document.createElement("div");
const toggle = vi.fn();
@@ -288,9 +288,9 @@ function resolveToolRowVerb(view: ToolCallView, outcome: ToolCardOutcome): strin
}
const TOOL_ROW_ICONS: Partial<Record<ToolCallView["kind"], string>> = {
command: "terminal",
command: "squareTerminal",
read: "fileText",
edit: "penLine",
edit: "pencil",
write: "fileCode",
search: "search",
fetch: "globe",
@@ -319,6 +319,26 @@ export function syncToolDisclosureOverflow(event: Event): void {
);
}
export function toggleToolDisclosureKeepingScroll(event: Event, toggle: () => void): void {
const target = event.currentTarget;
const row = target instanceof Element ? target.closest<HTMLElement>(".chat-virtual-row") : null;
const scroller = row?.closest<HTMLElement>(".chat-thread");
const rowTop = row?.getBoundingClientRect().top;
toggle();
if (!row || !scroller || rowTop === undefined) {
return;
}
requestAnimationFrame(() => {
// ResizeObserver runs after rAF. Wait one more frame so TanStack applies
// its end-anchor delta before we restore this disclosure row's position.
requestAnimationFrame(() => {
if (row.isConnected) {
scroller.scrollTop += row.getBoundingClientRect().top - rowTop;
}
});
});
}
function renderToolRowContent(card: ToolCard, view: ToolCallView, outcome: ToolCardOutcome) {
if (view.kind === "command" && view.command) {
const commandPreview = firstCommandLine(view.command);
@@ -771,7 +791,7 @@ export function renderToolCard(
const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details });
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const isRunning = outcome === "running";
const expanded = opts.expanded || isRunning;
const expanded = opts.expanded;
const icon = TOOL_ROW_ICONS[view.kind] ?? display.icon;
const workspaceFilePath =
view.kind === "read" || view.kind === "edit" || view.kind === "write"
@@ -794,7 +814,8 @@ export function renderToolCard(
type="button"
aria-expanded=${String(expanded)}
aria-label=${resolveToolRowText(card, opts.runActive)}
@click=${() => opts.onToggleExpanded(card.id)}
@click=${(event: MouseEvent) =>
toggleToolDisclosureKeepingScroll(event, () => opts.onToggleExpanded(card.id))}
></button>
<span class="chat-tool-msg-summary__icon">${renderToolIcon(icon)}</span>
<span class="chat-tool-disclosure__content"
@@ -818,7 +839,7 @@ export function renderToolCard(
@focus=${syncToolDisclosureOverflow}
@click=${(event: MouseEvent) => {
if (shouldToggleSelectableDisclosure(event)) {
opts.onToggleExpanded(card.id);
toggleToolDisclosureKeepingScroll(event, () => opts.onToggleExpanded(card.id));
}
}}
>
@@ -238,8 +238,12 @@ export function projectChatTranscript(
const questionPrompts = new Map(
(props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]),
);
const toggleToolCardExpanded = (toolCardId: string) => {
setExpansionState(expandedToolCards, toolCardId, !expandedToolCards.get(toolCardId));
const toggleToolCardExpanded = (toolCardId: string, expanded?: boolean) => {
setExpansionState(
expandedToolCards,
toolCardId,
!(expanded ?? expandedToolCards.get(toolCardId) ?? false),
);
requestUpdate();
};
const toggleAssistantMessageExpanded = (messageId: string) => {
+8 -2
View File
@@ -110,8 +110,8 @@
.chat-group.tool {
--chat-message-column-max: var(--chat-message-max-width, min(980px, 100%));
display: block;
margin-inline-start: 0;
padding-inline-start: 0;
margin-inline-start: 4px;
padding-inline-start: 46px;
}
.chat-group.tool.chat-group--with-footer {
@@ -120,6 +120,7 @@
.chat-group.tool.chat-group--with-footer > .chat-group-messages {
max-width: var(--chat-message-column-max);
margin-inline-start: 1px;
}
.chat-group.user .chat-group-footer {
@@ -678,6 +679,11 @@ img.chat-avatar.chat-avatar--logo {
--chat-message-column-max: var(--chat-message-max-width, min(980px, 100%));
}
.chat-thread--direct .chat-group.tool {
margin-inline-start: 0;
padding-inline-start: 0;
}
.chat-duplicate-count {
display: inline-flex;
align-items: center;
+1 -8
View File
@@ -656,12 +656,6 @@
}
.chat-tool-card__header > .chat-tool-card__actions {
opacity: 0;
transition: opacity 120ms ease-out;
}
.chat-tool-card__header:hover > .chat-tool-card__actions,
.chat-tool-card__header:focus-within > .chat-tool-card__actions {
opacity: 1;
}
@@ -677,8 +671,7 @@
}
@media (pointer: coarse) {
.chat-tool-card--flush > .chat-tool-card__actions,
.chat-tool-card__header > .chat-tool-card__actions {
.chat-tool-card--flush > .chat-tool-card__actions {
opacity: 1;
}
}