Compare commits

...

1 Commits

Author SHA1 Message Date
Akshay da682cce5f fix(web): separate adjacent assistant text blocks 2026-06-29 15:21:26 +08:00
3 changed files with 158 additions and 3 deletions
@@ -0,0 +1,74 @@
"""E2E: adjacent assistant text messages remain visually separated.
Native forwarders can persist multiple assistant message items under the same
``response_id``. The transcript model should keep those items in one assistant
bubble, but each logical text item needs a small visual pause so separate
received messages do not read as one undifferentiated block.
"""
from __future__ import annotations
import re
import httpx
from playwright.sync_api import Page, expect
_AGENT_NAME = "hello_world"
_ASSISTANT = '[data-testid="message-bubble"][data-role="assistant"]'
_TEXT_SECTION = '[data-testid="assistant-text-section"]'
_RESPONSE_ID = "resp_adjacent_assistant_text_spacing"
_FIRST_TEXT = "First assistant section for spacing."
_SECOND_TEXT = "Second **markdown** section for spacing."
def _seed_assistant_text(base_url: str, session_id: str, text: str) -> None:
"""Append one deterministic assistant message to ``session_id``."""
resp = httpx.post(
f"{base_url}/v1/sessions/{session_id}/events",
json={
"type": "external_assistant_message",
"data": {
"agent": _AGENT_NAME,
"response_id": _RESPONSE_ID,
"text": text,
},
},
timeout=10.0,
)
resp.raise_for_status()
def test_adjacent_assistant_text_items_have_subtle_spacing(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Two text items in one assistant bubble render as separated sections."""
base_url, session_id = seeded_session
_seed_assistant_text(base_url, session_id, _FIRST_TEXT)
_seed_assistant_text(base_url, session_id, _SECOND_TEXT)
page.goto(f"{base_url}/c/{session_id}")
bubble = page.locator(_ASSISTANT).filter(has_text=_FIRST_TEXT)
expect(bubble).to_have_count(1, timeout=30_000)
expect(bubble).to_contain_text("Second markdown section for spacing.")
sections = bubble.locator(_TEXT_SECTION)
expect(sections).to_have_count(2)
expect(sections.nth(0)).not_to_have_class(re.compile(r"(^| )mt-2( |$)"))
expect(sections.nth(1)).to_have_class(re.compile(r"(^| )mt-2( |$)"))
margin_top = sections.nth(1).evaluate("el => getComputedStyle(el).marginTop")
assert margin_top == "8px"
visible_gap_px = sections.nth(1).evaluate(
"""el => {
const previous = el.previousElementSibling;
const previousBox = previous.getBoundingClientRect();
const currentBox = el.getBoundingClientRect();
return Math.round(currentBox.top - previousBox.bottom);
}"""
)
assert visible_gap_px >= 12
expect(sections.nth(1).locator('[data-streamdown="strong"]')).to_have_text("markdown")
@@ -157,6 +157,70 @@ describe("BlockRenderer dispatch", () => {
expect(screen.queryByText("Thinking...")).toBeNull();
});
it("adds subtle separation between adjacent assistant text items", async () => {
const items: RenderItem[] = [
{ kind: "text", itemId: "t1", text: "First message.", final: true },
{ kind: "text", itemId: "t2", text: "Second **markdown** message.", final: true },
];
const { container } = render(
<FileViewerContext.Provider value={FILE_VIEWER_NOOP}>
<BlockRenderer items={items} sessionStatus="idle" />
</FileViewerContext.Provider>,
);
const sections = container.querySelectorAll<HTMLElement>(
'[data-testid="assistant-text-section"]',
);
expect(sections).toHaveLength(2);
expect(sections[0]!).not.toHaveClass("mt-2");
expect(sections[1]!).toHaveClass("mt-2");
expect(screen.getByText("First message.")).toBeDefined();
expect(screen.getByText(/Second/)).toBeDefined();
const strong = await screen.findByText("markdown", {
selector: '[data-streamdown="strong"]',
});
expect(sections[1]!.contains(strong)).toBe(true);
});
it("does not add adjacent-text spacing across tool items", () => {
const items: RenderItem[] = [
{ kind: "text", itemId: "t1", text: "Before tool.", final: true },
{
kind: "tool",
itemId: "fc_1",
execution: {
name: "read_file",
arguments: {},
argsSummary: "",
callId: "call_1",
agentName: "test",
executedBy: "server",
output: "ok",
},
output: "ok",
state: "output-available",
startedAt: null,
duration: undefined,
},
{ kind: "text", itemId: "t2", text: "After tool.", final: true },
];
const { container } = render(
<FileViewerContext.Provider value={FILE_VIEWER_NOOP}>
<BlockRenderer items={items} sessionStatus="idle" />
</FileViewerContext.Provider>,
);
const sections = container.querySelectorAll<HTMLElement>(
'[data-testid="assistant-text-section"]',
);
expect(sections).toHaveLength(2);
expect(sections[0]!).not.toHaveClass("mt-2");
expect(sections[1]!).not.toHaveClass("mt-2");
});
it("'See N steps' counts the whole tool run, including the streaming tail", () => {
// While streaming, the most-recent tools render as a visible tail
// OUTSIDE the fold. The "See N steps" label must count the whole run
+20 -3
View File
@@ -295,6 +295,7 @@ interface BlockRendererProps {
export function BlockRenderer({ items, sessionStatus }: BlockRendererProps) {
const rendered: ReactNode[] = [];
let previousRenderedItemWasText = false;
const isAgentActive = sessionStatus === "running" || sessionStatus === "waiting";
const streamingRunStart = isAgentActive ? findStreamingRunStart(items) : -1;
// Reasoning is "currently streaming" iff the agent is live AND this
@@ -342,10 +343,13 @@ export function BlockRenderer({ items, sessionStatus }: BlockRendererProps) {
rendered.push(renderItem(tool, runStart, false));
}
}
previousRenderedItemWasText = false;
continue;
}
rendered.push(renderItem(item, i, i === reasoningStreamingIdx));
const followsText = item.kind === "text" && previousRenderedItemWasText;
rendered.push(renderItem(item, i, i === reasoningStreamingIdx, followsText));
previousRenderedItemWasText = item.kind === "text";
}
return <>{rendered}</>;
@@ -403,11 +407,24 @@ function isInProgressTool(item: RenderItem): boolean {
return item.kind === "tool" && item.state === "input-available";
}
function renderItem(item: RenderItem, index: number, isReasoningStreaming: boolean): ReactNode {
function renderItem(
item: RenderItem,
index: number,
isReasoningStreaming: boolean,
followsText = false,
): ReactNode {
const key = keyFor(item, index);
switch (item.kind) {
case "text":
return <FilePathAwareMessageResponse key={key}>{item.text}</FilePathAwareMessageResponse>;
return (
<div
key={key}
data-testid="assistant-text-section"
className={cn("min-w-0", followsText && "mt-2")}
>
<FilePathAwareMessageResponse>{item.text}</FilePathAwareMessageResponse>
</div>
);
case "reasoning":
return (
<ReasoningView