@bakapiano/ccsm 0.22.4 → 0.22.6

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.6",
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",
@@ -152,25 +152,26 @@ body.is-resizing-sidebar .app {
152
152
  gap: var(--s-2);
153
153
  flex-shrink: 0;
154
154
  }
155
- .session-title-text {
156
- font-weight: 500;
157
- font-size: 13px;
158
- letter-spacing: -0.005em;
159
- color: var(--ink);
160
- flex-shrink: 0;
161
- }
162
- .session-title-meta {
163
- display: flex;
164
- align-items: center;
165
- gap: 6px;
166
- color: var(--ink-muted);
167
- font-size: 11.5px;
168
- overflow: hidden;
169
- white-space: nowrap;
170
- }
171
- .session-title-meta .mono {
172
- font-family: var(--mono);
173
- overflow: hidden;
174
- text-overflow: ellipsis;
175
- max-width: 40vw;
176
- }
155
+ .session-title-text {
156
+ font-weight: 500;
157
+ font-size: 13px;
158
+ letter-spacing: 0;
159
+ color: var(--ink);
160
+ flex: 0 1 auto;
161
+ min-width: 0;
162
+ max-width: min(32ch, 36vw);
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ white-space: nowrap;
166
+ }
167
+ .session-title-cwd {
168
+ color: var(--ink-muted);
169
+ font-size: 11.5px;
170
+ font-family: var(--mono);
171
+ flex: 1 1 auto;
172
+ min-width: 0;
173
+ max-width: min(72ch, 52vw);
174
+ overflow: hidden;
175
+ text-overflow: ellipsis;
176
+ white-space: nowrap;
177
+ }
@@ -292,45 +292,112 @@
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: 1;
387
+ flex: 0 0 auto;
388
+ cursor: pointer;
389
+ }
390
+ .session-tab-close:hover {
391
+ background: rgba(255, 255, 255, 0.12);
392
+ }
393
+ .session-tab-close svg {
394
+ width: 12px;
395
+ height: 12px;
396
+ }
397
+ .session-tab-add {
398
+ background: transparent;
399
+ max-width: none;
400
+ padding: 0 8px;
334
401
  color: #fff;
335
402
  }
336
403
  .session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@@ -472,14 +539,43 @@
472
539
  gap: var(--s-2);
473
540
  flex-shrink: 0;
