diff --git a/ui/src/components/icons-tools.ts b/ui/src/components/icons-tools.ts index fb7a2c4d41a5..984369189d4d 100644 --- a/ui/src/components/icons-tools.ts +++ b/ui/src/components/icons-tools.ts @@ -161,6 +161,14 @@ export const toolIcons = { `), terminal: strokeIcon(svg` `), + squareTerminal: strokeIcon(svg` + + `), + listTree: strokeIcon(svg` + + + + `), claw: strokeIcon(svg` diff --git a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts index ac9e4c83541e..c7593ed724d3 100644 --- a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts +++ b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts @@ -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(".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(); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index f818b6722645..368fa32acaee 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -202,7 +202,7 @@ function activityAlignmentHtml() { return `
-
+ + `, + ); + + 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, diff --git a/ui/src/pages/chat/components/chat-message-bubble.ts b/ui/src/pages/chat/components/chat-message-bubble.ts index 1ea4df6cb1a6..9251b94edf0a 100644 --- a/ui/src/pages/chat/components/chat-message-bubble.ts +++ b/ui/src/pages/chat/components/chat-message-bubble.ts @@ -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`
- ${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, - }), - )} + }); + })}
`; } @@ -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, + ), + ); } }} > diff --git a/ui/src/pages/chat/components/chat-message-group.ts b/ui/src/pages/chat/components/chat-message-group.ts index 485f4f322df6..387dc6addcff 100644 --- a/ui/src/pages/chat/components/chat-message-group.ts +++ b/ui/src/pages/chat/components/chat-message-group.ts @@ -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`