codebay 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.mochi/manifest.json +526 -455
  2. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
  7. package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
  8. package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
  9. package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
  10. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
  13. package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
  14. package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
  15. package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
  16. package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
  17. package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
  18. package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
  21. package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
  22. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
  23. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
  24. package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
  25. package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
  26. package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
  27. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
  28. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  29. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  30. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  31. package/README.md +32 -1
  32. package/migrations/0009_agent_runs.sql +24 -0
  33. package/migrations/0010_agent_run_model.sql +2 -0
  34. package/package.json +6 -2
  35. package/src/components/AgentLogBox.svelte +458 -0
  36. package/src/components/AppShell.svelte +11 -0
  37. package/src/components/FolderBrowser.svelte +13 -5
  38. package/src/components/InstanceCard.svelte +38 -0
  39. package/src/components/InstanceView.svelte +8 -0
  40. package/src/components/SettingsView.svelte +186 -0
  41. package/src/components/UiShowcase.svelte +4 -2
  42. package/src/container-injections/claude-statusline.ts +4 -1
  43. package/src/container-injections/github-credentials.ts +12 -1
  44. package/src/index.ts +32 -7
  45. package/src/lib/agent-run-stream.ts +246 -0
  46. package/src/lib/agent-runs.server.ts +724 -0
  47. package/src/lib/auth.server.ts +11 -2
  48. package/src/lib/build-freshness.server.ts +71 -0
  49. package/src/lib/config.server.ts +29 -6
  50. package/src/lib/container-files.server.ts +1 -1
  51. package/src/lib/data-dir-lock.server.ts +78 -0
  52. package/src/lib/db.server.ts +120 -1
  53. package/src/lib/devcontainer.server.ts +60 -14
  54. package/src/lib/docker-client.server.ts +11 -5
  55. package/src/lib/drives.ts +11 -0
  56. package/src/lib/exec.server.ts +66 -5
  57. package/src/lib/instances.server.ts +45 -3
  58. package/src/lib/log-capture.server.ts +17 -5
  59. package/src/lib/mcp-auth.server.ts +52 -0
  60. package/src/lib/picker.server.ts +23 -2
  61. package/src/lib/sandbox-ops.server.ts +263 -0
  62. package/src/mcp/routes.server.ts +25 -0
  63. package/src/mcp/server.server.ts +52 -0
  64. package/src/mcp/tools.server.ts +494 -0
  65. package/src/pages/Settings.svelte +12 -0
  66. package/src/routes.ts +63 -1
  67. package/src/types.ts +15 -0
  68. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  73. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  74. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  75. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  76. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  77. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  78. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
