diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:31 +0900 |
| commit | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch) | |
| tree | 94e1923180ae38d571d34b578afecb0a18913c24 /src/features/chat/ui.test.ts | |
| parent | 80f99665034a0e510300793205c162fc7a46769f (diff) | |
| parent | 08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff) | |
| download | dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# src/app/App.svelte
# src/app/store.svelte.ts
# src/app/store.test.ts
# src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/chat/ui.test.ts')
| -rw-r--r-- | src/features/chat/ui.test.ts | 1526 |
1 files changed, 763 insertions, 763 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index b8b3193..a2fd944 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -10,797 +10,797 @@ import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; describe("ChatView", () => { - it("renders a message's text chunk", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "text", text: "Hello world" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("renders multiple chunks", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hi there")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - }); - - 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 }, - ]; - - let resolveEarlier: (() => void) | undefined; - const onShowEarlier = vi.fn( - () => - new Promise<void>((resolve) => { - resolveEarlier = resolve; - }), - ); - - render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); - - const button = screen.getByRole("button", { name: /show earlier messages/i }); - const user = userEvent.setup(); - await user.click(button); - - expect(onShowEarlier).toHaveBeenCalledTimes(1); - // While the page-in is awaited the button is disabled (no double-fire). - expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); - - resolveEarlier?.(); - await vi.waitFor(() => { - expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); - }); - }); - - it("hides the show-earlier button when nothing is unloaded", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, - ]; - - render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); - - expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); - }); - - it("renders tool-call chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp/test.txt" }, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - const pre = screen.getByText((content, element) => { - return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); - }); - expect(pre).toBeInTheDocument(); - }); - - it("renders tool-result chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("file contents here")).toBeInTheDocument(); - }); - - it("renders error chunks with alert role", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Something failed" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent("Something failed"); - }); - - it("renders error chunks with code", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Rate limited")).toBeInTheDocument(); - expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); - }); - - it("renders system chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "system", - chunk: { type: "system", text: "System context loaded" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("System context loaded")).toBeInTheDocument(); - }); - - it("renders provisional (in-flight) chunks without any dimming", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "text", text: "Streaming..." }, - provisional: true, - }, - ]; - - render(ChatView, { props: { chunks } }); - - // In-flight chunks render at full opacity (no faded "disabled" look). - const wrapper = screen.getByText("Streaming...").closest("div"); - expect(wrapper).not.toHaveClass("opacity-50"); - }); - - it("renders empty transcript", () => { - render(ChatView, { props: { chunks: [] } }); - - const log = screen.getByRole("log"); - expect(log).toBeInTheDocument(); - expect(log.children).toHaveLength(0); - }); - - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "a", - toolName: "read_file", - input: { path: "/a" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "b", - toolName: "list_dir", - input: { path: "/b" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "a", - toolName: "read_file", - content: "contents-of-a", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - // Batched calls render as collapsible cards (one per call), not a list. - const collapses = container.querySelectorAll(".collapse"); - expect(collapses).toHaveLength(2); - - // Both call names + the available result are shown; the result is absorbed - // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); - expect(screen.getByText("contents-of-a")).toBeInTheDocument(); - }); - - it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "Let me think..." }, - provisional: true, - streaming: true, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - const collapse = container.querySelector(".collapse"); - expect(collapse).not.toBeNull(); - expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon - expect(collapse).not.toHaveClass("collapse-plus"); - // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); - }); - - it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { - const streaming: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "hmm" }, - provisional: true, - streaming: true, - }, - ]; - - const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); - - // Streaming: "Thinking" + loading dots. - expect(screen.getByText("Thinking")).toBeInTheDocument(); - expect(screen.queryByText("Thoughts")).toBeNull(); - expect(container.querySelector(".loading")).not.toBeNull(); - - // Open it. - const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); - await userEvent.click(checkbox); - expect(checkbox).toBeChecked(); - - // Transition generating → completed/committed (seq assigned, no longer streaming). - await rerender({ - chunks: [ - { - seq: 1, - role: "assistant", - chunk: { type: "thinking", text: "hmm, all done" }, - provisional: false, - }, - ], - }); - - // Completed: "Thoughts", no dots — and the open state survived the transition. - expect(screen.getByText("Thoughts")).toBeInTheDocument(); - expect(screen.queryByText("Thinking")).toBeNull(); - expect(container.querySelector(".loading")).toBeNull(); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); - expect(container).toHaveTextContent("hmm, all done"); - }); - - it("renders step and turn metrics as separate rows", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - durationMs: 1200, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); - expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); - }); - - it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, - steps: [], - }, - }, - ]; - - const { container } = render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Last turn:")).toBeInTheDocument(); - expect(screen.getByText("Chat Total:")).toBeInTheDocument(); - // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge - const badges = container.querySelectorAll(".badge"); - expect(badges).toHaveLength(2); - for (const b of badges) { - expect(b.textContent).toBe("93%"); - expect(b.classList.contains("badge-success")).toBe(true); - } - }); - - it("renders step-metrics inline after tool group", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { command: "ls" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - content: "file.txt", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 4, - role: "assistant", - chunk: { type: "text", text: "Done!" }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 80, outputTokens: 20 }, - durationMs: 500, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Both step-metrics and turn-metrics render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); - - // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); - expect(stepEl).not.toBe(turnEl); - }); - - it("renders no metrics bubble when turnMetrics is empty", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics: [] } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.queryByText(/step 1/)).toBeNull(); - expect(screen.queryByText(/^turn/)).toBeNull(); - }); - - it("omits null view values from metrics bubbles", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Response" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics rendered - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); - // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); - // No "null" or "undefined" in the DOM - expect(screen.queryByText("null")).toBeNull(); - expect(screen.queryByText("undefined")).toBeNull(); - }); - - it("renders step text but no turn total for a progressive turn (total: null)", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: null, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); - - // Turn total should NOT render (total is null — turn still in progress) - expect(screen.queryByText(/^turn/)).toBeNull(); - }); + it("renders a message's text chunk", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "Hello world" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("renders multiple chunks", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hi there")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + 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 }, + ]; + + let resolveEarlier: (() => void) | undefined; + const onShowEarlier = vi.fn( + () => + new Promise<void>((resolve) => { + resolveEarlier = resolve; + }), + ); + + render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); + + const button = screen.getByRole("button", { name: /show earlier messages/i }); + const user = userEvent.setup(); + await user.click(button); + + expect(onShowEarlier).toHaveBeenCalledTimes(1); + // While the page-in is awaited the button is disabled (no double-fire). + expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); + + resolveEarlier?.(); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); + }); + }); + + it("hides the show-earlier button when nothing is unloaded", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, + ]; + + render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); + + expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); + }); + + it("renders tool-call chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + const pre = screen.getByText((content, element) => { + return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); + }); + expect(pre).toBeInTheDocument(); + }); + + it("renders tool-result chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("file contents here")).toBeInTheDocument(); + }); + + it("renders error chunks with alert role", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Something failed" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something failed"); + }); + + it("renders error chunks with code", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); + }); + + it("renders system chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "system", + chunk: { type: "system", text: "System context loaded" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("System context loaded")).toBeInTheDocument(); + }); + + it("renders provisional (in-flight) chunks without any dimming", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "text", text: "Streaming..." }, + provisional: true, + }, + ]; + + render(ChatView, { props: { chunks } }); + + // In-flight chunks render at full opacity (no faded "disabled" look). + const wrapper = screen.getByText("Streaming...").closest("div"); + expect(wrapper).not.toHaveClass("opacity-50"); + }); + + it("renders empty transcript", () => { + render(ChatView, { props: { chunks: [] } }); + + const log = screen.getByRole("log"); + expect(log).toBeInTheDocument(); + expect(log.children).toHaveLength(0); + }); + + it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + // Batched calls render as collapsible cards (one per call), not a list. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + + // Both call names + the available result are shown; the result is absorbed + // (no standalone tool-result card). + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + }); + + it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "Let me think..." }, + provisional: true, + streaming: true, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + const collapse = container.querySelector(".collapse"); + expect(collapse).not.toBeNull(); + expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon + expect(collapse).not.toHaveClass("collapse-plus"); + // Visible bubble, like tool cards. + expect(collapse).toHaveClass("bg-base-200"); + expect(collapse).toHaveClass("rounded-box"); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); + }); + + it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { + const streaming: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "hmm" }, + provisional: true, + streaming: true, + }, + ]; + + const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); + + // Streaming: "Thinking" + loading dots. + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.queryByText("Thoughts")).toBeNull(); + expect(container.querySelector(".loading")).not.toBeNull(); + + // Open it. + const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + // Transition generating → completed/committed (seq assigned, no longer streaming). + await rerender({ + chunks: [ + { + seq: 1, + role: "assistant", + chunk: { type: "thinking", text: "hmm, all done" }, + provisional: false, + }, + ], + }); + + // Completed: "Thoughts", no dots — and the open state survived the transition. + expect(screen.getByText("Thoughts")).toBeInTheDocument(); + expect(screen.queryByText("Thinking")).toBeNull(); + expect(container.querySelector(".loading")).toBeNull(); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); + expect(container).toHaveTextContent("hmm, all done"); + }); + + it("renders step and turn metrics as separate rows", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1200, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/150 tok/)).toHaveLength(2); + expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); + }); + + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, + steps: [], + }, + }, + ]; + + const { container } = render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Last turn:")).toBeInTheDocument(); + expect(screen.getByText("Chat Total:")).toBeInTheDocument(); + // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge + const badges = container.querySelectorAll(".badge"); + expect(badges).toHaveLength(2); + for (const b of badges) { + expect(b.textContent).toBe("93%"); + expect(b.classList.contains("badge-success")).toBe(true); + } + }); + + it("renders step-metrics inline after tool group", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { command: "ls" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + content: "file.txt", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { type: "text", text: "Done!" }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 80, outputTokens: 20 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Both step-metrics and turn-metrics render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + + // They are in separate elements (different rows) + const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + expect(stepEl).not.toBe(turnEl); + }); + + it("renders no metrics bubble when turnMetrics is empty", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics: [] } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.queryByText(/step 1/)).toBeNull(); + expect(screen.queryByText(/^turn/)).toBeNull(); + }); + + it("omits null view values from metrics bubbles", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Response" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics rendered + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + // Turn metrics rendered + expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + // No "null" or "undefined" in the DOM + expect(screen.queryByText("null")).toBeNull(); + expect(screen.queryByText("undefined")).toBeNull(); + }); + + it("renders step text but no turn total for a progressive turn (total: null)", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: null, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics should render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + + // Turn total should NOT render (total is null — turn still in progress) + expect(screen.queryByText(/^turn/)).toBeNull(); + }); }); describe("Composer", () => { - it("calls onSend with the typed text and clears", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("calls onSend with the typed text and clears", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Hello world"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Hello world"); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("Hello world"); - expect(textarea).toHaveValue(""); - }); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("Hello world"); + expect(textarea).toHaveValue(""); + }); - it("does not call onSend with empty text", async () => { - const onSend = vi.fn(); - const _user = userEvent.setup(); + it("does not call onSend with empty text", async () => { + const onSend = vi.fn(); + const _user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const sendButton = screen.getByRole("button", { name: "Send" }); - expect(sendButton).toBeDisabled(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); - expect(onSend).not.toHaveBeenCalled(); - }); + expect(onSend).not.toHaveBeenCalled(); + }); - it("trims whitespace before sending", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("trims whitespace before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, " hello "); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, " hello "); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledWith("hello"); - }); + expect(onSend).toHaveBeenCalledWith("hello"); + }); - it("sends on Enter key (without Shift)", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("sends on Enter key (without Shift)", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Test message{Enter}"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Test message{Enter}"); - expect(onSend).toHaveBeenCalledWith("Test message"); - }); + expect(onSend).toHaveBeenCalledWith("Test message"); + }); - it("does not send on Shift+Enter", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("does not send on Shift+Enter", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); - expect(onSend).not.toHaveBeenCalled(); - }); + expect(onSend).not.toHaveBeenCalled(); + }); }); describe("ModelSelector", () => { - const optionValues = (el: HTMLElement): string[] => - within(el) - .getAllByRole("option") - .map((o) => (o as HTMLOptionElement).value); - - it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; - render(ModelSelector, { - props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, - }); - - const keySelect = screen.getByRole("combobox", { name: "Key selector" }); - const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); - expect(keySelect).toHaveValue("anthropic"); - expect(modelSelect).toHaveValue("claude-3"); - - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); - // only the models under the selected key - expect(optionValues(modelSelect)).toEqual(["claude-3"]); - }); - - it("selecting a key switches to the first model under it", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4o", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); - }); - - it("selecting a model keeps the current key", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); - }); + const optionValues = (el: HTMLElement): string[] => + within(el) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value); + + it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; + render(ModelSelector, { + props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(keySelect).toHaveValue("anthropic"); + expect(modelSelect).toHaveValue("claude-3"); + + expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // only the models under the selected key + expect(optionValues(modelSelect)).toEqual(["claude-3"]); + }); + + it("selecting a key switches to the first model under it", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4o", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); + }); + + it("selecting a model keeps the current key", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); + }); }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); - - const select = screen.getByRole("combobox", { name: "Reasoning effort" }); - expect(select).toHaveValue("high"); - expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); - }); - - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); - - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); - }); - - it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ - ok: true as const, - reasoningEffort: level, - })); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: null, save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - expect(save).toHaveBeenCalledTimes(1); - expect(save).toHaveBeenCalledWith("max"); - await vi.waitFor(() => { - expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); - }); - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); - }); - - it("a failed save shows the error and reverts to the persisted value", async () => { - const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - await vi.waitFor(() => { - expect(screen.getByText("nope")).toBeInTheDocument(); - }); - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); - }); - - it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; - const save = vi.fn( - () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { - resolveSave = resolve; - }), - ); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: null, save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - - resolveSave?.({ ok: true, reasoningEffort: "max" }); - await vi.waitFor(() => { - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); - }); - }); + it("renders null (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + + const select = screen.getByRole("combobox", { name: "Reasoning effort" }); + expect(select).toHaveValue("high"); + expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); + // All five ladder levels are offered. + expect(within(select).getAllByRole("option")).toHaveLength(5); + }); + + it("renders a persisted level as selected", () => { + render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); + }); + + it("selecting a level saves it via the injected port and confirms", async () => { + const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + reasoningEffort: level, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: null, save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith("max"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); + }); + + it("a failed save shows the error and reverts to the persisted value", async () => { + const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + await vi.waitFor(() => { + expect(screen.getByText("nope")).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); + }); + + it("disables the select while a save is in flight (no double-fire)", async () => { + let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + const save = vi.fn( + () => + new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + resolveSave = resolve; + }), + ); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: null, save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); + + resolveSave?.({ ok: true, reasoningEffort: "max" }); + await vi.waitFor(() => { + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); + }); + }); }); |
