summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui/RunModal.svelte
blob: 92068ae793f08f5f12e8e095ca13ca85cb16c214 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
<script lang="ts">
	import { tick } from "svelte";
	import { ChatView } from "../../chat";
	import type { ChatStore } from "../../chat";
	import type { HeartbeatRunView } from "../logic/view-model";
	import type { StopHeartbeatRun } from "../logic/types";

	let {
		run,
		openChat,
		closeChat,
		stopRun,
		onClose,
		apiBaseUrl = "",
	}: {
		/** The run to display (its conversation's chat is shown live). */
		run: HeartbeatRunView;
		/**
		 * Open a live watch on a conversation (the store's `watchConversation`):
		 * returns a {@link ChatStore} subscribed to the conversation's turn stream
		 * + history loaded. The modal owns the watch lifecycle — calls
		 * `closeChat` on unmount.
		 */
		openChat: (conversationId: string) => ChatStore;
		/** Dispose + unsubscribe the watch opened by `openChat`. */
		closeChat: (conversationId: string) => void;
		/** Stop the heartbeat run (`POST .../runs/:runId/stop`). */
		stopRun: StopHeartbeatRun;
		onClose: () => void;
		/**
		 * The HTTP API base URL, to resolve persisted image chunk URLs
		 * (`/images/…`) in the run's transcript. Defaults to "" (root-relative).
		 */
		apiBaseUrl?: string;
	} = $props();

	// Open the live watch ONCE on mount (the modal is keyed per run.id, so a run
	// switch remounts it). `untrack` avoids re-running if the prop fn identity
	// changes — `run.conversationId` is the real dependency, captured once here.
	let chat = $state<ChatStore | null>(null);
	$effect(() => {
		chat = openChat(run.conversationId);
		return () => closeChat(run.conversationId);
	});

	// Live scroll: keep the transcript pinned to the bottom while it streams
	// (unless the reader has scrolled up — then we don't fight them).
	let scrollEl = $state<HTMLDivElement | undefined>();
	let contentEl = $state<HTMLDivElement | undefined>();
	let pinned = $state(true);

	function onScroll() {
		const el = scrollEl;
		if (el === undefined) return;
		pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
	}

	// Follow the bottom on new content while pinned. Reads `chunks.length` so the
	// effect re-runs on every streamed append.
	const chunkCount = $derived(chat?.chunks.length ?? 0);
	$effect(() => {
		void chunkCount;
		if (!pinned) return;
		void tick().then(() => {
			const el = scrollEl;
			if (el !== undefined) el.scrollTop = el.scrollHeight;
		});
	});

	// Stop state.
	let stopping = $state(false);
	let stopError = $state<string | null>(null);

	async function handleStop() {
		if (stopping) return;
		stopping = true;
		stopError = null;
		const result = await stopRun(run.id);
		stopping = false;
		if (result === null) return;
		if (!result.ok) stopError = result.error;
	}

	// The live "running" signal: the chat store's `generating` reflects the
	// actual event stream (turn-start…turn-sealed). True while a turn streams —
	// that is when a Stop is meaningful. Falls back to the run's status snapshot
	// before the stream attaches.
	const live = $derived(chat?.generating ?? run.busy);

	function handleKeydown(e: KeyboardEvent) {
		if (e.key === "Escape") onClose();
	}
</script>

<svelte:window onkeydown={handleKeydown} />

<!-- Fullscreen overlay. -->
<div class="fixed inset-0 z-50 flex flex-col bg-base-100">
	<!-- Header -->
	<header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2">
		<div class="flex min-w-0 items-center gap-2">
			<button
				type="button"
				class="btn btn-ghost btn-sm"
				onclick={onClose}
				aria-label="Close run chat"
			>
				✕
			</button>
			<span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span>
			{#if live}
				<span class="badge badge-sm badge-warning gap-1">
					<span class="loading loading-spinner loading-xs"></span>
					Running
				</span>
			{:else}
				<span class="badge badge-sm badge-ghost">{run.statusLabel}</span>
			{/if}
		</div>
		<div class="flex items-center gap-2">
			{#if stopError}
				<span class="text-xs text-error">{stopError}</span>
			{/if}
			{#if live}
				<button
					type="button"
					class="btn btn-sm btn-error btn-outline"
					disabled={stopping}
					onclick={handleStop}
				>
					{#if stopping}
						<span class="loading loading-spinner loading-xs"></span>
						Stopping…
					{:else}
						Stop
					{/if}
				</button>
			{/if}
		</div>
	</header>

	<!-- Transcript -->
	<div class="relative min-h-0 flex-1">
		<div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}>
			<div bind:this={contentEl} class="p-4">
				{#if chat === null}
					<div class="flex h-full items-center justify-center">
						<span class="loading loading-spinner loading-md"></span>
					</div>
				{:else if chat.chunks.length === 0 && chat.pendingSync}
					<div class="flex h-full items-center justify-center">
						<span class="loading loading-spinner loading-md"></span>
					</div>
				{:else}
					<ChatView
						chunks={chat.chunks}
						turnMetrics={chat.turnMetrics}
						hasEarlier={chat.hasEarlier}
						onShowEarlier={chat.showEarlier}
						thinkingKeyBase={chat.thinkingKeyBase}
						providerRetry={chat.providerRetry}
						apiBaseUrl={apiBaseUrl}
					/>
				{/if}
			</div>
		</div>
		{#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync}
			<div
				class="pointer-events-none absolute inset-0 flex items-center justify-center"
				aria-hidden="true"
			>
				<span class="select-none text-2xl font-bold opacity-10">No messages</span>
			</div>
		{/if}
	</div>
</div>