diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index c0fee7785..aeeb6cff7 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -8,6 +8,7 @@ import { } from "./helpers/agent-stream"; import { expectScrollStaysFixed, + clickToolCallBesideScrollToBottomButton, readScrollMetrics, scrollAgentChatToBottom, scrollChatAwayFromBottom, @@ -205,6 +206,37 @@ test.describe("Agent stream UI", () => { await expectScrollStaysFixed(page, baseline); }); + test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => { + test.setTimeout(60_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "stream-scroll-button-hit-area-", + title: "Scroll button hit area", + model: "ten-second-stream", + initialPrompt: "Stream enough content to exercise the scroll button hit area.", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, { + workspaceId: agent.workspaceId, + agentId: agent.agentId, + }); + await waitForScrollableChat(page, { + minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE, + timeout: 30_000, + }); + + const hitArea = await clickToolCallBesideScrollToBottomButton(page); + + expect(hitArea).toEqual({ + outsideButton: true, + toolCallReceivesPointer: true, + withinButtonBand: true, + }); + } finally { + await agent.cleanup(); + } + }); + test("working-indicator transitions to copy-button when stream ends", async ({ page }) => { test.setTimeout(60_000); const agent = await startRunningMockAgent(page, { diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index a0ff65ba2..c13244ef2 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -124,6 +124,111 @@ export async function scrollChatAwayFromBottom( return readScrollMetrics(page); } +export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{ + outsideButton: boolean; + toolCallReceivesPointer: boolean; + withinButtonBand: boolean; +}> { + await scrollChatAwayFromBottom(page, { + deltaY: -900, + minDistanceFromBottom: 300, + }); + + const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" }); + await expect(scrollToBottomButton).toBeVisible(); + + const buttonBounds = await scrollToBottomButton.boundingBox(); + expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull(); + const visibleButtonBounds = buttonBounds!; + + const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]'); + const toolCallBounds = await Promise.all( + Array.from({ length: await toolCalls.count() }, async (_, index) => ({ + index, + bounds: await toolCalls.nth(index).boundingBox(), + })), + ); + const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2; + const candidate = toolCallBounds + .filter( + (entry): entry is { index: number; bounds: NonNullable } => + entry.bounds !== null && entry.bounds.width > 0, + ) + .sort( + (left, right) => + Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) - + Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY), + )[0]; + expect( + candidate, + `Expected at least one rendered tool-call badge: ${JSON.stringify({ + buttonBounds, + scrollMetrics: await readScrollMetrics(page), + toolCallBounds, + })}`, + ).toBeDefined(); + const visibleToolCall = candidate!; + const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2; + await getVisibleChatScroll(page).evaluate((scroll, deltaY) => { + (scroll as HTMLElement).scrollTop += deltaY; + }, initialToolCallCenterY - buttonCenterY); + + const alignedToolCall = toolCalls.nth(visibleToolCall.index); + await expect + .poll(async () => { + const [currentButtonBounds, currentToolCallBounds] = await Promise.all([ + scrollToBottomButton.boundingBox(), + alignedToolCall.boundingBox(), + ]); + if (!currentButtonBounds || !currentToolCallBounds) { + return false; + } + const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2; + return ( + toolCallCenterY >= currentButtonBounds.y && + toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height + ); + }) + .toBe(true); + + const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([ + scrollToBottomButton.boundingBox(), + alignedToolCall.boundingBox(), + ]); + expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull(); + expect( + visibleToolCallBounds, + "Expected aligned tool-call badge to remain visible", + ).not.toBeNull(); + const finalButtonBounds = alignedButtonBounds!; + const finalToolCallBounds = visibleToolCallBounds!; + + const clickPoint = { + x: finalToolCallBounds.x + 24, + y: finalToolCallBounds.y + finalToolCallBounds.height / 2, + }; + const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => { + const hit = document.elementFromPoint(point.x, point.y); + return hit !== null && toolCall.contains(hit); + }, clickPoint); + const hitArea = { + clickPoint, + outsideButton: + clickPoint.x < finalButtonBounds.x || + clickPoint.x > finalButtonBounds.x + finalButtonBounds.width, + toolCallReceivesPointer, + withinButtonBand: + clickPoint.y >= finalButtonBounds.y && + clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height, + }; + await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y); + return { + outsideButton: hitArea.outsideButton, + toolCallReceivesPointer: hitArea.toolCallReceivesPointer, + withinButtonBand: hitArea.withinButtonBand, + }; +} + export async function expectScrollStaysFixed( page: Page, baseline: ScrollMetrics, diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 9d9e79950..b5edb8a2d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -926,12 +926,8 @@ const AgentStreamViewComponent = forwardRef {!isNearBottom && ( - - + + - - + + )} @@ -1430,13 +1426,6 @@ const stylesheet = StyleSheet.create((theme) => ({ left: 0, right: 0, alignItems: "center", - pointerEvents: "box-none", - }, - scrollToBottomInner: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - alignSelf: "center", - alignItems: "center", }, scrollToBottomButton: { width: 48, diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 54232acd7..d3b0e179f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -2466,7 +2466,11 @@ describe("OpenCode provider subagent contract", () => { expect(events).toContainEqual( expect.objectContaining({ type: "timeline", - item: { type: "assistant_message", text: "child says hi" }, + item: { + type: "assistant_message", + text: "child says hi", + messageId: "msg_assistant", + }, }), ); }); @@ -2809,7 +2813,11 @@ describe("OpenCode provider subagent contract", () => { event: { type: "timeline", id: "ses_child_background", - item: { type: "assistant_message", text: "Background findings." }, + item: { + type: "assistant_message", + text: "Background findings.", + messageId: "msg_child_background", + }, }, }); expect(events.at(-1)).toEqual({ @@ -2998,6 +3006,7 @@ describe("OpenCode provider subagent contract", () => { item: { type: "assistant_message", text: "Persisted child result.", + messageId: "msg_child_history", }, timestamp: "1970-01-01T00:00:02.000Z", }, @@ -3075,6 +3084,7 @@ describe("OpenCode provider subagent contract", () => { item: { type: "assistant_message", text: "Hydration did not lose this.", + messageId: "msg_child_hydrating", }, }), }),