474
541
  }
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
- }
542
+ .session-pane-body {
543
+ flex: 1;
544
+ min-height: 0;
545
+ background: var(--term-surface);
546
+ position: relative;
547
+ overflow: hidden;
548
+ }
549
+ .terminal-stack {
550
+ position: absolute;
551
+ inset: 0;
552
+ min-width: 0;
553
+ min-height: 0;
554
+ }
555
+ .terminal-layer {
556
+ position: absolute;
557
+ inset: 0;
558
+ min-width: 0;
559
+ min-height: 0;
560
+ display: flex;
561
+ flex-direction: column;
562
+ visibility: hidden;
563
+ pointer-events: none;
564
+ z-index: 0;
565
+ }
566
+ .terminal-layer.is-active {
567
+ visibility: visible;
568
+ pointer-events: auto;
569
+ z-index: 1;
570
+ }
571
+ .session-pane-body .terminal-host {
572
+ height: 100%;
573
+ }
574
+ .terminal-layer .terminal-host {
575
+ flex: 1 1 auto;
576
+ min-height: 0;
577
+ height: auto;
578
+ }
483
579
  .session-pane-body .terminal-empty {
484
580
  background: var(--term-surface);
485
581
  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;
@@ -197,15 +205,6 @@ export class TerminalInstance {
197
205
  ro.observe(host);
198
206
  this.disposables.push(() => ro.disconnect());
199
207
 
200
- const vv = window.visualViewport;
201
- const onVisualResize = () => this.scheduleLayout({ retries: true });
202
- vv?.addEventListener?.('resize', onVisualResize);
203
- vv?.addEventListener?.('scroll', onVisualResize);
204
- this.disposables.push(() => {
205
- vv?.removeEventListener?.('resize', onVisualResize);
206
- vv?.removeEventListener?.('scroll', onVisualResize);
207
- });
208
-
209
208
  const onHostClick = () => this.xterm.focus();
210
209
  if (this.xterm.isMobile) {
211
210
  host.addEventListener('click', onHostClick);
@@ -361,6 +360,9 @@ export class TerminalInstance {
361
360
  this._beginReplay();
362
361
  this.xterm.write(data, () => {
363
362
  this._endReplay();
363
+ if (this.isVisible) {
364
+ this.scheduleLayout({ immediate: true, retries: true, forceRedraw: true });
365
+ }
364
366
  });
365
367
  }
366
368
 
@@ -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
  }
@@ -61,6 +61,8 @@ export class XtermTerminal {
61
61
  this.webglAddon = null;
62
62
  this.webglContextLossDisposable = null;
63
63
  this.refreshDimensionListeners = new Set();
64
+ this.resizeScrollState = null;
65
+ this.resizeScrollStateTimer = null;
64
66
  this.host = null;
65
67
 
66
68
  this.raw = new Terminal({
@@ -99,6 +101,7 @@ export class XtermTerminal {
99
101
  attachToElement(host) {
100
102
  this.host = host;
101
103
  this.raw.open(host);
104
+ host.xterm = this.raw;
102
105
  this._enableWebglRenderer();
103
106
  try {
104
107
  document.fonts?.ready?.then(() => {
@@ -150,7 +153,7 @@ export class XtermTerminal {
150
153
  if (!proposed) return null;
151
154
 
152
155
  if (proposed.cols !== this.raw.cols || proposed.rows !== this.raw.rows) {
153
- try { this.raw.resize(proposed.cols, proposed.rows); } catch {}
156
+ this._resizeRaw(proposed.cols, proposed.rows);
154
157
  }
155
158
  lastKnownGridDimensions = proposed;
156
159
  return proposed;
@@ -162,7 +165,7 @@ export class XtermTerminal {
162
165
 
163
166
  resize(cols, rows) {
164
167
  if (!(cols > 0 && rows > 0)) return;
165
- try { this.raw.resize(cols, rows); } catch {}
168
+ this._resizeRaw(cols, rows);
166
169
  lastKnownGridDimensions = { cols: this.raw.cols, rows: this.raw.rows };
167
170
  }
168
171
 
@@ -195,6 +198,14 @@ export class XtermTerminal {
195
198
  try { this.raw.focus(); } catch {}
196
199
  }
197
200
 
201
+ blur() {
202
+ try {
203
+ if (this.helperTextarea && document.activeElement === this.helperTextarea) {
204
+ this.helperTextarea.blur();
205
+ }
206
+ } catch {}
207
+ }
208
+
198
209
  onData(listener) {
199
210
  return this.raw.onData(listener);
200
211
  }
@@ -208,6 +219,12 @@ export class XtermTerminal {
208
219
  }
209
220
 
210
221
  dispose() {
222
+ if (this.resizeScrollStateTimer) clearTimeout(this.resizeScrollStateTimer);
223
+ this.resizeScrollState = null;
224
+ this.resizeScrollStateTimer = null;
225
+ if (this.host?.xterm === this.raw) {
226
+ try { delete this.host.xterm; } catch { this.host.xterm = undefined; }
227
+ }
211
228
  this.host = null;
212
229
  this._disposeWebglRenderer(false);
213
230
  this.refreshDimensionListeners.clear();
@@ -256,6 +273,61 @@ export class XtermTerminal {
256
273
  }
257
274
  }
258
275
 
276
+ _resizeRaw(cols, rows) {
277
+ const scrollState = this._scrollStateForResize();
278
+ try { this.raw.resize(cols, rows); } catch {}
279
+ this._restoreScrollStateIfNeeded(scrollState);
280
+ requestAnimationFrame(() => this._restoreScrollStateIfNeeded(scrollState));
281
+ setTimeout(() => this._restoreScrollStateIfNeeded(scrollState), 150);
282
+ setTimeout(() => this._restoreScrollStateIfNeeded(scrollState), 350);
283
+ }
284
+
285
+ _scrollStateForResize() {
286
+ const state = this._captureScrollState();
287
+ if (state?.viewportY > 0) {
288
+ this._rememberResizeScrollState(state);
289
+ return state;
290
+ }
291
+ return this.resizeScrollState;
292
+ }
293
+
294
+ _rememberResizeScrollState(state) {
295
+ this.resizeScrollState = state;
296
+ if (this.resizeScrollStateTimer) clearTimeout(this.resizeScrollStateTimer);
297
+ this.resizeScrollStateTimer = setTimeout(() => {
298
+ this.resizeScrollState = null;
299
+ this.resizeScrollStateTimer = null;
300
+ }, 500);
301
+ }
302
+
303
+ _captureScrollState() {
304
+ const buffer = this.raw?.buffer?.active;
305
+ if (!buffer) return null;
306
+ return {
307
+ viewportY: buffer.viewportY,
308
+ baseY: buffer.baseY,
309
+ atBottom: buffer.viewportY >= buffer.baseY,
310
+ };
311
+ }
312
+
313
+ _restoreScrollStateIfNeeded(state) {
314
+ if (!state || !(state.viewportY > 0)) return;
315
+ const buffer = this.raw?.buffer?.active;
316
+ if (!buffer) return;
317
+
318
+ if (state.atBottom) {
319
+ if (buffer.viewportY < buffer.baseY) {
320
+ try { this.raw.scrollToBottom(); } catch {}
321
+ }
322
+ return;
323
+ }
324
+
325
+ const target = Math.min(state.viewportY, buffer.baseY);
326
+ if (target > 0 && buffer.viewportY !== target) {
327
+ try { this.raw.scrollToLine(target); } catch {}
328
+ }
329
+ }
330
+
259
331
  _installSelectionCopyGuard() {
260
332
  this.raw.attachCustomKeyEventHandler((ev) => {
261
333
  if (ev.type === 'keydown'
@@ -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}
@@ -118,6 +148,7 @@ export function SessionsPage() {
118
148
  const session = id ? list.find((s) => s.id === id) : null;
119
149
  const [resumeError, setResumeError] = useState(null);
120
150
  const [actionBusy, setActionBusy] = useState(false);
151
+ const [openTerminalIds, setOpenTerminalIds] = useState(() => new Set());
121
152
  // Bumps to force the auto-resume effect to re-run on Retry without
122
153
  // mutating any signal. Primitive in the dep array → identity changes.
123
154
  const [retryNonce, setRetryNonce] = useState(0);
@@ -141,16 +172,80 @@ export function SessionsPage() {
141
172
  .then((launched) => { if (launched?.id) selectSession(launched.id); })
142
173
  .catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
143
174
  }, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
144
-
145
- if (!session) return null;
175
+
176
+ useEffect(() => {
177
+ const existingIds = new Set(list.map((s) => s.id));
178
+ setOpenTerminalIds((prev) => {
179
+ const next = new Set();
180
+ let changed = false;
181
+ for (const sid of prev) {
182
+ if (existingIds.has(sid)) {
183
+ next.add(sid);
184
+ } else {
185
+ changed = true;
186
+ }
187
+ }
188
+ if (session?.id && existingIds.has(session.id) && !next.has(session.id)) {
189
+ next.add(session.id);
190
+ changed = true;
191
+ }
192
+ return changed || next.size !== prev.size ? next : prev;
193
+ });
194
+ }, [list, session?.id]);
195
+
196
+ if (!session) return null;
146
197
 
147
198
  const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
199
+ const cliForSession = (s) => (config.value?.clis || []).find((c) => c.id === s.cliId);
148
200
  const switchableClis = cli
149
201
  ? (config.value?.clis || []).filter((c) => c.id !== cli.id && c.type === cli.type)
150
202
  : [];
151
203
  const running = session.status === 'running';
204
+ const openSessions = Array.from(openTerminalIds)
205
+ .map((sid) => list.find((s) => s.id === sid))
206
+ .filter(Boolean);
207
+ const tabSessions = session && !openSessions.some((s) => s.id === session.id)
208
+ ? [...openSessions, session]
209
+ : openSessions;
210
+ const terminalSessions = tabSessions.filter((s) => s.status === 'running');
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 = tabSessions.findIndex((s) => s.id === sid);
223
+ const remaining = tabSessions.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 existingIds = new Set(list.map((s) => s.id));
237
+ setOpenTerminalIds((prev) => {
238
+ const nextIds = [];
239
+ for (const sid of orderedIds) {
240
+ if (existingIds.has(sid) && !nextIds.includes(sid)) nextIds.push(sid);
241
+ }
242
+ for (const sid of prev) {
243
+ if (existingIds.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);
@@ -222,21 +317,17 @@ export function SessionsPage() {
222
317
  } catch (e) { setToast(e.message, 'error'); }
223
318
  };
224
319
 
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>
232
- ${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
233
- <span>·</span>
234
- <span>${running ? 'running' : (resumeError ? 'resume failed' : (session.manualStopped ? 'stopped' : 'resuming…'))}</span>
235
- </span>
320
+ return html`
321
+ <${PageTitleBar} title=${html`
322
+ <span class="session-title-text" title=${title}>${title}</span>
323
+ <span class="session-title-cwd" title=${session.cwd}>${session.cwd}</span>
236
324
  `} />
237
325
  <${SessionTabs}
238
326
  activeId=${session.id}
327
+ openSessions=${tabSessions}
239
328
  onActivate=${(sid) => selectSession(sid)}
329
+ onClose=${onCloseTab}
330
+ onReorder=${onReorderTabs}
240
331
  onNew=${() => selectTab('launch')}
241
332
  kebab=${html`
242
333
  <${SessionControls} running=${running}
@@ -249,12 +340,32 @@ export function SessionsPage() {
249
340
  onDelete=${onDelete}
250
341
  onOpenEditor=${onOpenEditor}
251
342
  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">
343
+ <div class="session-pane">
344
+ <div class="session-pane-body">
345
+ ${terminalSessions.length ? html`
346
+ <div class="terminal-stack">
347
+ ${terminalSessions.map((s) => {
348
+ const sCli = cliForSession(s);
349
+ const active = running && s.id === session.id;
350
+ return html`
351
+ <div key=${s.id}
352
+ class=${`terminal-layer${active ? ' is-active' : ''}`}
353
+ data-terminal-id=${s.id}
354
+ data-active=${active || null}
355
+ aria-hidden=${!active}>
356
+ <${TerminalView}
357
+ key=${s.id}
358
+ terminalId=${s.id}
359
+ cliType=${sCli?.type}
360
+ visible=${active}
361
+ />
362
+ </div>`;
363
+ })}
364
+ </div>
365
+ ` : null}
366
+ ${!running
367
+ ? html`
368
+ <div class="terminal-empty">
258
369
  ${resumeError ? html`
259
370
  <div>Failed to resume: <span class="mono">${resumeError}</span></div>
260
371
  <button class="action primary" onClick=${onRetry}>Retry</button>
@@ -266,7 +377,8 @@ export function SessionsPage() {
266
377
  ` : html`
267
378
  <div>Resuming session…</div>
268
379
  `}
269
- </div>`}
270
- </div>
271
- </div>`;
272
- }
380
+ </div>`
381
+ : null}
382
+ </div>
383
+ </div>`;
384
+ }