@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.20.1",
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,18 +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
  }
25
- /* .fab base is var(--ink) (a light slab in dark mode) with var(--bg-elev)
26
- text; its hover hardcoded #000, which would render dark text on black.
27
- Send the hover lighter instead, matching .action.primary. */
28
- [data-theme="dark"] .fab:hover { background: #ffffff; }
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
- /* 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. */
40
65
  [data-theme="dark"] select {
41
- 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>");
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
@@ -76,7 +76,14 @@
76
76
 
77
77
  .input, input[type="text"], input[type="number"], select, textarea {
78
78
  appearance: none;
79
- 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);
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
- 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>");
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
- .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;
@@ -2583,10 +2570,12 @@
2583
2570
  letter-spacing: 0.08em;
2584
2571
  padding: 1px 7px;
2585
2572
  border-radius: 999px;
2586
- background: var(--ink);
2587
- /* var(--bg-elev) tracks the --ink slab (light in dark mode) so the code
2588
- stays legible; #fff would be white-on-light. */
2589
- color: var(--bg-elev);
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
- // ...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;
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, 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 {