diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 16:22:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 16:22:31 +0900 |
| commit | 17bc0a2cdaeefd4974f785c907d3515a38d45363 (patch) | |
| tree | 1834867d2f0ad5e82fbb985d7f602d8e1dffdb42 /src/features/chat/ui/ChatView.svelte | |
| parent | 635cb6de7342ac87b27243652b1ad3b3a133d6a4 (diff) | |
| download | dispatch-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.svelte | 143 |
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} |
