diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
| commit | 83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch) | |
| tree | ebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/chat/ui/ChatView.svelte | |
| parent | a4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff) | |
| parent | c8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff) | |
| download | dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip | |
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/chat/ui/ChatView.svelte')
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 109 |
1 files changed, 99 insertions, 10 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 18fea7c..c7f566a 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,7 +2,9 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, @@ -10,6 +12,7 @@ type TurnMetricsEntry, } from "../../../core/metrics"; import { Markdown } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; import LazyCollapse from "./LazyCollapse.svelte"; import ToolCard from "./ToolCard.svelte"; @@ -21,14 +24,18 @@ let { chunks, + recovering = false, turnMetrics = [], hasEarlier = false, onShowEarlier, thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; + recovering?: boolean; turnMetrics?: readonly TurnMetricsEntry[]; /** Earlier history is unloaded (chat limit) and can be paged back in. */ hasEarlier?: boolean; @@ -55,10 +62,25 @@ * "" (root-relative — a browser resolves `/images/…` against its origin). */ apiBaseUrl?: string; + /** + * The active conversation's working directory (null when unset). Relative + * Markdown image srcs (``) resolve against it; local absolute + * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. + */ + cwd?: string | null; + /** + * The cache timer (gaps between model requests): a running step gap shows + * live at the bottom (under the executing tools), a running turn gap live + * on that turn's metrics row; finished gaps are stamped on their step / + * turn metrics line. `null` (the default) hides it entirely — the caller + * gates on the model. + */ + cacheTimer?: CacheTimerState | null; } = $props(); // True while a show-earlier page-in is awaited (disables the button). let loadingEarlier = $state(false); + let failedImages = $state<string[]>([]); async function showEarlier() { if (!onShowEarlier || loadingEarlier) return; @@ -70,10 +92,35 @@ } } - const groups = $derived(groupRenderedChunks(chunks)); + const groups = $derived(groupRenderedChunks(chunks, recovering)); const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most + // recent turn's row (when no user message follows it). + const seededTurnRow = $derived( + cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null + ? lastTurnRowIndex(rows) + : null, + ); + + /** Start of the running turn gap if it belongs on this turn row, else undefined. */ + function liveTurnSince(turnId: string, rowIndex: number): number | undefined { + const r = cacheTimer?.running; + if (r?.kind !== "turn") return undefined; + if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) { + return r.startedAt; + } + return undefined; + } + + // A running step gap with tool calls → the live timer under the executing tools. + const liveStepGap = $derived( + cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0 + ? cacheTimer.running + : null, + ); + // Tool cards use toolCallId keys across pending/result/seal and orphan page-in. // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that // survives the provisional→committed (seq null → seq N) transition, so the @@ -105,6 +152,12 @@ }); </script> +{#if recovering} + <p role="status" class="px-4 py-2 text-sm text-base-content/70"> + Recovering conversation history… Omitted tool input/output is unavailable until persisted. + </p> +{/if} + {#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"} <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk @@ -117,13 +170,22 @@ {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "image"} - <img - src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} - alt={rendered.chunk.mimeType ?? "pasted image"} - loading="lazy" - decoding="async" - class="max-h-80 max-w-full rounded" - /> + {#if failedImages.includes(rendered.chunk.url)} + <span class="text-sm opacity-70">image expired</span> + {:else} + <img + src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} + alt={rendered.chunk.mimeType ?? "pasted image"} + loading="lazy" + decoding="async" + onerror={() => { + if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) { + failedImages = [...failedImages, rendered.chunk.url]; + } + }} + class="max-h-80 max-w-full rounded" + /> + {/if} {/if} </div> </div> @@ -150,7 +212,11 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + <Markdown + text={rendered.chunk.text} + streaming={rendered.streaming ?? false} + image={{ apiBase: apiBaseUrl, cwd }} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -180,13 +246,14 @@ </button> </div> {/if} - {#each keyedRows as { row, key } (key)} + {#each keyedRows as { row, key }, i (key)} <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint for rows outside the viewport (see app.css). --> <div class="chat-row"> {#if row.kind === "step-metrics"} {@const sv = viewStepMetrics(row.step, row.index)} {@const stepCache = viewCacheRate(row.step.usage)} + {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-wrap items-center gap-x-1 text-xs"> @@ -203,6 +270,9 @@ class="badge badge-xs {badgeClass[stepCache.level]}" data-testid="step-cache-badge">{stepCache.pct}%</span > + {#if stepStamp !== undefined} + <CacheTimerBadge elapsedMs={stepStamp} size="xs" /> + {/if} </div> </div> </div> @@ -211,6 +281,8 @@ {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)} + {@const turnLive = liveTurnSince(row.turn.turnId, i)} + {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> @@ -236,6 +308,11 @@ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> </span> {/if} + {#if turnLive !== undefined} + <CacheTimerBadge since={turnLive} /> + {:else if turnStamp !== undefined} + <CacheTimerBadge elapsedMs={turnStamp} /> + {/if} </div> </div> </div> @@ -252,6 +329,18 @@ {/if} </div> {/each} + {#if liveStepGap} + <!-- Live cache timer while the last step's tools execute; stamped onto that + step's metrics line once they return (or the next step starts). --> + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools"> + <span class="opacity-70">Running tools</span> + <CacheTimerBadge since={liveStepGap.startedAt} size="xs" /> + </div> + </div> + </div> + {/if} {#if providerRetry} {@const rv = viewProviderRetry(providerRetry)} <!-- Transient yellow warning: a provider error is being retried with backoff. |
