codebay 0.14.0 → 0.16.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 (108) hide show
  1. package/.mochi/manifest.json +778 -646
  2. package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
  7. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
  8. package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
  9. package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
  10. package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
  11. package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
  12. package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
  13. package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
  14. package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
  15. package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
  16. package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
  17. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
  23. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
  24. package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
  25. package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
  26. package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
  27. package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
  28. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
  29. package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
  30. package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
  31. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
  32. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  33. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  34. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  35. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  36. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  37. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  38. package/README.md +43 -3
  39. package/migrations/0009_agent_runs.sql +24 -0
  40. package/migrations/0010_agent_run_model.sql +2 -0
  41. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  42. package/package.json +13 -9
  43. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  44. package/src/avatars/dog.ts +1 -1
  45. package/src/avatars/lightning.ts +8 -9
  46. package/src/avatars/rocket.ts +4 -5
  47. package/src/components/AgentLogBox.svelte +458 -0
  48. package/src/components/AppShell.svelte +112 -1
  49. package/src/components/DropZone.svelte +77 -0
  50. package/src/components/FolderBrowser.svelte +13 -5
  51. package/src/components/HealthBox.svelte +6 -5
  52. package/src/components/IdeBar.svelte +222 -76
  53. package/src/components/InstanceCard.svelte +38 -0
  54. package/src/components/InstanceView.svelte +83 -2
  55. package/src/components/SettingsView.svelte +273 -0
  56. package/src/components/TerminalPane.svelte +54 -0
  57. package/src/components/UiShowcase.svelte +23 -3
  58. package/src/container-injections/claude-code-install.ts +1 -1
  59. package/src/container-injections/claude-history.ts +52 -0
  60. package/src/container-injections/claude-statusline.ts +4 -1
  61. package/src/container-injections/github-credentials.ts +12 -1
  62. package/src/container-injections/tmux.ts +6 -2
  63. package/src/index.ts +35 -7
  64. package/src/lib/agent-run-stream.ts +286 -0
  65. package/src/lib/agent-runs.server.ts +735 -0
  66. package/src/lib/auth.server.ts +11 -2
  67. package/src/lib/build-freshness.server.ts +71 -0
  68. package/src/lib/config.server.ts +36 -7
  69. package/src/lib/container-files.server.ts +1 -1
  70. package/src/lib/data-dir-lock.server.ts +78 -0
  71. package/src/lib/db.server.ts +124 -1
  72. package/src/lib/devcontainer.server.ts +93 -16
  73. package/src/lib/docker-client.server.ts +11 -5
  74. package/src/lib/drives.ts +11 -0
  75. package/src/lib/exec.server.ts +66 -5
  76. package/src/lib/injections.server.ts +5 -1
  77. package/src/lib/instances.server.ts +66 -7
  78. package/src/lib/log-capture.server.ts +63 -23
  79. package/src/lib/mcp-auth.server.ts +52 -0
  80. package/src/lib/osc52.ts +18 -0
  81. package/src/lib/picker.server.ts +23 -2
  82. package/src/lib/sandbox-ops.server.ts +263 -0
  83. package/src/lib/upload.ts +32 -0
  84. package/src/lib/uploads.server.ts +164 -0
  85. package/src/mcp/routes.server.ts +25 -0
  86. package/src/mcp/server.server.ts +55 -0
  87. package/src/mcp/tools.server.ts +660 -0
  88. package/src/pages/Instance.svelte +6 -2
  89. package/src/pages/Settings.svelte +15 -0
  90. package/src/routes.ts +79 -3
  91. package/src/types.ts +17 -0
  92. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  93. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  94. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  95. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  96. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  97. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  98. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  99. package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
  100. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  101. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  102. package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
  103. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  104. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
  105. package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
  106. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  107. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  108. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -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>
@@ -5,6 +5,7 @@
5
5
  import { SvelteSet } from 'svelte/reactivity';
6
6
  import { nextTabIndex } from '../lib/tab-nav.ts';
7
7
  import DashboardView from './DashboardView.svelte';
8
+ import DropZone from './DropZone.svelte';
8
9
  import IdeBar from './IdeBar.svelte';
9
10
  import IdeLoader from './IdeLoader.svelte';
