@bakapiano/ccsm 0.22.3 → 0.22.5

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 (61) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +645 -543
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +159 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/TerminalView.js +15 -2
  44. package/public/js/components/XtermTerminal.js +74 -15
  45. package/public/js/components/useDragSort.js +67 -67
  46. package/public/js/dialog.js +67 -67
  47. package/public/js/icons.js +212 -212
  48. package/public/js/main.js +296 -296
  49. package/public/js/pages/AboutPage.js +90 -90
  50. package/public/js/pages/ConfigurePage.js +713 -713
  51. package/public/js/pages/LaunchPage.js +421 -421
  52. package/public/js/pages/RemotePage.js +743 -743
  53. package/public/js/pages/SessionsPage.js +199 -80
  54. package/public/js/state.js +335 -335
  55. package/public/manifest.webmanifest +25 -0
  56. package/public/setup/index.html +567 -0
  57. package/scripts/dev.js +149 -149
  58. package/scripts/install.js +153 -153
  59. package/scripts/restart-helper.js +96 -96
  60. package/scripts/upgrade-helper.js +687 -687
  61. package/server.js +1807 -1807
@@ -1,9 +1,9 @@
1
- // Sessions page · the main pane. Shows the terminal for the currently
2
- // selected session (activeSessionId), with a thin header providing
3
- // session metadata + a session-tabs strip (future multi-tab support)
4
- // and a kebab menu top-right for per-session actions. When a session is
5
- // selected but not running we auto-resume it — no manual button.
6
-
1
+ // Sessions page · the main pane. Shows the terminal for the currently
2
+ // selected session (activeSessionId), with a thin header providing
3
+ // session metadata + a session-tabs strip (future multi-tab support)
4
+ // and a kebab menu top-right for per-session actions. When a session is
5
+ // selected but not running we auto-resume it — no manual button.
6
+
7
7
  import { html } from '../html.js';
8
8
  import { useEffect, useRef, useState } from 'preact/hooks';
9
9
  import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
@@ -13,58 +13,88 @@ import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
13
13
  import { TerminalView } from '../components/TerminalView.js';
14
14
  import { PageTitleBar } from '../components/PageTitleBar.js';
15
15
  import { Popover } from '../components/Popover.js';
16
+ import { useDragSort } from '../components/useDragSort.js';
16
17
  import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal, IconPlay, IconStop } from '../icons.js';
17
18
  import { fmtAgo } from '../util.js';
18
19
 
19
- function SessionTabs({ activeId, onActivate, onNew, kebab }) {
20
- // For now we only show the currently active session as a single tab —
21
- // other open sessions are hidden, and the "+ new" affordance is parked
22
- // until multi-tab UX lands.
20
+ function SessionTabs({ activeId, openSessions, onActivate, onClose, onReorder, onNew, kebab }) {
23
21
  const active = activeId ? sessions.value.find((s) => s.id === activeId) : null;
24
- if (!active) return null;
25
- const open = [active];
22
+ const base = Array.isArray(openSessions) ? openSessions : [];
23
+ const open = active && !base.some((s) => s.id === active.id)
24
+ ? [...base, active]
25
+ : base;
26
+ const dnd = useDragSort(open.map((s) => s.id), onReorder);
27
+ if (!open.length) return null;
26
28
  return html`
27
29
  <div class="session-tabs" role="tablist">
28
30
  <div class="session-tabs-list">
29
31
  ${open.map((s) => {
30
- const cli = (config.value?.clis || []).find((c) => c.id === s.cliId);
32
+ const cli = (config.value?.clis || []).find((c) => c.id === s.cliId);
31
33
  const Icon = IconForCliType(cli?.type) || IconTerminal;
32
34
  const t = s.title || s.workspace || s.id.slice(0, 12);
33
35
  const isActive = s.id === activeId;
36
+ const running = s.status === 'running';
37
+ const working = running && s.activity === 'working';
38
+ const statusText = running ? (working ? 'running, working' : 'running') : 'stopped';
39
+ const statusClass = `${running ? ' is-running' : ' is-stopped'}${working ? ' is-working' : ''}`;
40
+ const onKeyDown = (ev) => {
41
+ if (ev.key !== 'Enter' && ev.key !== ' ') return;
42
+ ev.preventDefault();
43
+ onActivate(s.id);
44
+ };
34
45
  return html`
35
- <button key=${s.id}
36
- role="tab"
37
- aria-selected=${isActive}
38
- class=${`session-tab${isActive ? ' is-active' : ''}`}
39
- onClick=${() => onActivate(s.id)}
40
- title=${`${t} · ${s.cwd}`}>
41
- <span class="session-tab-icon"><${Icon} /></span>
42
- <span class="session-tab-label">${t}</span>
43
- ${s.status !== 'running' ? html`<span class="session-tab-meta">·</span>` : null}
44
- </button>`;
46
+ <div key=${s.id}
47
+ role="tab"
48
+ aria-selected=${isActive}
49
+ aria-label=${`${t}, ${statusText}`}
50
+ tabIndex=${0}
51
+ class=${`session-tab${isActive ? ' is-active' : ''}${statusClass}`}
52
+ data-session-id=${s.id}
53
+ title=${`${t} · ${statusText} · ${s.cwd}`}
54
+ onKeyDown=${onKeyDown}
55
+ ...${dnd.rowProps(s.id)}>
56
+ <div class="session-tab-main"
57
+ onClick=${() => onActivate(s.id)}
58
+ ...${dnd.handleProps(s.id)}>
59
+ <span class="session-tab-icon"><${Icon} /></span>
60
+ <span class="session-tab-label">${t}</span>
61
+ </div>
62
+ <button class="session-tab-close"
63
+ type="button"
64
+ title="Close tab"
65
+ aria-label=${`Close ${t}`}
66
+ onPointerDown=${(ev) => ev.stopPropagation()}
67
+ onClick=${(ev) => {
68
+ ev.preventDefault();
69
+ ev.stopPropagation();
70
+ onClose(s.id);
71
+ }}>
72
+ <${IconClose} />
73
+ </button>
74
+ </div>`;
45
75
  })}
