summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/chat/ui.test.ts
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/chat/ui.test.ts')
-rw-r--r--src/features/chat/ui.test.ts345
1 files changed, 339 insertions, 6 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 91585e2..74c0619 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,8 +3,13 @@ import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
-import type { TurnMetricsEntry } from "../../core/metrics";
+import {
+ type CacheTimerState,
+ initialCacheTimerState,
+ type TurnMetricsEntry,
+} from "../../core/metrics";
import type { ConvertedJpeg } from "./adapter/image-encode";
+import CacheTimerBadge from "./ui/CacheTimerBadge.svelte";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
@@ -51,6 +56,25 @@ describe("ChatView", () => {
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
+ it("renders local-path Markdown images through the backend image route", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "![pic](/home/u/pic.png)" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ });
+
it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
const chunks: RenderedChunk[] = [
{ seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
@@ -825,6 +849,57 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Flush" });
+ expect(button).toHaveTextContent(/^Flush$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Flush" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Flush" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
@@ -1005,17 +1080,28 @@ describe("Composer", () => {
expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
- it("queues (steers) text-only and never forwards images", async () => {
- // While running, the Send button becomes "Queue"; steering is text-only.
+ it("still queues text-only with images omitted", async () => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ render(Composer, { props: { onSend, onQueue, status: "running" } });
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("queues text and images while running and clears the composer", async () => {
const onQueue = vi.fn();
const onSend = vi.fn();
const user = userEvent.setup();
- const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+ const { container } = render(Composer, {
+ props: { onSend, onQueue, status: "running", encodeJpeg: async () => null },
+ });
const textarea = screen.getByRole("textbox", { name: "Message input" });
await user.type(textarea, "steer here");
- // Also stage an image โ€” it must NOT be forwarded on a queue.
+ // Stage an image along with the steering text.
const file = new File(["PNG"], "shot.png", { type: "image/png" });
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -1025,8 +1111,66 @@ describe("Composer", () => {
});
await user.click(screen.getByRole("button", { name: "Queue" }));
- expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [
+ { url: "data:image/png;base64,UE5H", mimeType: "image/png" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("keeps images and text staged while %s without onQueue", async (status) => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container, rerender } = render(Composer, { props: { onSend, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "{Enter}"); // image-only: no queue callback
+ await user.type(textarea, "keep this{Enter}");
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+
expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("keep this");
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled();
+ // Once idle, the same staged input can actually be sent.
+ await rerender({ status: "idle" });
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
});
@@ -1542,3 +1686,192 @@ describe("ReasoningEffortSelector", () => {
});
});
});
+
+describe("ChatView โ€” cache timer", () => {
+ const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 };
+ function step(stepId: string) {
+ return { stepId: stepId as StepId, usage, decodeMs: 100 };
+ }
+ function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry {
+ const steps = stepIds.map(step);
+ return { turnId, steps, total: { turnId, usage, steps } };
+ }
+ function exchange(turnId: string, base: number): RenderedChunk[] {
+ return [
+ {
+ seq: base,
+ role: "user",
+ chunk: { type: "text", text: `q-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ {
+ seq: base + 1,
+ role: "assistant",
+ chunk: { type: "text", text: `a-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ ];
+ }
+ function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk {
+ return {
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: `call-${stepId}`,
+ toolName: "read_file",
+ input: {},
+ stepId: stepId as StepId,
+ },
+ provisional: false,
+ turnId,
+ };
+ }
+ function state(partial: Partial<CacheTimerState>): CacheTimerState {
+ return { ...initialCacheTimerState(), ...partial };
+ }
+
+ it("renders nothing when the feature is off (cacheTimer null)", () => {
+ render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } });
+ expect(screen.queryByTestId("cache-timer-live")).toBeNull();
+ expect(screen.queryByTestId("cache-timer-stamp")).toBeNull();
+ });
+
+ it("shows a live timer under executing tools, counting up to 10+ min", async () => {
+ vi.useFakeTimers();
+ try {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 1,
+ pending: new Set(["call-t1#0"]),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ const row = screen.getByTestId("cache-timer-tools");
+ expect(row).toHaveTextContent(/Running tools/);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30");
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("hides the live tools row for a step with no tool calls", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 0,
+ pending: new Set(),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("stamps a finished step gap on that step's line, next to its cache badge", () => {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }),
+ },
+ });
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("1:05");
+ // Sized to match the step's (xs) cache badge.
+ expect(stamp).toHaveClass("badge-xs");
+ expect(stamp).not.toHaveClass("badge-sm");
+ // Same line as the step's cache badge.
+ expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge"));
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: "t2", startedAt: Date.now() },
+ turnStamps: new Map([["t1", 11 * 60_000]]),
+ }),
+ },
+ });
+ const live = screen.getByTestId("cache-timer-live");
+ expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ expect(live).toHaveClass("badge-sm");
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("10+ min");
+ expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/);
+ });
+
+ it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => {
+ // Regression: the badge's effect once READ the `current` state it wrote, so
+ // with a real (always-advancing) clock it re-ran itself until Svelte threw
+ // `effect_update_depth_exceeded` โ€” aborting the flush, so turn data showed late.
+ let calls = 0;
+ const now = () => {
+ calls++;
+ return 1_000_000 + calls;
+ };
+ render(CacheTimerBadge, { props: { since: 1_000_000, now } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(calls).toBeLessThan(5);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ });
+
+ it("renders the turn's metrics alongside a live turn timer", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [turn("t1")],
+ cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }),
+ },
+ });
+ expect(screen.getByText(/turn 1 ยท 10 tok out/)).toBeInTheDocument();
+ expect(screen.getByText("a-t1")).toBeInTheDocument();
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/);
+ });
+
+ it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 },
+ }),
+ },
+ });
+ const badges = screen.getAllByTestId("cache-timer-live");
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveTextContent("2:00");
+ expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ });
+});