summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 17:14:40 +0900
committerAdam Malczewski <[email protected]>2026-06-07 17:14:40 +0900
commit2e79dd122e5664353e02e0d33715ae8c1041a379 (patch)
tree737822344118e5c1c840b8399a554a1898f07093 /src/features/chat/ui
parentc8c86dbc3fd23001cca7904791ab539300ec60f4 (diff)
downloaddispatch-web-2e79dd122e5664353e02e0d33715ae8c1041a379.tar.gz
dispatch-web-2e79dd122e5664353e02e0d33715ae8c1041a379.zip
feat(chat): restyle thinking — visible bubble, collapse, title swap, persisted open
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).
Diffstat (limited to 'src/features/chat/ui')
-rw-r--r--src/features/chat/ui/ChatView.svelte52
1 files changed, 44 insertions, 8 deletions
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<n>`) 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 };
+ });
+ });
</script>
{#snippet chunkRow(rendered: RenderedChunk)}
@@ -16,6 +37,26 @@
{/if}
</div>
</div>
+ {:else if rendered.chunk.type === "thinking"}
+ <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
+ (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
+ while generating, then "Thoughts" with no dots once complete. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle thoughts" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
+ {#if rendered.streaming}
+ <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
+ </div>
+ </div>
+ </div>
+ </div>
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
<!-- Single tool call/result: a regular (non-speech) card. Nested in the
chat-start grid via a transparent, padding-stripped chat-bubble shim so
@@ -39,17 +80,12 @@
</div>
</div>
{:else}
- <!-- Assistant / system / error: an INVISIBLE speech bubble — same chat-start
- grid as the user bubble, so it inherits identical left spacing. -->
+ <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
+ chat-start grid as the user bubble, so it inherits identical left spacing. -->
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "text"}
<p>{rendered.chunk.text}</p>
- {:else if rendered.chunk.type === "thinking"}
- <details>
- <summary>Thinking</summary>
- <p>{rendered.chunk.text}</p>
- </details>
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -66,7 +102,7 @@
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each groups as group, i (group.kind === "tool-batch" ? `b${group.stepId}` : group.chunk.seq != null ? `c${group.chunk.seq}` : `p${i}`)}
+ {#each rows as { group, key } (key)}
{#if group.kind === "single"}
{@render chunkRow(group.chunk)}
{:else}