@bakapiano/ccsm 0.18.3 → 0.18.4

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.18.3",
3
+ "version": "0.18.4",
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",
@@ -87,11 +87,13 @@
87
87
  select { font-size: 16px !important; }
88
88
  }
89
89
 
90
- /* FAB + backdrop · sit above page content but BELOW dialogs. */
90
+ /* FAB + backdrop · sit above page content but BELOW dialogs. The
91
+ `left` and `bottom` inline styles come from MobileNavFab.js (drag-
92
+ persisted), so we don't set defaults here — the component seeds
93
+ them on mount. `touch-action: none` stops the browser from also
94
+ scrolling the page while the user is dragging the FAB. */
91
95
  .mobile-nav-fab {
92
96
  position: fixed;
93
- bottom: max(16px, env(safe-area-inset-bottom));
94
- left: max(16px, env(safe-area-inset-left));
95
97
  z-index: 210;
96
98
  width: 52px;
97
99
  height: 52px;
@@ -105,12 +107,18 @@
105
107
  box-shadow:
106
108
  0 10px 24px -6px rgba(0,0,0,.28),
107
109
  0 2px 4px rgba(0,0,0,.10);
108
- cursor: pointer;
109
- transition: transform .15s, box-shadow .15s, background .15s;
110
+ cursor: grab;
111
+ touch-action: none;
112
+ user-select: none;
113
+ transition: box-shadow .15s, background .15s;
110
114
  }
111
- .mobile-nav-fab:hover { transform: translateY(-1px); background: var(--bg); }
112
- .mobile-nav-fab:active { transform: translateY(0); }
113
- .mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; }
115
+ /* No translateY on hover — would fight the inline left/bottom we set
116
+ on every pointermove during drag, making the FAB jitter under the
117
+ finger as :hover toggles on/off. Background-only hover for desktop
118
+ pointers; touch never matches :hover. */
119
+ .mobile-nav-fab:hover { background: var(--bg); }
120
+ .mobile-nav-fab:active { cursor: grabbing; }
121
+ .mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; pointer-events: none; }
114
122
  /* Open state stays the same paper white — only the icon swaps to X.
115
123
  Keeping the colour consistent reads as "same button, different
116
124
  mode" instead of two different controls. */
@@ -1,6 +1,11 @@
1
1
  /* Left collapsible sidebar nav · brand mark · util items · collapse toggle */
2
2
 
3
3
  .sidebar {
4
+ /* One value drives all three "section break" gaps in the sidebar
5
+ column: brand-strip → first nav item, last nav item → "Sessions"
6
+ header, and "Sessions" header → first folder. Bump or shrink to
7
+ adjust how breathy the rail feels. */
8
+ --sidebar-section-gap: var(--s-3);
4
9
  position: sticky;
5
10
  top: 0;
6
11
  height: 100vh;
@@ -262,7 +267,9 @@
262
267
  align-items: center;
263
268
  padding: 0;
264
269
  min-height: 40px;
265
- margin-bottom: var(--s-2);
270
+ /* Sit flush above the first nav item — no extra breathing room
271
+ between the brand strip and the nav list. */
272
+ margin-bottom: 0;
266
273
  }
