From 2e79dd122e5664353e02e0d33715ae8c1041a379 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 17:14:40 +0900 Subject: feat(chat): restyle thinking — visible bubble, collapse, title swap, persisted open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thinking renders inside a visible rounded-card bubble (like tool calls), capped to the same max-w-5xl column as assistant text. Uses a DaisyUI checkbox collapse (no arrow/plus icon) with smooth animation. Title reads "Thinking" + loading-dots while the model is actively generating, then flips to "Thoughts" with no dots once done. Open/closed state persists across the generating→completed→sealed transition via stable ordinal keys (per-conversation isolation via {#key} in App). Added optional streaming flag to RenderedChunk (pure selector, only on the accumulating chunk). --- src/features/chat/ui.test.ts | 62 +++++++++++++++++++++++++++--------- src/features/chat/ui/ChatView.svelte | 52 +++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 23 deletions(-) (limited to 'src/features') diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index c118115..b31cbf1 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -213,38 +213,70 @@ describe("ChatView", () => { expect(screen.getByText("contents-of-a")).toBeInTheDocument(); }); - it("thinking
stays open across a streaming update", async () => { - const initial: RenderedChunk[] = [ + 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 { rerender } = render(ChatView, { props: { chunks: initial } }); + const { container } = render(ChatView, { props: { chunks } }); - const details = screen.getByText("Thinking").closest("details"); - expect(details).not.toBeNull(); - expect(details).not.toHaveAttribute("open"); - if (details) details.open = true; - expect(details).toHaveAttribute("open"); + 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(); + }); - const updated: RenderedChunk[] = [ + 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: "Let me think... step by step" }, + chunk: { type: "thinking", text: "hmm" }, provisional: true, + streaming: true, }, ]; - await rerender({ chunks: updated }); - const detailsAfter = screen.getByText("Thinking").closest("details"); - expect(detailsAfter).not.toBeNull(); - expect(detailsAfter).toHaveAttribute("open"); - expect(detailsAfter).toHaveTextContent("Let me think... step by step"); + 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"); }); }); diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 76d122d..3a078fb 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -4,6 +4,27 @@ let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); const groups = $derived(groupRenderedChunks(chunks)); + + // Stable per-row keys. Thinking blocks get an ordinal key (`think`) that + // survives the provisional→committed (seq null → seq N) transition, so the + // collapse's open/close state is NOT lost when a turn seals. (App isolates + // these keys per conversation via {#key}.) + const rows = $derived.by(() => { + let thinking = 0; + return groups.map((group, i) => { + let key: string; + if (group.kind === "tool-batch") { + key = `b${group.stepId}`; + } else if (group.chunk.chunk.type === "thinking") { + key = `think${thinking++}`; + } else if (group.chunk.seq != null) { + key = `c${group.chunk.seq}`; + } else { + key = `p${i}`; + } + return { group, key }; + }); + }); {#snippet chunkRow(rendered: RenderedChunk)} @@ -16,6 +37,26 @@ {/if} + {:else if rendered.chunk.type === "thinking"} + +
+
+
+ +
+ {rendered.streaming ? "Thinking" : "Thoughts"} + {#if rendered.streaming} + + {/if} +
+
+

{rendered.chunk.text}

+
+
+
+
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} +
{#if rendered.chunk.type === "text"}

{rendered.chunk.text}

- {:else if rendered.chunk.type === "thinking"} -
- Thinking -

{rendered.chunk.text}

-
{:else if rendered.chunk.type === "error"}