diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
| commit | 83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch) | |
| tree | ebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/chat/ui.test.ts | |
| parent | a4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff) | |
| parent | c8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff) | |
| download | dispatch-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.ts | 345 |
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: "" }, + 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/); + }); +}); |
