fix: stabilize streaming footer transition

This commit is contained in:
Mohamed Boudra
2026-05-01 19:01:19 +07:00
parent a9ea9f3e37
commit aa547cf66b
2 changed files with 226 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ import { AgentStreamView } from "./agent-stream-view";
const assistantMessageCalls = vi.hoisted(
() => [] as Array<{ message: string; spacing: string | undefined }>,
);
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
const mockSessionState = vi.hoisted(() => ({
sessions: {
@@ -121,7 +122,13 @@ vi.mock("./message", async () => {
SpeakMessage: () => null,
TodoListCard: () => null,
ToolCall: () => null,
TurnCopyButton: () => null,
TurnCopyButton: (props: { getContent: () => string }) => {
turnCopyButtonCalls.push(props);
return ReactModule.createElement("button", {
"data-testid": "turn-copy-button",
type: "button",
});
},
UserMessage: () => null,
};
});
@@ -174,6 +181,24 @@ function assistantBlock(params: {
};
}
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "npm test",
result: null,
status: "executing",
},
},
};
}
describe("AgentStreamView", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -187,6 +212,7 @@ describe("AgentStreamView", () => {
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
assistantMessageCalls.length = 0;
turnCopyButtonCalls.length = 0;
mockSessionState.sessions.server.agentStreamHead = new Map();
container = document.createElement("div");
document.body.appendChild(container);
@@ -255,4 +281,135 @@ describe("AgentStreamView", () => {
Array.from({ length: headCalls.length }, () => "compactTop"),
);
});
it("renders running dots in the assistant turn footer when live text is streaming", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
});
it("only renders running dots on the live assistant row", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "History paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-2:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [tailBlock],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
});
it("keeps the auxiliary running dots when there is no live assistant row", () => {
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).not.toBeNull();
});
it("replaces the running footer with the copy button when the assistant turn idles", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Complete paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
);
});
});

View File

@@ -292,6 +292,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(() => {
if (agent.status !== "running") {
return null;
}
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
if (item.kind !== "assistant_message") {
return false;
}
return (
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) === undefined
);
});
return footerItem?.id ?? null;
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
useImperativeHandle(
ref,
() => ({
@@ -590,21 +609,31 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const isRunningAssistantTurnFooter =
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
{footer}
</StreamItemWrapper>
);
},
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
[
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
],
);
const pendingPermissionItems = useMemo(
@@ -612,7 +641,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showWorkingIndicator = agent.status === "running";
const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
@@ -626,12 +656,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workingIndicatorNode = useMemo(
() =>
showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator />
</View>
) : null,
[showWorkingIndicator],
[showAuxiliaryWorkingIndicator],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
@@ -800,7 +830,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() {
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0);
useEffect(() => {
@@ -852,8 +882,13 @@ function WorkingIndicator() {
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={stylesheet.workingIndicatorBubble}>
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
@@ -863,6 +898,14 @@ function WorkingIndicator() {
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
@@ -1227,6 +1270,17 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
inlineTurnFooter: {
alignSelf: "flex-start",
marginTop: theme.spacing[2],
padding: theme.spacing[2],
paddingTop: 0,
},
inlineWorkingIndicatorFrame: {
height: 18,
alignItems: "center",
justifyContent: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",