fix: stabilize streaming footer transition
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user