Keep agent stream controls clickable under the scroll button (#2007)
* fix(app): keep stream controls clickable under scroll button The full-width animated overlay won browser hit testing even outside the circular control. Keep positioning on a pass-through container and animate only the button-sized child. * test(app): preserve scroll hit-test diagnostics * test(app): align scroll hit-test fixture deterministically * test(server): include OpenCode message IDs in subagent assertions
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./helpers/agent-stream";
|
} from "./helpers/agent-stream";
|
||||||
import {
|
import {
|
||||||
expectScrollStaysFixed,
|
expectScrollStaysFixed,
|
||||||
|
clickToolCallBesideScrollToBottomButton,
|
||||||
readScrollMetrics,
|
readScrollMetrics,
|
||||||
scrollAgentChatToBottom,
|
scrollAgentChatToBottom,
|
||||||
scrollChatAwayFromBottom,
|
scrollChatAwayFromBottom,
|
||||||
@@ -205,6 +206,37 @@ test.describe("Agent stream UI", () => {
|
|||||||
await expectScrollStaysFixed(page, baseline);
|
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("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
const agent = await startRunningMockAgent(page, {
|
const agent = await startRunningMockAgent(page, {
|
||||||
|
|||||||
@@ -124,6 +124,111 @@ export async function scrollChatAwayFromBottom(
|
|||||||
return readScrollMetrics(page);
|
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<typeof entry.bounds> } =>
|
||||||
|
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(
|
export async function expectScrollStaysFixed(
|
||||||
page: Page,
|
page: Page,
|
||||||
baseline: ScrollMetrics,
|
baseline: ScrollMetrics,
|
||||||
|
|||||||
@@ -926,12 +926,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
})}
|
})}
|
||||||
</MessageOuterSpacingProvider>
|
</MessageOuterSpacingProvider>
|
||||||
{!isNearBottom && (
|
{!isNearBottom && (
|
||||||
<Animated.View
|
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||||
style={stylesheet.scrollToBottomContainer}
|
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||||
entering={scrollIndicatorFadeIn}
|
|
||||||
exiting={scrollIndicatorFadeOut}
|
|
||||||
>
|
|
||||||
<View style={stylesheet.scrollToBottomInner}>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={stylesheet.scrollToBottomButton}
|
style={stylesheet.scrollToBottomButton}
|
||||||
onPress={scrollToBottom}
|
onPress={scrollToBottom}
|
||||||
@@ -941,8 +937,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
>
|
>
|
||||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ToolCallSheetProvider>
|
</ToolCallSheetProvider>
|
||||||
@@ -1430,13 +1426,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
pointerEvents: "box-none",
|
|
||||||
},
|
|
||||||
scrollToBottomInner: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
|
||||||
alignSelf: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
},
|
||||||
scrollToBottomButton: {
|
scrollToBottomButton: {
|
||||||
width: 48,
|
width: 48,
|
||||||
|
|||||||
@@ -2466,7 +2466,11 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
expect(events).toContainEqual(
|
expect(events).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "timeline",
|
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: {
|
event: {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
id: "ses_child_background",
|
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({
|
expect(events.at(-1)).toEqual({
|
||||||
@@ -2998,6 +3006,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
item: {
|
item: {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: "Persisted child result.",
|
text: "Persisted child result.",
|
||||||
|
messageId: "msg_child_history",
|
||||||
},
|
},
|
||||||
timestamp: "1970-01-01T00:00:02.000Z",
|
timestamp: "1970-01-01T00:00:02.000Z",
|
||||||
},
|
},
|
||||||
@@ -3075,6 +3084,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
item: {
|
item: {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: "Hydration did not lose this.",
|
text: "Hydration did not lose this.",
|
||||||
|
messageId: "msg_child_hydrating",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user