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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
|
<script lang="ts">
import { DEFAULT_REASONING_EFFORT } from "@dispatch/core/src/types/index.js";
import { onMount } from "svelte";
import AgentBuilder from "./lib/components/AgentBuilder.svelte";
import ChatInput from "./lib/components/ChatInput.svelte";
import ChatPanel from "./lib/components/ChatPanel.svelte";
import Header from "./lib/components/Header.svelte";
import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
import PermissionPrompt from "./lib/components/PermissionPrompt.svelte";
import SidebarPanel from "./lib/components/SidebarPanel.svelte";
import TabBar from "./lib/components/TabBar.svelte";
import { config } from "./lib/config.js";
import { router } from "./lib/router.svelte.js";
import { tabStore } from "./lib/tabs.svelte.js";
import { applyTheme, loadStoredTheme } from "./lib/theme.js";
import type { KeyInfo } from "./lib/types.js";
import { wsClient } from "./lib/ws.svelte.js";
let modelsData = $state<{ keys: KeyInfo[] }>({
keys: [],
});
let sidebarOpen = $state(true);
// Add Key modal state (rendered at page level to escape sidebar transform)
let showAddKeyModal = $state(false);
let addKeyProvider = $state("anthropic");
let addKeyId = $state("");
let addKeyError = $state<string | null>(null);
let addKeySaving = $state(false);
async function addNewKey(): Promise<void> {
if (!addKeyId.trim()) return;
addKeySaving = true;
addKeyError = null;
try {
const res = await fetch(`${config.apiBase}/models/add-key`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }),
});
const data = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || !data.success) {
addKeyError = data.error ?? "Failed to add key";
} else {
showAddKeyModal = false;
addKeyId = "";
addKeyProvider = "anthropic";
await new Promise((r) => setTimeout(r, 500));
window.location.reload();
}
} catch (e) {
addKeyError = e instanceof Error ? e.message : "Network error";
} finally {
addKeySaving = false;
}
}
async function fetchModels() {
try {
const res = await fetch(`${config.apiBase}/models`);
if (!res.ok) return;
const data = await res.json();
modelsData = {
keys: data.keys ?? [],
};
} catch {
// ignore fetch errors
}
}
$effect(() => {
if (tabStore.configReloaded) {
fetchModels();
}
});
// ─── Context-window max lookup ─────────────────────────────────
// Resolve the active model's MAXIMUM context window from models.dev (via the
// API), so the Context Window sidebar view can show `current / max`. Cached
// per provider+model; `null` when unknown (the view then hides the
// denominator/percentage). Only Claude-backed providers are resolvable.
let contextLimit = $state<number | null>(null);
const contextLimitCache = new Map<string, number | null>();
$effect(() => {
const tab = tabStore.activeTab;
const keyId = tab?.keyId ?? null;
const modelId = tab?.modelId ?? null;
const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
if (!provider || !modelId) {
contextLimit = null;
return;
}
const cacheKey = `${provider}/${modelId}`;
if (contextLimitCache.has(cacheKey)) {
contextLimit = contextLimitCache.get(cacheKey) ?? null;
return;
}
// Clear immediately so a slow/failed fetch can't leave the PREVIOUS
// model's max on screen (which would render this model's tokens against
// the wrong denominator). The view degrades to a bare token count until
// the fetch resolves.
contextLimit = null;
// Fetch is async; guard against a stale response overwriting a newer
// selection by re-checking the active tab's key/model on resolve.
void (async () => {
try {
const res = await fetch(
`${config.apiBase}/models/context-limit?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
);
if (!res.ok) return;
const data = (await res.json()) as { contextLimit?: number | null };
const limit = data.contextLimit ?? null;
contextLimitCache.set(cacheKey, limit);
const current = tabStore.activeTab;
const currentProvider = current?.keyId
? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
: null;
if (currentProvider === provider && current?.modelId === modelId) {
contextLimit = limit;
}
} catch {
// Leave contextLimit as-is on network error; view falls back to
// showing the bare token count.
}
})();
});
// ─── Image / PDF capability lookup ─────────────────────────────
// Resolve whether the active model accepts image/pdf INPUT from models.dev (via
// the API), so the chat input can block sending an unsupported attachment
// (no tokens spent) while staying permissive when the capability is unknown.
// `null` = unknown (catalog offline / unsupported provider) → optimistic allow.
let imageSupport = $state<{ image: boolean; pdf: boolean } | null>(null);
const capabilityCache = new Map<string, { image: boolean; pdf: boolean } | null>();
$effect(() => {
const tab = tabStore.activeTab;
const keyId = tab?.keyId ?? null;
const modelId = tab?.modelId ?? null;
const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
if (!provider || !modelId) {
imageSupport = null;
return;
}
const cacheKey = `${provider}/${modelId}`;
if (capabilityCache.has(cacheKey)) {
imageSupport = capabilityCache.get(cacheKey) ?? null;
return;
}
// Clear immediately so a slow/failed fetch can't leave the PREVIOUS model's
// capability on screen (which could wrongly block/allow this model).
imageSupport = null;
void (async () => {
try {
const res = await fetch(
`${config.apiBase}/models/capabilities?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
);
if (!res.ok) return;
const data = (await res.json()) as {
capabilities?: { image: boolean; pdf: boolean } | null;
};
const caps = data.capabilities ?? null;
capabilityCache.set(cacheKey, caps);
const current = tabStore.activeTab;
const currentProvider = current?.keyId
? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
: null;
if (currentProvider === provider && current?.modelId === modelId) {
imageSupport = caps;
}
} catch {
// Leave imageSupport as null (unknown → permissive) on network error.
}
})();
});
onMount(() => {
// Apply persisted theme (or the shared DEFAULT_THEME if nothing is
// stored) so the first paint matches what the Settings panel will
// show as the selected option. Without this, daisyUI falls back to
// the first theme in `app.css` (light) while Settings shows "dark".
applyTheme(loadStoredTheme());
// Connect WebSocket in parallel with hydration. The `statuses`
// snapshot delivered on WS open is idempotent against
// already-hydrated tabs (the handler reconciles per-tab).
wsClient.connect();
// Initial models fetch (fire-and-forget; UI tolerates models
// arriving later than tabs).
fetchModels();
// Restore tabs from the backend. The user's previous session is
// the source of truth; only fall back to a fresh tab if nothing
// was restored (first-ever load, or DB was wiped, or HTTP failed).
void (async () => {
const restored = await tabStore.hydrateFromBackend();
if (restored === 0 && tabStore.tabs.length === 0) {
await tabStore.createNewTab();
}
})();
return () => {
wsClient.disconnect();
};
});
</script>
<div class="flex flex-col h-screen overflow-hidden">
<Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
{#if router.page === "dashboard"}
<div class="flex flex-1 overflow-hidden relative">
<!-- Main chat area -->
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<TabBar />
<div class="flex-1 overflow-hidden">
<ChatPanel />
</div>
<ChatInput {contextLimit} {imageSupport} />
</div>
<!-- Right sidebar: overlay on small screens, inline on large -->
<div
class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out
sm:relative sm:z-auto
absolute right-0 top-0 bottom-0 z-30"
class:w-80={sidebarOpen}
class:w-0={!sidebarOpen}
>
<div
class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out"
style="transform: translateX({sidebarOpen ? '0' : '100%'})"
>
<SidebarPanel
keys={modelsData.keys}
tasks={tabStore.activeTab?.tasks ?? []}
cacheStats={tabStore.activeTab?.cacheStats ?? null}
cacheTabTitle={tabStore.activeTab?.title ?? null}
{contextLimit}
permissionLog={tabStore.permissionLog}
apiBase={config.apiBase}
activeTabId={tabStore.activeTabId}
activeKeyId={tabStore.activeTab?.keyId ?? null}
activeModelId={tabStore.activeTab?.modelId ?? null}
reasoningEffort={tabStore.activeTab?.reasoningEffort ?? DEFAULT_REASONING_EFFORT}
activeAgentSlug={tabStore.activeTab?.agentSlug ?? null}
activeTabParentId={tabStore.activeTab?.parentTabId ?? null}
activeAgentModels={tabStore.activeTab?.agentModels ?? null}
workingDirectory={tabStore.activeTab?.workingDirectory ?? null}
onKeyChange={(keyId) => tabStore.setKey(keyId)}
onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)}
onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)}
onAgentChange={(agent) => tabStore.setAgent(agent)}
onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
onCompact={() => { const id = tabStore.activeTab?.id; if (id) void tabStore.startCompaction(id); }}
canCompact={(tabStore.activeTab?.agentStatus ?? "idle") === "idle" && (tabStore.activeTab?.chunks.length ?? 0) > 0 && !(tabStore.activeTab?.compactingSource) && !(tabStore.activeTab?.isCompacting)}
compacting={tabStore.activeTab?.isCompacting ?? false}
onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
/>
</div>
</div>
</div>
{:else if router.page === "agent-builder"}
<AgentBuilder keys={modelsData.keys} />
{/if}
</div>
<!-- Backdrop for sidebar on small screens -->
{#if sidebarOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/30 z-20 sm:hidden"
role="button"
tabindex="0"
onclick={() => sidebarOpen = false}
onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') sidebarOpen = false; }}
aria-label="Close sidebar"
></div>
{/if}
<!-- Fixed overlay elements -->
<PermissionPrompt
pending={tabStore.pendingPermissions}
onReply={(id, reply) => tabStore.replyPermission(id, reply)}
/>
<!-- Hot reload indicator fixed top-right -->
<div class="fixed top-4 right-4 z-50">
<HotReloadIndicator active={tabStore.configReloaded} />
</div>
<!-- Add New Key Modal (page-level to escape sidebar transform) -->
{#if showAddKeyModal}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
<div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
<h3 class="text-sm font-semibold">Add New Key</h3>
<div class="flex flex-col gap-1">
<label class="text-xs text-base-content/60" for="add-key-provider">Provider</label>
<select id="add-key-provider" class="select select-bordered select-sm w-full" bind:value={addKeyProvider}>
<option value="anthropic">Anthropic</option>
<option value="opencode-go">OpenCode</option>
<option value="google">Google (Gemini)</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-base-content/60" for="add-key-id">Key ID</label>
<input
id="add-key-id"
type="text"
class="input input-bordered input-sm w-full"
placeholder="e.g. claude-max, copilot-2"
bind:value={addKeyId}
/>
<p class="text-xs text-base-content/40">Unique identifier for this key</p>
</div>
{#if addKeyError}
<p class="text-xs text-error">{addKeyError}</p>
{/if}
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-sm btn-ghost"
onclick={() => { showAddKeyModal = false; }}>
Cancel
</button>
<button type="button" class="btn btn-sm btn-primary"
disabled={!addKeyId.trim() || addKeySaving}
onclick={addNewKey}>
{#if addKeySaving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Add Key
{/if}
</button>
</div>
</div>
</div>
{/if}
|