codebay 0.15.0 → 0.16.1

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 (91) hide show
  1. package/.mochi/manifest.json +490 -429
  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-5pg0be5f.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-ds9yn9cq.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-a2hty99b.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-68f9a9fq.server.js → App-8mpgrxza.server.js} +489 -295
  20. package/.mochi/svelte-compile/{Avatars-vyh3rwxx.server.js → Avatars-y5nf6q25.server.js} +102 -86
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/{Instance-yfw7s5mp.server.js → Instance-3sbzyp0g.server.js} +186 -118
  23. package/.mochi/svelte-compile/{Settings-0b5t9kw9.server.js → Settings-ysvh5vhh.server.js} +739 -628
  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-0y3wrae9.js → chunk-jq7jj8jd.js} +10 -6
  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-g0eravvj.js → chunk-zk4a9zms.js} +25 -17
  32. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  33. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  34. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  35. package/README.md +13 -4
  36. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  37. package/package.json +9 -9
  38. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  39. package/src/avatars/dog.ts +1 -1
  40. package/src/avatars/lightning.ts +8 -9
  41. package/src/avatars/rocket.ts +4 -5
  42. package/src/components/AppShell.svelte +101 -1
  43. package/src/components/DropZone.svelte +77 -0
  44. package/src/components/HealthBox.svelte +6 -5
  45. package/src/components/IdeBar.svelte +222 -76
  46. package/src/components/InstanceView.svelte +75 -2
  47. package/src/components/SettingsView.svelte +87 -0
  48. package/src/components/TerminalPane.svelte +54 -0
  49. package/src/components/UiShowcase.svelte +19 -1
  50. package/src/container-injections/claude-code-install.ts +1 -1
  51. package/src/container-injections/claude-history.ts +52 -0
  52. package/src/container-injections/tmux.ts +6 -2
  53. package/src/index.ts +3 -0
  54. package/src/lib/agent-run-stream.ts +41 -1
  55. package/src/lib/agent-runs.server.ts +14 -3
  56. package/src/lib/config.server.ts +16 -1
  57. package/src/lib/db.server.ts +6 -2
  58. package/src/lib/devcontainer.server.ts +37 -4
  59. package/src/lib/injections.server.ts +5 -1
  60. package/src/lib/instances.server.ts +21 -4
  61. package/src/lib/log-capture.server.ts +46 -18
  62. package/src/lib/osc52.ts +18 -0
  63. package/src/lib/spawn.server.ts +7 -2
  64. package/src/lib/upload.ts +32 -0
  65. package/src/lib/uploads.server.ts +164 -0
  66. package/src/mcp/server.server.ts +4 -1
  67. package/src/mcp/tools.server.ts +178 -12
  68. package/src/pages/Instance.svelte +6 -2
  69. package/src/pages/Settings.svelte +3 -0
  70. package/src/routes.ts +16 -2
  71. package/src/types.ts +2 -0
  72. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +0 -1
  73. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +0 -2
  74. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +0 -14
  75. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +0 -3
  76. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +0 -44
  77. package/.mochi/svelte-client/chunk-0mstgbtr.js +0 -2
  78. package/.mochi/svelte-client/chunk-0q9g0kpg.js +0 -1
  79. package/.mochi/svelte-client/chunk-3fwjzw1r.js +0 -1
  80. package/.mochi/svelte-client/chunk-fn03gxer.js +0 -4
  81. package/.mochi/svelte-client/chunk-jhgq7f65.js +0 -2
  82. package/.mochi/svelte-client/chunk-p5a07kx1.js +0 -1
  83. package/.mochi/svelte-client/chunk-p5wpyhsf.js +0 -2
  84. package/.mochi/svelte-client/chunk-t2kfvtb6.js +0 -2
  85. package/.mochi/svelte-client/chunk-v3wg9mdy.js +0 -1
  86. package/.mochi/svelte-compile/chunk-2qzc07gt.js +0 -34
  87. package/.mochi/svelte-compile/chunk-ef3hvegw.js +0 -21
  88. package/.mochi/svelte-compile/chunk-f9td8ej2.js +0 -24
  89. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  90. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  91. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -7,10 +7,12 @@
