summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ChatView.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/ui/ChatView.svelte')
-rw-r--r--src/features/chat/ui/ChatView.svelte93
1 files changed, 25 insertions, 68 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 6acda53..3a078fb 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,27 +1,16 @@
<script lang="ts">
import { groupRenderedChunks, type RenderedChunk } from "../index";
- import type { TelemetryState } from "../../../core/telemetry";
- import { stepMetrics, stepTps } from "../../../core/telemetry";
- interface Props {
- chunks: readonly RenderedChunk[];
- telemetry: TelemetryState;
- currentTurnId: string | null;
- }
-
- let { chunks, telemetry, currentTurnId }: Props = $props();
+ let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
const groups = $derived(groupRenderedChunks(chunks));
- function formatMs(ms: number): string {
- if (ms < 1000) return `${Math.round(ms)}ms`;
- const s = ms / 1000;
- return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
- }
-
+ // 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;
- let stepIdx = 0;
return groups.map((group, i) => {
let key: string;
if (group.kind === "tool-batch") {
@@ -33,17 +22,14 @@
} else {
key = `p${i}`;
}
- const si = stepIdx;
- if (group.kind === "tool-batch" || (group.kind === "single" && (group.chunk.chunk.type === "tool-call" || group.chunk.chunk.type === "tool-result"))) {
- stepIdx++;
- }
- return { group, key, stepIdx: si };
+ return { group, key };
});
});
</script>
-{#snippet chunkRow(rendered: RenderedChunk, sIdx: number)}
+{#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">
{#if rendered.chunk.type === "text"}
@@ -52,6 +38,9 @@
</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">
@@ -69,18 +58,14 @@
</div>
</div>
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined}
- {@const toolDur = step?.toolDurationMs}
+ <!-- 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">
{#if rendered.chunk.type === "tool-call"}
<div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
- <div class="flex items-center gap-2">
- <strong>{rendered.chunk.toolName}</strong>
- {#if toolDur !== undefined && toolDur > 0}
- <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
- {/if}
- </div>
+ <strong>{rendered.chunk.toolName}</strong>
<pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
</div>
{:else}
@@ -88,43 +73,19 @@
class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
class:text-error={rendered.chunk.isError}
>
- <div class="flex items-center gap-2">
- <strong>{rendered.chunk.toolName}</strong>
- {#if toolDur !== undefined && toolDur > 0}
- <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
- {/if}
- </div>
+ <strong>{rendered.chunk.toolName}</strong>
<pre class="text-xs mt-1">{rendered.chunk.content}</pre>
</div>
{/if}
</div>
</div>
{:else}
- {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined}
- {@const tps = step ? stepTps(step) : undefined}
+ <!-- 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"}
- <ul class="list rounded-box text-sm">
- <li class="list-row">
- <p>{rendered.chunk.text}</p>
- </li>
- {#if step && (step.genTotalMs !== undefined || tps !== undefined || step.usage?.outputTokens !== undefined)}
- <li class="list-row">
- {#if step.genTotalMs !== undefined}
- <span class="badge badge-ghost badge-xs">{formatMs(step.genTotalMs)}</span>
- {/if}
- <span>·</span>
- {#if tps !== undefined}
- <span class="badge badge-ghost badge-xs">{Math.round(tps)} t/s</span>
- {/if}
- <span>·</span>
- {#if step.usage?.outputTokens !== undefined}
- <span class="badge badge-ghost badge-xs">{step.usage.outputTokens} tok</span>
- {/if}
- </li>
- {/if}
- </ul>
+ <p>{rendered.chunk.text}</p>
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -141,24 +102,20 @@
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each rows as { group, key, stepIdx } (key)}
+ {#each rows as { group, key } (key)}
{#if group.kind === "single"}
- {@render chunkRow(group.chunk, stepIdx)}
+ {@render chunkRow(group.chunk)}
{:else}
- {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, stepIdx) : undefined}
- {@const toolDur = step?.toolDurationMs}
+ <!-- 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">
<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>
- <div class="flex items-center gap-2">
- <strong>{entry.call.toolName}</strong>
- {#if toolDur !== undefined && toolDur > 0}
- <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
- {/if}
- </div>
+ <strong>{entry.call.toolName}</strong>
<pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre>
{#if entry.result}
<pre