summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ChatView.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/chat/ui/ChatView.svelte
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-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.svelte109
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 (`![x](shot.png)`) 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.