@bakapiano/ccsm 0.20.0 → 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/lib/tunnel.js +29 -3
- package/package.json +1 -1
- package/public/assets/copilot-color.svg +1 -1
- package/public/css/dark.css +52 -7
- package/public/css/feedback.css +38 -24
- package/public/css/forms.css +22 -4
- package/public/css/responsive.css +16 -4
- package/public/css/widgets.css +13 -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/icons.js +9 -0
- package/public/js/main.js +20 -0
- package/public/js/pages/ConfigurePage.js +7 -5
- package/public/js/pages/LaunchPage.js +11 -21
- package/public/js/pages/RemotePage.js +20 -11
package/lib/tunnel.js
CHANGED
|
@@ -288,13 +288,39 @@ async function probe(force = false) {
|
|
|
288
288
|
return probeCache;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Kick a single background probe refresh, deduped so overlapping callers
|
|
292
|
+
// share one shell-out. Never throws.
|
|
293
|
+
let probeRefreshing = null;
|
|
294
|
+
function kickProbeRefresh() {
|
|
295
|
+
if (!probeRefreshing) {
|
|
296
|
+
probeRefreshing = probe(true)
|
|
297
|
+
.catch(() => probeCache)
|
|
298
|
+
.finally(() => { probeRefreshing = null; });
|
|
299
|
+
}
|
|
300
|
+
return probeRefreshing;
|
|
301
|
+
}
|
|
302
|
+
|
|
291
303
|
// Invalidate the cache when callers know the on-disk state likely changed
|
|
292
|
-
// (post-install, post-login, etc.)
|
|
293
|
-
|
|
304
|
+
// (post-install, post-login, etc.) and immediately start repopulating it
|
|
305
|
+
// in the background so the next status poll is already fresh.
|
|
306
|
+
function invalidateProbe() { probeCache = null; probeCacheAt = 0; kickProbeRefresh(); }
|
|
307
|
+
|
|
308
|
+
// Stale-while-revalidate accessor used by status(). NEVER shells out in
|
|
309
|
+
// the request path: returns whatever's cached right now (possibly stale,
|
|
310
|
+
// or null on the very first call before the boot prewarm lands) and kicks
|
|
311
|
+
// off a background refresh when the cache is stale. This is what keeps
|
|
312
|
+
// /api/tunnel/status — and therefore the whole Remote page's live refresh
|
|
313
|
+
// (plus the device list, which the client used to bundle into the same
|
|
314
|
+
// round-trip) — from stalling ~700ms every time the 30s cache expires.
|
|
315
|
+
function probeCachedSWR() {
|
|
316
|
+
const fresh = probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS;
|
|
317
|
+
if (!fresh) kickProbeRefresh();
|
|
318
|
+
return probeCache;
|
|
319
|
+
}
|
|
294
320
|
|
|
295
321
|
async function status() {
|
|
296
322
|
return {
|
|
297
|
-
providers:
|
|
323
|
+
providers: probeCachedSWR(),
|
|
298
324
|
running: !!current,
|
|
299
325
|
provider: current?.provider || null,
|
|
300
326
|
url: current?.url || null,
|
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,14 +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
|
}
|
|
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
|
+
}
|
|
25
52
|
/* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
|
|
26
53
|
ground — switch to a light wash so the affordance stays visible. */
|
|
27
54
|
[data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
|
|
@@ -32,9 +59,11 @@
|
|
|
32
59
|
[data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
|
|
33
60
|
[data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
|
|
34
61
|
|
|
35
|
-
/*
|
|
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. */
|
|
36
65
|
[data-theme="dark"] select {
|
|
37
|
-
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>");
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
/* ── brand mark ──────────────────────────────────────────────────── */
|
|
@@ -45,6 +74,22 @@
|
|
|
45
74
|
fill untouched — it's already legible there.) */
|
|
46
75
|
[data-theme="dark"] .brand-rect { fill: #38342f; }
|
|
47
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
|
+
|
|
48
93
|
/* ── paper grain ─────────────────────────────────────────────────── */
|
|
49
94
|
/* The noise texture is a dark-tinted SVG multiplied over the surface —
|
|
50
95
|
invisible (and wrong blend) on a dark ground. Screen-blend it at low
|
package/public/css/feedback.css
CHANGED
|
@@ -202,39 +202,50 @@
|
|
|
202
202
|
text-align: center;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
/* RestartOverlay —
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
/* RestartOverlay — a transient pill shown while a user-initiated backend
|
|
206
|
+
restart is in flight. Styled as a toast and pinned to the same
|
|
207
|
+
bottom-right slot so every floating notification in the app lives in
|
|
208
|
+
one place and reads the same. (It only ever coexists with a real toast
|
|
209
|
+
on restart *failure*, by which point the banner has already unmounted,
|
|
210
|
+
so sharing the slot doesn't collide.) Colors track the theme via
|
|
211
|
+
var(--ink)/var(--bg) — the old hardcoded #fff text went invisible on
|
|
212
|
+
the light pill dark mode produces. */
|
|
209
213
|
.restart-banner {
|
|
210
214
|
position: fixed;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
transform: translateX(-50%);
|
|
215
|
+
bottom: var(--s-5);
|
|
216
|
+
right: var(--s-5);
|
|
214
217
|
z-index: 1200;
|
|
215
|
-
display:
|
|
218
|
+
display: flex;
|
|
216
219
|
align-items: center;
|
|
217
|
-
gap:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
gap: 12px;
|
|
221
|
+
max-width: 380px;
|
|
222
|
+
padding: 11px 16px 11px 14px;
|
|
220
223
|
background: var(--ink);
|
|
221
|
-
color:
|
|
224
|
+
color: var(--bg);
|
|
225
|
+
border-radius: 6px;
|
|
222
226
|
font-size: 12.5px;
|
|
223
|
-
font-weight:
|
|
227
|
+
font-weight: 400;
|
|
224
228
|
letter-spacing: -0.005em;
|
|
225
|
-
|
|
226
|
-
|
|
229
|
+
line-height: 1.45;
|
|
230
|
+
box-shadow:
|
|
231
|
+
0 10px 32px -8px rgba(26, 24, 21, 0.30),
|
|
232
|
+
0 2px 6px rgba(26, 24, 21, 0.12),
|
|
233
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
234
|
+
animation: toast-pop-in .28s cubic-bezier(.34, 1.4, .64, 1);
|
|
227
235
|
}
|
|
228
|
-
@keyframes
|
|
229
|
-
from { opacity: 0; transform:
|
|
230
|
-
to { opacity: 1; transform:
|
|
236
|
+
@keyframes toast-pop-in {
|
|
237
|
+
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
|
238
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
231
239
|
}
|
|
232
240
|
.restart-banner-spinner {
|
|
233
|
-
width:
|
|
234
|
-
height:
|
|
241
|
+
width: 13px;
|
|
242
|
+
height: 13px;
|
|
243
|
+
flex-shrink: 0;
|
|
235
244
|
border-radius: 50%;
|
|
236
|
-
|
|
237
|
-
|
|
245
|
+
/* currentColor = the pill's text color (var(--bg)), so the spinner
|
|
246
|
+
stays legible in both themes without a hardcoded white. */
|
|
247
|
+
border: 2px solid color-mix(in srgb, currentColor 28%, transparent);
|
|
248
|
+
border-top-color: currentColor;
|
|
238
249
|
animation: restart-spin 0.7s linear infinite;
|
|
239
250
|
}
|
|
240
251
|
.restart-banner-text { white-space: nowrap; }
|
|
@@ -312,8 +323,11 @@
|
|
|
312
323
|
font-weight: 500;
|
|
313
324
|
padding: 3px 6px;
|
|
314
325
|
border-radius: 4px;
|
|
315
|
-
|
|
316
|
-
|
|
326
|
+
/* currentColor = the pill's text (var(--bg)), so the default chip stays
|
|
327
|
+
visible whether the pill is dark (light theme) or light (dark theme).
|
|
328
|
+
The .ok / .error variants below override with their own colored bg. */
|
|
329
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
330
|
+
color: currentColor;
|
|
317
331
|
flex-shrink: 0;
|
|
318
332
|
line-height: 1;
|
|
319
333
|
}
|
package/public/css/forms.css
CHANGED
|
@@ -63,7 +63,11 @@
|
|
|
63
63
|
.action.danger {
|
|
64
64
|
background: var(--red);
|
|
65
65
|
border-color: var(--red);
|
|
66
|
-
|
|
66
|
+
/* Always light text — the danger red is dark in both themes, so it must
|
|
67
|
+
NOT follow --bg-elev (which is a dark surface in dark mode and would
|
|
68
|
+
render black text on red). In light mode --bg-elev was #fff anyway,
|
|
69
|
+
so this is identical there. */
|
|
70
|
+
color: #fff;
|
|
67
71
|
}
|
|
68
72
|
.action.danger:hover {
|
|
69
73
|
background: #9a3636;
|
|
@@ -72,7 +76,14 @@
|
|
|
72
76
|
|
|
73
77
|
.input, input[type="text"], input[type="number"], select, textarea {
|
|
74
78
|
appearance: none;
|
|
75
|
-
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);
|
|
76
87
|
border: 1px solid var(--border-strong);
|
|
77
88
|
color: var(--ink);
|
|
78
89
|
padding: 8px 12px;
|
|
@@ -90,7 +101,10 @@
|
|
|
90
101
|
box-shadow: 0 0 0 3px rgba(26, 24, 21, 0.08);
|
|
91
102
|
}
|
|
92
103
|
select {
|
|
93
|
-
|
|
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>");
|
|
94
108
|
background-repeat: no-repeat;
|
|
95
109
|
background-position: right 10px center;
|
|
96
110
|
background-size: 10px;
|
|
@@ -121,11 +135,15 @@ textarea {
|
|
|
121
135
|
font-family: var(--body);
|
|
122
136
|
font-size: 12.5px;
|
|
123
137
|
font-weight: 500;
|
|
124
|
-
padding: 5px
|
|
138
|
+
padding: 5px 13px;
|
|
125
139
|
border-radius: 6px;
|
|
126
140
|
cursor: pointer;
|
|
127
141
|
transition: background .14s ease, color .14s ease;
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: 6px;
|
|
128
145
|
}
|
|
146
|
+
.seg-btn svg { width: 14px; height: 14px; opacity: 0.85; }
|
|
129
147
|
.seg-btn:hover { color: var(--ink); }
|
|
130
148
|
.seg-btn.is-active {
|
|
131
149
|
background: var(--bg-elev);
|
|
@@ -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;
|
|
@@ -1334,12 +1321,14 @@
|
|
|
1334
1321
|
.adopt-tab.is-active {
|
|
1335
1322
|
background: var(--ink);
|
|
1336
1323
|
border-color: var(--ink);
|
|
1337
|
-
|
|
1324
|
+
/* var(--bg-elev), not #fff — the slab is var(--ink), which is LIGHT in
|
|
1325
|
+
dark mode, so white text would be invisible. --bg-elev tracks it. */
|
|
1326
|
+
color: var(--bg-elev);
|
|
1338
1327
|
font-weight: 500;
|
|
1339
1328
|
}
|
|
1340
1329
|
.adopt-tab.is-active .adopt-tab-count {
|
|
1341
|
-
background:
|
|
1342
|
-
color:
|
|
1330
|
+
background: color-mix(in srgb, var(--bg-elev) 22%, transparent);
|
|
1331
|
+
color: var(--bg-elev);
|
|
1343
1332
|
}
|
|
1344
1333
|
.adopt-tab-icon { display: inline-flex; width: 16px; height: 16px; }
|
|
1345
1334
|
.adopt-tab-icon svg { width: 100%; height: 100%; }
|
|
@@ -2581,7 +2570,11 @@
|
|
|
2581
2570
|
letter-spacing: 0.08em;
|
|
2582
2571
|
padding: 1px 7px;
|
|
2583
2572
|
border-radius: 999px;
|
|
2584
|
-
|
|
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);
|
|
2585
2578
|
color: #fff;
|
|
2586
2579
|
font-variant-numeric: tabular-nums;
|
|
2587
2580
|
}
|
|
@@ -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/icons.js
CHANGED
|
@@ -113,6 +113,15 @@ export const IconMonitor = ic('0 0 24 24', html`
|
|
|
113
113
|
<line x1="12" y1="16" x2="12" y2="20"/>
|
|
114
114
|
`, 13);
|
|
115
115
|
|
|
116
|
+
// Light / dark theme glyphs for the Appearance toggle.
|
|
117
|
+
export const IconSun = ic('0 0 24 24', html`
|
|
118
|
+
<circle cx="12" cy="12" r="4"/>
|
|
119
|
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
|
120
|
+
`, 14);
|
|
121
|
+
export const IconMoon = ic('0 0 24 24', html`
|
|
122
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
123
|
+
`, 14);
|
|
124
|
+
|
|
116
125
|
// "> _" terminal prompt — for the Terminals nav tab
|
|
117
126
|
export const IconTerminal = ic('0 0 24 24', html`
|
|
118
127
|
<polyline points="4 17 10 11 4 5"/>
|
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
|
|
@@ -24,7 +24,7 @@ import { Card } from '../components/Card.js';
|
|
|
24
24
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
25
25
|
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
26
26
|
import { useDragSort } from '../components/useDragSort.js';
|
|
27
|
-
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
|
|
27
|
+
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSun, IconMoon, IconMonitor } from '../icons.js';
|
|
28
28
|
import { parseArgs, formatArgs } from '../util.js';
|
|
29
29
|
|
|
30
30
|
// Tokenize the three free-form args fields into string[] before they hit
|
|
@@ -584,9 +584,9 @@ function RestartButton() {
|
|
|
584
584
|
function ThemeToggle() {
|
|
585
585
|
const mode = themeMode.value;
|
|
586
586
|
const opts = [
|
|
587
|
-
{ id: 'light', label: 'Light' },
|
|
588
|
-
{ id: 'dark', label: 'Dark' },
|
|
589
|
-
{ id: 'system', label: 'System' },
|
|
587
|
+
{ id: 'light', label: 'Light', icon: IconSun },
|
|
588
|
+
{ id: 'dark', label: 'Dark', icon: IconMoon },
|
|
589
|
+
{ id: 'system', label: 'System', icon: IconMonitor },
|
|
590
590
|
];
|
|
591
591
|
return html`
|
|
592
592
|
<div class="seg" role="group" aria-label="Appearance">
|
|
@@ -594,7 +594,9 @@ function ThemeToggle() {
|
|
|
594
594
|
<button key=${o.id} type="button"
|
|
595
595
|
class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
|
|
596
596
|
aria-pressed=${mode === o.id}
|
|
597
|
-
onClick=${() => setThemeMode(o.id)}
|
|
597
|
+
onClick=${() => setThemeMode(o.id)}>
|
|
598
|
+
<${o.icon} /><span>${o.label}</span>
|
|
599
|
+
</button>`)}
|
|
598
600
|
</div>`;
|
|
599
601
|
}
|
|
600
602
|
|
|
@@ -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 {
|
|
@@ -311,14 +311,16 @@ export function RemotePage() {
|
|
|
311
311
|
const [deviceList, setDeviceList] = useState([]);
|
|
312
312
|
const pollRef = useRef(null);
|
|
313
313
|
|
|
314
|
-
|
|
314
|
+
// Tunnel status and the device list are fetched INDEPENDENTLY, not as a
|
|
315
|
+
// bundled Promise.all. /api/tunnel/status can lag behind a cold provider
|
|
316
|
+
// probe; the device list is cheap. Coupling them made the (fast) device
|
|
317
|
+
// list wait on the (slow) status round-trip, so the whole page appeared
|
|
318
|
+
// to refresh in one delayed lump. Now each updates its own state the
|
|
319
|
+
// moment its own fetch lands.
|
|
320
|
+
async function refreshStatus() {
|
|
315
321
|
try {
|
|
316
|
-
const
|
|
317
|
-
api('GET', '/api/tunnel/status'),
|
|
318
|
-
api('GET', '/api/devices').catch(() => ({ devices: [] })),
|
|
319
|
-
]);
|
|
322
|
+
const s = await api('GET', '/api/tunnel/status');
|
|
320
323
|
setStatus(s);
|
|
321
|
-
setDeviceList(devs.devices || []);
|
|
322
324
|
setTokenLocal((cur) => cur || s.token || '');
|
|
323
325
|
setProvider((cur) => {
|
|
324
326
|
if (s.running && s.provider) return s.provider;
|
|
@@ -336,6 +338,13 @@ export function RemotePage() {
|
|
|
336
338
|
} catch {}
|
|
337
339
|
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
338
340
|
}
|
|
341
|
+
async function refreshDevices() {
|
|
342
|
+
try {
|
|
343
|
+
const devs = await api('GET', '/api/devices');
|
|
344
|
+
setDeviceList(devs.devices || []);
|
|
345
|
+
} catch { /* non-critical — keep the last good list on a transient error */ }
|
|
346
|
+
}
|
|
347
|
+
function refresh() { refreshStatus(); refreshDevices(); }
|
|
339
348
|
|
|
340
349
|
useEffect(() => {
|
|
341
350
|
refresh();
|
|
@@ -344,11 +353,11 @@ export function RemotePage() {
|
|
|
344
353
|
}, []);
|
|
345
354
|
|
|
346
355
|
async function onApproveDevice(id) {
|
|
347
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`);
|
|
356
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
|
|
348
357
|
catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
|
|
349
358
|
}
|
|
350
359
|
async function onRejectDevice(id) {
|
|
351
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`);
|
|
360
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
|
|
352
361
|
catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
|
|
353
362
|
}
|
|
354
363
|
async function onDeleteDevice(d) {
|
|
@@ -357,7 +366,7 @@ export function RemotePage() {
|
|
|
357
366
|
{ title: 'Delete device record', okLabel: 'Delete', danger: true },
|
|
358
367
|
);
|
|
359
368
|
if (!ok) return;
|
|
360
|
-
try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`);
|
|
369
|
+
try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
|
|
361
370
|
catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
|
|
362
371
|
}
|
|
363
372
|
async function onRevokeDevice(d) {
|
|
@@ -365,13 +374,13 @@ export function RemotePage() {
|
|
|
365
374
|
title: 'Revoke device', okLabel: 'Revoke', danger: true,
|
|
366
375
|
});
|
|
367
376
|
if (!ok) return;
|
|
368
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`);
|
|
377
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
|
|
369
378
|
catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
|
|
370
379
|
}
|
|
371
380
|
async function onRenameDevice(d) {
|
|
372
381
|
const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
|
|
373
382
|
if (next === null) return;
|
|
374
|
-
try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() });
|
|
383
|
+
try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
|
|
375
384
|
catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
|
|
376
385
|
}
|
|
377
386
|
|