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,77 @@
1
+ <script lang="ts">
2
+ // With a drop handler the overlay takes the drop itself (an iframe beneath would swallow it);
3
+ // without one it stays inert and the drop lands on whatever is underneath.
4
+ let { ondrop }: { ondrop?: (e: DragEvent) => void } = $props();
5
+ </script>
6
+
7
+ <div
8
+ class="dropzone"
9
+ class:catches={ondrop}
10
+ role="presentation"
11
+ ondragover={ondrop ? (e) => e.preventDefault() : undefined}
12
+ {ondrop}
13
+ >
14
+ <!-- Two interleaved dot patterns taking turns, like alternating LCD segments. -->
15
+ <svg class="frame" aria-hidden="true">
16
+ <rect class="dots" width="100%" height="100%" />
17
+ <rect class="dots odd" width="100%" height="100%" />
18
+ </svg>
19
+ <span class="label">Drop to save into codebay-inbox/</span>
20
+ </div>
21
+
22
+ <style>
23
+ .dropzone {
24
+ --gutter: 16px;
25
+ position: absolute;
26
+ inset: 0;
27
+ z-index: 5;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ background: color-mix(in srgb, var(--bg) 80%, transparent);
32
+ color: var(--ink);
33
+ font-family: var(--font-mono);
34
+ font-weight: 600;
35
+ text-transform: uppercase;
36
+ letter-spacing: 0.06em;
37
+ font-size: 13px;
38
+ pointer-events: none;
39
+ }
40
+ .dropzone.catches {
41
+ pointer-events: auto;
42
+ }
43
+ /* An inline svg sizes like a replaced element, so insets alone won't stretch it. */
44
+ .frame {
45
+ position: absolute;
46
+ top: var(--gutter);
47
+ left: var(--gutter);
48
+ width: calc(100% - 2 * var(--gutter));
49
+ height: calc(100% - 2 * var(--gutter));
50
+ overflow: visible;
51
+ }
52
+ .dots {
53
+ fill: none;
54
+ stroke: currentColor;
55
+ stroke-width: 4;
56
+ stroke-dasharray: 4 12;
57
+ animation: segment 2s step-end infinite;
58
+ }
59
+ .dots.odd {
60
+ stroke-dashoffset: 8;
61
+ animation-delay: -1s;
62
+ }
63
+ @keyframes segment {
64
+ 50% {
65
+ opacity: 0;
66
+ }
67
+ }
68
+ @media (prefers-reduced-motion: reduce) {
69
+ .dots {
70
+ animation: none;
71
+ }
72
+ }
73
+ /* Positioned so it paints above the frame, which comes first in source order. */
74
+ .label {
75
+ position: relative;
76
+ }
77
+ </style>
@@ -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>
@@ -31,13 +31,14 @@
31
31
  ] as const);
32
32
 
33
33
  // The value word stays uniform because the tick/cross icon already conveys pass vs fail.
34
- const checks = $derived.by((): { label: string; ok: boolean; value: string }[] => {
34
+ // Keyed by id, not label: a repeated label would make the keyed each throw and freeze the panel.
35
+ const checks = $derived.by((): { key: string; label: string; ok: boolean; value: string }[] => {
35
36
  if (!health) return [];
36
37
  const v = (ok: boolean) => ({ ok, value: ok ? 'OK' : '—' });
37
38
  return [
38
- { label: FIXED_CHECKS[0], ...v(health.containerRunning) },
39
- { label: FIXED_CHECKS[1], ...v(health.codeServerAccessible) },
40
- ...health.injections.map((i) => ({ label: i.label, ...v(i.ok) }))
39
+ { key: 'container', label: FIXED_CHECKS[0], ...v(health.containerRunning) },
40
+ { key: 'surface', label: FIXED_CHECKS[1], ...v(health.codeServerAccessible) },
41
+ ...health.injections.map((i) => ({ key: i.id, label: i.label, ...v(i.ok) }))
41
42
  ];
42
43
  });
43
44
 
@@ -64,7 +65,7 @@
64
65
  <div class="panel-bar">Health</div>
65
66
  <div class="health">
66
67
  {#if health}
67
- {#each checks as check (check.label)}
68
+ {#each checks as check (check.key)}
68
69
  <div class="hrow">
69
70
  <span class="box {check.ok ? 'ok' : 'bad'}">
70
71
  {#if check.ok}<Check size={12} strokeWidth={3} />{:else}<X
@@ -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;
@@ -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);