codebay 0.4.0 → 0.6.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 (110) hide show
  1. package/.mochi/manifest.json +272 -220
  2. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-1y0r9tae.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +13 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +1 -0
  6. package/.mochi/svelte-client/{_hydrate-SettingsView_8hmarwmf8onq-r9vvc7rx.js → _hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js} +3 -3
  7. package/.mochi/svelte-client/chunk-330mrspw.js +2 -0
  8. package/.mochi/svelte-client/{chunk-dz1019cy.js → chunk-4jgkak13.js} +1 -1
  9. package/.mochi/svelte-client/chunk-fqg2qzj8.js +2 -0
  10. package/.mochi/svelte-client/chunk-k5m4kt20.js +2 -0
  11. package/.mochi/svelte-client/{chunk-nmf4dq86.js → chunk-qj7zy2d9.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-ssa0ad2f.js → chunk-rpdphch7.js} +1 -1
  13. package/.mochi/svelte-compile/{App-9qjy14bc.server.js → App-v5mwmv6t.server.js} +26 -2
  14. package/.mochi/svelte-compile/{Avatars-sq8mjn6k.server.js → Avatars-pzyz0tv3.server.js} +75 -15
  15. package/.mochi/svelte-compile/{Instance-13j59daw.server.js → Instance-49ksrp11.server.js} +15 -3
  16. package/.mochi/svelte-compile/{Settings-p0s2awg1.server.js → Settings-sz5d6qj7.server.js} +3 -3
  17. package/.mochi/svelte-compile/{chunk-dfe2km6a.js → chunk-q8rbesaw.js} +324 -123
  18. package/.mochi/svelte-css/Avatar-3pi30db6ozvf0.css +1 -0
  19. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +1 -0
  20. package/.mochi/svelte-css/{AvatarsView-3hq37u4dgtljn.css → AvatarsView-2ughbzc26luyb.css} +1 -1
  21. package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +1 -0
  22. package/.mochi/svelte-css/{InstanceView-wq85bxtusont.css → InstanceView-3vzow3ttxzlh3.css} +1 -1
  23. package/README.md +2 -0
  24. package/package.json +2 -2
  25. package/src/avatars/anchor.ts +1 -0
  26. package/src/avatars/ant-head.ts +17 -0
  27. package/src/avatars/apple.ts +17 -0
  28. package/src/avatars/bear.ts +1 -0
  29. package/src/avatars/bee.ts +1 -0
  30. package/src/avatars/bodybuilder.ts +17 -0
  31. package/src/avatars/cat.ts +1 -0
  32. package/src/avatars/cherry.ts +1 -0
  33. package/src/avatars/crab.ts +1 -0
  34. package/src/avatars/crown.ts +1 -0
  35. package/src/avatars/diamond.ts +1 -0
  36. package/src/avatars/dog.ts +1 -0
  37. package/src/avatars/editor.ts +17 -0
  38. package/src/avatars/fish.ts +1 -0
  39. package/src/avatars/flower.ts +1 -0
  40. package/src/avatars/fox.ts +1 -0
  41. package/src/avatars/frog.ts +1 -0
  42. package/src/avatars/ghost.ts +1 -0
  43. package/src/avatars/heart.ts +8 -8
  44. package/src/avatars/horse.ts +17 -0
  45. package/src/avatars/index.ts +20 -0
  46. package/src/avatars/invader.ts +1 -0
  47. package/src/avatars/key.ts +1 -0
  48. package/src/avatars/lightning.ts +1 -0
  49. package/src/avatars/mushroom.ts +1 -0
  50. package/src/avatars/octopus.ts +1 -0
  51. package/src/avatars/owl.ts +1 -0
  52. package/src/avatars/penguin.ts +1 -0
  53. package/src/avatars/planet.ts +1 -0
  54. package/src/avatars/praying-mantis.ts +17 -0
  55. package/src/avatars/rabbit.ts +1 -0
  56. package/src/avatars/robot.ts +1 -0
  57. package/src/avatars/rocket.ts +1 -0
  58. package/src/avatars/serialize.ts +4 -3
  59. package/src/avatars/skull.ts +6 -6
  60. package/src/avatars/snail.ts +1 -0
  61. package/src/avatars/snowflake.ts +17 -0
  62. package/src/avatars/star.ts +1 -0
  63. package/src/avatars/strawberry.ts +17 -0
  64. package/src/avatars/target.ts +1 -0
  65. package/src/avatars/tree.ts +1 -0
  66. package/src/avatars/types.ts +6 -3
  67. package/src/avatars/vangogh-almond-blossoms.ts +17 -0
  68. package/src/avatars/wasp.ts +17 -0
  69. package/src/avatars/whale.ts +1 -0
  70. package/src/avatars/windmill.ts +17 -0
  71. package/src/components/AppShell.svelte +13 -0
  72. package/src/components/Avatar.svelte +4 -1
  73. package/src/components/AvatarEditor.svelte +83 -5
  74. package/src/components/AvatarsView.svelte +14 -1
  75. package/src/components/DashboardView.svelte +24 -2
  76. package/src/components/InstanceCard.svelte +55 -6
  77. package/src/components/InstanceView.svelte +41 -8
  78. package/src/components/SettingsView.svelte +2 -2
  79. package/src/container-injections/attention-hooks.ts +19 -38
  80. package/src/container-injections/claude-aliases.ts +7 -23
  81. package/src/container-injections/claude-code-credentials.ts +12 -8
  82. package/src/container-injections/claude-code-custom.ts +35 -48
  83. package/src/container-injections/claude-code-ide-extension.ts +39 -0
  84. package/src/container-injections/claude-code-models.ts +8 -24
  85. package/src/container-injections/claude-model.ts +43 -0
  86. package/src/container-injections/claude-no-coauthor.ts +14 -16
  87. package/src/container-injections/claude-skip-permissions.ts +7 -17
  88. package/src/container-injections/claude-statusline.ts +40 -27
  89. package/src/container-injections/claude-trust.ts +73 -0
  90. package/src/container-injections/host-env-vars.ts +13 -24
  91. package/src/container-injections/tmux.ts +2 -11
  92. package/src/index.ts +2 -0
  93. package/src/lib/claude-settings.server.ts +35 -0
  94. package/src/lib/container-files.server.ts +219 -0
  95. package/src/lib/devcontainer.server.ts +38 -1
  96. package/src/lib/exec.server.ts +2 -7
  97. package/src/lib/injections.server.ts +6 -0
  98. package/src/lib/instances.server.ts +14 -0
  99. package/src/lib/theme.server.ts +7 -3
  100. package/src/shell.html +2 -0
  101. package/.mochi/svelte-client/HydratableIsland-79z9xq4b.js +0 -1
  102. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-f5rzyjm8.js +0 -2
  103. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-fxyd5qx4.js +0 -13
  104. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-nv31ptdp.js +0 -1
  105. package/.mochi/svelte-client/chunk-6eexkr6p.js +0 -2
  106. package/.mochi/svelte-client/chunk-7znwaf7t.js +0 -2
  107. package/.mochi/svelte-client/chunk-zmmb1c4b.js +0 -2
  108. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +0 -1
  109. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +0 -1
  110. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +0 -1
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'target',
5
+ robot: true,
5
6
  pixels: [
6
7
  '..####..',
7
8
  '.#....#.',
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'tree',
5
+ robot: true,
5
6
  pixels: [
6
7
  '...##...',
7
8
  '..####..',
@@ -1,14 +1,17 @@
1
1
  // The string legend keeps the art human-editable — you can eyeball a creature in the source.
2
2
  //
3
- // '#' → on (lit pixel)
4
- // ' ' or '.' → off (unlit pixel)
3
+ // '#' → on (lit pixel)
4
+ // '+' → gray (half-lit pixel)
5
+ // ' ' or '.' → off (unlit pixel)
5
6
  export type AvatarArt = {
6
7
  name: string; // also the uniqueness key
7
8
  pixels: string[];
9
+ robot?: boolean; // true for the original AI-generated sprites, slated to be redrawn by hand
8
10
  };
9
11
 
10
12
  export const OFF = 0;
11
13
  export const ON = 1;
14
+ export const GRAY = 2;
12
15
 
13
16
  export const ROWS = 8;
14
17
  export const COLS = 8;
@@ -17,7 +20,7 @@ export function decode(art: AvatarArt): number[] {
17
20
  const cells: number[] = [];
18
21
  for (const row of art.pixels) {
19
22
  for (const ch of row) {
20
- cells.push(ch === '#' ? ON : OFF);
23
+ cells.push(ch === '#' ? ON : ch === '+' ? GRAY : OFF);
21
24
  }
22
25
  }
23
26
  return cells;
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'vangogh-almond-blossoms',
5
+ pixels: [
6
+ '....#.#.',
7
+ '.....#..',
8
+ '####....',
9
+ '#...#..#',
10
+ '.#...##.',
11
+ '..#....#',
12
+ '.#.#....',
13
+ '..#.....'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'wasp',
5
+ pixels: [
6
+ '.....##.',
7
+ '.##.###.',
8
+ '.###....',
9
+ '..#.#...',
10
+ '.#.#.#..',
11
+ '##..##..',
12
+ '##......',
13
+ '........'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'whale',
5
+ robot: true,
5
6
  pixels: [
6
7
  '....#...',
7
8
  '...###..',
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'windmill',
5
+ pixels: [
6
+ '........',
7
+ '.#...#..',
8
+ '..#.#...',
9
+ '...#....',
10
+ '..#.#...',
11
+ '.#.#.#..',
12
+ '..###...',
13
+ '..###...'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -114,6 +114,19 @@
114
114
  return () => window.removeEventListener('popstate', onPop);
115
115
  });
116
116
 
117
+ // Native unsaved-changes guard: a reload/close/external-nav tears down the code-server
118
+ // iframes and loses in-editor state. In-app /↔/ide transitions are pushState (no unload)
119
+ // and Settings/Details links open in new tabs, so this only fires on a true teardown.
120
+ $effect(() => {
121
+ if (running.length === 0) return;
122
+ function onBeforeUnload(e: BeforeUnloadEvent) {
123
+ e.preventDefault();
124
+ e.returnValue = '';
125
+ }
126
+ window.addEventListener('beforeunload', onBeforeUnload);
127
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
128
+ });
129
+
117
130
  // Mounted lazily, then kept mounted (hidden via CSS) so the editor survives tab switches.
118
131
  const visited = new SvelteSet<string>();
119
132
  $effect(() => {
@@ -96,7 +96,7 @@
96
96
  onlostpointercapture={release}
97
97
  >
98
98
  {#each grid as cell, i (i)}
99
- <span class="px" class:on={cell === 1}></span>
99
+ <span class="px" class:on={cell === 1} class:gray={cell === 2}></span>
100
100
  {/each}
101
101
  {#if interactive}
102
102
  <span class="invert" aria-hidden="true"></span>
@@ -121,6 +121,9 @@
121
121
  background-clip: content-box;
122
122
  background-color: var(--rule-soft); /* unlit LED — faint */
123
123
  }
124
+ .px.gray {
125
+ background-color: var(--led-gray); /* half-lit LED */
126
+ }
124
127
  .px.on {
125
128
  background-color: var(--ink);
126
129
  }
@@ -4,7 +4,8 @@
4
4
  import Contrast from '@lucide/svelte/icons/contrast';
5
5
  import ExternalLink from '@lucide/svelte/icons/external-link';
6
6
  import toast from 'svelte-french-toast';
7
- import { ROWS, COLS, ON, OFF, type AvatarArt } from '../avatars/types.ts';
7
+ import { ROWS, COLS, ON, OFF, GRAY, type AvatarArt } from '../avatars/types.ts';
8
+ import { MODES, nextValue, type Mode } from '../avatars/editor.ts';
8
9
  import {
9
10
  cellsToPixels,
10
11
  normalizeName,
@@ -24,7 +25,26 @@
24
25
  const slug = $derived(normalizeName(avatarName));
25
26
  const art = $derived<AvatarArt>({ name: slug || 'my-avatar', pixels: cellsToPixels(cells) });
26
27
  // Contributing needs a name and at least one lit pixel — a blank sprite is no sprite.
27
- const canContribute = $derived(slug.length > 0 && cells.some((c) => c === ON));
28
+ const canContribute = $derived(slug.length > 0 && cells.some((c) => c !== OFF));
29
+
30
+ // Cycle is the default so both shades are reachable without switching mode.
31
+ let mode = $state<Mode>('cycle');
32
+ let modeButtons = $state<HTMLButtonElement[]>([]);
33
+
34
+ // Roving-tabindex arrow navigation, as a radiogroup is expected to behave.
35
+ function onModeKeydown(e: KeyboardEvent, i: number) {
36
+ const dir =
37
+ e.key === 'ArrowRight' || e.key === 'ArrowDown'
38
+ ? 1
39
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp'
40
+ ? -1
41
+ : 0;
42
+ if (dir === 0) return;
43
+ e.preventDefault();
44
+ const next = (i + dir + MODES.length) % MODES.length;
45
+ mode = MODES[next]!.id;
46
+ modeButtons[next]?.focus();
47
+ }
28
48
 
29
49
  // Capturing the pointer on the grid is what lets one stroke paint every cell it crosses.
30
50
  let gridEl = $state<HTMLDivElement | null>(null);
@@ -51,7 +71,7 @@
51
71
  if (i == null) return;
52
72
  (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
53
73
  painting = true;
54
- paintValue = cells[i] === ON ? OFF : ON;
74
+ paintValue = nextValue(mode, cells[i]!);
55
75
  cells[i] = paintValue;
56
76
  }
57
77
 
@@ -69,8 +89,9 @@
69
89
  cells = Array(ROWS * COLS).fill(OFF);
70
90
  }
71
91
 
92
+ // Only swaps off↔on; gray sits in the middle and is left untouched.
72
93
  function invert() {
73
- cells = cells.map((c) => (c === ON ? OFF : ON));
94
+ cells = cells.map((c) => (c === ON ? OFF : c === OFF ? ON : c));
74
95
  }
75
96
 
76
97
  async function copyModule() {
@@ -106,7 +127,7 @@
106
127
  onlostpointercapture={stopPainting}
107
128
  >
108
129
  {#each cells as cell, i (i)}
109
- <span class="cell" class:on={cell === ON}></span>
130
+ <span class="cell" class:on={cell === ON} class:gray={cell === GRAY}></span>
110
131
  {/each}
111
132
  </div>
112
133
  </div>
@@ -134,6 +155,27 @@
134
155
  />
135
156
  </label>
136
157
 
158
+ <div class="mode-field">
159
+ <span class="preview-label">Mode</span>
160
+ <div class="modes" role="radiogroup" aria-label="Drawing mode">
161
+ {#each MODES as m, i (m.id)}
162
+ <button
163
+ type="button"
164
+ class="mode"
165
+ class:active={mode === m.id}
166
+ role="radio"
167
+ aria-checked={mode === m.id}
168
+ tabindex={mode === m.id ? 0 : -1}
169
+ bind:this={modeButtons[i]}
170
+ onclick={() => (mode = m.id)}
171
+ onkeydown={(e) => onModeKeydown(e, i)}
172
+ >
173
+ {m.label}
174
+ </button>
175
+ {/each}
176
+ </div>
177
+ </div>
178
+
137
179
  <div class="tools">
138
180
  <Button size="sm" icon={Eraser} onclick={clear}>Clear</Button>
139
181
  <Button size="sm" icon={Contrast} onclick={invert}>Invert</Button>
@@ -224,6 +266,9 @@
224
266
  background-clip: content-box;
225
267
  background-color: var(--rule-soft); /* unlit LED — faint */
226
268
  }
269
+ .cell.gray {
270
+ background-color: var(--led-gray); /* half-lit LED */
271
+ }
227
272
  .cell.on {
228
273
  background-color: var(--ink);
229
274
  }
@@ -267,6 +312,39 @@
267
312
  outline: none;
268
313
  box-shadow: inset 3px 3px 0 var(--rule-soft);
269
314
  }
315
+ .mode-field {
316
+ display: flex;
317
+ flex-direction: column;
318
+ gap: 6px;
319
+ }
320
+ .modes {
321
+ display: flex;
322
+ border: 1px solid var(--ink);
323
+ }
324
+ .mode {
325
+ flex: 1;
326
+ padding: 7px 6px;
327
+ border: none;
328
+ border-right: 1px solid var(--ink);
329
+ background: var(--bg);
330
+ color: var(--ink);
331
+ font-family: var(--font-mono);
332
+ font-size: 11px;
333
+ text-transform: uppercase;
334
+ letter-spacing: 0.06em;
335
+ cursor: pointer;
336
+ }
337
+ .mode:last-child {
338
+ border-right: none;
339
+ }
340
+ .mode.active {
341
+ background: var(--ink);
342
+ color: var(--bg);
343
+ }
344
+ .mode:focus-visible {
345
+ outline: none;
346
+ box-shadow: inset 0 0 0 2px var(--rule-soft);
347
+ }
270
348
  .tools {
271
349
  display: flex;
272
350
  gap: 8px;
@@ -3,6 +3,7 @@
3
3
  import AppBar from './AppBar.svelte';
4
4
  import Avatar from './Avatar.svelte';
5
5
  import AvatarEditor from './AvatarEditor.svelte';
6
+ import Bot from '@lucide/svelte/icons/bot';
6
7
  import { avatars } from '../avatars/index.ts';
7
8
 
8
9
  let { namePlaceholder }: { namePlaceholder: string } = $props();
@@ -25,7 +26,12 @@
25
26
  {#each avatars as art (art.name)}
26
27
  <li>
27
28
  <Avatar {art} name={art.name} scale={8} interactive />
28
- <span class="label">{art.name}</span>
29
+ <span class="label">
30
+ {#if art.robot}
31
+ <span class="robot-mark" title="AI-generated"><Bot size={11} /></span>
32
+ {/if}
33
+ {art.name}
34
+ </span>
29
35
  </li>
30
36
  {/each}
31
37
  </ul>
@@ -106,10 +112,17 @@
106
112
  gap: 8px;
107
113
  }
108
114
  .label {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ gap: 4px;
109
118
  font-family: var(--font-mono);
110
119
  font-size: 11px;
111
120
  text-transform: uppercase;
112
121
  letter-spacing: 0.08em;
113
122
  color: var(--ink-soft);
114
123
  }
124
+ .robot-mark {
125
+ display: inline-flex;
126
+ color: var(--ink-faint);
127
+ }
115
128
  </style>
@@ -16,14 +16,32 @@
16
16
  pet
17
17
  }: { preflight: Preflight; instances: Instance[]; loaded: boolean; pet?: AvatarArt } = $props();
18
18
 
19
+ type Action = 'start' | 'stop' | 'delete' | 'rebuild';
20
+
19
21
  let browserOpen = $state(false);
20
22
  let creating = $state(false);
21
23
  let actionError = $state<string | null>(null);
22
24
  let editingId = $state<string | null>(null);
23
25
  let editingName = $state('');
26
+ // Per-instance in-flight action + the status it started from, so the busy overlay clears
27
+ // only once the live stream reports the resulting change (avoids flicker on the round-trip).
28
+ let pending = $state<Record<string, { action: Action; since: Instance['status'] }>>({});
24
29
 
25
30
  const ready = $derived(preflight.docker && preflight.cli);
26
31
 
32
+ // Drop a pending entry once its instance is gone (deleted) or its status has moved on.
33
+ $effect(() => {
34
+ for (const [id, { since }] of Object.entries(pending)) {
35
+ const instance = instances.find((i) => i.id === id);
36
+ if (!instance || instance.status !== since) clearPending(id);
37
+ }
38
+ });
39
+
40
+ function clearPending(id: string) {
41
+ const { [id]: _, ...rest } = pending;
42
+ pending = rest;
43
+ }
44
+
27
45
  async function createFrom(sourcePath: string, opts?: { branch?: string }) {
28
46
  browserOpen = false;
29
47
  creating = true;
@@ -42,7 +60,7 @@
42
60
  }
43
61
  }
44
62
 
45
- async function act(id: string, action: 'start' | 'stop' | 'delete' | 'rebuild') {
63
+ async function act(id: string, action: Action) {
46
64
  actionError = null;
47
65
  if (action === 'delete' && !confirm('Delete this instance and its copied files?')) return;
48
66
  // Rebuild discards the container; the workspace copy lives on the host and survives.
@@ -51,11 +69,14 @@
51
69
  !confirm('Recreate this container and re-run setup? The workspace is kept.')
52
70
  )
53
71
  return;
72
+ const since = instances.find((i) => i.id === id)?.status;
73
+ if (since) pending = { ...pending, [id]: { action, since } };
54
74
  try {
55
75
  await apiPost(`/api/instances/${id}/${action}`, undefined, `Failed to ${action}`);
56
- // The live stream reflects the resulting state.
76
+ // The live stream reflects the resulting state; the $effect clears the busy overlay.
57
77
  } catch (err) {
58
78
  actionError = (err as Error).message;
79
+ clearPending(id);
59
80
  }
60
81
  }
61
82
 
@@ -138,6 +159,7 @@
138
159
  <InstanceCard
139
160
  {instance}
140
161
  editing={editingId === instance.id}
162
+ pending={pending[instance.id]?.action ?? null}
141
163
  bind:editingName
142
164
  onact={(action) => act(instance.id, action)}
143
165
  onstartrename={() => startRename(instance)}
@@ -7,10 +7,13 @@
7
7
  import Button from './Button.svelte';
8
8
  import { withPopupMarker } from '../lib/popup-nav.ts';
9
9
 
10
+ type Action = 'start' | 'stop' | 'delete' | 'rebuild';
11
+
10
12
  let {
11
13
  instance,
12
14
  editing,
13
15
  editingName = $bindable(),
16
+ pending = null,
14
17
  onact,
15
18
  onstartrename,
16
19
  oncommitrename,
@@ -19,7 +22,9 @@
19
22
  instance: Instance;
20
23
  editing: boolean;
21
24
  editingName: string;
22
- onact: (action: 'start' | 'stop' | 'delete' | 'rebuild') => void;
25
+ /** The user-triggered action currently in flight, if any — drives the busy overlay. */
26
+ pending?: Action | null;
27
+ onact: (action: Action) => void;
23
28
  onstartrename: () => void;
24
29
  oncommitrename: () => void;
25
30
  oncancelrename: () => void;
@@ -31,9 +36,21 @@
31
36
  instance.status === 'stopped' ||
32
37
  (instance.status === 'error' && !!instance.container_id)
33
38
  );
39
+
40
+ const BUSY_LABEL: Record<Action, string> = {
41
+ start: 'Starting…',
42
+ stop: 'Stopping…',
43
+ delete: 'Deleting…',
44
+ rebuild: 'Rebuilding…'
45
+ };
34
46
  </script>
35
47
 
36
- <li class="card panel">
48
+ <li class="card panel" class:busy={!!pending} aria-busy={!!pending || undefined}>
49
+ {#if pending}
50
+ <div class="busy-overlay" aria-live="polite">
51
+ <span class="busy-label">{BUSY_LABEL[pending]}</span>
52
+ </div>
53
+ {/if}
37
54
  <div class="card-head">
38
55
  <Avatar id={instance.id} name={instance.name} interactive />
39
56
  {#if editing}
@@ -109,16 +126,50 @@
109
126
 
110
127
  <style>
111
128
  .card {
129
+ position: relative;
112
130
  background: var(--bg-card);
113
131
  padding: 16px 16px 14px;
114
132
  transition:
115
133
  transform 0.08s steps(2),
116
134
  box-shadow 0.08s steps(2);
117
135
  }
118
- .card:hover {
136
+ .card:hover:not(.busy) {
119
137
  transform: translate(-1px, -1px);
120
138
  box-shadow: 6px 6px 0 var(--ink);
121
139
  }
140
+ /* Translucent scrim dims the card body and blocks clicks on the buttons beneath. */
141
+ .busy-overlay {
142
+ position: absolute;
143
+ inset: 0;
144
+ z-index: 1;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ background: color-mix(in srgb, var(--bg-card) 78%, transparent);
149
+ }
150
+ .busy-label {
151
+ font-family: var(--font-mono);
152
+ font-weight: 600;
153
+ font-size: 12px;
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.1em;
156
+ padding: 4px 9px;
157
+ border: 1px solid var(--ink);
158
+ background: var(--ink);
159
+ color: var(--bg);
160
+ animation: lcd-blink 1.1s steps(1) infinite;
161
+ }
162
+ @keyframes lcd-blink {
163
+ 50% {
164
+ background: transparent;
165
+ color: var(--ink);
166
+ }
167
+ }
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .busy-label {
170
+ animation: none;
171
+ }
172
+ }
122
173
  .card-head {
123
174
  display: flex;
124
175
  align-items: center;
@@ -167,9 +218,7 @@
167
218
  font-family: var(--font-mono);
168
219
  font-weight: 600;
169
220
  font-size: 15px;
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- white-space: nowrap;
221
+ overflow-wrap: anywhere;
173
222
  /* Reset button chrome so it reads as plain text until hovered. */
174
223
  margin: 0;
175
224
  padding: 2px 4px;
@@ -11,6 +11,7 @@
11
11
  import { enhance } from 'mochi-framework';
12
12
  import type { MochiEnhanceOptions } from 'mochi-framework';
13
13
  import { onBackLinkClick, installPopupBackTrap } from '../lib/popup-nav.ts';
14
+ import { tick } from 'svelte';
14
15
 
15
16
  let { id, injectionChecks = 0 }: { id: string; injectionChecks?: number } = $props();
16
17
 
@@ -18,11 +19,29 @@
18
19
  let health = $state<InstanceHealth | null>(null);
19
20
  let lastFetchedAt = $state<number | null>(null);
20
21
  let logs = $state('');
22
+ let logEl = $state<HTMLDivElement | null>(null);
23
+ let following = $state(true);
21
24
 
22
- // Runs after the DOM updates, so scrollHeight is fresh without a tick().
23
- function autoscroll(node: HTMLDivElement) {
24
- void logs; // the dependency this effect exists for
25
- node.scrollTop = node.scrollHeight;
25
+ // Pin to the newest output whenever logs grow — but only while following.
26
+ // tick() lets the <pre> text node grow before we scroll, so we reach the true bottom.
27
+ $effect(() => {
28
+ if (!following || logs.length === 0) return; // reading logs.length tracks appended chunks
29
+ tick().then(() => {
30
+ if (following) logEl?.scrollTo(0, logEl.scrollHeight);
31
+ });
32
+ });
33
+
34
+ // A user scrolling up pauses following; programmatic scroll-to-bottom keeps the
35
+ // gap ~0, so it never trips this — only re-clicking "Follow log" resumes.
36
+ function onLogScroll() {
37
+ if (!logEl) return;
38
+ const gap = logEl.scrollHeight - logEl.clientHeight - logEl.scrollTop;
39
+ if (gap > 40) following = false;
40
+ }
41
+
42
+ function toggleFollow() {
43
+ following = !following;
44
+ if (following) logEl?.scrollTo(0, logEl.scrollHeight);
26
45
  }
27
46
 
28
47
  $effect(() => installPopupBackTrap());
@@ -234,11 +253,16 @@
234
253
  <div class="logwrap panel">
235
254
  <div class="log-bar panel-bar">
236
255
  <span>Boot log</span>
237
- <button class="copy" onclick={copyLogs} disabled={!logs}>
238
- {copied ? 'Copied!' : 'Copy logs'}
239
- </button>
256
+ <div class="log-actions">
257
+ <button class="copy" class:active={following} onclick={toggleFollow}>
258
+ {following ? 'Following' : 'Follow log'}
259
+ </button>
260
+ <button class="copy" onclick={copyLogs} disabled={!logs}>
261
+ {copied ? 'Copied!' : 'Copy logs'}
262
+ </button>
263
+ </div>
240
264
  </div>
241
- <div class="logs" {@attach autoscroll}>
265
+ <div class="logs" bind:this={logEl} onscroll={onLogScroll}>
242
266
  <pre>{logs || 'Waiting for output…'}<span class="caret"></span></pre>
243
267
  </div>
244
268
  {#if instance?.status === 'error' && instance.error}
@@ -504,6 +528,11 @@
504
528
  align-items: center;
505
529
  justify-content: space-between;
506
530
  }
531
+ .log-actions {
532
+ display: flex;
533
+ gap: 6px;
534
+ align-items: center;
535
+ }
507
536
  .copy {
508
537
  font-family: var(--font-mono);
509
538
  font-weight: 600;
@@ -520,6 +549,10 @@
520
549
  background: transparent;
521
550
  color: var(--bg);
522
551
  }
552
+ .copy.active {
553
+ background: transparent;
554
+ color: var(--bg);
555
+ }
523
556
  .copy:disabled {
524
557
  opacity: 0.5;
525
558
  cursor: not-allowed;
@@ -878,8 +878,8 @@
878
878
  <div class="text">
879
879
  <div class="name">Rebuild running containers (no cache)</div>
880
880
  <div class="desc">
881
- Re-run <code>devcontainer up --build-no-cache</code> for every currently-running instance.
882
- In-container edits are kept; stopped instances are left alone.
881
+ Rebuild every currently-running instance from scratch, without using any cached image
882
+ layers. In-container edits are kept; stopped instances are left alone.
883
883
  </div>
884
884
  {#if rebuildError}
885
885
  <div class="msg error">{rebuildError}</div>