267
274
  .collapse-toggle {
268
275
  appearance: none;
@@ -337,12 +344,18 @@ body.is-resizing-sidebar * {
337
344
 
338
345
  /* Compact top nav: smaller height + smaller font, so the folder tree
339
346
  below dominates. */
347
+ /* Match the dimensions of .tree-folder-head + .tree-session below so
348
+ the top nav, the folder head, and the session rows form one visually
349
+ continuous column: same left padding (icon at x=8), same icon-label
350
+ gap, same row height, same corner radius. Without this the nav
351
+ icons sit 2px further right than the folder icons and the rows are
352
+ noticeably taller. */
340
353
  .sidebar-nav.compact .nav-item {
341
354
  font-size: 13px;
342
- padding: 4px 10px;
343
- min-height: 30px;
344
- gap: 10px;
345
- border-radius: 6px;
355
+ padding: 4px 8px;
356
+ min-height: 28px;
357
+ gap: 8px;
358
+ border-radius: 4px;
346
359
  position: relative;
347
360
  letter-spacing: -0.005em;
348
361
  transition: background .14s ease, color .14s ease;
@@ -367,7 +380,7 @@ body.is-resizing-sidebar * {
367
380
  /* Tree section header. Looks like codex: uppercase label, small +
368
381
  button on hover. */
369
382
  .tree {
370
- margin-top: var(--s-3);
383
+ margin-top: var(--sidebar-section-gap);
371
384
  display: flex;
372
385
  flex-direction: column;
373
386
  gap: 2px;
@@ -394,6 +407,9 @@ body.is-resizing-sidebar * {
394
407
  font-weight: 500;
395
408
  letter-spacing: 0;
396
409
  color: var(--ink-mid);
410
+ /* No margin-bottom — let the parent .tree's `gap: 2px` carry the
411
+ space below, matching what sits between folder rows. The big gap
412
+ above "Sessions" still comes from .tree margin-top. */
397
413
  }
398
414
  .tree-head-action {
399
415
  appearance: none;
@@ -485,7 +501,11 @@ body.is-resizing-sidebar * {
485
501
  display: none;
486
502
  gap: 2px;
487
503
  }
488
- .tree-folder-head:hover .tree-folder-actions { display: inline-flex; }
504
+ /* Same touch carve-out as session-actions above don't reveal folder
505
+ rename/delete on a tap-emulated hover. */
506
+ @media (hover: hover) and (pointer: fine) {
507
+ .tree-folder-head:hover .tree-folder-actions { display: inline-flex; }
508
+ }
489
509
  .tree-folder-action {
490
510
  appearance: none;
491
511
  background: transparent;
@@ -643,8 +663,18 @@ body.is-resizing-sidebar * {
643
663
  gap: 2px;
644
664
  flex-shrink: 0;
645
665
  }
646
- .tree-session:hover .tree-session-actions { display: inline-flex; }
647
- .tree-session:hover .tree-meta { display: none; }
666
+ /* Hover-only on real-pointer devices. On touch devices the first tap
667
+ emulates :hover (revealing rename/delete), which means the user has
668
+ to tap a second time to actually open the session. (hover: hover)
669
+ matches a real mouse / trackpad; (pointer: fine) further requires
670
+ precise cursor (so hybrid touch+mouse laptops with a stylus get
671
+ hover but pure-touch phones / tablets do not.) Phones keep the
672
+ timestamp visible and never reveal the action buttons here — they
673
+ can use the kebab in the session pane's top bar instead. */
674
+ @media (hover: hover) and (pointer: fine) {
675
+ .tree-session:hover .tree-session-actions { display: inline-flex; }
676
+ .tree-session:hover .tree-meta { display: none; }
677
+ }
648
678
  .tree-session-action {
649
679
  appearance: none;
650
680
  background: transparent;
@@ -6,24 +6,111 @@
6
6
  // full-screen overlay. A backdrop captures taps outside the sidebar
7
7
  // and dismisses.
8
8
  //
9
- // Visible only when isMobile signal is truesaves a render branch
10
- // elsewhere.
9
+ // The FAB is draggablelong-press-and-move lets the user reposition
10
+ // it (the default bottom-left can cover page content). A short tap with
11
+ // no drag still toggles the drawer. Position persists in localStorage
12
+ // so the user doesn't have to re-place it each session.
11
13
 
12
14
  import { html } from '../html.js';
15
+ import { useRef, useState, useEffect } from 'preact/hooks';
13
16
  import { isMobile, mobileDrawerOpen } from '../state.js';
14
17
  import { IconSidebarToggle, IconClose } from '../icons.js';
15
18
 
19
+ const LS_POS = 'ccsm.fab.pos';
20
+ const FAB_SIZE = 52;
21
+ const SAFE_MARGIN = 8;
22
+ // Movement threshold (px) before pointermove counts as a drag instead
23
+ // of a tap. Below this, pointerup fires the toggle and the FAB stays
24
+ // put — matches what a user expects when they meant to "press" the
25
+ // button, not move it.
26
+ const DRAG_HYST_PX = 6;
27
+
28
+ function loadPos() {
29
+ try {
30
+ const raw = localStorage.getItem(LS_POS);
31
+ if (!raw) return null;
32
+ const p = JSON.parse(raw);
33
+ if (typeof p.left === 'number' && typeof p.bottom === 'number') return p;
34
+ } catch {}
35
+ return null;
36
+ }
37
+ function savePos(p) {
38
+ try { localStorage.setItem(LS_POS, JSON.stringify(p)); } catch {}
39
+ }
40
+ function clampPos(p) {
41
+ // Re-clamp on every render so a position saved at one viewport size
42
+ // doesn't trap the FAB off-screen at a smaller size (rotation,
43
+ // resize, etc.).
44
+ const vw = window.innerWidth;
45
+ const vh = window.innerHeight;
46
+ return {
47
+ left: Math.max(SAFE_MARGIN, Math.min(vw - FAB_SIZE - SAFE_MARGIN, p.left)),
48
+ bottom: Math.max(SAFE_MARGIN, Math.min(vh - FAB_SIZE - SAFE_MARGIN, p.bottom)),
49
+ };
50
+ }
51
+
16
52
  export function MobileNavFab() {
17
53
  if (!isMobile.value) return null;
18
54
  const open = mobileDrawerOpen.value;
55
+ const [pos, setPos] = useState(() => loadPos() || { left: 16, bottom: 24 });
56
+ const dragRef = useRef({ start: null, moved: false });
57
+
58
+ // Re-clamp on viewport changes so a rotation doesn't strand the FAB
59
+ // beyond the new edge.
60
+ useEffect(() => {
61
+ const onResize = () => setPos((p) => clampPos(p));
62
+ window.addEventListener('resize', onResize);
63
+ return () => window.removeEventListener('resize', onResize);
64
+ }, []);
65
+
66
+ const onPointerDown = (ev) => {
67
+ ev.currentTarget.setPointerCapture(ev.pointerId);
68
+ dragRef.current = {
69
+ start: { x: ev.clientX, y: ev.clientY, fromPos: pos },
70
+ moved: false,
71
+ };
72
+ };
73
+ const onPointerMove = (ev) => {
74
+ const d = dragRef.current;
75
+ if (!d.start) return;
76
+ const dx = ev.clientX - d.start.x;
77
+ const dy = ev.clientY - d.start.y;
78
+ if (!d.moved && Math.hypot(dx, dy) < DRAG_HYST_PX) return;
79
+ d.moved = true;
80
+ // Stop the page from also scrolling while we drag.
81
+ ev.preventDefault();
82
+ setPos(clampPos({
83
+ // bottom = distance from bottom edge to the FAB's bottom edge.
84
+ // Pointer moved DOWN (+dy) → FAB moves down → bottom decreases.
85
+ left: d.start.fromPos.left + dx,
86
+ bottom: d.start.fromPos.bottom - dy,
87
+ }));
88
+ };
89
+ const onPointerUp = (ev) => {
90
+ const d = dragRef.current;
91
+ try { ev.currentTarget.releasePointerCapture(ev.pointerId); } catch {}
92
+ dragRef.current = { start: null, moved: false };
93
+ if (d.moved) {
94
+ // Drag finished — persist the new resting spot.
95
+ savePos(pos);
96
+ return;
97
+ }
98
+ // No appreciable movement → treat as tap.
99
+ mobileDrawerOpen.value = !open;
100
+ };
101
+
19
102
  return html`
20
103
  ${open ? html`
21
104
  <div class="mobile-nav-backdrop"
22
105
  onClick=${() => { mobileDrawerOpen.value = false; }} />
23
106
  ` : null}
24
107
  <button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
108
+ style=${`left: ${pos.left}px; bottom: ${pos.bottom}px;`}
25
109
  aria-label=${open ? 'close navigation' : 'open navigation'}
26
- onClick=${() => { mobileDrawerOpen.value = !open; }}>
110
+ onPointerDown=${onPointerDown}
111
+ onPointerMove=${onPointerMove}
112
+ onPointerUp=${onPointerUp}
113
+ onPointerCancel=${onPointerUp}>
27
114
  ${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
28
115
  </button>`;
29
116
  }
@@ -140,9 +140,16 @@ function SessionRow({ s, folderId, siblingIds }) {
140
140
  .catch((e) => setToast(e.message, 'error'));
141
141
  };
142
142
 
143
+ // Skip the HTML5 drag affordance on touch devices — `draggable=true`
144
+ // makes mobile browsers interpret the first tap as a drag-start
145
+ // gesture, swallowing the click event entirely. The user then needs
146
+ // a second tap to navigate. Touch users don't reorder sessions by
147
+ // drag anyway; we'd add a dedicated "move to folder" affordance if
148
+ // anyone asked.
149
+ const touchDevice = isMobile.value || (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches);
143
150
  return html`
144
151
  <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}${showInsertLine ? ' is-reorder-target' : ''}`}
145
- draggable=${true}
152
+ draggable=${!touchDevice}
146
153
  onDragStart=${onDragStart}
147
154
  onDragEnd=${onDragEnd}
148
155
  onDragOver=${onRowDragOver}
@@ -32,7 +32,7 @@ const THEME = {
32
32
  white: '#e8e3d5', brightWhite: '#faf9f5',
33
33
  };
34
34
 
35
- export function TerminalView({ terminalId }) {
35
+ export function TerminalView({ terminalId, cliType }) {
36
36
  const hostRef = useRef(null);
37
37
  const termRef = useRef(null);
38
38
  const wsRef = useRef(null);
@@ -280,12 +280,18 @@ export function TerminalView({ terminalId }) {
280
280
  // Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
281
281
  // Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
282
282
  // Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
283
- // protocols (enabled in vtExtensions above) WOULD distinguish them,
284
- // but they're opt-in by the running app claude code doesn't enable
285
- // either, so we never get the distinction "for free".
283
+ // protocols WOULD distinguish them, but they're opt-in by the
284
+ // running app and most CLIs don't enable them, so we never get the
285
+ // distinction "for free". Each CLI handles modified-Enter differently:
286
+ //
287
+ // claude · expects a literal LF (0x0A) — its prompt treats \n
288
+ // as "insert newline", \r as "submit". Workaround = '\n'.
289
+ // codex / others · use ratatui or similar TUI libs that decode
290
+ // the kitty keyboard CSI u sequence. We synthesise it
291
+ // explicitly: `CSI 13 ; <mod> u` where mod = 2 for
292
+ // Shift, 5 for Ctrl. That maps to the exact key+mod
293
+ // the user pressed and ratatui inserts a newline.
286
294
  //
287
- // Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
288
- // treat \n inside a prompt as a literal newline insert, \r as submit.
289
295
  // Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
290
296
  // leave that alone.
291
297
  const onShiftEnter = (ev) => {
@@ -293,11 +299,21 @@ export function TerminalView({ terminalId }) {
293
299
  if (!(ev.shiftKey || ev.ctrlKey)) return;
294
300
  if (ev.metaKey || ev.altKey) return;
295
301
  if (!isOurs()) return;
302
+ // claude → LF (its prompt parses \n as insert-newline).
303
+ // others → ESC+CR i.e. Alt+Enter. crossterm (codex/copilot
304
+ // TUI libs) decodes ESC-prefixed sequences as Alt-modified
305
+ // without needing the kitty keyboard protocol enabled — and
306
+ // codex's default keymap binds Alt+Enter to insert_newline
307
+ // alongside Shift+Enter (see openai/codex
308
+ // codex-rs/tui/src/keymap.rs L904-909). The kitty CSI u
309
+ // sequence we tried first only works after the app has
310
+ // negotiated kitty mode, which codex doesn't do by default.
311
+ const data = cliType === 'claude' ? '\n' : '\x1b\r';
296
312
  ev.preventDefault();
297
313
  ev.stopPropagation();
298
314
  ev.stopImmediatePropagation();
299
315
  if (ws.readyState === 1) {
300
- ws.send(JSON.stringify({ type: 'input', data: '\n' }));
316
+ ws.send(JSON.stringify({ type: 'input', data }));
301
317
  }
302
318
  };
303
319
  document.addEventListener('keydown', onShiftEnter, true);
@@ -150,7 +150,7 @@ export function SessionsPage() {
150
150
  <div class="session-pane">
151
151
  <div class="session-pane-body">
152
152
  ${running
153
- ? html`<${TerminalView} terminalId=${session.id} />`
153
+ ? html`<${TerminalView} terminalId=${session.id} cliType=${cli?.type} />`
154
154
  : html`
155
155
  <div class="terminal-empty">
156
156
  ${resumeError ? html`