@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 +1 -1
- package/public/css/responsive.css +16 -8
- package/public/css/sidebar.css +39 -9
- package/public/js/components/MobileNavFab.js +90 -3
- package/public/js/components/Sidebar.js +8 -1
- package/public/js/components/TerminalView.js +23 -7
- package/public/js/pages/SessionsPage.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.18.
|
|
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:
|
|
109
|
-
|
|
110
|
+
cursor: grab;
|
|
111
|
+
touch-action: none;
|
|
112
|
+
user-select: none;
|
|
113
|
+
transition: box-shadow .15s, background .15s;
|
|
110
114
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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. */
|
package/public/css/sidebar.css
CHANGED
|
@@ -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
|
-
|
|
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
|
|
343
|
-
min-height:
|
|
344
|
-
gap:
|
|
345
|
-
border-radius:
|
|
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(--
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// The FAB is draggable — long-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
|
-
|
|
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=${
|
|
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
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
|
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`
|