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.
- package/.mochi/manifest.json +490 -429
- package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
- package/.mochi/svelte-client/{chunk-5pg0be5f.js → chunk-0vqvn81s.js} +1 -1
- package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
- package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
- package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
- package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
- package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
- package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
- package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
- package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
- package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
- package/.mochi/svelte-client/{chunk-ds9yn9cq.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-a2hty99b.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-68f9a9fq.server.js → App-8mpgrxza.server.js} +489 -295
- package/.mochi/svelte-compile/{Avatars-vyh3rwxx.server.js → Avatars-y5nf6q25.server.js} +102 -86
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/{Instance-yfw7s5mp.server.js → Instance-3sbzyp0g.server.js} +186 -118
- package/.mochi/svelte-compile/{Settings-0b5t9kw9.server.js → Settings-ysvh5vhh.server.js} +739 -628
- package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
- package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
- package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
- package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
- package/.mochi/svelte-compile/{chunk-0y3wrae9.js → chunk-jq7jj8jd.js} +10 -6
- package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
- package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
- package/.mochi/svelte-compile/{chunk-g0eravvj.js → chunk-zk4a9zms.js} +25 -17
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/README.md +13 -4
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +9 -9
- package/src/__fixtures__/IdeBarStrip.svelte +41 -0
- package/src/avatars/dog.ts +1 -1
- package/src/avatars/lightning.ts +8 -9
- package/src/avatars/rocket.ts +4 -5
- package/src/components/AppShell.svelte +101 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceView.svelte +75 -2
- package/src/components/SettingsView.svelte +87 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +19 -1
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +3 -0
- package/src/lib/agent-run-stream.ts +41 -1
- package/src/lib/agent-runs.server.ts +14 -3
- package/src/lib/config.server.ts +16 -1
- package/src/lib/db.server.ts +6 -2
- package/src/lib/devcontainer.server.ts +37 -4
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +21 -4
- package/src/lib/log-capture.server.ts +46 -18
- package/src/lib/osc52.ts +18 -0
- package/src/lib/spawn.server.ts +7 -2
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/server.server.ts +4 -1
- package/src/mcp/tools.server.ts +178 -12
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +3 -0
- package/src/routes.ts +16 -2
- package/src/types.ts +2 -0
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +0 -3
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +0 -44
- package/.mochi/svelte-client/chunk-0mstgbtr.js +0 -2
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +0 -1
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +0 -1
- package/.mochi/svelte-client/chunk-fn03gxer.js +0 -4
- package/.mochi/svelte-client/chunk-jhgq7f65.js +0 -2
- package/.mochi/svelte-client/chunk-p5a07kx1.js +0 -1
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +0 -2
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +0 -2
- package/.mochi/svelte-client/chunk-v3wg9mdy.js +0 -1
- package/.mochi/svelte-compile/chunk-2qzc07gt.js +0 -34
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +0 -21
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +0 -24
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
- 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
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
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<
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? '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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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="
|
|
177
|
-
aria-label={
|
|
178
|
-
? '
|
|
179
|
-
: '
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
251
|
+
{/if}
|
|
252
|
+
</div>
|
|
184
253
|
{/each}
|
|
185
|
-
</
|
|
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 {
|
|
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
|
|
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"
|