summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/chat/ui.test.ts
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-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.ts1526
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();
+ });
+ });
});