@bakapiano/ccsm 0.20.1 → 0.20.2
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/assets/copilot-color.svg +1 -1
- package/public/css/dark.css +52 -11
- package/public/css/forms.css +12 -2
- package/public/css/responsive.css +16 -4
- package/public/css/widgets.css +9 -20
- package/public/js/components/EntityFormModal.js +14 -14
- package/public/js/components/Modal.js +9 -3
- package/public/js/components/TerminalView.js +65 -39
- package/public/js/main.js +20 -0
- package/public/js/pages/LaunchPage.js +11 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
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",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg fill="
|
|
1
|
+
<svg fill="#8957e5" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
package/public/css/dark.css
CHANGED
|
@@ -14,18 +14,41 @@
|
|
|
14
14
|
* session tabs, and the mobile key bar — those are dark in both themes. */
|
|
15
15
|
|
|
16
16
|
/* ── buttons ─────────────────────────────────────────────────────── */
|
|
17
|
-
/* .
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/* Primary CTA. In light mode it's the ink slab (near-black bg, light
|
|
18
|
+
text). The faithful inversion in dark mode is a near-WHITE slab, which
|
|
19
|
+
reads as a harsh pure-white button floating in the dark popups (the
|
|
20
|
+
Create / Done / Save buttons). Use the accent instead — that's the
|
|
21
|
+
conventional dark-UI primary and matches the accent-colored Launch CTA,
|
|
22
|
+
so every "do this" button in dark mode is one coherent color. */
|
|
23
|
+
[data-theme="dark"] .action.primary {
|
|
24
|
+
background: var(--accent);
|
|
25
|
+
border-color: var(--accent);
|
|
26
|
+
color: #fff;
|
|
27
|
+
}
|
|
20
28
|
[data-theme="dark"] .action.primary:hover {
|
|
21
|
-
background:
|
|
22
|
-
border-color:
|
|
29
|
+
background: var(--accent-deep);
|
|
30
|
+
border-color: var(--accent-deep);
|
|
23
31
|
box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.6);
|
|
24
32
|
}
|
|
25
|
-
/* .fab
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
/* .fab is the same ink-slab pattern (var(--ink) bg) — give it the same
|
|
34
|
+
accent treatment so it isn't a white circle either. */
|
|
35
|
+
[data-theme="dark"] .fab {
|
|
36
|
+
background: var(--accent);
|
|
37
|
+
color: #fff;
|
|
38
|
+
}
|
|
39
|
+
[data-theme="dark"] .fab:hover { background: var(--accent-deep); }
|
|
40
|
+
/* Active "Working directory" mode card (Launch page). Its selected
|
|
41
|
+
highlight uses var(--ink) for the border + icon chip, which flips to
|
|
42
|
+
light cream in dark mode → a glaring white frame + white icon square.
|
|
43
|
+
Use the accent highlight instead, matching the dark primary button. */
|
|
44
|
+
[data-theme="dark"] .workdir-mode-opt.is-active {
|
|
45
|
+
border-color: var(--accent);
|
|
46
|
+
box-shadow: 0 0 0 1px var(--accent) inset;
|
|
47
|
+
}
|
|
48
|
+
[data-theme="dark"] .workdir-mode-opt.is-active .workdir-mode-icon {
|
|
49
|
+
background: var(--accent);
|
|
50
|
+
color: #fff;
|
|
51
|
+
}
|
|
29
52
|
/* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
|
|
30
53
|
ground — switch to a light wash so the affordance stays visible. */
|
|
31
54
|
[data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
|
|
@@ -36,9 +59,11 @@
|
|
|
36
59
|
[data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
|
|
37
60
|
[data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
|
|
38
61
|
|
|
39
|
-
/*
|
|
62
|
+
/* Filled triangle (see forms.css note) tinted to the faint dark-mode ink
|
|
63
|
+
so the select arrow is a calm solid mark instead of a pair of bright
|
|
64
|
+
jagged strokes. */
|
|
40
65
|
[data-theme="dark"] select {
|
|
41
|
-
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0
|
|
66
|
+
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'><path d='M1 1 L5 5 L9 1 Z' fill='%236d6a62'/></svg>");
|
|
42
67
|
}
|
|
43
68
|
|
|
44
69
|
/* ── brand mark ──────────────────────────────────────────────────── */
|
|
@@ -49,6 +74,22 @@
|
|
|
49
74
|
fill untouched — it's already legible there.) */
|
|
50
75
|
[data-theme="dark"] .brand-rect { fill: #38342f; }
|
|
51
76
|
|
|
77
|
+
/* ── notifications (toast + restart pill) ────────────────────────── */
|
|
78
|
+
/* Both use var(--ink) as their surface and var(--bg) as their text — a
|
|
79
|
+
deliberate high-contrast inverted pill in light mode. But var(--ink)
|
|
80
|
+
flips to light cream in dark mode, so the bottom-right notification
|
|
81
|
+
showed up as a pale pill that read as "still light / not following the
|
|
82
|
+
theme". In dark mode, give them a dark elevated surface with light text
|
|
83
|
+
+ a defined border so they sit IN the dark theme like a snackbar. The
|
|
84
|
+
::before chip and the spinner already use currentColor, so they invert
|
|
85
|
+
along with the text for free. */
|
|
86
|
+
[data-theme="dark"] .toast,
|
|
87
|
+
[data-theme="dark"] .restart-banner {
|
|
88
|
+
background: var(--bg-elev);
|
|
89
|
+
color: var(--ink);
|
|
90
|
+
border: 1px solid var(--border-strong);
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
/* ── paper grain ─────────────────────────────────────────────────── */
|
|
53
94
|
/* The noise texture is a dark-tinted SVG multiplied over the surface —
|
|
54
95
|
invisible (and wrong blend) on a dark ground. Screen-blend it at low
|
package/public/css/forms.css
CHANGED
|
@@ -76,7 +76,14 @@
|
|
|
76
76
|
|
|
77
77
|
.input, input[type="text"], input[type="number"], select, textarea {
|
|
78
78
|
appearance: none;
|
|
79
|
-
background
|
|
79
|
+
/* background-COLOR, not the `background` shorthand — the shorthand resets
|
|
80
|
+
background-repeat/position/size to their initial values (repeat / 0% 0%
|
|
81
|
+
/ auto), and since this rule matches <select> via the higher-specificity
|
|
82
|
+
`.input` selector, it was overriding the `select` rule's no-repeat +
|
|
83
|
+
positioning. Result: the dropdown arrow SVG tiled across the whole
|
|
84
|
+
select as a grid of little triangles. Setting only the color leaves the
|
|
85
|
+
select rule's background-* longhands intact. */
|
|
86
|
+
background-color: var(--bg-elev);
|
|
80
87
|
border: 1px solid var(--border-strong);
|
|
81
88
|
color: var(--ink);
|
|
82
89
|
padding: 8px 12px;
|
|
@@ -94,7 +101,10 @@
|
|
|
94
101
|
box-shadow: 0 0 0 3px rgba(26, 24, 21, 0.08);
|
|
95
102
|
}
|
|
96
103
|
select {
|
|
97
|
-
|
|
104
|
+
/* A single FILLED triangle, not a 2-stroke chevron. Thin strokes alias
|
|
105
|
+
into jagged "teeth" at this ~9px size; a solid shape anti-aliases
|
|
106
|
+
cleanly and reads as one arrow rather than a pair of serrations. */
|
|
107
|
+
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'><path d='M1 1 L5 5 L9 1 Z' fill='%238a8475'/></svg>");
|
|
98
108
|
background-repeat: no-repeat;
|
|
99
109
|
background-position: right 10px center;
|
|
100
110
|
background-size: 10px;
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
layout; a circular floating button bottom-left toggles a full-screen
|
|
15
15
|
drawer that re-mounts the sidebar over everything else. */
|
|
16
16
|
@media (max-width: 640px) {
|
|
17
|
-
|
|
17
|
+
/* Shrink the whole app to the visible area ABOVE the soft keyboard.
|
|
18
|
+
--app-vh is the visualViewport height (main.js); the layout-viewport
|
|
19
|
+
100vh never shrinks for the keyboard, which left the terminal's bottom
|
|
20
|
+
rows hidden behind it. 100dvh is the fallback before the JS runs. */
|
|
21
|
+
.app.is-mobile { grid-template-columns: 1fr !important; height: var(--app-vh, 100dvh); }
|
|
22
|
+
/* Keyboard up: keep the terminal's content above the floating key bar
|
|
23
|
+
(TerminalKeyBar, ~50px). Only the terminal pane needs this — other
|
|
24
|
+
pages have their own scroll padding. */
|
|
25
|
+
body.kb-open .app.is-mobile .session-pane-body { padding-bottom: 50px; }
|
|
18
26
|
.app.is-mobile .sidebar {
|
|
19
27
|
/* Collapsed (drawer closed): out of the flow + invisible. */
|
|
20
28
|
position: fixed;
|
|
@@ -96,7 +104,7 @@
|
|
|
96
104
|
scrolling the page while the user is dragging the FAB. */
|
|
97
105
|
.mobile-nav-fab {
|
|
98
106
|
position: fixed;
|
|
99
|
-
z-index:
|
|
107
|
+
z-index: 220; /* above the terminal key bar (215) */
|
|
100
108
|
width: 52px;
|
|
101
109
|
height: 52px;
|
|
102
110
|
border-radius: 50%;
|
|
@@ -112,8 +120,12 @@
|
|
|
112
120
|
cursor: grab;
|
|
113
121
|
touch-action: none;
|
|
114
122
|
user-select: none;
|
|
115
|
-
transition: box-shadow .15s, background .15s;
|
|
123
|
+
transition: box-shadow .15s, background .15s, transform .18s ease;
|
|
116
124
|
}
|
|
125
|
+
/* When the soft keyboard (and the terminal key bar that floats above it)
|
|
126
|
+
is up, lift the FAB clear of the key bar so they don't overlap. The
|
|
127
|
+
key bar is ~50px tall; nudge up a bit more for breathing room. */
|
|
128
|
+
body.kb-open .mobile-nav-fab { transform: translateY(-60px); }
|
|
117
129
|
/* No translateY on hover — would fight the inline left/bottom we set
|
|
118
130
|
on every pointermove during drag, making the FAB jitter under the
|
|
119
131
|
finger as :hover toggles on/off. Background-only hover for desktop
|
|
@@ -141,7 +153,7 @@
|
|
|
141
153
|
.mobile-nav-backdrop {
|
|
142
154
|
position: fixed;
|
|
143
155
|
inset: 0;
|
|
144
|
-
z-index: 199; /* below sidebar (200) + fab (
|
|
156
|
+
z-index: 199; /* below sidebar (200) + fab (220), above content */
|
|
145
157
|
background: rgba(26, 24, 21, 0.45);
|
|
146
158
|
backdrop-filter: blur(2px);
|
|
147
159
|
animation: panel-in .15s ease-out;
|
package/public/css/widgets.css
CHANGED
|
@@ -415,15 +415,6 @@
|
|
|
415
415
|
}
|
|
416
416
|
.workdir-detail .filex-body { height: 320px; }
|
|
417
417
|
|
|
418
|
-
.workdir-foot {
|
|
419
|
-
display: flex;
|
|
420
|
-
justify-content: flex-end;
|
|
421
|
-
gap: 8px;
|
|
422
|
-
margin: 4px -20px -18px;
|
|
423
|
-
padding: 12px 20px;
|
|
424
|
-
border-top: 1px solid var(--border-soft);
|
|
425
|
-
background: var(--bg);
|
|
426
|
-
}
|
|
427
418
|
|
|
428
419
|
.icon-radio-sub {
|
|
429
420
|
font-size: 11px;
|
|
@@ -981,13 +972,9 @@
|
|
|
981
972
|
gap: 8px;
|
|
982
973
|
padding: 4px 0;
|
|
983
974
|
}
|
|
984
|
-
.
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
gap: 6px;
|
|
988
|
-
margin-top: 4px;
|
|
989
|
-
}
|
|
990
|
-
.entity-test-button { margin-right: auto; }
|
|
975
|
+
/* EntityFormModal's actions live in the modal footer (.modal-foot) now;
|
|
976
|
+
this keeps the Test button pushed to the left of Cancel/Save there. */
|
|
977
|
+
.modal-foot .entity-test-button { margin-right: auto; }
|
|
991
978
|
|
|
992
979
|
.entity-test-result {
|
|
993
980
|
margin-top: 4px;
|
|
@@ -2583,10 +2570,12 @@
|
|
|
2583
2570
|
letter-spacing: 0.08em;
|
|
2584
2571
|
padding: 1px 7px;
|
|
2585
2572
|
border-radius: 999px;
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2573
|
+
/* Brand-accent chip, not the ink slab. var(--ink) flips to a light cream
|
|
2574
|
+
in dark mode, which made this a glaring white pill; the accent is a
|
|
2575
|
+
saturated mid-tone that's distinct (eye lands on it) and identical in
|
|
2576
|
+
both themes. */
|
|
2577
|
+
background: var(--accent);
|
|
2578
|
+
color: #fff;
|
|
2590
2579
|
font-variant-numeric: tabular-nums;
|
|
2591
2580
|
}
|
|
2592
2581
|
.remote-device-name { min-width: 0; }
|
|
@@ -65,9 +65,21 @@ export function EntityFormModal({
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const footer = html`
|
|
69
|
+
${onTest ? html`
|
|
70
|
+
<button type="button" class="action small subtle entity-test-button"
|
|
71
|
+
disabled=${testing} onClick=${runTest}>
|
|
72
|
+
${testing ? 'Testing…' : testLabel}
|
|
73
|
+
</button>` : null}
|
|
74
|
+
<button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
|
|
75
|
+
<button type="submit" form="entity-form-modal" class=${`action small ${danger ? 'danger' : 'primary'}`}
|
|
76
|
+
disabled=${saving}>
|
|
77
|
+
${saving ? 'Saving…' : submitLabel}
|
|
78
|
+
</button>`;
|
|
79
|
+
|
|
68
80
|
return html`
|
|
69
|
-
<${Modal} title=${title} onClose=${onClose} width=${440}>
|
|
70
|
-
<form class="entity-form" onSubmit=${submit}>
|
|
81
|
+
<${Modal} title=${title} onClose=${onClose} width=${440} footer=${footer}>
|
|
82
|
+
<form id="entity-form-modal" class="entity-form" onSubmit=${submit}>
|
|
71
83
|
${fields.map((f) => html`
|
|
72
84
|
<label class="entity-field" key=${f.key}>
|
|
73
85
|
<span class="entity-field-label">${f.label}</span>
|
|
@@ -130,18 +142,6 @@ export function EntityFormModal({
|
|
|
130
142
|
${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
|
|
131
143
|
${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
|
|
132
144
|
</div>` : null}
|
|
133
|
-
<div class="entity-form-actions">
|
|
134
|
-
${onTest ? html`
|
|
135
|
-
<button type="button" class="action small subtle entity-test-button"
|
|
136
|
-
disabled=${testing} onClick=${runTest}>
|
|
137
|
-
${testing ? 'Testing…' : testLabel}
|
|
138
|
-
</button>` : null}
|
|
139
|
-
<button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
|
|
140
|
-
<button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
|
|
141
|
-
disabled=${saving}>
|
|
142
|
-
${saving ? 'Saving…' : submitLabel}
|
|
143
|
-
</button>
|
|
144
|
-
</div>
|
|
145
145
|
</form>
|
|
146
146
|
</${Modal}>`;
|
|
147
147
|
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// Centered modal dialog with backdrop. Closes via Esc, the corner X,
|
|
2
2
|
// or a click on the backdrop.
|
|
3
3
|
//
|
|
4
|
-
// <${Modal} onClose=${close} title="Choose CLI" width=${440}
|
|
5
|
-
//
|
|
4
|
+
// <${Modal} onClose=${close} title="Choose CLI" width=${440}
|
|
5
|
+
// footer=${html`<button ...>Cancel</button> ...`}>
|
|
6
|
+
// ...body (scrolls)...
|
|
6
7
|
// </${Modal}>
|
|
8
|
+
//
|
|
9
|
+
// When `footer` is given it renders in a fixed .modal-foot below the
|
|
10
|
+
// scrollable body — the body grows/scrolls between a pinned head and a
|
|
11
|
+
// pinned footer (the .modal is a flex column capped at 90vh).
|
|
7
12
|
|
|
8
13
|
import { html } from '../html.js';
|
|
9
14
|
import { useEffect, useRef } from 'preact/hooks';
|
|
10
15
|
import { createPortal } from 'preact/compat';
|
|
11
16
|
|
|
12
|
-
export function Modal({ title, width = 440, onClose, children }) {
|
|
17
|
+
export function Modal({ title, width = 440, onClose, children, footer }) {
|
|
13
18
|
const panelRef = useRef(null);
|
|
14
19
|
|
|
15
20
|
useEffect(() => {
|
|
@@ -44,6 +49,7 @@ export function Modal({ title, width = 440, onClose, children }) {
|
|
|
44
49
|
</button>
|
|
45
50
|
</div>` : null}
|
|
46
51
|
<div class="modal-body">${children}</div>
|
|
52
|
+
${footer ? html`<div class="modal-foot">${footer}</div>` : null}
|
|
47
53
|
</div>
|
|
48
54
|
</div>`,
|
|
49
55
|
document.body
|
|
@@ -215,50 +215,74 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
215
215
|
if (dev) params.set('device', dev);
|
|
216
216
|
const qs = params.toString();
|
|
217
217
|
const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
// Auto-reconnect. Mobile networks drop the WS constantly (radio sleep,
|
|
219
|
+
// cell↔wifi handoff, tab backgrounding) — leaving a dead "[disconnected]"
|
|
220
|
+
// terminal is the #1 mobile annoyance. We retry with capped backoff and
|
|
221
|
+
// re-attach to the same PTY. The server replays its FULL history on every
|
|
222
|
+
// attach (lib/webTerminal.js), so on a reconnect we reset the screen
|
|
223
|
+
// first, otherwise the replay stacks on top of what's already shown.
|
|
224
|
+
let closedByUs = false;
|
|
225
|
+
let reconnectTimer = null;
|
|
226
|
+
let attempts = 0;
|
|
227
|
+
let everOpened = false;
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
229
|
+
const connect = () => {
|
|
230
|
+
const ws = new WebSocket(wsUrl);
|
|
231
|
+
ws.binaryType = 'arraybuffer';
|
|
232
|
+
wsRef.current = ws;
|
|
233
|
+
|
|
234
|
+
ws.onopen = () => {
|
|
235
|
+
if (everOpened) {
|
|
236
|
+
// Reconnect: clear so the replayed history repopulates cleanly.
|
|
237
|
+
try { term.reset(); } catch {}
|
|
238
|
+
}
|
|
239
|
+
everOpened = true;
|
|
240
|
+
attempts = 0;
|
|
241
|
+
// Fit synchronously before sending cols/rows — the handshake often
|
|
242
|
+
// completes before the rAF-scheduled fit, so without this we'd ship
|
|
243
|
+
// the default 80x24 and claude would wrap its prompt at 80 cols.
|
|
244
|
+
scheduleFit();
|
|
245
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
246
|
+
};
|
|
247
|
+
ws.onmessage = (ev) => {
|
|
248
|
+
let frame;
|
|
249
|
+
try { frame = JSON.parse(ev.data); } catch { return; }
|
|
250
|
+
if (frame.type === 'output') {
|
|
251
|
+
term.write(frame.data);
|
|
252
|
+
} else if (frame.type === 'exit') {
|
|
253
|
+
term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
ws.onclose = (ev) => {
|
|
257
|
+
if (closedByUs) return;
|
|
258
|
+
// Displaced by another client (latest-wins, code 4001) — reconnecting
|
|
259
|
+
// would just ping-pong, so show the "Take it back" pane instead.
|
|
260
|
+
if (ev && ev.code === 4001) { setDisplaced(true); return; }
|
|
261
|
+
// PTY is gone (server restarted / session ended, code 4404) — a
|
|
262
|
+
// reconnect can't revive it; the session needs a full resume.
|
|
263
|
+
if (ev && ev.code === 4404) {
|
|
264
|
+
term.write('\r\n\x1b[2m[session ended]\x1b[0m\r\n');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Network blip — retry with backoff (0.5/1/2/4/8s cap), indefinitely
|
|
268
|
+
// until the effect tears down (cleanup flips closedByUs).
|
|
269
|
+
attempts++;
|
|
270
|
+
const delay = Math.min(8000, 500 * 2 ** Math.min(attempts - 1, 4));
|
|
271
|
+
term.write('\r\n\x1b[2m[disconnected · reconnecting…]\x1b[0m\r\n');
|
|
272
|
+
reconnectTimer = setTimeout(() => { if (!closedByUs) connect(); }, delay);
|
|
273
|
+
};
|
|
255
274
|
};
|
|
275
|
+
connect();
|
|
256
276
|
|
|
277
|
+
// onData/onResize read wsRef.current (not a captured socket) so they keep
|
|
278
|
+
// working across reconnects.
|
|
257
279
|
const onData = (data) => {
|
|
258
|
-
|
|
280
|
+
const ws = wsRef.current;
|
|
281
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
259
282
|
};
|
|
260
283
|
const onResize = ({ cols, rows }) => {
|
|
261
|
-
|
|
284
|
+
const ws = wsRef.current;
|
|
285
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
262
286
|
};
|
|
263
287
|
term.onData(onData);
|
|
264
288
|
term.onResize(onResize);
|
|
@@ -458,7 +482,9 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
458
482
|
if (panelMo) panelMo.disconnect();
|
|
459
483
|
vv?.removeEventListener?.('resize', onVisualResize);
|
|
460
484
|
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
461
|
-
|
|
485
|
+
closedByUs = true;
|
|
486
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
487
|
+
try { wsRef.current?.close(); } catch {}
|
|
462
488
|
try { term.dispose(); } catch {}
|
|
463
489
|
termRef.current = null;
|
|
464
490
|
wsRef.current = null;
|
package/public/js/main.js
CHANGED
|
@@ -156,6 +156,26 @@ function syncTitlebarHeight() {
|
|
|
156
156
|
syncTitlebarHeight();
|
|
157
157
|
navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitlebarHeight);
|
|
158
158
|
|
|
159
|
+
// Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
|
|
160
|
+
// when the on-screen keyboard slides up — only `visualViewport` does — so a
|
|
161
|
+
// full-height terminal keeps its bottom rows hidden behind the keyboard. We
|
|
162
|
+
// publish the visible height as --app-vh (used by .app.is-mobile in
|
|
163
|
+
// responsive.css to shrink the whole app to the area above the keyboard)
|
|
164
|
+
// and flag body.kb-open when the keyboard is up (so the terminal can reserve
|
|
165
|
+
// room for the floating key bar). cap at a 120px delta so a browser
|
|
166
|
+
// URL-bar collapse doesn't read as a keyboard.
|
|
167
|
+
function syncViewportHeight() {
|
|
168
|
+
const vv = window.visualViewport;
|
|
169
|
+
if (!vv) return;
|
|
170
|
+
document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
|
|
171
|
+
const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
|
|
172
|
+
document.body.classList.toggle('kb-open', kbUp);
|
|
173
|
+
}
|
|
174
|
+
syncViewportHeight();
|
|
175
|
+
window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
|
|
176
|
+
window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
|
|
177
|
+
window.addEventListener('resize', syncViewportHeight);
|
|
178
|
+
|
|
159
179
|
(async () => {
|
|
160
180
|
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
161
181
|
// upgraded since this per-version frontend was loaded, bounce back to
|
|
@@ -6,7 +6,7 @@ import { html } from '../html.js';
|
|
|
6
6
|
import { useState, useEffect } from 'preact/hooks';
|
|
7
7
|
import { signal } from '@preact/signals';
|
|
8
8
|
import { config, folders, selectSession, selectTab } from '../state.js';
|
|
9
|
-
import { createCli, createFolder, createRepo,
|
|
9
|
+
import { createCli, createFolder, createRepo, refreshAll } from '../api.js';
|
|
10
10
|
import { setToast } from '../toast.js';
|
|
11
11
|
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
12
12
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
@@ -15,7 +15,6 @@ import { Modal } from '../components/Modal.js';
|
|
|
15
15
|
import { PickerPanel } from '../components/Picker.js';
|
|
16
16
|
import { DirectoryPicker } from '../components/DirectoryPicker.js';
|
|
17
17
|
import { AdoptModal } from '../components/AdoptModal.js';
|
|
18
|
-
import { useDragSort } from '../components/useDragSort.js';
|
|
19
18
|
import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSparkle, IconWorkspace, IconArrowRight } from '../icons.js';
|
|
20
19
|
|
|
21
20
|
const ROOT_ID = 'newSessionProgress';
|
|
@@ -95,13 +94,6 @@ function LaunchHero() {
|
|
|
95
94
|
});
|
|
96
95
|
}, [cliId, folderId, mode, cwd, selectedRepos.value]);
|
|
97
96
|
|
|
98
|
-
const folderDnd = useDragSort(
|
|
99
|
-
folders.value.map((f) => f.id),
|
|
100
|
-
async (nextIds) => {
|
|
101
|
-
try { await reorderFolders(nextIds); }
|
|
102
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
103
|
-
},
|
|
104
|
-
);
|
|
105
97
|
|
|
106
98
|
const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
|
|
107
99
|
useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
|
|
@@ -205,8 +197,8 @@ function LaunchHero() {
|
|
|
205
197
|
|
|
206
198
|
// --- Folder picker config --------------------------------------------
|
|
207
199
|
const folderItems = [
|
|
208
|
-
{ id: '', label: 'Unsorted', meta: 'no folder', undraggable: true },
|
|
209
|
-
...folders.value.map((f) => ({ id: f.id, label: f.name })),
|
|
200
|
+
{ id: '', label: 'Unsorted', meta: 'no folder', undraggable: true, icon: html`<${IconFolderOpen} />` },
|
|
201
|
+
...folders.value.map((f) => ({ id: f.id, label: f.name, icon: html`<${IconFolder} />` })),
|
|
210
202
|
];
|
|
211
203
|
const folderCreateFields = [
|
|
212
204
|
{ key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
|
|
@@ -281,7 +273,14 @@ function LaunchHero() {
|
|
|
281
273
|
<span class="pill-chev"><${IconChevronDown} /></span>
|
|
282
274
|
</button>
|
|
283
275
|
${openPicker === 'workdir' ? html`
|
|
284
|
-
<${Modal} title="Working directory" onClose=${close} width=${640}
|
|
276
|
+
<${Modal} title="Working directory" onClose=${close} width=${640}
|
|
277
|
+
footer=${html`
|
|
278
|
+
<button type="button" class="action subtle" onClick=${close}>Cancel</button>
|
|
279
|
+
<button type="button" class="action primary"
|
|
280
|
+
disabled=${mode === 'cwd' && !cwd}
|
|
281
|
+
onClick=${close}>
|
|
282
|
+
${mode === 'cwd' ? 'Use folder' : 'Done'}
|
|
283
|
+
</button>`}>
|
|
285
284
|
<div class="workdir-modal">
|
|
286
285
|
<div class="workdir-mode-grid">
|
|
287
286
|
<button type="button"
|
|
@@ -321,14 +320,6 @@ function LaunchHero() {
|
|
|
321
320
|
onPick=${(p) => { setCwd(p); }} />
|
|
322
321
|
`}
|
|
323
322
|
</div>
|
|
324
|
-
<div class="workdir-foot">
|
|
325
|
-
<button type="button" class="action subtle" onClick=${close}>Cancel</button>
|
|
326
|
-
<button type="button" class="action primary"
|
|
327
|
-
disabled=${mode === 'cwd' && !cwd}
|
|
328
|
-
onClick=${close}>
|
|
329
|
-
${mode === 'cwd' ? 'Use folder' : 'Done'}
|
|
330
|
-
</button>
|
|
331
|
-
</div>
|
|
332
323
|
</div>
|
|
333
324
|
</${Modal}>` : null}
|
|
334
325
|
|
|
@@ -344,7 +335,6 @@ function LaunchHero() {
|
|
|
344
335
|
<${Modal} title="Choose folder" onClose=${close} width=${400}>
|
|
345
336
|
<${PickerPanel} items=${folderItems} selectedId=${folderId}
|
|
346
337
|
showSearch=${false}
|
|
347
|
-
dnd=${folderDnd}
|
|
348
338
|
onSelect=${(id) => setFolderId(id)}
|
|
349
339
|
onCreate=${async (v) => {
|
|
350
340
|
try {
|