Smooth WebUI streaming with state-driven viewport motion (#4696)
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format";
|
||||
import {
|
||||
fmtDateTime,
|
||||
formatMessageEndTime,
|
||||
formatTurnLatency,
|
||||
relativeTime,
|
||||
} from "@/lib/format";
|
||||
|
||||
describe("localized format helpers", () => {
|
||||
beforeEach(() => {
|
||||
@@ -62,6 +67,34 @@ describe("localized format helpers", () => {
|
||||
expect(english).not.toBe(french);
|
||||
});
|
||||
|
||||
it("shows only the local clock time for messages completed today", async () => {
|
||||
const value = Date.parse("2026-04-18T08:34:56Z");
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
|
||||
expect(formatMessageEndTime(value)).toBe(
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the local date for messages completed before today", async () => {
|
||||
const value = Date.parse("2026-04-16T08:34:56Z");
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
|
||||
expect(formatMessageEndTime(value)).toBe(
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats turn latency with locale-aware units", async () => {
|
||||
await setAppLanguage("en");
|
||||
const subMinute = formatTurnLatency(2400, "en");
|
||||
|
||||
@@ -424,31 +424,28 @@ describe("MarkdownTextRenderer", () => {
|
||||
expect(container.firstElementChild).not.toHaveClass("space-y-0");
|
||||
});
|
||||
|
||||
it("uses Streamdown's incremental reveal while content is streaming", () => {
|
||||
it("keeps streaming parsing without a competing reveal animation", () => {
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).toHaveClass(
|
||||
expect(container).toHaveTextContent("春天");
|
||||
expect(container.firstElementChild).not.toHaveClass(
|
||||
"[&>*:last-child]:after:content-[var(--streamdown-caret)]",
|
||||
);
|
||||
const animatedUnits = container.querySelectorAll<HTMLElement>("[data-sd-animate]");
|
||||
expect(animatedUnits).toHaveLength(1);
|
||||
expect(animatedUnits[0]).toHaveTextContent("春天");
|
||||
expect(animatedUnits[0].getAttribute("style")).toContain("--sd-duration: 180ms");
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes animation markup when a streamed response completes", async () => {
|
||||
it("does not add animation markup when a streamed response completes", () => {
|
||||
const { container, rerender } = render(
|
||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||
);
|
||||
expect(container.querySelector("[data-sd-animate]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<MarkdownTextRenderer>春天</MarkdownTextRenderer>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toHaveTextContent("春天");
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not create one DOM node per CJK character for long responses", () => {
|
||||
@@ -456,7 +453,7 @@ describe("MarkdownTextRenderer", () => {
|
||||
<MarkdownTextRenderer streaming>{"长".repeat(6_001)}</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(1);
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/components/MarkdownTextRenderer", () => ({
|
||||
<div
|
||||
data-testid="markdown-renderer"
|
||||
data-highlight-code={String(highlightCode)}
|
||||
data-streaming-layout={String(streaming)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -106,6 +107,33 @@ describe("MarkdownText", () => {
|
||||
expect(rendererMountSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can complete without replacing the streaming renderer layout", async () => {
|
||||
const source = "A layout-stable answer";
|
||||
const { rerender } = render(
|
||||
<MarkdownText streaming preserveStreamingLayout>{source}</MarkdownText>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-streaming-layout",
|
||||
"true",
|
||||
);
|
||||
|
||||
rerender(<MarkdownText preserveStreamingLayout>{source}</MarkdownText>);
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-streaming-layout",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("defers syntax highlighting until the final render", async () => {
|
||||
rendererSpy.mockClear();
|
||||
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
@@ -298,6 +299,52 @@ describe("MessageBubble", () => {
|
||||
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the assistant completion time in the former latency slot", () => {
|
||||
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||
const { container } = render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "a-completed-at",
|
||||
role: "assistant",
|
||||
content: "Finished answer",
|
||||
latencyMs: 13_000,
|
||||
completedAt,
|
||||
createdAt: Date.now(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
const time = container.querySelector("[data-assistant-completed-at]");
|
||||
expect(time).toHaveTextContent(formatMessageEndTime(completedAt));
|
||||
expect(time).toHaveAttribute("dateTime", new Date(completedAt).toISOString());
|
||||
expect(time).toHaveAttribute("title", fmtDateTime(completedAt));
|
||||
expect(time).toHaveClass(
|
||||
"text-[11px]",
|
||||
"leading-none",
|
||||
"text-muted-foreground/70",
|
||||
"tabular-nums",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer completion time from the assistant creation timestamp", () => {
|
||||
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||
const latencyMs = 13_000;
|
||||
const { container } = render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "a-replayed-completion",
|
||||
role: "assistant",
|
||||
content: "Replayed answer",
|
||||
latencyMs,
|
||||
createdAt,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-assistant-completed-at]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders installed CLI app mentions inside sent user messages", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli",
|
||||
@@ -482,6 +529,37 @@ describe("MessageBubble", () => {
|
||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps assistant footer geometry mounted across stream completion", () => {
|
||||
const streaming: UIMessage = {
|
||||
id: "a-footer-stable",
|
||||
role: "assistant",
|
||||
content: "Stable answer",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const { container, rerender } = render(<MessageBubble message={streaming} />);
|
||||
|
||||
const reservedFooter = container.querySelector("[data-assistant-footer]");
|
||||
expect(reservedFooter).not.toBeNull();
|
||||
expect(reservedFooter).toHaveAttribute("data-state", "reserved");
|
||||
expect(reservedFooter).toHaveClass("mt-2", "min-h-8", "opacity-0");
|
||||
|
||||
rerender(
|
||||
<MessageBubble
|
||||
message={{
|
||||
...streaming,
|
||||
isStreaming: false,
|
||||
completedAt: Date.now(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const visibleFooter = container.querySelector("[data-assistant-footer]");
|
||||
expect(visibleFooter).toBe(reservedFooter);
|
||||
expect(visibleFooter).toHaveAttribute("data-state", "visible");
|
||||
expect(visibleFooter).toHaveClass("mt-2", "min-h-8", "opacity-100");
|
||||
});
|
||||
|
||||
it("does not show copy when showCopyAction is false", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-mid",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ThreadCameraController,
|
||||
type ThreadCameraScheduler,
|
||||
type ThreadCameraViewport,
|
||||
} from "@/components/thread/thread-camera";
|
||||
|
||||
function cameraHarness(prefersReducedMotion = false) {
|
||||
let now = 0;
|
||||
let nextFrameId = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const viewport: ThreadCameraViewport = {
|
||||
scrollTop: 0,
|
||||
};
|
||||
const scheduler: ThreadCameraScheduler = {
|
||||
request: vi.fn((callback) => {
|
||||
const id = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
}),
|
||||
cancel: vi.fn((id) => {
|
||||
frames.delete(id);
|
||||
}),
|
||||
now: () => now,
|
||||
};
|
||||
const camera = new ThreadCameraController(() => viewport, {
|
||||
scheduler,
|
||||
prefersReducedMotion: () => prefersReducedMotion,
|
||||
});
|
||||
const advance = (deltaMs: number) => {
|
||||
now += deltaMs;
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, callback] of pending) callback(now);
|
||||
};
|
||||
return { camera, viewport, scheduler, frames, advance };
|
||||
}
|
||||
|
||||
describe("ThreadCameraController", () => {
|
||||
it("responds immediately, then eases out as a static target gets closer", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
|
||||
camera.followTo(60);
|
||||
advance(16);
|
||||
const firstStep = viewport.scrollTop;
|
||||
advance(16);
|
||||
const secondStep = viewport.scrollTop - firstStep;
|
||||
advance(16);
|
||||
const thirdStep = viewport.scrollTop - firstStep - secondStep;
|
||||
|
||||
expect(firstStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeGreaterThan(0);
|
||||
expect(thirdStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeLessThan(firstStep);
|
||||
expect(thirdStep).toBeLessThan(secondStep);
|
||||
});
|
||||
|
||||
it("retargets an active follow without adding another loop", () => {
|
||||
const { camera, viewport, frames, advance } = cameraHarness();
|
||||
|
||||
expect(camera.followTo(100)).toBe("started");
|
||||
expect(frames).toHaveLength(1);
|
||||
advance(16);
|
||||
expect(frames).toHaveLength(1);
|
||||
|
||||
expect(camera.followTo(180)).toBe("retargeted");
|
||||
expect(frames).toHaveLength(1);
|
||||
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
||||
expect(viewport.scrollTop).toBe(180);
|
||||
});
|
||||
|
||||
it("tracks repeated target growth as one monotonic camera movement", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
|
||||
camera.followTo(80);
|
||||
advance(16);
|
||||
const first = viewport.scrollTop;
|
||||
camera.followTo(140);
|
||||
advance(16);
|
||||
const second = viewport.scrollTop;
|
||||
camera.followTo(220);
|
||||
advance(16);
|
||||
const third = viewport.scrollTop;
|
||||
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(second).toBeGreaterThan(first);
|
||||
expect(third).toBeGreaterThan(second);
|
||||
expect(camera.isFollowing()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a faster motion profile for explicit long-distance navigation", () => {
|
||||
const follow = cameraHarness();
|
||||
const navigation = cameraHarness();
|
||||
|
||||
follow.camera.followTo(1_000);
|
||||
navigation.camera.navigateTo(1_000);
|
||||
follow.advance(16);
|
||||
navigation.advance(16);
|
||||
|
||||
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop);
|
||||
expect(navigation.viewport.scrollTop).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it("gives an immediate jump command priority over an active follow", () => {
|
||||
const { camera, viewport, scheduler, frames } = cameraHarness();
|
||||
|
||||
camera.followTo(240);
|
||||
expect(frames).toHaveLength(1);
|
||||
camera.jumpTo(40);
|
||||
|
||||
expect(camera.isFollowing()).toBe(false);
|
||||
expect(viewport.scrollTop).toBe(40);
|
||||
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves spatial continuity with a shorter reduced-motion chase", () => {
|
||||
const regular = cameraHarness();
|
||||
const reduced = cameraHarness(true);
|
||||
|
||||
expect(regular.camera.followTo(240)).toBe("started");
|
||||
expect(reduced.camera.followTo(240)).toBe("started");
|
||||
|
||||
regular.advance(16);
|
||||
reduced.advance(16);
|
||||
|
||||
expect(reduced.viewport.scrollTop).toBeGreaterThan(regular.viewport.scrollTop);
|
||||
expect(reduced.viewport.scrollTop).toBeLessThan(240);
|
||||
expect(reduced.camera.isFollowing()).toBe(true);
|
||||
|
||||
for (let frame = 0; frame < 60; frame += 1) reduced.advance(16);
|
||||
expect(reduced.camera.isFollowing()).toBe(false);
|
||||
expect(reduced.viewport.scrollTop).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -1073,13 +1073,59 @@ describe("ThreadComposer", () => {
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveTextContent(/Running/);
|
||||
expect(status).toHaveTextContent(/2:05/);
|
||||
expect(status.parentElement).toHaveClass("composer-status-strip");
|
||||
expect(status.parentElement).toHaveAttribute("data-state", "enter");
|
||||
expect(status).toHaveClass("composer-status-drawer-content");
|
||||
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
|
||||
"data-state",
|
||||
"open",
|
||||
);
|
||||
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens and closes the run timer through one persistent drawer", () => {
|
||||
const { container, rerender } = render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = container.querySelector("[data-composer-status-drawer]");
|
||||
expect(drawer).not.toBeNull();
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
rerender(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={Math.floor(Date.now() / 1000)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveClass("composer-status-drawer-content");
|
||||
|
||||
rerender(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(drawer?.querySelector('[role="status"]')).toBe(status);
|
||||
});
|
||||
|
||||
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
||||
const longObjective =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "@/lib/thread-display-compat";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const STARTED_AT = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||
|
||||
function message(
|
||||
role: UIMessage["role"],
|
||||
overrides: Partial<Omit<UIMessage, "role">> = {},
|
||||
): UIMessage {
|
||||
return {
|
||||
id: role,
|
||||
role,
|
||||
content: role,
|
||||
createdAt: STARTED_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeLegacyLongTaskMessages", () => {
|
||||
it("maps legacy long_task rows to trace lines", () => {
|
||||
const legacy = {
|
||||
@@ -23,17 +38,19 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
||||
});
|
||||
|
||||
it("removes model and silent-command turns without hiding concurrent replies", () => {
|
||||
const message = (
|
||||
id: string,
|
||||
role: UIMessage["role"],
|
||||
content: string,
|
||||
turnId?: string,
|
||||
): UIMessage => ({ id, role, content, createdAt: 1, turnId });
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("model", "user", "/model fast", "model-turn"),
|
||||
message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"),
|
||||
message("silent", "user", "/restart", "webui-system:restart"),
|
||||
message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"),
|
||||
message("user", { id: "model", content: "/model fast", turnId: "model-turn" }),
|
||||
message("assistant", {
|
||||
id: "model-reply",
|
||||
content: "Switched model preset to fast.",
|
||||
turnId: "model-turn",
|
||||
}),
|
||||
message("user", { id: "silent", content: "/restart", turnId: "webui-system:restart" }),
|
||||
message("assistant", {
|
||||
id: "reply",
|
||||
content: "This unrelated reply stays visible.",
|
||||
turnId: "other-turn",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible.map(({ content }) => content)).toEqual([
|
||||
@@ -47,3 +64,74 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
||||
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectWebuiThreadMessages", () => {
|
||||
it("derives replayed completion time from the matching user turn", () => {
|
||||
const firstOutputAt = STARTED_AT + 5_000;
|
||||
const latencyMs = 13_000;
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-1",
|
||||
createdAt: firstOutputAt,
|
||||
latencyMs,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(STARTED_AT + latencyMs);
|
||||
expect(visible[1]?.completedAt).not.toBe(firstOutputAt + latencyMs);
|
||||
});
|
||||
|
||||
it("preserves the exact completion time received for a live turn", () => {
|
||||
const completedAt = STARTED_AT + 13_000;
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-1",
|
||||
latencyMs: 13_000,
|
||||
completedAt,
|
||||
createdAt: STARTED_AT + 5_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(completedAt);
|
||||
});
|
||||
|
||||
it("does not borrow a timestamp from a different turn", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-2",
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 60_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the nearest user start for legacy rows without turn metadata", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user"),
|
||||
message("assistant", {
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 5_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(STARTED_AT + 13_000);
|
||||
});
|
||||
|
||||
it("does not attach a previous user turn to proactive messages", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user"),
|
||||
message("assistant", {
|
||||
source: { kind: "cron" },
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 26 * 60_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ThreadMessages,
|
||||
unitKeysForDisplay,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -15,6 +16,118 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
|
||||
const completed: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "latest answer", createdAt: 2 },
|
||||
];
|
||||
const { rerender } = render(
|
||||
<ThreadMessages messages={completed} isStreaming={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
...completed,
|
||||
{ id: "u2", role: "user", content: "next question", createdAt: 3 },
|
||||
]}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||
});
|
||||
|
||||
it("still defers historical non-tail answers on their initial render", () => {
|
||||
render(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "historical answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "latest question", createdAt: 3 },
|
||||
]}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("historical answer").closest(".thread-render-unit")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||
await preloadMarkdownText();
|
||||
const turnId = "turn-1";
|
||||
const streaming: UIMessage[] = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "question",
|
||||
createdAt: 1,
|
||||
turnId,
|
||||
turnPhase: "prompt",
|
||||
},
|
||||
{
|
||||
id: "live-answer",
|
||||
role: "assistant",
|
||||
content: "stable final answer",
|
||||
createdAt: 2,
|
||||
isStreaming: true,
|
||||
turnId,
|
||||
turnPhase: "answer",
|
||||
},
|
||||
];
|
||||
const { container, rerender } = render(
|
||||
<ThreadMessages messages={streaming} isStreaming />,
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(container.querySelector(".markdown-content")).not.toBeNull(),
|
||||
{ timeout: 3_000 },
|
||||
);
|
||||
const paragraph = screen.getByText("stable final answer").closest("p");
|
||||
expect(paragraph).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
streaming[0],
|
||||
{
|
||||
...streaming[1],
|
||||
id: "canonical-answer",
|
||||
isStreaming: false,
|
||||
},
|
||||
]}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
streaming[0],
|
||||
{
|
||||
...streaming[1],
|
||||
id: "canonical-answer",
|
||||
isStreaming: false,
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
role: "user",
|
||||
content: "next question",
|
||||
createdAt: 3,
|
||||
turnId: "turn-2",
|
||||
turnPhase: "prompt",
|
||||
},
|
||||
]}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
});
|
||||
|
||||
it("offers a follow-up action for text selected within one completed answer", async () => {
|
||||
const onQuoteSelection = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ThreadMotionCoordinator,
|
||||
type ThreadMotionGeometry,
|
||||
type ThreadMotionScheduler,
|
||||
} from "@/components/thread/thread-motion";
|
||||
|
||||
function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
||||
let nextFrameId = 1;
|
||||
let cameraFollowing = false;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let geometry: ThreadMotionGeometry = {
|
||||
scrollTop: 400,
|
||||
scrollHeight: 1_900,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 1_400,
|
||||
composerHeight: 120,
|
||||
promptTop: 404,
|
||||
...initial,
|
||||
};
|
||||
const scheduler: ThreadMotionScheduler = {
|
||||
request: vi.fn((callback) => {
|
||||
const id = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
}),
|
||||
cancel: vi.fn((id) => {
|
||||
frames.delete(id);
|
||||
}),
|
||||
};
|
||||
const camera = {
|
||||
cancel: vi.fn(() => {
|
||||
cameraFollowing = false;
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
followTo: vi.fn(() => "started" as const),
|
||||
isFollowing: vi.fn(() => cameraFollowing),
|
||||
navigateTo: vi.fn(() => {
|
||||
cameraFollowing = true;
|
||||
return "started" as const;
|
||||
}),
|
||||
};
|
||||
const onGeometry = vi.fn();
|
||||
const onAutoFollow = vi.fn();
|
||||
const measure = vi.fn(() => geometry);
|
||||
const coordinator = new ThreadMotionCoordinator({
|
||||
camera,
|
||||
measure,
|
||||
onGeometry,
|
||||
onAutoFollow,
|
||||
scheduler,
|
||||
});
|
||||
const advanceFrame = () => {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, callback] of pending) callback(16);
|
||||
};
|
||||
const setGeometry = (next: Partial<ThreadMotionGeometry>) => {
|
||||
const updated = { ...geometry, ...next };
|
||||
geometry = {
|
||||
...updated,
|
||||
maxScrollTop: next.maxScrollTop
|
||||
?? Math.max(0, updated.scrollHeight - updated.clientHeight),
|
||||
};
|
||||
};
|
||||
const setCameraFollowing = (following: boolean) => {
|
||||
cameraFollowing = following;
|
||||
};
|
||||
|
||||
return {
|
||||
camera,
|
||||
coordinator,
|
||||
frames,
|
||||
measure,
|
||||
onAutoFollow,
|
||||
onGeometry,
|
||||
scheduler,
|
||||
advanceFrame,
|
||||
setCameraFollowing,
|
||||
setGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ThreadMotionCoordinator", () => {
|
||||
it("coalesces discrete invalidations into one authoritative geometry frame", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
frames,
|
||||
measure,
|
||||
scheduler,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
coordinator.invalidateGeometry();
|
||||
|
||||
expect(scheduler.request).toHaveBeenCalledTimes(1);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
|
||||
advanceFrame();
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
expect(measure).toHaveBeenCalledWith("prompt-1");
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(404);
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
expect(coordinator.snapshot()).toMatchObject({
|
||||
mode: "follow-output",
|
||||
promptPositioned: true,
|
||||
measurementPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("retargets repeated output growth without restarting camera ownership", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.cancel.mockClear();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
setGeometry({ scrollTop: 1_400, scrollHeight: 1_928 });
|
||||
coordinator.invalidateGeometry();
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
setGeometry({ scrollTop: 1_410, scrollHeight: 1_944 });
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.followTo.mock.calls).toEqual([[1_428], [1_444]]);
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"geometry-before-completion",
|
||||
"completion-before-geometry",
|
||||
] as const)("keeps following final layout for %s ordering", (ordering) => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.cancel.mockClear();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
const applyFinalGeometry = () => {
|
||||
setGeometry({
|
||||
scrollTop: 1_400,
|
||||
scrollHeight: 2_400,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
};
|
||||
if (ordering === "geometry-before-completion") applyFinalGeometry();
|
||||
coordinator.completeTurn();
|
||||
if (ordering === "completion-before-geometry") applyFinalGeometry();
|
||||
|
||||
expect(coordinator.snapshot().mode).toBe("follow-completion");
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_900);
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 1_900,
|
||||
scrollHeight: 2_450,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
||||
});
|
||||
|
||||
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
measure,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-stable",
|
||||
promptId: "optimistic-prompt",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.jumpTo.mockClear();
|
||||
measure.mockClear();
|
||||
|
||||
setGeometry({ scrollHeight: 2_020, promptTop: 120 });
|
||||
coordinator.updateTurn({
|
||||
id: "turn-stable",
|
||||
promptId: "canonical-prompt",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(measure).toHaveBeenCalledWith(null);
|
||||
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_520);
|
||||
});
|
||||
|
||||
it("continues from the bottom when a running turn is restored before prompt identity", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: "turn-restored",
|
||||
promptId: null,
|
||||
hasOutput: true,
|
||||
entry: "restored",
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
expect(coordinator.snapshot()).toMatchObject({
|
||||
mode: "follow-output",
|
||||
promptPositioned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues measuring while browsing history but does not steal the viewport", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
onGeometry,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.followTo.mockClear();
|
||||
onGeometry.mockClear();
|
||||
|
||||
coordinator.takeUserControl();
|
||||
setGeometry({ scrollTop: 200, scrollHeight: 2_100 });
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(onGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
coordinator.resumeAutoFollow();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
||||
});
|
||||
|
||||
it("treats scroll events as observations until explicit user intent takes control", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness({
|
||||
scrollTop: 700,
|
||||
scrollHeight: 1_200,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 700,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.jumpTo.mockClear();
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 700,
|
||||
scrollHeight: 1_280,
|
||||
});
|
||||
expect(coordinator.observeScroll(false)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
advanceFrame();
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||
|
||||
coordinator.takeUserControl();
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
expect(coordinator.observeScroll(true)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
});
|
||||
|
||||
it("preserves history browsing when an active turn is cleared", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.takeUserControl();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: null,
|
||||
promptId: null,
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps history navigation independent from active turn completion", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.navigateHistoryTo(900);
|
||||
camera.cancel.mockClear();
|
||||
|
||||
coordinator.completeTurn();
|
||||
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||
});
|
||||
|
||||
it("moves from rail navigation to history browsing on user intent", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
setCameraFollowing,
|
||||
} = motionHarness();
|
||||
|
||||
coordinator.handleUserScrollIntent(false);
|
||||
expect(coordinator.snapshot().mode).toBe("idle");
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.navigateHistoryTo(900);
|
||||
expect(camera.navigateTo).toHaveBeenCalledWith(900);
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||
|
||||
camera.cancel.mockClear();
|
||||
coordinator.handleUserScrollIntent(false);
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
|
||||
coordinator.navigateHistoryTo(700);
|
||||
camera.cancel.mockClear();
|
||||
coordinator.takeUserControl();
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
camera.cancel.mockClear();
|
||||
coordinator.takeUserControl();
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.navigateHistoryTo(600);
|
||||
setCameraFollowing(false);
|
||||
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
});
|
||||
|
||||
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness({
|
||||
scrollHeight: 930,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 430,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
expect(camera.jumpTo).toHaveBeenLastCalledWith(430);
|
||||
|
||||
setGeometry({
|
||||
composerHeight: 148,
|
||||
scrollHeight: 958,
|
||||
promptTop: 404,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo.mock.calls).toEqual([[430], [458]]);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 458,
|
||||
scrollHeight: 990,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo.mock.calls).toEqual([[430], [458], [490]]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
@@ -18,6 +19,7 @@ function makeClient() {
|
||||
(modelName: string | null, modelPreset?: string | null) => void
|
||||
>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
@@ -31,7 +33,7 @@ function makeClient() {
|
||||
runtimeModelHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
getRunStartedAt: () => null,
|
||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||
let handlers = chatHandlers.get(chatId);
|
||||
@@ -60,6 +62,18 @@ function makeClient() {
|
||||
for (const h of errorHandlers) h(err);
|
||||
},
|
||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||
if (
|
||||
ev.event === "goal_status"
|
||||
&& ev.status === "running"
|
||||
&& typeof ev.started_at === "number"
|
||||
) {
|
||||
runStartedAtByChatId.set(chatId, ev.started_at);
|
||||
} else if (
|
||||
(ev.event === "goal_status" && ev.status === "idle")
|
||||
|| ev.event === "turn_end"
|
||||
) {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
}
|
||||
if (ev.event === "goal_state") {
|
||||
goalStateByChatId.set(chatId, ev.goal_state);
|
||||
}
|
||||
@@ -142,6 +156,36 @@ function httpJson(body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
interface ThreadResizeObserverInstance {
|
||||
elements: Element[];
|
||||
callback: ResizeObserverCallback;
|
||||
}
|
||||
|
||||
function stubThreadResizeObserver() {
|
||||
const original = globalThis.ResizeObserver;
|
||||
const observers: ThreadResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
elements: Element[] = [];
|
||||
callback: ResizeObserverCallback;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.elements.push(element);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
return {
|
||||
observers,
|
||||
restore: () => vi.stubGlobal("ResizeObserver", original),
|
||||
};
|
||||
}
|
||||
|
||||
function modelSettings(model: string, provider: string): SettingsPayload {
|
||||
return {
|
||||
agent: {
|
||||
@@ -1351,6 +1395,40 @@ describe("ThreadShell", () => {
|
||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("restores the stop control when returning to a running chat", async () => {
|
||||
const client = makeClient();
|
||||
const view = (chatId: string) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session(chatId)}
|
||||
title={`Chat ${chatId}`}
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
);
|
||||
const { rerender } = render(view("chat-a"));
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-a", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-a",
|
||||
status: "running",
|
||||
started_at: Date.now() / 1000,
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(view("chat-b"));
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(view("chat-a"));
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
@@ -1526,9 +1604,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||
const client = makeClient();
|
||||
const scrollTo = vi.fn();
|
||||
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -1569,13 +1645,13 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||
await waitFor(() => expect(scrollTo).toHaveBeenCalled());
|
||||
await waitFor(() => expect(jumpTo).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
jumpTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
client._emitSessionUpdate("chat-a");
|
||||
@@ -1583,9 +1659,112 @@ describe("ThreadShell", () => {
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument());
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(jumpTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||
jumpTo.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an active completion follow alive while canonical history refreshes", async () => {
|
||||
const resizeObserver = stubThreadResizeObserver();
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
const pendingRefresh = new Promise<ReturnType<typeof httpJson>>(() => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-follow/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
if (historyCalls > 1) return pendingRefresh;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "old question" },
|
||||
{ role: "assistant", content: "old answer" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
|
||||
try {
|
||||
const { container } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-follow")}
|
||||
title="Chat follow"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2_000 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 1_500 },
|
||||
scrollTo: {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
if (typeof top === "number") scroller.scrollTop = top;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "new question" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(screen.getByText("new question")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-follow", {
|
||||
event: "delta",
|
||||
chat_id: "chat-follow",
|
||||
text: "new answer",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("new answer")).toBeInTheDocument());
|
||||
|
||||
requestFrame.mockImplementation(() => 9_001);
|
||||
cancelFrame.mockImplementation(() => undefined);
|
||||
cancelFrame.mockClear();
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2_120,
|
||||
});
|
||||
const messageContent = screen.getByTestId("thread-message-region").firstElementChild;
|
||||
const contentObserver = resizeObserver.observers.find(
|
||||
(observer) => observer.elements.includes(messageContent!),
|
||||
);
|
||||
expect(contentObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
||||
});
|
||||
expect(requestFrame).toHaveBeenCalled();
|
||||
cancelFrame.mockClear();
|
||||
|
||||
act(() => {
|
||||
client._emitSessionUpdate("chat-follow", "thread");
|
||||
});
|
||||
|
||||
expect(cancelFrame).not.toHaveBeenCalledWith(9_001);
|
||||
expect(historyCalls).toBe(2);
|
||||
} finally {
|
||||
requestFrame.mockRestore();
|
||||
cancelFrame.mockRestore();
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1649,12 +1828,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
||||
});
|
||||
|
||||
it("opens slash commands on the blank welcome page", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,33 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].turnPhase).toBe("user");
|
||||
});
|
||||
|
||||
it("returns the submitted turn identity used by the optimistic row and wire frame", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-submitted-turn", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("bind the camera");
|
||||
});
|
||||
|
||||
expect(submitted).not.toBeNull();
|
||||
expect(submitted?.sideChannel).toBe(false);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: submitted?.userMessageId,
|
||||
turnId: submitted?.turnId,
|
||||
role: "user",
|
||||
});
|
||||
expect(fake.client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-submitted-turn",
|
||||
"bind the camera",
|
||||
undefined,
|
||||
expect.objectContaining({ turnId: submitted?.turnId }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds optimistic user file attachments as media", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
||||
@@ -2097,30 +2124,39 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps latency on the last assistant bubble from turn_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "delta",
|
||||
chat_id: "chat-lat",
|
||||
text: "Hi",
|
||||
it("stamps completion time and latency on the last assistant bubble from turn_end", () => {
|
||||
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(completedAt);
|
||||
try {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-lat",
|
||||
latency_ms: 2400,
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "delta",
|
||||
chat_id: "chat-lat",
|
||||
text: "Hi",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant");
|
||||
expect(lastAssistant?.latencyMs).toBe(2400);
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-lat",
|
||||
latency_ms: 2400,
|
||||
});
|
||||
});
|
||||
|
||||
const lastAssistant = [...result.current.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
expect(lastAssistant?.latencyMs).toBe(2400);
|
||||
expect(lastAssistant?.completedAt).toBe(completedAt);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks goal_status running and clears on idle", () => {
|
||||
@@ -2130,6 +2166,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2140,6 +2177,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2149,6 +2187,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("clears runStartedAt on turn_end even without idle", () => {
|
||||
@@ -2166,6 +2205,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2174,6 +2214,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
||||
@@ -2195,9 +2236,11 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(4242);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
rerender({ chatId: "chat-b" });
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-a", {
|
||||
@@ -2210,6 +2253,7 @@ describe("useNanobotStream", () => {
|
||||
|
||||
rerender({ chatId: "chat-a" });
|
||||
expect(result.current.runStartedAt).toBe(9001);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks goal_state per chat and restores after switching sessions", () => {
|
||||
|
||||
Reference in New Issue
Block a user