7
7
  import LayoutTemplate from '@lucide/svelte/icons/layout-template';
8
8
  import ChevronLeft from '@lucide/svelte/icons/chevron-left';
9
9
  import ChevronRight from '@lucide/svelte/icons/chevron-right';
10
+ import CircleStop from '@lucide/svelte/icons/circle-stop';
10
11
  import Avatar from './Avatar.svelte';
11
12
  import AppBar from './AppBar.svelte';
12
13
  import { withPopupMarker } from '../lib/popup-nav.ts';
13
14
  import { nextTabIndex } from '../lib/tab-nav.ts';
15
+ import { filesFrom } from '../lib/upload.ts';
14
16
 
15
17
  let {
16
18
  running,
@@ -18,8 +20,11 @@
18
20
  attention,
19
21
  editingId,
20
22
  editingName = $bindable(),
23
+ stopping = [],
21
24
  onreload,
22
25
  onselect,
26
+ onstop,
27
+ ondropfiles,
23
28
  onstartrename,
24
29
  oncommitrename,
25
30
  oncancelrename
@@ -29,21 +34,59 @@
29
34
  attention: Record<string, 'done' | 'waiting' | null>;
30
35
  editingId: string | null;
31
36
  editingName: string;
37
+ /** Ids with a stop already in flight — the tab survives until the stream drops it from `running`. */
38
+ stopping?: readonly string[];
32
39
  /** Absent for terminal tabs, which carry their own reload, and until the iframe exists. */
33
40
  onreload?: () => void;
34
41
  onselect: (id: string) => void;
42
+ /** Absent on /debug, where there is no instance to stop. */
43
+ onstop?: (id: string) => void;
44
+ /** Absent while workspace uploads are switched off, so tabs stay plain. */
45
+ ondropfiles?: (id: string, files: File[]) => void;
35
46
  onstartrename: (instance: Instance) => void;
36
47
  oncommitrename: (id: string) => void;
37
48
  oncancelrename: () => void;
38
49
  } = $props();
39
50
 
40
- const activeIndex = $derived(running.findIndex((i) => i.id === active));
41
- // Falls back to the first tab so the strip always offers exactly one tab stop,
42
- // even when `active` names an instance that has since stopped.
43
- const focusIndex = $derived(activeIndex >= 0 ? activeIndex : 0);
51
+ const isStopping = (id: string) => stopping.includes(id);
52
+
53
+ // The tooltip is fixed-positioned from the slot's rect, since the scrolling strip clips anything
54
+ // hanging below the bar.
55
+ let dropTarget = $state<{ id: string; x: number; y: number; maxX: number } | null>(null);
56
+ let tipWidth = $state(0);
57
+ // Centred under the tab but kept on screen, since the first and last tabs sit near the edges.
58
+ const tipLeft = $derived(
59
+ dropTarget ? Math.max(8, Math.min(dropTarget.x - tipWidth / 2, dropTarget.maxX - tipWidth)) : 0
60
+ );
61
+
62
+ function onSlotDrag(e: DragEvent, id: string) {
63
+ if (!ondropfiles || !e.dataTransfer?.types.includes('Files')) return;
64
+ e.preventDefault();
65
+ e.dataTransfer.dropEffect = 'copy';
66
+ if (dropTarget?.id === id) return;
67
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
68
+ dropTarget = {
69
+ id,
70
+ x: r.left + r.width / 2,
71
+ y: r.bottom,
72
+ maxX: document.documentElement.clientWidth - 8
73
+ };
74
+ }
75
+
76
+ function onSlotLeave(e: DragEvent) {
77
+ if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) return;
78
+ dropTarget = null;
79
+ }
80
+
81
+ function onSlotDrop(e: DragEvent, id: string) {
82
+ if (!ondropfiles) return;
83
+ e.preventDefault();
84
+ dropTarget = null;
85
+ ondropfiles(id, filesFrom(e.dataTransfer));
86
+ }
44
87
 