46
- ${/* <button class="session-tab session-tab-add" onClick=${onNew} title="New session">
47
- <${IconPlus} />
48
- </button> */ null}
49
- </div>
50
- ${kebab ? html`<div class="session-tabs-right">${kebab}</div>` : null}
51
- </div>`;
52
- }
53
-
76
+ ${/* <button class="session-tab session-tab-add" onClick=${onNew} title="New session">
77
+ <${IconPlus} />
78
+ </button> */ null}
79
+ </div>
80
+ ${kebab ? html`<div class="session-tabs-right">${kebab}</div>` : null}
81
+ </div>`;
82
+ }
83
+
54
84
  function SessionMenu({ session, switchableClis, onRename, onDelete, onOpenEditor, onSwitchCli }) {
55
85
  const [open, setOpen] = useState(false);
56
86
  const anchor = useRef(null);
57
87
  return html`
58
88
  <button class="session-menu-btn" ref=${anchor}
59
89
  aria-label="Session actions" title="Session actions"
60
- onClick=${() => setOpen((v) => !v)}>
61
- <${IconMoreVert} />
62
- </button>
63
- ${open ? html`
64
- <${Popover} anchor=${anchor} align="right" width=${200}
65
- onClose=${() => setOpen(false)}>
66
- <div class="session-menu">
67
- <button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
90
+ onClick=${() => setOpen((v) => !v)}>
91
+ <${IconMoreVert} />
92
+ </button>
93
+ ${open ? html`
94
+ <${Popover} anchor=${anchor} align="right" width=${200}
95
+ onClose=${() => setOpen(false)}>
96
+ <div class="session-menu">
97
+ <button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
68
98
  <${IconExternal} /> Open in editor
69
99
  </button>
70
100
  ${switchableClis.length ? html`
@@ -84,8 +114,8 @@ function SessionMenu({ session, switchableClis, onRename, onDelete, onOpenEditor
84
114
  </button>
85
115
  <button class="session-menu-item danger" onClick=${() => { setOpen(false); onDelete(); }}>
86
116
  <${IconClose} /> Delete
87
- </button>
88
- </div>
117
+ </button>
118
+ </div>
89
119
  </${Popover}>` : null}`;
90
120
  }
91
121
 
@@ -116,22 +146,24 @@ export function SessionsPage() {
116
146
  const id = activeSessionId.value;
117
147
  const list = sessions.value;
118
148
  const session = id ? list.find((s) => s.id === id) : null;
149
+ const runningSessions = list.filter((s) => s.status === 'running');
119
150
  const [resumeError, setResumeError] = useState(null);
120
151
  const [actionBusy, setActionBusy] = useState(false);
152
+ const [openTerminalIds, setOpenTerminalIds] = useState(() => new Set());
121
153
  // Bumps to force the auto-resume effect to re-run on Retry without
122
154
  // mutating any signal. Primitive in the dep array → identity changes.
123
155
  const [retryNonce, setRetryNonce] = useState(0);
124
-
125
- // No session selected → bounce to the Launch page. Done in an effect so
126
- // we don't mutate signals during render. Returning null while the bounce
127
- // is in flight avoids a flash of empty content.
128
- useEffect(() => {
129
- if (!session) selectTab('launch');
130
- }, [session]);
131
-
132
- // Auto-resume when the active session is exited. resumeSession() in
133
- // api.js dedups in-flight calls per session id, so simultaneous fires
134
- // from here and from Sidebar.onClick collapse into one request.
156
+
157
+ // No session selected → bounce to the Launch page. Done in an effect so
158
+ // we don't mutate signals during render. Returning null while the bounce
159
+ // is in flight avoids a flash of empty content.
160
+ useEffect(() => {
161
+ if (!session) selectTab('launch');
162
+ }, [session]);
163
+
164
+ // Auto-resume when the active session is exited. resumeSession() in
165
+ // api.js dedups in-flight calls per session id, so simultaneous fires
166
+ // from here and from Sidebar.onClick collapse into one request.
135
167
  useEffect(() => {
136
168
  if (!session) return;
137
169
  if (session.status === 'running') { setResumeError(null); return; }
@@ -142,15 +174,78 @@ export function SessionsPage() {
142
174
  .catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
143
175
  }, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
144
176
 
145
- if (!session) return null;
177
+ useEffect(() => {
178
+ const runningIds = new Set(runningSessions.map((s) => s.id));
179
+ setOpenTerminalIds((prev) => {
180
+ const next = new Set();
181
+ let changed = false;
182
+ for (const sid of prev) {
183
+ if (runningIds.has(sid)) {
184
+ next.add(sid);
185
+ } else {
186
+ changed = true;
187
+ }
188
+ }
189
+ if (session?.status === 'running' && !next.has(session.id)) {
190
+ next.add(session.id);
191
+ changed = true;
192
+ }
193
+ return changed || next.size !== prev.size ? next : prev;
194
+ });
195
+ }, [list, session?.id, session?.status]);
146
196
 
197
+ if (!session) return null;
198
+
147
199
  const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
200
+ const cliForSession = (s) => (config.value?.clis || []).find((c) => c.id === s.cliId);
148
201
  const switchableClis = cli
149
202
  ? (config.value?.clis || []).filter((c) => c.id !== cli.id && c.type === cli.type)
150
203
  : [];
151
204
  const running = session.status === 'running';
205
+ const retainedSessions = Array.from(openTerminalIds)
206
+ .map((sid) => list.find((s) => s.id === sid))
207
+ .filter((s) => s && s.status === 'running');
208
+ const terminalSessions = running && !retainedSessions.some((s) => s.id === session.id)
209
+ ? [...retainedSessions, session]
210
+ : retainedSessions;
152
211
  const title = session.title || session.workspace || session.id.slice(0, 12);
153
212
 
213
+ const onCloseTab = (sid) => {
214
+ setOpenTerminalIds((prev) => {
215
+ if (!prev.has(sid)) return prev;
216
+ const next = new Set(prev);
217
+ next.delete(sid);
218
+ return next;
219
+ });
220
+
221
+ if (sid !== session.id) return;
222
+ const currentIndex = terminalSessions.findIndex((s) => s.id === sid);
223
+ const remaining = terminalSessions.filter((s) => s.id !== sid);
224
+ const replacement = currentIndex >= 0
225
+ ? remaining[Math.min(currentIndex, remaining.length - 1)] || remaining[remaining.length - 1]
226
+ : remaining[0];
227
+ if (replacement) {
228
+ selectSession(replacement.id);
229
+ } else {
230
+ activeSessionId.value = null;
231
+ selectTab('launch');
232
+ }
233
+ };
234
+
235
+ const onReorderTabs = (orderedIds) => {
236
+ const runningIds = new Set(runningSessions.map((s) => s.id));
237
+ setOpenTerminalIds((prev) => {
238
+ const nextIds = [];
239
+ for (const sid of orderedIds) {
240
+ if (runningIds.has(sid) && !nextIds.includes(sid)) nextIds.push(sid);
241
+ }
242
+ for (const sid of prev) {
243
+ if (runningIds.has(sid) && !nextIds.includes(sid)) nextIds.push(sid);
244
+ }
245
+ return new Set(nextIds);
246
+ });
247
+ };
248
+
154
249
  const onResume = async () => {
155
250
  clearResumeFailure(session.id);
156
251
  setResumeError(null);
@@ -180,21 +275,21 @@ export function SessionsPage() {
180
275
  setActionBusy(false);
181
276
  }
182
277
  };
183
- const onRename = async () => {
184
- const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
185
- if (next === null) return;
186
- try { await setSessionTitle(session.id, next.trim()); }
187
- catch (e) { setToast(e.message, 'error'); }
188
- };
189
- const onDelete = async () => {
190
- const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
191
- title: 'Delete session', okLabel: 'Delete', danger: true });
192
- if (!ok) return;
193
- try {
194
- await deleteSession(session.id);
195
- activeSessionId.value = null;
196
- } catch (e) { setToast(e.message, 'error'); }
197
- };
278
+ const onRename = async () => {
279
+ const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
280
+ if (next === null) return;
281
+ try { await setSessionTitle(session.id, next.trim()); }
282
+ catch (e) { setToast(e.message, 'error'); }
283
+ };
284
+ const onDelete = async () => {
285
+ const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
286
+ title: 'Delete session', okLabel: 'Delete', danger: true });
287
+ if (!ok) return;
288
+ try {
289
+ await deleteSession(session.id);
290
+ activeSessionId.value = null;
291
+ } catch (e) { setToast(e.message, 'error'); }
292
+ };
198
293
  const onOpenEditor = async () => {
199
294
  try {
200
295
  const r = await openSessionInEditor(session.id);
@@ -221,14 +316,14 @@ export function SessionsPage() {
221
316
  }
222
317
  } catch (e) { setToast(e.message, 'error'); }
223
318
  };
224
-
225
- return html`
226
- <${PageTitleBar} title=${html`
227
- <span class="session-title-text">${title}</span>
228
- <span class="session-title-meta">
229
- <span class="mono">${session.cwd}</span>
230
- <span>·</span>
231
- <span>${cli ? cli.name : session.cliId}</span>
319
+
320
+ return html`
321
+ <${PageTitleBar} title=${html`
322
+ <span class="session-title-text">${title}</span>
323
+ <span class="session-title-meta">
324
+ <span class="mono">${session.cwd}</span>
325
+ <span>·</span>
326
+ <span>${cli ? cli.name : session.cliId}</span>
232
327
  ${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
233
328
  <span>·</span>
234
329
  <span>${running ? 'running' : (resumeError ? 'resume failed' : (session.manualStopped ? 'stopped' : 'resuming…'))}</span>
@@ -236,7 +331,10 @@ export function SessionsPage() {
236
331
  `} />
237
332
  <${SessionTabs}
238
333
  activeId=${session.id}
334
+ openSessions=${terminalSessions}
239
335
  onActivate=${(sid) => selectSession(sid)}
336
+ onClose=${onCloseTab}
337
+ onReorder=${onReorderTabs}
240
338
  onNew=${() => selectTab('launch')}
241
339
  kebab=${html`
242
340
  <${SessionControls} running=${running}
@@ -251,9 +349,29 @@ export function SessionsPage() {
251
349
  onSwitchCli=${onSwitchCli} />`} />
252
350
  <div class="session-pane">
253
351
  <div class="session-pane-body">
254
- ${running
255
- ? html`<${TerminalView} terminalId=${session.id} cliType=${cli?.type} />`
256
- : html`
352
+ ${terminalSessions.length ? html`
353
+ <div class="terminal-stack">
354
+ ${terminalSessions.map((s) => {
355
+ const sCli = cliForSession(s);
356
+ const active = running && s.id === session.id;
357
+ return html`
358
+ <div key=${s.id}
359
+ class=${`terminal-layer${active ? ' is-active' : ''}`}
360
+ data-terminal-id=${s.id}
361
+ data-active=${active || null}
362
+ aria-hidden=${!active}>
363
+ <${TerminalView}
364
+ key=${s.id}
365
+ terminalId=${s.id}
366
+ cliType=${sCli?.type}
367
+ visible=${active}
368
+ />
369
+ </div>`;
370
+ })}
371
+ </div>
372
+ ` : null}
373
+ ${!running
374
+ ? html`
257
375
  <div class="terminal-empty">
258
376
  ${resumeError ? html`
259
377
  <div>Failed to resume: <span class="mono">${resumeError}</span></div>
@@ -266,7 +384,8 @@ export function SessionsPage() {
266
384
  ` : html`
267
385
  <div>Resuming session…</div>
268
386
  `}
269
- </div>`}
387
+ </div>`
388
+ : null}
270
389
  </div>
271
390
  </div>`;
272
391
  }