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
@@ -11,6 +11,7 @@ import { AgentStreamView } from "./agent-stream-view";
const assistantMessageCalls = vi.hoisted( const assistantMessageCalls = vi.hoisted(
() => [] as Array<{ message: string; spacing: string | undefined }>, () => [] as Array<{ message: string; spacing: string | undefined }>,
); );
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
const mockSessionState = vi.hoisted(() => ({ const mockSessionState = vi.hoisted(() => ({
sessions: { sessions: {
@@ -121,7 +122,13 @@ vi.mock("./message", async () => {
SpeakMessage: () => null, SpeakMessage: () => null,
TodoListCard: () => null, TodoListCard: () => null,
ToolCall: () => 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, 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", () => { describe("AgentStreamView", () => {
let root: Root | null = null; let root: Root | null = null;
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
@@ -187,6 +212,7 @@ describe("AgentStreamView", () => {
originalScrollTo = HTMLElement.prototype.scrollTo; originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn(); HTMLElement.prototype.scrollTo = vi.fn();
assistantMessageCalls.length = 0; assistantMessageCalls.length = 0;
turnCopyButtonCalls.length = 0;
mockSessionState.sessions.server.agentStreamHead = new Map(); mockSessionState.sessions.server.agentStreamHead = new Map();
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -255,4 +281,135 @@ describe("AgentStreamView", () => {
Array.from({ length: headCalls.length }, () => "compactTop"), 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"),
);
});
}); });
@@ -292,6 +292,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [isMobile, streamHead, streamItems]); }, [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( useImperativeHandle(
ref, ref,
() => ({ () => ({
@@ -590,21 +609,31 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" && item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" || (nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running")); (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 ( return (
<StreamItemWrapper gapBelow={gapBelow}> <StreamItemWrapper gapBelow={gapBelow}>
{content} {content}
{isEndOfAssistantTurn ? ( {footer}
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
</StreamItemWrapper> </StreamItemWrapper>
); );
}, },
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy], [
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
],
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -612,7 +641,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId], [pendingPermissions, agentId],
); );
const showWorkingIndicator = agent.status === "running"; const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const pendingPermissionsNode = useMemo( const pendingPermissionsNode = useMemo(
() => () =>
pendingPermissionItems.length > 0 ? ( pendingPermissionItems.length > 0 ? (
@@ -626,12 +656,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const workingIndicatorNode = useMemo( const workingIndicatorNode = useMemo(
() => () =>
showWorkingIndicator ? ( showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}> <View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator /> <WorkingIndicator />
</View> </View>
) : null, ) : null,
[showWorkingIndicator], [showAuxiliaryWorkingIndicator],
); );
const renderModel = useMemo<AgentStreamRenderModel>(() => { const renderModel = useMemo<AgentStreamRenderModel>(() => {
return { return {
@@ -800,7 +830,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent); export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView"; AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() { function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0); const progress = useSharedValue(0);
useEffect(() => { useEffect(() => {
@@ -852,8 +882,13 @@ function WorkingIndicator() {
[dotThreeStyle], [dotThreeStyle],
); );
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return ( return (
<View style={stylesheet.workingIndicatorBubble}> <View style={containerStyle}>
<View style={stylesheet.workingDotsRow}> <View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} /> <Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} /> <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 // Permission Request Card Component
type TurnContentStrategy = Parameters< type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy typeof collectAssistantTurnContentForStreamRenderStrategy
@@ -1227,6 +1270,17 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
gap: 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: { workingIndicatorBubble: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",