10
11
  import TerminalSplit from './TerminalSplit.svelte';
@@ -15,6 +16,7 @@
15
16
  import { syncTheme } from '../theme.ts';
16
17
  import toast, { Toaster } from 'svelte-french-toast';
17
18
  import { TOAST_OPTIONS } from '../toast.ts';
19
+ import { filesFrom, uploadFile } from '../lib/upload.ts';
18
20
 
19
21
  // `snapshot` seeds the live state so neither view renders a loading flash first.
20
22
  let {
@@ -56,6 +58,22 @@
56
58
  let editingId = $state<string | null>(null);
57
59
  let editingName = $state('');
58
60
 
61
+ // The tab only vanishes once the stream drops the instance from `running`, so the
62
+ // button stays disabled across the round-trip rather than flickering back to armed.
63
+ const stopping = new SvelteSet<string>();
64
+
65
+ async function stopTab(id: string) {
66
+ if (stopping.has(id)) return;
67
+ stopping.add(id);
68
+ try {
69
+ await apiPost(`/api/instances/${id}/stop`, undefined, 'Failed to stop instance');
70
+ // The live stream removes the tab once the container is down.
71
+ } catch (err) {
72
+ toast.error((err as Error).message);
73
+ stopping.delete(id);
74
+ }
75
+ }
76
+
59
77
  function startRename(instance: Instance) {
60
78
  editingId = instance.id;
61
79
  editingName = instance.name;
@@ -250,6 +268,72 @@
250
268
  };
251
269
  });
252
270
 
