@bakapiano/ccsm 0.22.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.22.4",
3
+ "version": "0.22.5",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -292,45 +292,118 @@
292
292
  .page-title-bar + .session-tabs {
293
293
  margin-top: calc(-1 * var(--s-4));
294
294
  }
295
- .session-tab {
296
- appearance: none;
297
- background: var(--term-tab);
298
- border: 0;
299
- border-bottom: 2px solid transparent;
300
- margin-bottom: -1px; /* overlap container border-bottom */
301
- padding: 0 10px;
302
- display: inline-flex;
303
- align-items: center;
304
- gap: 6px;
305
- font: inherit;
306
- font-size: 12px;
295
+ .session-tab {
296
+ appearance: none;
297
+ background: var(--term-tab);
298
+ border: 0;
299
+ border-bottom: 2px solid transparent;
300
+ margin-bottom: -1px; /* overlap container border-bottom */
301
+ display: inline-flex;
302
+ align-items: center;
303
+ gap: 6px;
304
+ font: inherit;
305
+ font-size: 12px;
307
306
  color: var(--term-tab-text);
308
- cursor: pointer;
309
- max-width: 200px;
310
- min-width: 0;
311
- transition: background-color .12s, color .12s;
312
- }
313
- .session-tab:hover { background: var(--term-tab-hover); color: var(--term-on); }
314
- .session-tab.is-active {
315
- background: var(--term-surface);
316
- color: var(--term-on);
317
- border-bottom-color: var(--term-surface);
318
- }
319
- .session-tab-icon { display: inline-flex; flex-shrink: 0; }
320
- .session-tab-icon svg { width: 14px; height: 14px; }
321
- .session-tab-icon img { width: 14px; height: 14px; }
322
- .session-tab-label {
323
- white-space: nowrap;
324
- overflow: hidden;
325
- text-overflow: ellipsis;
326
- min-width: 0;
327
- }
328
- .session-tab-meta { color: var(--term-tab-text); font-size: 11px; }
329
- .session-tab.is-active .session-tab-meta { color: rgba(255, 255, 255, 0.6); }
330
- .session-tab-add {
331
- background: transparent;
332
- max-width: none;
333
- padding: 0 8px;
307
+ cursor: pointer;
308
+ max-width: 200px;
309
+ min-width: 0;
310
+ transition: background-color .12s, color .12s;
311
+ user-select: none;
312
+ position: relative;
313
+ }
314
+ .session-tab:hover { background: var(--term-tab-hover); color: var(--term-on); }
315
+ .session-tab.is-active {
316
+ background: var(--term-surface);
317
+ color: var(--term-on);
318
+ border-bottom-color: var(--term-surface);
319
+ }
320
+ .session-tab:focus-visible {
321
+ outline: 1px solid var(--accent);
322
+ outline-offset: -2px;
323
+ }
324
+ .session-tab[data-dnd-over="true"] {
325
+ box-shadow: inset 2px 0 0 var(--accent);
326
+ }
327
+ .session-tab::before {
328
+ content: "";
329
+ position: absolute;
330
+ left: 3px;
331
+ top: 0;
332
+ bottom: 0;
333
+ width: 3px;
334
+ border-radius: 0;
335
+ background: var(--ink-faint);
336
+ opacity: .55;
337
+ }
338
+ .session-tab.is-running::before {
339
+ background: var(--green);
340
+ opacity: .9;
341
+ }
342
+ .session-tab.is-working::before {
343
+ background: var(--blue, #4a73a5);
344
+ opacity: .95;
345
+ }
346
+ .session-tab.is-stopped::before {
347
+ background: var(--ink-faint);
348
+ opacity: .45;
349
+ }
350
+ .session-tab-main {
351
+ display: inline-flex;
352
+ align-items: center;
353
+ gap: 6px;
354
+ min-width: 0;
355
+ flex: 1 1 auto;
356
+ height: 100%;
357
+ padding: 0 0 0 14px;
358
+ }
359
+ .session-tab-main[draggable="true"] {
360
+ cursor: grab;
361
+ }
362
+ .session-tab-main[draggable="true"]:active {
363
+ cursor: grabbing;
364
+ }
365
+ .session-tab-icon { display: inline-flex; flex-shrink: 0; }
366
+ .session-tab-icon svg { width: 14px; height: 14px; }
367
+ .session-tab-icon img { width: 14px; height: 14px; }
368
+ .session-tab-label {
369
+ white-space: nowrap;
370
+ overflow: hidden;
371
+ text-overflow: ellipsis;
372
+ min-width: 0;
373
+ }
374
+ .session-tab-close {
375
+ appearance: none;
376
+ border: 0;
377
+ background: transparent;
378
+ color: currentColor;
379
+ display: inline-flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ width: 18px;
383
+ height: 18px;
384
+ margin-right: 5px;
385
+ border-radius: 3px;
386
+ opacity: 0;
387
+ flex: 0 0 auto;
388
+ cursor: pointer;
389
+ }
390
+ .session-tab:hover .session-tab-close,
391
+ .session-tab.is-active .session-tab-close,
392
+ .session-tab:focus-within .session-tab-close {
393
+ opacity: .72;
394
+ }
395
+ .session-tab-close:hover {
396
+ opacity: 1;
397
+ background: rgba(255, 255, 255, 0.12);
398
+ }
399
+ .session-tab-close svg {
400
+ width: 12px;
401
+ height: 12px;
402
+ }
403
+ .session-tab-add {
404
+ background: transparent;
405
+ max-width: none;
406
+ padding: 0 8px;
334
407
  color: #fff;
335
408
  }
336
409
  .session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@@ -472,14 +545,43 @@
472
545
  gap: var(--s-2);
473
546
  flex-shrink: 0;
474
547
  }
475
- .session-pane-body {
476
- flex: 1;
477
- min-height: 0;
478
- background: var(--term-surface);
479
- }
480
- .session-pane-body .terminal-host {
481
- height: 100%;
482
- }
548
+ .session-pane-body {
549
+ flex: 1;
550
+ min-height: 0;
551
+ background: var(--term-surface);
552
+ position: relative;
553
+ overflow: hidden;
554
+ }
555
+ .terminal-stack {
556
+ position: absolute;
557
+ inset: 0;
558
+ min-width: 0;
559
+ min-height: 0;
560
+ }
561
+ .terminal-layer {
562
+ position: absolute;
563
+ inset: 0;
564
+ min-width: 0;
565
+ min-height: 0;
566
+ display: flex;
567
+ flex-direction: column;
568
+ visibility: hidden;
569
+ pointer-events: none;
570
+ z-index: 0;
571
+ }
572
+ .terminal-layer.is-active {
573
+ visibility: visible;
574
+ pointer-events: auto;
575
+ z-index: 1;
576
+ }
577
+ .session-pane-body .terminal-host {
578
+ height: 100%;
579
+ }
580
+ .terminal-layer .terminal-host {
581
+ flex: 1 1 auto;
582
+ min-height: 0;
583
+ height: auto;
584
+ }
483
585
  .session-pane-body .terminal-empty {
484
586
  background: var(--term-surface);
485
587
  color: var(--term-on);
@@ -63,6 +63,14 @@ export class TerminalInstance {
63
63
  this.xterm.applyResolvedTheme();
64
64
  }
65
65
 
66
+ focus() {
67
+ this.xterm.focus();
68
+ }
69
+
70
+ blur() {
71
+ this.xterm.blur();
72
+ }
73
+
66
74
  layout(width, height, immediate = false) {
67
75
  const layoutDimensions = this._resolveLayoutDimensions(width, height);
68
76
  if (!layoutDimensions) return null;
@@ -361,6 +369,9 @@ export class TerminalInstance {
361
369
  this._beginReplay();
362
370
  this.xterm.write(data, () => {
363
371
  this._endReplay();
372
+ if (this.isVisible) {
373
+ this.scheduleLayout({ immediate: true, retries: true, forceRedraw: true });
374
+ }
364
375
  });
365
376
  }
366
377
 
@@ -8,7 +8,7 @@ import { themeMode } from '../state.js';
8
8
  import { TerminalKeyBar } from './TerminalKeyBar.js';
9
9
  import { TerminalInstance } from './TerminalInstance.js';
10
10
 
11
- export function TerminalView({ terminalId, cliType }) {
11
+ export function TerminalView({ terminalId, cliType, visible = true }) {
12
12
  const hostRef = useRef(null);
13
13
  const instanceRef = useRef(null);
14
14
  const [displaced, setDisplaced] = useState(false);
@@ -38,6 +38,8 @@ export function TerminalView({ terminalId, cliType }) {
38
38
  });
39
39
  instanceRef.current = instance;
40
40
  instance.attachToElement(host);
41
+ instance.setVisible(visible);
42
+ if (visible) instance.focus();
41
43
 
42
44
  return () => {
43
45
  if (instanceRef.current === instance) instanceRef.current = null;
@@ -49,6 +51,17 @@ export function TerminalView({ terminalId, cliType }) {
49
51
  instanceRef.current?.setCliType(cliType);
50
52
  }, [cliType, terminalId, reattachNonce]);
51
53
 
54
+ useEffect(() => {
55
+ const instance = instanceRef.current;
56
+ if (!instance) return;
57
+ instance.setVisible(visible);
58
+ if (visible) {
59
+ instance.focus();
60
+ } else {
61
+ instance.blur();
62
+ }
63
+ }, [visible, terminalId, reattachNonce]);
64
+
52
65
  if (!terminalId) {
53
66
  return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
54
67
  }
@@ -80,6 +93,6 @@ export function TerminalView({ terminalId, cliType }) {
80
93
  return html`
81
94
  <${Fragment}>
82
95
  <div key="host" ref=${hostRef} class="terminal-host"></div>
83
- <${TerminalKeyBar} send=${sendInput} cliType=${cliType} />
96
+ ${visible ? html`<${TerminalKeyBar} send=${sendInput} cliType=${cliType} />` : null}
84
97
  </${Fragment}>`;
85
98
  }
@@ -99,6 +99,7 @@ export class XtermTerminal {
99
99
  attachToElement(host) {
100
100
  this.host = host;
101
101
  this.raw.open(host);
102
+ host.xterm = this.raw;
102
103
  this._enableWebglRenderer();
103
104
  try {
104
105
  document.fonts?.ready?.then(() => {
@@ -195,6 +196,14 @@ export class XtermTerminal {
195
196
  try { this.raw.focus(); } catch {}
196
197
  }
197
198
 
199
+ blur() {
200
+ try {
201
+ if (this.helperTextarea && document.activeElement === this.helperTextarea) {
202
+ this.helperTextarea.blur();
203
+ }
204
+ } catch {}
205
+ }
206
+
198
207
  onData(listener) {
199
208
  return this.raw.onData(listener);
200
209
  }
@@ -208,6 +217,9 @@ export class XtermTerminal {
208
217
  }
209
218
 
210
219
  dispose() {
220
+ if (this.host?.xterm === this.raw) {
221
+ try { delete this.host.xterm; } catch { this.host.xterm = undefined; }
222
+ }
211
223
  this.host = null;
212
224
  this._disposeWebglRenderer(false);
213
225
  this.refreshDimensionListeners.clear();
@@ -13,36 +13,66 @@ 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
- 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.
23
- const active = activeId ? sessions.value.find((s) => s.id === activeId) : null;
24
- if (!active) return null;
25
- const open = [active];
26
- return html`
27
- <div class="session-tabs" role="tablist">
28
- <div class="session-tabs-list">
29
- ${open.map((s) => {
19
+
20
+ function SessionTabs({ activeId, openSessions, onActivate, onClose, onReorder, onNew, kebab }) {
21
+ const active = activeId ? sessions.value.find((s) => s.id === activeId) : null;
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;
28
+ return html`
29
+ <div class="session-tabs" role="tablist">
30
+ <div class="session-tabs-list">
31
+ ${open.map((s) => {
30
32
  const cli = (config.value?.clis || []).find((c) => c.id === s.cliId);
31
- const Icon = IconForCliType(cli?.type) || IconTerminal;
32
- const t = s.title || s.workspace || s.id.slice(0, 12);
33
- const isActive = s.id === activeId;
34
- 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>`;
45
- })}
33
+ const Icon = IconForCliType(cli?.type) || IconTerminal;
34
+ const t = s.title || s.workspace || s.id.slice(0, 12);
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
+ };
45
+ return html`
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>`;
75
+ })}
46
76
  ${/* <button class="session-tab session-tab-add" onClick=${onNew} title="New session">
47
77
  <${IconPlus} />
48
78
  </button> */ null}
@@ -116,8 +146,10 @@ 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);
@@ -141,16 +173,79 @@ export function SessionsPage() {
141
173
  .then((launched) => { if (launched?.id) selectSession(launched.id); })
142
174
  .catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
143
175
  }, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
144
-
145
- if (!session) return null;
176
+
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]);
196
+
197
+ if (!session) return null;
146
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);
@@ -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}
@@ -249,12 +347,32 @@ export function SessionsPage() {
249
347
  onDelete=${onDelete}
250
348
  onOpenEditor=${onOpenEditor}
251
349
  onSwitchCli=${onSwitchCli} />`} />
252
- <div class="session-pane">
253
- <div class="session-pane-body">
254
- ${running
255
- ? html`<${TerminalView} terminalId=${session.id} cliType=${cli?.type} />`
256
- : html`
257
- <div class="terminal-empty">
350
+ <div class="session-pane">
351
+ <div class="session-pane-body">
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`
375
+ <div class="terminal-empty">
258
376
  ${resumeError ? html`
259
377
  <div>Failed to resume: <span class="mono">${resumeError}</span></div>
260
378
  <button class="action primary" onClick=${onRetry}>Retry</button>
@@ -266,7 +384,8 @@ export function SessionsPage() {
266
384
  ` : html`
267
385
  <div>Resuming session…</div>
268
386
  `}
269
- </div>`}
270
- </div>
271
- </div>`;
272
- }
387
+ </div>`
388
+ : null}
389
+ </div>
390
+ </div>`;
391
+ }