@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 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.). Next probe() re-shells.
293
- function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
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: await probe(),
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.0",
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="currentColor" 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>
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>
@@ -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
- /* .action.primary is bg:var(--ink)/text:var(--bg-elev) already inverts
18
- correctly (light slab, dark text) when the vars flip. Only its hover
19
- hardcoded #000, which would darken the wrong way; send it brighter. */
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: #ffffff;
22
- border-color: #ffffff;
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
- /* The select chevron SVG is baked with a mid-gray stroke; lighten it. */
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 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23b4ab98' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
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
@@ -202,39 +202,50 @@
202
202
  text-align: center;
203
203
  }
204
204
 
205
- /* RestartOverlay — small top-of-viewport pill (not fullscreen) while
206
- a user-initiated backend restart is in flight. Slides down from the
207
- top, slides out on dismiss. Pinned center so it survives PWA WCO /
208
- standalone window-control overlays without bumping into them. */
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
- top: env(titlebar-area-height, 16px);
212
- left: 50%;
213
- transform: translateX(-50%);
215
+ bottom: var(--s-5);
216
+ right: var(--s-5);
214
217
  z-index: 1200;
215
- display: inline-flex;
218
+ display: flex;
216
219
  align-items: center;
217
- gap: 10px;
218
- padding: 8px 16px;
219
- border-radius: 999px;
220
+ gap: 12px;
221
+ max-width: 380px;
222
+ padding: 11px 16px 11px 14px;
220
223
  background: var(--ink);
221
- color: #fff;
224
+ color: var(--bg);
225
+ border-radius: 6px;
222
226
  font-size: 12.5px;
223
- font-weight: 500;
227
+ font-weight: 400;
224
228
  letter-spacing: -0.005em;
225
- box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
226
- animation: restart-banner-in .18s ease-out;
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 restart-banner-in {
229
- from { opacity: 0; transform: translate(-50%, -8px); }
230
- to { opacity: 1; transform: translate(-50%, 0); }
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: 12px;
234
- height: 12px;
241
+ width: 13px;
242
+ height: 13px;
243
+ flex-shrink: 0;
235
244
  border-radius: 50%;
236
- border: 2px solid rgba(255, 255, 255, 0.25);
237
- border-top-color: #fff;
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
- background: rgba(255, 255, 255, 0.10);
316
- color: rgba(255, 255, 255, 0.75);
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
  }
@@ -63,7 +63,11 @@
63
63
  .action.danger {
64
64
  background: var(--red);
65
65
  border-color: var(--red);
66
- color: var(--bg-elev);
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: var(--bg-elev);
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
- background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%238a8475' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
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 14px;
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
- .app.is-mobile { grid-template-columns: 1fr !important; }
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: 210;
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 (210), above content */
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;
@@ -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
- .entity-form-actions {
985
- display: flex;
986
- justify-content: flex-end;
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
- color: #fff;
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: rgba(255, 255, 255, 0.22);
1342
- color: #fff;
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
- background: var(--ink);
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
- // ...body...
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
- const ws = new WebSocket(wsUrl);
219
- ws.binaryType = 'arraybuffer';
220
- wsRef.current = ws;
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
- ws.onopen = () => {
223
- // Fit synchronously here before reading cols/rows. On localhost the
224
- // WS handshake usually completes within a few ms — well before the
225
- // rAF-scheduled initial fit runs — so without this we'd ship the
226
- // xterm.js default 80x24 to the PTY, claude would print its prompt
227
- // wrapped at 80 cols, and the follow-up resize from the rAF fit
228
- // wouldn't reflow the already-emitted bytes. Visible as squeezed
229
- // text on every session switch.
230
- scheduleFit();
231
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
232
- };
233
- ws.onmessage = (ev) => {
234
- let frame;
235
- try { frame = JSON.parse(ev.data); } catch { return; }
236
- if (frame.type === 'output') {
237
- term.write(frame.data);
238
- } else if (frame.type === 'exit') {
239
- term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
240
- }
241
- };
242
- ws.onclose = (ev) => {
243
- // Server uses code 4001 + reason "displaced by another client"
244
- // when a fresh attach takes over the session (latest-wins policy
245
- // in lib/webTerminal.js's attach). We replace the terminal with
246
- // a full-pane prompt + Take it back button via setDisplaced(true).
247
- // Generic disconnects (network blip, server restart, PTY exit)
248
- // get the dim inline notice as before — those usually self-heal
249
- // and aren't worth a modal.
250
- if (ev && ev.code === 4001) {
251
- setDisplaced(true);
252
- } else {
253
- term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
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
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
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
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
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
- try { ws.close(); } catch {}
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;
@@ -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)}>${o.label}</button>`)}
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, reorderFolders, refreshAll } from '../api.js';
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
- async function refresh() {
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 [s, devs] = await Promise.all([
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`); refresh(); setToast('Device approved', 'ok'); }
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`); refresh(); setToast('Device rejected', 'ok'); }
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)}`); refresh(); setToast('Device deleted', 'ok'); }
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`); refresh(); setToast('Access revoked', 'ok'); }
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() }); refresh(); }
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