@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 +1 -1
- package/public/css/layout.css +23 -22
- package/public/css/terminals.css +142 -46
- package/public/js/components/TerminalInstance.js +11 -9
- package/public/js/components/TerminalView.js +15 -2
- package/public/js/components/XtermTerminal.js +74 -2
- package/public/js/pages/SessionsPage.js +163 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.22.
|
|
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",
|
package/public/css/layout.css
CHANGED
|
@@ -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:
|
|
159
|
-
color: var(--ink);
|
|
160
|
-
flex
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|
package/public/css/terminals.css
CHANGED
|
@@ -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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
font:
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
.session-tab-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
.session-tab
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
${
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
380
|
+
</div>`
|
|
381
|
+
: null}
|
|
382
|
+
</div>
|
|
383
|
+
</div>`;
|
|
384
|
+
}
|