summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ChatView.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 16:22:31 +0900
committerAdam Malczewski <[email protected]>2026-06-07 16:22:31 +0900
commit17bc0a2cdaeefd4974f785c907d3515a38d45363 (patch)
tree1834867d2f0ad5e82fbb985d7f602d8e1dffdb42 /src/features/chat/ui/ChatView.svelte
parent635cb6de7342ac87b27243652b1ad3b3a133d6a4 (diff)
downloaddispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.tar.gz
dispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.zip
feat(chat): group batched tool calls into one DaisyUI list
Consume the backend's new stepId grouping key (wire/transport-contract 0.1.0 -> 0.2.0). foldEvent copies event.stepId onto live tool chunks so live and replay group identically. New pure selector groupRenderedChunks (core/chunks) folds a step's 2+ tool calls into one tool-batch group, pairing each call with its result by toolCallId; single/no-stepId calls stay as cards. ChatView renders a batch as a DaisyUI list (list-row per pair). Fixtures updated for the now-required event stepId.
Diffstat (limited to 'src/features/chat/ui/ChatView.svelte')
-rw-r--r--src/features/chat/ui/ChatView.svelte143
1 files changed, 84 insertions, 59 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 0234852..60da571 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,70 +1,95 @@
<script lang="ts">
- import type { RenderedChunk } from "../index";
+ import { groupRenderedChunks, type RenderedChunk } from "../index";
let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+
+ const groups = $derived(groupRenderedChunks(chunks));
</script>
-<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)}
- {#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
- <div class="chat chat-start">
- <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}>
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {/if}
- </div>
+{#snippet chunkRow(rendered: RenderedChunk)}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}>
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {/if}
</div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Tool: a regular (non-speech) card. Nested in the chat-start grid via
- a transparent, padding-stripped chat-bubble shim so the card inherits
- the same left offset as the bubble bodies (no magic margin). -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
- <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}>
- {#if rendered.chunk.type === "tool-call"}
- <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- {:else}
- <div
- class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
- class:text-error={rendered.chunk.isError}
- >
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
- </div>
- {/if}
- </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
+ the card inherits the same left offset as the bubble bodies. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
+ <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}>
+ {#if rendered.chunk.type === "tool-call"}
+ <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
+ </div>
+ {:else}
+ <div
+ class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
+ class:text-error={rendered.chunk.isError}
+ >
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
+ </div>
+ {/if}
</div>
- {:else}
- <!-- Assistant / system / error: an INVISIBLE speech bubble — the same
- DaisyUI chat-start grid as the user bubble, so it inherits the
- identical left spacing (incl. the small leading gap). Transparent
- bg means no visible body and no visible tail; full width capped to
- a readable column. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div
- class="chat-bubble w-full bg-transparent"
- class:opacity-50={rendered.provisional}
- >
- {#if rendered.chunk.type === "text"}
+ </div>
+ {:else}
+ <!-- Assistant / 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" class:opacity-50={rendered.provisional}>
+ {#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>
- {: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}
- {#if rendered.chunk.code}
- <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
- {/if}
- </div>
- {:else if rendered.chunk.type === "system"}
- <div class="text-sm opacity-70">{rendered.chunk.text}</div>
- {/if}
+ </details>
+ {:else if rendered.chunk.type === "error"}
+ <div class="text-error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="text-sm opacity-70">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
+{/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}`)}
+ {#if group.kind === "single"}
+ {@render chunkRow(group.chunk)}
+ {:else}
+ <!-- Batched tool calls (one step): a single bubble holding a DaisyUI list,
+ one row per call paired with its result. Same chat-start grid shim as
+ the single tool card so it lines up with the other messages. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
+ <div class="chat-bubble bg-transparent" class:opacity-50={group.provisional}>
+ <ul class="list w-fit max-w-full rounded-box bg-base-200 text-sm">
+ {#each group.entries as entry (entry.call.toolCallId)}
+ <li class="list-row">
+ <div>
+ <strong>{entry.call.toolName}</strong>
+ <pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre>
+ {#if entry.result}
+ <pre
+ class="text-xs mt-1"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </div>
+ </li>
+ {/each}
+ </ul>
</div>
</div>
{/if}