45
88
  let viewport = $state<HTMLDivElement | null>(null);
46
- let content = $state<HTMLDivElement | null>(null);
89
+ let content = $state<HTMLElement | null>(null);
47
90
  let atStart = $state(true);
48
91
  let atEnd = $state(true);
49
92
  // `$state` rather than a plain object so `bind:this` into it is a tracked write —
@@ -67,8 +110,9 @@
67
110
  tabEls[inst.id]?.focus();
68
111
  }
69
112
 
70
- function onTabKeydown(e: KeyboardEvent) {
71
- const next = nextTabIndex(activeIndex, e.key, running.length);
113
+ // Relative to the focused tab, not the active one: every tab is in the Tab order now.
114
+ function onTabKeydown(e: KeyboardEvent, index: number) {
115
+ const next = nextTabIndex(index, e.key, running.length);
72
116
  if (next === null) return;
73
117
  e.preventDefault();
74
118
  select(next);
@@ -115,74 +159,99 @@
115
159
  >
116
160
  {/if}
117
161
  <div class="viewport" bind:this={viewport}>
118
- <div class="tabs" role="tablist" aria-label="Instances" bind:this={content}>
162
+ <!-- A nav of buttons rather than a tablist: a tablist may only own tabs, so the
163
+ stop buttons beside them fail ARIA, and each tab switches the route anyway. -->
164
+ <nav class="tabs" aria-label="Instances" bind:this={content}>
119
165
  {#each running as inst, i (inst.id)}
120
- {#if editingId === inst.id}
121
- <div class="tab editing">
122
- <Avatar
123
- name={inst.name}
124
- art={findAvatar(inst.avatar ?? undefined) ?? null}
125
- scale={3}
126
- />
127
- <!-- svelte-ignore a11y_autofocus -->
128
- <input
129
- class="tab-name-edit"
130
- bind:value={editingName}
131
- autofocus
132
- onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
133
- onblur={() => oncommitrename(inst.id)}
134
- onkeydown={(e) => {
135
- if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
136
- else if (e.key === 'Escape') oncancelrename();
137
- }}
138
- />
139
- </div>
140
- {:else}
141
- <button
142
- bind:this={tabEls[inst.id]}
143
- type="button"
144
- class="tab"
145
- class:active={inst.id === active}
146
- id="tab-{inst.id}"
147
- role="tab"
148
- aria-selected={inst.id === active}
149
- tabindex={i === focusIndex ? 0 : -1}
150
- onclick={() => onselect(inst.id)}
151
- ondblclick={() => onstartrename(inst)}
152
- onkeydown={onTabKeydown}
153
- title={inst.name}
154
- >
155
- <Avatar
156
- name={inst.name}
157
- art={findAvatar(inst.avatar ?? undefined) ?? null}
158
- scale={3}
159
- />
160
- <span class="tab-name">{inst.name}</span>
161
- <span
162
- class="tab-mode"
163
- aria-label={inst.mode === 'terminal'
164
- ? 'Terminal-only instance'
165
- : 'Full IDE instance'}
166
+ <!-- The slot, not the tab button, carries the chrome: a stop button nested inside
167
+ a <button> would be invalid markup. -->
168
+ <div
169
+ class="tab-slot"
170
+ class:active={inst.id === active}
171
+ class:drop-target={dropTarget?.id === inst.id}
172
+ role="presentation"
173
+ ondragenter={(e) => onSlotDrag(e, inst.id)}
174
+ ondragover={(e) => onSlotDrag(e, inst.id)}
175
+ ondragleave={onSlotLeave}
176
+ ondrop={(e) => onSlotDrop(e, inst.id)}
177
+ >
178
+ {#if editingId === inst.id}
179
+ <div class="tab editing">
180
+ <Avatar
181
+ name={inst.name}
182
+ art={findAvatar(inst.avatar ?? undefined) ?? null}
183
+ scale={3}
184
+ />
185
+ <!-- svelte-ignore a11y_autofocus -->
186
+ <input
187
+ class="tab-name-edit"
188
+ bind:value={editingName}
189
+ autofocus
190
+ onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
191
+ onblur={() => oncommitrename(inst.id)}
192
+ onkeydown={(e) => {
193
+ if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
194
+ else if (e.key === 'Escape') oncancelrename();
195
+ }}
196
+ />
197
+ </div>
198
+ {:else}
199
+ <button
200
+ bind:this={tabEls[inst.id]}
201
+ type="button"
202
+ class="tab"
203
+ class:active={inst.id === active}
204
+ id="tab-{inst.id}"
205
+ aria-current={inst.id === active ? 'page' : undefined}
206
+ onclick={() => onselect(inst.id)}
207
+ ondblclick={() => onstartrename(inst)}
208
+ onkeydown={(e) => onTabKeydown(e, i)}
209
+ title={inst.name}
166
210
  >
167
- {#if inst.mode === 'terminal'}
168
- <Terminal size={11} />
169
- {:else}
170
- <LayoutTemplate size={11} />
171
- {/if}
172
- </span>
173
- <!-- The focused tab never lights up, so a lit LED always means "needs your eyes". -->
174
- {#if inst.id !== active && attention[inst.id]}
211
+ <Avatar
212
+ name={inst.name}
213
+ art={findAvatar(inst.avatar ?? undefined) ?? null}
214
+ scale={3}
215
+ />
216
+ <span class="tab-name">{inst.name}</span>
175
217
  <span
176
- class="attn {attention[inst.id]}"
177
- aria-label={attention[inst.id] === 'waiting'
178
- ? 'Claude is waiting for input'
179
- : 'Claude finished'}
180
- ></span>
218
+ class="tab-mode"
219
+ aria-label={inst.mode === 'terminal'
220
+ ? 'Terminal-only instance'
221
+ : 'Full IDE instance'}
222
+ >
223
+ {#if inst.mode === 'terminal'}
224
+ <Terminal size={11} />
225
+ {:else}
226
+ <LayoutTemplate size={11} />
227
+ {/if}
228
+ </span>
229
+ <!-- The focused tab never lights up, so a lit LED always means "needs your eyes". -->
230
+ {#if inst.id !== active && attention[inst.id]}
231
+ <span
232
+ class="attn {attention[inst.id]}"
233
+ aria-label={attention[inst.id] === 'waiting'
234
+ ? 'Claude is waiting for input'
235
+ : 'Claude finished'}
236
+ ></span>
237
+ {/if}
238
+ </button>
239
+ {#if onstop}
240
+ <button
241
+ type="button"
242
+ class="tab-stop"
243
+ disabled={isStopping(inst.id)}
244
+ onclick={() => onstop(inst.id)}
245
+ title={isStopping(inst.id) ? 'Stopping…' : `Stop ${inst.name}`}
246
+ aria-label={isStopping(inst.id) ? 'Stopping…' : `Stop ${inst.name}`}
247
+ >
248
+ <CircleStop size={20} />
249
+ </button>
181
250
  {/if}
182
- </button>
183
- {/if}
251
+ {/if}
252
+ </div>
184
253
  {/each}
185
- </div>
254
+ </nav>
186
255
  </div>
187
256
  {#if overflowing}
188
257
  <button
@@ -215,6 +284,16 @@
215
284
  >
216
285
  </div>
217
286
  </AppBar>
287
+ {#if dropTarget}
288
+ <div
289
+ class="drop-tip panel"
290
+ bind:offsetWidth={tipWidth}
291
+ style:left="{tipLeft}px"
292
+ style:top="{dropTarget.y}px"
293
+ >
294
+ Drop to save into codebay-inbox/
295
+ </div>
296
+ {/if}
218
297
 
219
298
  <style>
220
299
  /* Grouped so the auto-margin doesn't have to move when the reload button comes and goes. */
@@ -307,6 +386,44 @@
307
386
  outline-offset: -2px;
308
387
  }
309
388
 
389
+ /* The slot owns the separator and the active plate so the stop button sits inside them. */
390
+ .tab-slot {
391
+ display: inline-flex;
392
+ align-items: stretch;
393
+ flex: none;
394
+ border-right: 1px solid var(--rule);
395
+ }
396
+ .tab-slot.active {
397
+ background: var(--fill);
398
+ color: var(--fill-ink);
399
+ /* The key has sunk into the chassis; this strip is the slot it dropped out of. */
400
+ box-shadow: inset 0 3px 0 var(--bg);
401
+ }
402
+ .tab-slot:not(.active):hover,
403
+ .tab-slot.drop-target:not(.active) {
404
+ background: color-mix(in srgb, var(--ink) 12%, transparent);
405
+ }
406
+ .tab-slot.drop-target {
407
+ outline: 2px dashed currentColor;
408
+ outline-offset: -5px;
409
+ }
410
+ /* pointer-events: none, or the tip would steal the drag from the tab it's pointing at. */
411
+ .drop-tip {
412
+ position: fixed;
413
+ z-index: 20;
414
+ transform: translateY(6px);
415
+ padding: 6px 10px;
416
+ background: var(--bg-card);
417
+ color: var(--ink);
418
+ font-family: var(--font-mono);
419
+ font-weight: 600;
420
+ font-size: 12px;
421
+ text-transform: uppercase;
422
+ letter-spacing: 0.06em;
423
+ white-space: nowrap;
424
+ pointer-events: none;
425
+ }
426
+
310
427
  .tab {
311
428
  display: inline-flex;
312
429
  align-items: center;
@@ -317,7 +434,6 @@
317
434
  margin: 0;
318
435
  padding: 0 12px;
319
436
  border: 0;
320
- border-right: 1px solid var(--rule);
321
437
  background: transparent;
322
438
  color: var(--ink-soft);
323
439
  font-family: var(--font-display);
@@ -331,16 +447,12 @@
331
447
  }
332
448
  /* Inversion is the house hover idiom, but it's also the active state here, so hovering
333
449
  gets a tint instead — that keeps idle → hover → active legible as three steps. */
334
- .tab:not(.active):hover {
335
- background: color-mix(in srgb, var(--ink) 12%, transparent);
450
+ .tab-slot:not(.active):hover .tab {
336
451
  color: var(--ink);
337
452
  }
338
453
  .tab.active {
339
- background: var(--fill);
340
454
  color: var(--fill-ink);
341
455
  cursor: default;
342
- /* The key has sunk into the chassis; this strip is the slot it dropped out of. */
343
- box-shadow: inset 0 3px 0 var(--bg);
344
456
  }
345
457
  .tab:focus-visible {
346
458
  outline: 2px solid var(--ink);
@@ -354,6 +466,40 @@
354
466
  overflow: hidden;
355
467
  text-overflow: ellipsis;
356
468
  }
469
+ /* Reads as part of the tab, so it inherits the slot's colour rather than declaring its own. */
470
+ .tab-stop {
471
+ display: inline-flex;
472
+ align-items: center;
473
+ justify-content: center;
474
+ flex: none;
475
+ appearance: none;
476
+ margin: 0;
477
+ padding: 0 10px 0 0;
478
+ border: 0;
479
+ background: transparent;
480
+ color: inherit;
481
+ opacity: 0.55;
482
+ cursor: pointer;
483
+ }
484
+ .tab-slot:not(.active) .tab-stop {
485
+ color: var(--ink-soft);
486
+ }
487
+ .tab-stop:hover:not(:disabled) {
488
+ opacity: 1;
489
+ color: var(--danger);
490
+ }
491
+ .tab-stop:disabled {
492
+ opacity: var(--dim);
493
+ cursor: default;
494
+ }
495
+ .tab-stop:focus-visible {
496
+ outline: 2px solid var(--ink);
497
+ outline-offset: -2px;
498
+ }
499
+ .tab-slot.active .tab-stop:focus-visible {
500
+ outline-color: var(--fill-ink);
501
+ }
502
+
357
503
  .tab-mode {
358
504
  display: inline-flex;
359
505
  align-items: center;
@@ -6,6 +6,7 @@
6
6
  import AgentLogBox from './AgentLogBox.svelte';
7
7
  import StatusBadge from './StatusBadge.svelte';
8
8
  import Skeleton from './Skeleton.svelte';
9
+ import DropZone from './DropZone.svelte';
9
10
  import { forwardedPortUrl } from '../lib/links.ts';
10
11
  import { liveSocket, liveStream } from '../live.ts';
11
12
  import { apiPost, apiDelete } from '../api.ts';
@@ -14,8 +15,19 @@
14
15
  import { onBackLinkClick, installPopupBackTrap } from '../lib/popup-nav.ts';
15
16
  import { syncTheme } from '../theme.ts';
16
17
  import { tick } from 'svelte';
18
+ import toast, { Toaster } from 'svelte-french-toast';
19
+ import { TOAST_OPTIONS } from '../toast.ts';
20
+ import { filesFrom, uploadFile } from '../lib/upload.ts';
17
21
 
18
- let { id, injectionChecks = 0 }: { id: string; injectionChecks?: number } = $props();
22
+ let {
23
+ id,
24
+ injectionChecks = 0,
25
+ uploadEnabled = false
26
+ }: { id: string; injectionChecks?: number; uploadEnabled?: boolean } = $props();
27
+
28
+ // DB-backed, so it initializes from the prop; the stream keeps it current across a Settings change.
29
+ // svelte-ignore state_referenced_locally
30
+ let uploadArmed = $state(uploadEnabled);
19
31
 
20
32
  let instance = $state<Instance | null>(null);
21
33
  let health = $state<InstanceHealth | null>(null);
@@ -70,6 +82,8 @@
70
82
  runBump += 1;
71
83
  } else if (msg.type === 'theme') {
72
84
  syncTheme(msg.data.value);
85
+ } else if (msg.type === 'upload-enabled') {
86
+ uploadArmed = msg.data.enabled;
73
87
  }
74
88
  })
75
89
  );
@@ -146,8 +160,57 @@
146
160
  clearTimeout(copyTimer);
147
161
  copyTimer = setTimeout(() => (copied = false), 2000);
148
162
  }
163
+
164
+ // Sequential, so the toast order matches the drop order.
165
+ async function handleUpload(files: File[]) {
166
+ for (const file of files) {
167
+ try {
168
+ const saved = await uploadFile(id, file);
169
+ toast.success(`Saved ${saved.containerPath}`);
170
+ void navigator.clipboard.writeText(saved.containerPath).catch(() => {});
171
+ } catch (err) {
172
+ toast.error((err as Error).message);
173
+ }
174
+ }
175
+ }
176
+
177
+ let dropping = $state(false);
178
+
179
+ function onDragEnter(e: DragEvent) {
180
+ if (!uploadArmed || !e.dataTransfer?.types.includes('Files')) return;
181
+ e.preventDefault();
182
+ dropping = true;
183
+ }
184
+
185
+ function onDragOver(e: DragEvent) {
186
+ if (dropping) e.preventDefault();
187
+ }
188
+
189
+ function onDragLeave(e: DragEvent) {
190
+ if (e.relatedTarget === null) dropping = false;
191
+ }
192
+
193
+ function onDrop(e: DragEvent) {
194
+ if (!dropping) return;
195
+ e.preventDefault();
196
+ dropping = false;
197
+ if (uploadArmed) void handleUpload(filesFrom(e.dataTransfer));
198
+ }
199
+
200
+ // Only claims pastes that carry files, so text pasted into the page's inputs is untouched.
201
+ function onPaste(e: ClipboardEvent) {
202
+ if (!uploadArmed) return;
203
+ const files = filesFrom(e.clipboardData);
204
+ if (!files.length) return;
205
+ e.preventDefault();
206
+ void handleUpload(files);
207
+ }
149
208
  </script>
150
209
 
210
+ <svelte:window onpaste={onPaste} />
211
+ <!-- Instance renders outside AppShell, which hosts the app-wide Toaster. -->
212
+ <Toaster toastOptions={TOAST_OPTIONS} />
213
+
151
214
  <header class="topbar">
152
215
  <a class="back" href="/" onclick={onBackLinkClick}><ArrowLeft size={15} /> All instances</a>
153
216
  <div class="title">
@@ -171,7 +234,16 @@
171
234
  </header>
172
235
  {#if restartError}<p class="restart-err">{restartError}</p>{/if}
173
236
 
174
- <main class="stage">
237
+ <main
238
+ class="stage"
239
+ ondragenter={onDragEnter}
240
+ ondragover={onDragOver}
241
+ ondragleave={onDragLeave}
242
+ ondrop={onDrop}
243
+ >
244
+ {#if dropping}
245
+ <DropZone />
246
+ {/if}
175
247
  <div class="meta">
176
248
  <span class="k">Source</span>
177
249
  {#if instance}<code>{instance.source_path}</code>{:else}<Skeleton variant="wide" />{/if}
@@ -374,6 +446,7 @@
374
446
  color: var(--danger);
375
447
  }
376
448
  .stage {
449
+ position: relative;
377
450
  max-width: 1200px;
378
451
  margin: 0 auto;
379
452
  padding: 20px 24px 40px;
@@ -9,6 +9,7 @@
9
9
  import SunMoon from '@lucide/svelte/icons/sun-moon';
10
10
  import ThemePicker from './ThemePicker.svelte';
11
11
  import Layers from '@lucide/svelte/icons/layers';
12
+ import Upload from '@lucide/svelte/icons/upload';
12
13
  import FolderMinus from '@lucide/svelte/icons/folder-minus';
13
14
  import FolderCog from '@lucide/svelte/icons/folder-cog';
14
15
  import Trash2 from '@lucide/svelte/icons/trash-2';
@@ -55,6 +56,7 @@
55
56
  import ShieldCheck from '@lucide/svelte/icons/shield-check';
56
57
  import Gauge from '@lucide/svelte/icons/gauge';
57
58
  import MessageSquare from '@lucide/svelte/icons/message-square';
59
+ import ClipboardX from '@lucide/svelte/icons/clipboard-x';
58
60
  import { installPopupBackTrap } from '../lib/popup-nav.ts';
59
61
 
60
62
  /** Every settings form action fails with the same `{ error }` shape. */
@@ -69,6 +71,7 @@
69
71
  defaultImage,
70
72
  builtinImage,
71
73
  disableBuildCache,
74
+ workspaceUploadEnabled,
72
75
  copyIgnorePatterns,
73
76
  builtinCopyIgnore,
74
77
  claudeConfigDir,
@@ -115,6 +118,7 @@
115
118
  defaultImage: string;
116
119
  builtinImage: string;
117
120
  disableBuildCache: boolean;
121
+ workspaceUploadEnabled: boolean;
118
122
  copyIgnorePatterns: string;
119
123
  builtinCopyIgnore: string;
120
124
  claudeConfigDir: string;
@@ -168,6 +172,17 @@
168
172
 
169
173
  $effect(() => installPopupBackTrap());
170
174
 
175
+ /** Only settleable after hydration — SSR has no origin to judge. */
176
+ let insecure = $state<{ origin: string; tunnel: string } | null>(null);
177
+ $effect(() => {
178
+ if (window.isSecureContext) return;
179
+ const port = location.port || '80';
180
+ insecure = {
181
+ origin: location.origin,
182
+ tunnel: `ssh -L ${port}:localhost:${port} ${location.hostname}`
183
+ };
184
+ });
185
+
171
186
  /** Reused by every plain save form below; only the per-control state setters differ. */
172
187
  function saveOpts<Success extends Record<string, unknown> = Record<string, unknown>>(handlers: {
173
188
  setSaving: (v: boolean) => void;
@@ -501,6 +516,18 @@
501
516
  setError: (v) => (cacheError = v)
502
517
  });
503
518
 
519
+ // DB-backed rather than localStorage, so it initializes from the prop.
520
+ // svelte-ignore state_referenced_locally
521
+ let workspaceUpload = $state(workspaceUploadEnabled);
522
+ let savingUpload = $state(false);
523
+ let uploadError = $state<string | null>(null);
524
+
525
+ const uploadToggleOpts = toggleOpts({
526
+ set: (v) => (workspaceUpload = v),
527
+ setSaving: (v) => (savingUpload = v),
528
+ setError: (v) => (uploadError = v)
529
+ });
530
+
504
531
  let clearing = $state(false);
505
532
  let clearMsg = $state<string | null>(null);
506
533
  let clearError = $state<string | null>(null);
@@ -960,6 +987,28 @@
960
987
  </AppBar>
961
988
 
962
989
  <main class="content">
990
+ {#if insecure}
991
+ <section class="card">
992
+ <div class="row">
993
+ <div class="label">
994
+ <ClipboardX size={20} />
995
+ <div class="text">
996
+ <div class="name">Copying out of a terminal may not work on this address</div>
997
+ <div class="desc">
998
+ Browsers hand out clipboard access only on a secure origin, and
999
+ <code>{insecure.origin}</code> isn't one — so a copy out of an instance's VS Code
1000
+ terminal reaches nothing (codebay's own terminal falls back to a legacy copy that
1001
+ usually still works). Either open codebay through <code>localhost</code> (<code
1002
+ >{insecure.tunnel}</code
1003
+ >), or allow this exact origin under
1004
+ <code>chrome://flags/#unsafely-treat-insecure-origin-as-secure</code>.
1005
+ </div>
1006
+ </div>
1007
+ </div>
1008
+ </div>
1009
+ </section>
1010
+ {/if}
1011
+
963
1012
  <section class="card">
964
1013
  <form
965
1014
  class="row"
@@ -1242,6 +1291,44 @@
1242
1291
  </form>
1243
1292
  </section>
1244
1293
 
1294
+ <section class="card">
1295
+ <form
1296
+ class="row"
1297
+ method="POST"
1298
+ action="?/workspaceUploadToggle"
1299
+ {@attach enhance(uploadToggleOpts)}
1300
+ >
1301
+ <div class="label">
1302
+ <Upload size={20} />
1303
+ <div class="text">
1304
+ <div class="name">Drop files into the workspace</div>
1305
+ <div class="desc">
1306
+ Drag-and-drop or paste files and images onto an instance to save them under
1307
+ <code>codebay-inbox/</code> in its workspace (git-excluded). The in-container path is shown
1308
+ so Claude can Read it. Turn off to drop straight into VS Code's explorer instead. Off by
1309
+ default.
1310
+ </div>
1311
+ </div>
1312
+ </div>
1313
+ <label class="switch">
1314
+ <input
1315
+ type="checkbox"
1316
+ name="enabled"
1317
+ checked={workspaceUpload}
1318
+ disabled={savingUpload}
1319
+ onchange={(e) => {
1320
+ workspaceUpload = e.currentTarget.checked;
1321
+ e.currentTarget.form?.requestSubmit();
1322
+ }}
1323
+ />
1324
+ <span class="track"><span class="thumb"></span></span>
1325
+ </label>
1326
+ </form>
1327
+ {#if uploadError}
1328
+ <div class="sub"><div class="msg error">{uploadError}</div></div>
1329
+ {/if}
1330
+ </section>
1331
+
1245
1332
  <section class="card">
1246
1333
  <form
1247
1334
  class="row image-row"