@@ -0,0 +1,458 @@
1
+ <script lang="ts">
2
+ import Bot from '@lucide/svelte/icons/bot';
3
+ import Terminal from '@lucide/svelte/icons/terminal';
4
+ import MessageSquare from '@lucide/svelte/icons/message-square';
5
+ import RefreshCw from '@lucide/svelte/icons/refresh-cw';
6
+ import CircleCheck from '@lucide/svelte/icons/circle-check';
7
+ import CircleX from '@lucide/svelte/icons/circle-x';
8
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
9
+ import { untrack } from 'svelte';
10
+ import { apiFetch } from '../api.ts';
11
+ import type { RunTimelineEntry } from '../lib/agent-run-stream.ts';
12
+ import type { AgentRunStatus } from '../types.ts';
13
+
14
+ interface AgentRun {
15
+ id: string;
16
+ status: AgentRunStatus;
17
+ prompt: string;
18
+ /** The id Claude reported once it started; null until then. */
19
+ model: string | null;
20
+ /** What the caller asked for, an alias like `sonnet`; null when the sandbox default was used. */
21
+ requested_model: string | null;
22
+ /** Claude's own session id, reported once the run starts; null until then. */
23
+ session_id: string | null;
24
+ /** Set when the run was launched to continue an earlier session, which is what makes it a continuation. */
25
+ resume_session_id: string | null;
26
+ result: string | null;
27
+ error: string | null;
28
+ is_error: boolean;
29
+ last_activity: string | null;
30
+ num_turns: number | null;
31
+ cost_usd: number | null;
32
+ duration_ms: number | null;
33
+ created_at: number;
34
+ started_at: number | null;
35
+ finished_at: number | null;
36
+ }
37
+
38
+ let {
39
+ id,
40
+ /** Bumped by the parent on every `run` stream frame, which is what drives live refresh. */
41
+ bump = 0
42
+ }: { id: string; bump?: number } = $props();
43
+
44
+ let runs = $state<AgentRun[]>([]);
45
+ let timelines = $state<Record<string, RunTimelineEntry[]>>({});
46
+ /** Expanded run ids. An array rather than a Set so Svelte tracks the changes. */
47
+ let open = $state<string[]>([]);
48
+ /** The newest run already reacted to, so one starting later can take the spotlight exactly once. */
49
+ let newest: string | null = null;
50
+ let loaded = $state(false);
51
+ let error = $state<string | null>(null);
52
+
53
+ const isOpen = (runId: string) => open.includes(runId);
54
+
55
+ async function load() {
56
+ try {
57
+ const q = open.length ? `?run_ids=${encodeURIComponent(open.join(','))}` : '';
58
+ const data = await apiFetch<{
59
+ runs: AgentRun[];
60
+ timelines: Record<string, RunTimelineEntry[]>;
61
+ }>(`/api/instances/${id}/agent-log${q}`, undefined, 'Could not load the agent log');
62
+
63
+ // This island can be older than the route (production serves the prebuilt bundle; routes
64
+ // load from source), so a key it does not know must degrade, not throw and unmount the box.
65
+ runs = Array.isArray(data.runs) ? data.runs : [];
66
+ timelines = { ...timelines, ...(data.timelines ?? {}) };
67
+
68
+ // A run that starts while the panel is open takes over: collapse the rest and expand it,
69
+ // so the box you are looking at is always the one currently doing something.
70
+ const top = data.runs[0]?.id ?? null;
71
+ if (top && top !== newest) {
72
+ newest = top;
73
+ open = [top];
74
+ }
75
+ error = null;
76
+ } catch (err) {
77
+ error = (err as Error).message;
78
+ } finally {
79
+ loaded = true;
80
+ }
81
+ }
82
+
83
+ $effect(() => {
84
+ // `bump` must be read as a real value. A bare `void bump;` is dead code the compiler drops,
85
+ // which leaves this effect with no dependency at all — the panel then renders the state it
86
+ // mounted with and never updates again, which is exactly what shipped first time round.
87
+ if (bump < 0) return;
88
+ // load() reads and writes `open`; tracked, that re-runs this effect off its own fetch.
89
+ untrack(() => void load());
90
+ });
91
+
92
+ function toggle(runId: string) {
93
+ const opening = !isOpen(runId);
94
+ open = opening ? [...open, runId] : open.filter((r) => r !== runId);
95
+ // Expanding a box whose timeline the server hasn't sent yet needs a fetch to fill it.
96
+ if (opening && !timelines[runId]) void load();
97
+ }
98
+
99
+ const ICONS = { tool: Terminal, text: MessageSquare, retry: RefreshCw, result: CircleCheck };
100
+
101
+ const isLive = (run: AgentRun) => run.status === 'running' || run.status === 'queued';
102
+
103
+ /** Newest step on top, keyed by the original position so a new step never re-keys the rest. */
104
+ const newestFirst = (entries: RunTimelineEntry[]) =>
105
+ entries.map((entry, index) => ({ entry, index })).reverse();
106
+
107
+ /** The id Claude reported, or the caller's alias until the run has started. */
108
+ const modelOf = (run: AgentRun) => run.model ?? run.requested_model;
109
+
110
+ function modelTitle(run: AgentRun): string | undefined {
111
+ if (run.model && run.requested_model && run.model !== run.requested_model) {
112
+ return `Requested as ${run.requested_model}`;
113
+ }
114
+ return run.model ? undefined : 'Requested; Claude has not reported the model it is using yet';
115
+ }
116
+
117
+ /** A resumed run reports the session it continued, so the two ids match and one short form labels both. */
118
+ const sessionOf = (run: AgentRun) => run.session_id ?? run.resume_session_id;
119
+
120
+ const shortSession = (sid: string) => sid.slice(0, 8);
121
+
122
+ function sessionTitle(run: AgentRun): string {
123
+ const sid = sessionOf(run);
124
+ if (!sid) return '';
125
+ const lead = run.resume_session_id
126
+ ? 'Continues an earlier run in this session'
127
+ : 'New session, started by this run';
128
+ return `${lead}\nSession ${sid}${run.session_id ? '' : ' (requested; not started yet)'}`;
129
+ }
130
+
131
+ function when(run: AgentRun): string {
132
+ return new Date(run.created_at).toLocaleTimeString([], {
133
+ hour: '2-digit',
134
+ minute: '2-digit',
135
+ second: '2-digit'
136
+ });
137
+ }
138
+
139
+ function meta(run: AgentRun): string {
140
+ const bits: string[] = [];
141
+ if (run.num_turns != null) bits.push(`${run.num_turns} turns`);
142
+ if (run.cost_usd != null) bits.push(`$${run.cost_usd.toFixed(4)}`);
143
+ if (run.duration_ms != null) bits.push(`${(run.duration_ms / 1000).toFixed(1)}s`);
144
+ return bits.join(' · ');
145
+ }
146
+
147
+ /** The one-line gist on a collapsed box, so the strip is worth scanning shut. */
148
+ function peek(run: AgentRun): string {
149
+ if (run.status === 'queued') return 'Queued';
150
+ if (isLive(run)) return (run.last_activity ?? 'Working…').replace(/\s+/g, ' ').trim();
151
+ return (run.error ?? run.result ?? run.prompt).replace(/\s+/g, ' ').trim();
152
+ }
153
+ </script>
154
+
155
+ {#if loaded && runs.length > 0}
156
+ <section class="agentwrap panel">
157
+ <div class="agent-bar panel-bar">
158
+ <span><Bot size={14} /> Agent log</span>
159
+ <span class="count">{runs.length} run{runs.length === 1 ? '' : 's'}</span>
160
+ </div>
161
+
162
+ {#each runs as run (run.id)}
163
+ {@const expanded = isOpen(run.id)}
164
+ <div class="run">
165
+ <button
166
+ type="button"
167
+ class="run-head"
168
+ aria-expanded={expanded}
169
+ onclick={() => toggle(run.id)}
170
+ >
171
+ <span class="chev" class:down={expanded}><ChevronRight size={14} /></span>
172
+ <span class="when">{when(run)}</span>
173
+ <span class="status" class:bad={run.is_error} class:live={isLive(run)}>
174
+ {#if isLive(run)}<span class="pulse"></span>{/if}
175
+ {run.status}
176
+ </span>
177
+ {#if modelOf(run)}
178
+ <span class="model" title={modelTitle(run)}>{modelOf(run)}</span>
179
+ {/if}
180
+ {#if sessionOf(run)}
181
+ {@const sid = sessionOf(run)!}
182
+ <span class="session" class:resumed={run.resume_session_id} title={sessionTitle(run)}>
183
+ {run.resume_session_id ? '⤷' : '◆'}
184
+ {shortSession(sid)}
185
+ </span>
186
+ {/if}
187
+ {#if !expanded}
188
+ <span class="peek">{peek(run)}</span>
189
+ {/if}
190
+ <span class="run-meta">{meta(run)}</span>
191
+ </button>
192
+
193
+ {#if expanded}
194
+ <div class="prompt">
195
+ <div class="ptitle">Initial prompt</div>
196
+ <div class="ptext">{run.prompt}</div>
197
+ </div>
198
+
199
+ <ol class="steps">
200
+ {#if isLive(run)}
201
+ <li class="step">
202
+ <span class="ico"><span class="pulse"></span></span>
203
+ <span class="body">
204
+ <!-- Deliberately not `last_activity`: while a run is live that is the same step
205
+ the newest timeline row already shows, so echoing it prints the line twice. -->
206
+ <span class="text">
207
+ {run.status === 'queued' ? 'Queued — waiting for the sandbox' : 'Working…'}
208
+ </span>
209
+ </span>
210
+ </li>
211
+ {:else if (timelines[run.id] ?? []).length === 0}
212
+ <li class="step">
213
+ <span class="body"><span class="text">No steps recorded.</span></span>
214
+ </li>
215
+ {/if}
216
+
217
+ {#each newestFirst(timelines[run.id] ?? []) as { entry, index } (index)}
218
+ <li class="step" class:sub={entry.subagent}>
219
+ <span class="ico" class:bad={entry.kind === 'result' && entry.isError}>
220
+ {#if entry.kind === 'result' && entry.isError}
221
+ <CircleX size={14} />
222
+ {:else}
223
+ {@const Icon = ICONS[entry.kind]}
224
+ <Icon size={14} />
225
+ {/if}
226
+ </span>
227
+ <span class="body">
228
+ {#if entry.name}<span class="tool">{entry.name}</span>{/if}
229
+ <span class="text">{entry.text}</span>
230
+ </span>
231
+ </li>
232
+ {/each}
233
+ </ol>
234
+
235
+ {#if run.error && !isLive(run)}
236
+ <div class="err">{run.error}</div>
237
+ {/if}
238
+ {/if}
239
+ </div>
240
+ {/each}
241
+
242
+ {#if error}
243
+ <div class="err">{error}</div>
244
+ {/if}
245
+ </section>
246
+ {/if}
247
+
248
+ <style>
249
+ .agentwrap {
250
+ overflow: hidden;
251
+ /* Matches the 18px the page puts under every other panel; this one sits above the boot log. */
252
+ margin-bottom: 18px;
253
+ }
254
+ .agent-bar {
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: space-between;
258
+ gap: 12px;
259
+ }
260
+ .agent-bar span {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 6px;
264
+ }
265
+ .count {
266
+ font-size: 11px;
267
+ opacity: 0.8;
268
+ }
269
+ .run {
270
+ background: var(--bg-card);
271
+ border-top: 1px solid var(--rule-soft);
272
+ }
273
+ .run:first-of-type {
274
+ border-top: none;
275
+ }
276
+ .run-head {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 10px;
280
+ width: 100%;
281
+ padding: 9px 14px;
282
+ background: none;
283
+ border: none;
284
+ color: inherit;
285
+ font: inherit;
286
+ text-align: left;
287
+ cursor: pointer;
288
+ min-width: 0;
289
+ }
290
+ .run-head:hover {
291
+ background: color-mix(in srgb, var(--ink) 6%, transparent);
292
+ }
293
+ .chev {
294
+ flex: none;
295
+ display: inline-flex;
296
+ color: var(--ink-soft);
297
+ transition: transform 120ms ease;
298
+ }
299
+ .chev.down {
300
+ transform: rotate(90deg);
301
+ }
302
+ .when,
303
+ .status,
304
+ .model,
305
+ .session,
306
+ .run-meta,
307
+ .peek {
308
+ font-family: var(--font-mono);
309
+ font-size: 11px;
310
+ }
311
+ .session {
312
+ flex: none;
313
+ display: inline-flex;
314
+ align-items: center;
315
+ gap: 4px;
316
+ color: var(--ink-faint);
317
+ }
318
+ /* A continuation is the exception worth spotting in the strip, so only it gets full-strength ink. */
319
+ .session.resumed {
320
+ color: var(--ink-soft);
321
+ }
322
+ .model {
323
+ flex: none;
324
+ color: var(--ink-soft);
325
+ max-width: 14em;
326
+ overflow: hidden;
327
+ text-overflow: ellipsis;
328
+ white-space: nowrap;
329
+ }
330
+ .when {
331
+ flex: none;
332
+ color: var(--ink-soft);
333
+ }
334
+ .status {
335
+ flex: none;
336
+ display: inline-flex;
337
+ align-items: center;
338
+ gap: 5px;
339
+ border: 1px solid var(--ink-faint);
340
+ padding: 0 5px;
341
+ }
342
+ .status.live {
343
+ border-color: var(--fill);
344
+ }
345
+ .status.bad {
346
+ color: var(--attn-waiting);
347
+ border-color: var(--attn-waiting);
348
+ }
349
+ /* Collapsed rows carry the gist, so the strip is scannable without opening anything. */
350
+ .peek {
351
+ flex: 1;
352
+ min-width: 0;
353
+ color: var(--ink-soft);
354
+ overflow: hidden;
355
+ text-overflow: ellipsis;
356
+ white-space: nowrap;
357
+ }
358
+ .run-meta {
359
+ margin-left: auto;
360
+ flex: none;
361
+ color: var(--ink-faint);
362
+ }
363
+ .prompt {
364
+ padding: 10px 14px;
365
+ border-top: 1px solid var(--rule-soft);
366
+ }
367
+ .ptitle {
368
+ font-family: var(--font-mono);
369
+ font-size: 10px;
370
+ text-transform: uppercase;
371
+ letter-spacing: 0.08em;
372
+ color: var(--ink-soft);
373
+ margin-bottom: 4px;
374
+ }
375
+ .ptext {
376
+ font-size: 12px;
377
+ line-height: 1.5;
378
+ /* The prompt is arbitrary user text, often multi-line — keep its shape but cap the height. */
379
+ white-space: pre-wrap;
380
+ max-height: 7.5em;
381
+ overflow-y: auto;
382
+ }
383
+ /* Newest step first, so the cap never hides what the run is doing right now. */
384
+ .steps {
385
+ list-style: none;
386
+ margin: 0;
387
+ padding: 0;
388
+ max-height: 420px;
389
+ overflow-y: auto;
390
+ }
391
+ .step {
392
+ display: flex;
393
+ gap: 10px;
394
+ align-items: flex-start;
395
+ padding: 8px 14px;
396
+ border-top: 1px solid var(--rule-soft);
397
+ }
398
+ /* A subagent's steps are the nested ones; indent rather than hide them. */
399
+ .step.sub {
400
+ padding-left: 34px;
401
+ opacity: 0.75;
402
+ }
403
+ .ico {
404
+ flex: none;
405
+ display: inline-flex;
406
+ width: 16px;
407
+ height: 16px;
408
+ align-items: center;
409
+ justify-content: center;
410
+ color: var(--ink-soft);
411
+ margin-top: 1px;
412
+ }
413
+ .ico.bad {
414
+ color: var(--attn-waiting);
415
+ }
416
+ .body {
417
+ display: flex;
418
+ gap: 6px;
419
+ min-width: 0;
420
+ flex-wrap: wrap;
421
+ align-items: baseline;
422
+ }
423
+ .tool {
424
+ font-family: var(--font-mono);
425
+ font-size: 11px;
426
+ border: 1px solid var(--ink-faint);
427
+ padding: 0 4px;
428
+ flex: none;
429
+ }
430
+ .text {
431
+ font-family: var(--font-mono);
432
+ font-size: 12px;
433
+ line-height: 1.5;
434
+ word-break: break-word;
435
+ min-width: 0;
436
+ }
437
+ .pulse {
438
+ flex: none;
439
+ width: 7px;
440
+ height: 7px;
441
+ border-radius: 50%;
442
+ background: var(--fill);
443
+ animation: agent-pulse 1.6s ease-in-out infinite;
444
+ }
445
+ @keyframes agent-pulse {
446
+ 50% {
447
+ opacity: 0.25;
448
+ }
449
+ }
450
+ .err {
451
+ padding: 8px 14px;
452
+ font-family: var(--font-mono);
453
+ font-size: 11px;
454
+ border-top: 1px solid var(--rule-soft);
455
+ color: var(--attn-waiting);
456
+ white-space: pre-wrap;
457
+ }
458
+ </style>
@@ -275,6 +275,17 @@
275
275
  syncTheme(msg.data.value);
276
276
  return;
277
277
  }
278
+ if (msg.type === 'run') {
279
+ // Its own frame because run progress ticks far faster than the instance list
280
+ // reconciles, so patch the one instance rather than waiting for a whole list.
281
+ const summary = msg.data;
282
+ instances = instances.map((i) =>
283
+ i.id === summary.instance_id
284
+ ? { ...i, active_run: summary.finished_at ? null : summary }
285
+ : i
286
+ );
287
+ return;
288
+ }
278
289
  if (msg.type === 'health') {
279
290
  // A tick in flight when a rebuild starts probes the *old* container and
280
291
  // reports it accessible, which would mount the iframe against the replacement too early.
@@ -2,6 +2,7 @@
2
2
  import { tick } from 'svelte';
3
3
  import type { BrowseResult, FolderHistoryEntry, InstanceMode } from '../types.ts';
4
4
  import { isRepoUrl } from '../lib/repo-url.ts';
5
+ import { DRIVES_ROOT, DRIVES_ROOT_LABEL } from '../lib/drives.ts';
5
6
  import Terminal from '@lucide/svelte/icons/terminal';
6
7
  import LayoutTemplate from '@lucide/svelte/icons/layout-template';
7
8
  import X from '@lucide/svelte/icons/x';
@@ -54,6 +55,9 @@
54
55
  : []
55
56
  );
56
57
 
58
+ // The virtual drive list is a chooser, not a folder — nothing in it can be handed to an instance.
59
+ const atDrivesRoot = $derived(result?.path === DRIVES_ROOT);
60
+
57
61
  const shownHistory = $derived(showAll ? history : history.slice(0, 5));
58
62
 
59
63
  async function loadHistory() {
@@ -180,7 +184,7 @@
180
184
  </div>
181
185
 
182
186
  <div class="crumbs">
183
- <code>{result?.path ?? '…'}</code>
187
+ <code>{atDrivesRoot ? DRIVES_ROOT_LABEL : (result?.path ?? '…')}</code>
184
188
  </div>
185
189
 
186
190
  {#if history.length > 0}
@@ -268,7 +272,7 @@
268
272
  {/if}
269
273
  </div>
270
274
 
271
- {#if result && !result.hasDevcontainer}
275
+ {#if result && !result.hasDevcontainer && !atDrivesRoot}
272
276
  <div class="warn">
273
277
  <TriangleAlert size={15} />
274
278
  <span
@@ -279,11 +283,15 @@
279
283
  {/if}
280
284
 
281
285
  <div class="foot">
282
- <span class="hint">Browse to a folder, then select this folder or any subfolder.</span>
286
+ <span class="hint"
287
+ >{atDrivesRoot
288
+ ? 'Pick a drive to browse.'
289
+ : 'Browse to a folder, then select this folder or any subfolder.'}</span
290
+ >
283
291
  <button
284
292
  class="primary"
285
- disabled={!result}
286
- onclick={() => result && onpick(result.path, { mode })}
293
+ disabled={!result || atDrivesRoot}
294
+ onclick={() => result && !atDrivesRoot && onpick(result.path, { mode })}
287
295
  >
288
296
  Select this folder
289
297
  </button>
@@ -94,6 +94,16 @@
94
94
  {#if instance.git_branch}
95
95
  <BranchBox branch={instance.git_branch} />
96
96
  {/if}
97
+ {#if instance.active_run}
98
+ <div class="agent-run" title="An MCP client is driving Claude in this sandbox">
99
+ <span class="agent-dot"></span>
100
+ <span class="agent-text">
101
+ {instance.active_run.status === 'queued'
102
+ ? 'Agent run queued'
103
+ : (instance.active_run.last_activity ?? 'Agent running')}
104
+ </span>
105
+ </div>
106
+ {/if}
97
107
  {#if instance.status === 'error' && instance.error}
98
108
  <div class="card-error">{instance.error}</div>
99
109
  {/if}
@@ -251,6 +261,34 @@
251
261
  color: var(--ink);
252
262
  outline: none;
253
263
  }
264
+ .agent-run {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 6px;
268
+ min-width: 0;
269
+ margin-top: 8px;
270
+ font-family: var(--font-mono);
271
+ font-size: 11px;
272
+ color: var(--ink-soft);
273
+ }
274
+ .agent-dot {
275
+ flex: none;
276
+ width: 6px;
277
+ height: 6px;
278
+ border-radius: 50%;
279
+ background: var(--fill);
280
+ animation: agent-pulse 1.6s ease-in-out infinite;
281
+ }
282
+ .agent-text {
283
+ overflow: hidden;
284
+ text-overflow: ellipsis;
285
+ white-space: nowrap;
286
+ }
287
+ @keyframes agent-pulse {
288
+ 50% {
289
+ opacity: 0.25;
290
+ }
291
+ }
254
292
  .path {
255
293
  margin-top: 10px;
256
294
  font-family: var(--font-mono);
@@ -3,6 +3,7 @@
3
3
  import ArrowLeft from '@lucide/svelte/icons/arrow-left';
4
4
  import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
5
5
  import HealthBox from './HealthBox.svelte';
6
+ import AgentLogBox from './AgentLogBox.svelte';
6
7
  import StatusBadge from './StatusBadge.svelte';
7
8
  import Skeleton from './Skeleton.svelte';
8
9
  import { forwardedPortUrl } from '../lib/links.ts';
@@ -20,6 +21,9 @@
20
21
  let health = $state<InstanceHealth | null>(null);
21
22
  let lastFetchedAt = $state<number | null>(null);
22
23
  let logs = $state('');
24
+ // Every `run` frame bumps this; AgentLogBox refetches on the change, so a live run's steps
25
+ // stream in without a second WebSocket.
26
+ let runBump = $state(0);
23
27
  let logEl = $state<HTMLDivElement | null>(null);
24
28
  let following = $state(true);
25
29
 
@@ -62,6 +66,8 @@
62
66
  } else if (msg.type === 'health' && msg.data.id === id) {
63
67
  health = msg.data.health;
64
68
  lastFetchedAt = Date.now();
69
+ } else if (msg.type === 'run' && msg.data.instance_id === id) {
70
+ runBump += 1;
65
71
  } else if (msg.type === 'theme') {
66
72
  syncTheme(msg.data.value);
67
73
  }
@@ -252,6 +258,8 @@
252
258
  </div>
253
259
  </section>
254
260
 
261
+ <AgentLogBox {id} bump={runBump} />
262
+
255
263
  <div class="logwrap panel">
256
264
  <div class="log-bar panel-bar">
257
265
  <span>Boot log</span>