271
+ // Sequential, so the response order (and the toast order) matches the drop order.
272
+ async function handleUpload(id: string, files: File[]) {
273
+ for (const file of files) {
274
+ try {
275
+ const saved = await uploadFile(id, file);
276
+ toast.success(`Saved ${saved.containerPath}`);
277
+ void navigator.clipboard.writeText(saved.containerPath).catch(() => {});
278
+ } catch (err) {
279
+ toast.error((err as Error).message);
280
+ }
281
+ }
282
+ }
283
+
284
+ let dropping = $state(false);
285
+
286
+ function onDrop(e: DragEvent) {
287
+ e.preventDefault();
288
+ dropping = false;
289
+ void handleUpload(active, filesFrom(e.dataTransfer));
290
+ }
291
+
292
+ // Covering the code-server iframe hijacks VS Code's own drops, so there only the tab takes a
293
+ // file; a terminal pane lives in this document and gets the full overlay.
294
+ $effect(() => {
295
+ if (!livePreflight.uploadEnabled || !onIde) return;
296
+ const hasFiles = (e: DragEvent) => e.dataTransfer?.types.includes('Files') ?? false;
297
+ const enter = (e: DragEvent) => {
298
+ if (hasFiles(e)) dropping = true;
299
+ };
300
+ // A drop target already claimed the drag; anywhere else refuses it, since the browser's
301
+ // default is to open the file in a new tab.
302
+ const over = (e: DragEvent) => {
303
+ if (!hasFiles(e) || e.defaultPrevented) return;
304
+ e.preventDefault();
305
+ e.dataTransfer!.dropEffect = 'none';
306
+ };
307
+ const drop = (e: DragEvent) => {
308
+ if (hasFiles(e)) e.preventDefault();
309
+ dropping = false;
310
+ };
311
+ const leave = (e: DragEvent) => {
312
+ if (e.relatedTarget === null) dropping = false;
313
+ };
314
+ // Capture phase: only fires for events that originate in the parent document (xterm's
315
+ // textarea, the tab bar) — paste inside the iframe never reaches here.
316
+ const paste = (e: ClipboardEvent) => {
317
+ const files = filesFrom(e.clipboardData);
318
+ if (files.length && active) {
319
+ e.preventDefault();
320
+ void handleUpload(active, files);
321
+ }
322
+ };
323
+ window.addEventListener('dragenter', enter);
324
+ window.addEventListener('dragover', over);
325
+ window.addEventListener('dragleave', leave);
326
+ window.addEventListener('drop', drop);
327
+ window.addEventListener('paste', paste, true);
328
+ return () => {
329
+ window.removeEventListener('dragenter', enter);
330
+ window.removeEventListener('dragover', over);
331
+ window.removeEventListener('dragleave', leave);
332
+ window.removeEventListener('drop', drop);
333
+ window.removeEventListener('paste', paste, true);
334
+ };
335
+ });
336
+
253
337
  $effect(() => {
254
338
  // The re-seed after a reconnect is a baseline, not a change — otherwise it replays chimes.
255
339
  let primed = false;
@@ -271,10 +355,25 @@
271
355
  livePreflight = { ...livePreflight, defaultMode: msg.data.mode };
272
356
  return;
273
357
  }
358
+ if (msg.type === 'upload-enabled') {
359
+ livePreflight = { ...livePreflight, uploadEnabled: msg.data.enabled };
360
+ return;
361
+ }
274
362
  if (msg.type === 'theme') {
275
363
  syncTheme(msg.data.value);
276
364
  return;
277
365
  }
366
+ if (msg.type === 'run') {
367
+ // Its own frame because run progress ticks far faster than the instance list
368
+ // reconciles, so patch the one instance rather than waiting for a whole list.
369
+ const summary = msg.data;
370
+ instances = instances.map((i) =>
371
+ i.id === summary.instance_id
372
+ ? { ...i, active_run: summary.finished_at ? null : summary }
373
+ : i
374
+ );
375
+ return;
376
+ }
278
377
  if (msg.type === 'health') {
279
378
  // A tick in flight when a rebuild starts probes the *old* container and
280
379
  // reports it accessible, which would mount the iframe against the replacement too early.
@@ -290,6 +389,7 @@
290
389
  for (const id of [...everReady]) if (!live.has(id)) everReady.delete(id);
291
390
  for (const id of [...forced]) if (!live.has(id)) forced.delete(id);
292
391
  for (const id of [...loadedFrames]) if (!live.has(id)) loadedFrames.delete(id);
392
+ for (const id of [...stopping]) if (!live.has(id)) stopping.delete(id);
293
393
  const nextAttention: Record<string, 'done' | 'waiting' | null> = {};
294
394
  for (const inst of next) nextAttention[inst.id] = inst.attention;
295
395
  if (primed) {
@@ -345,10 +445,15 @@
345
445
  {attention}
346
446
  {editingId}
347
447
  bind:editingName
448
+ stopping={[...stopping]}
348
449
  onreload={activeInstance && activeInstance.mode !== 'terminal' && mountable(active)
349
450
  ? reloadActive
350
451
  : undefined}
351
452
  onselect={(id) => navigate(`/ide/${id}`)}
453
+ onstop={stopTab}
454
+ ondropfiles={livePreflight.uploadEnabled
455
+ ? (id, files) => void handleUpload(id, files)
456
+ : undefined}
352
457
  onstartrename={startRename}
353
458
  oncommitrename={commitRename}
354
459
  oncancelrename={cancelRename}
@@ -371,7 +476,7 @@
371
476
  <div
372
477
  class="pane"
373
478
  class:active={inst.id === active}
374
- role="tabpanel"
479
+ role="region"
375
480
  aria-labelledby="tab-{inst.id}"
376
481
  >
377
482
  {#if mountable(inst.id)}
@@ -382,10 +487,13 @@
382
487
  initialOpen={inst.terminal_split === 1}
383
488
  />
384
489
  {:else}
490
+ <!-- code-server answers a terminal's OSC 52 through the Clipboard API, which
491
+ Permissions Policy gates per-frame. -->
385
492
  <iframe
386
493
  bind:this={frames[inst.id]}
387
494
  src={ideUrl(inst)}
388
495
  title={inst.name}
496
+ allow="clipboard-read; clipboard-write"
389
497
  onload={() => loadedFrames.add(inst.id)}
390
498
  ></iframe>
391
499
  {/if}
@@ -402,6 +510,9 @@
402
510
  {:else if inst.mode !== 'terminal' && !loadedFrames.has(inst.id)}
403
511
  <IdeLoader />
404
512
  {/if}
513
+ {#if dropping && inst.id === active && inst.mode === 'terminal'}
514
+ <DropZone ondrop={onDrop} />
515
+ {/if}
405
516
  </div>
406
517
  {/if}
407
518
  {/each}