@bakapiano/ccsm 0.17.10 → 0.17.11

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.17.10",
3
+ "version": "0.17.11",
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",
@@ -98,12 +98,15 @@ body.is-resizing-sidebar .app {
98
98
  }
99
99
 
100
100
  /* Per-page title strip — height matches the sidebar collapse-toggle
101
- (28px) so the topmost row of the window reads as one unified band. */
101
+ (28px) so the topmost row of the window reads as one unified band.
102
+ Title on the left, actions slot on the right. The WCO padding-right
103
+ reservation (wco.css) shifts actions inward so they clear the OS
104
+ window controls overlay. */
102
105
  .page-title-bar {
103
106
  display: flex;
104
- flex-direction: row-reverse;
107
+ flex-direction: row;
105
108
  align-items: center;
106
- justify-content: flex-start;
109
+ justify-content: space-between;
107
110
  gap: var(--s-3);
108
111
  height: calc(40px * var(--anti-zoom, 1));
109
112
  min-height: calc(40px * var(--anti-zoom, 1));
@@ -226,55 +226,146 @@
226
226
  min-height: 0;
227
227
  height: 100%;
228
228
  margin: 0 calc(-1 * var(--s-4));
229
- /* Cancel the tab-panel gap above so the terminal sits flush under the
230
- title bar with no white band. */
231
- margin-top: calc(-1 * var(--s-4));
232
229
  background: var(--bg-elev);
233
230
  overflow: hidden;
234
231
  }
235
- .session-actions {
232
+
233
+ /* Session tabs · sits between the page-title-bar and the session-pane.
234
+ One row of small tab buttons; the active one has a darker bg + accent
235
+ underline. Last child is a "+" button that bounces to /launch. The
236
+ strip is full-bleed (matches .session-pane horizontal extents). */
237
+ .session-tabs {
236
238
  display: flex;
237
239
  align-items: stretch;
238
- justify-content: flex-start;
239
240
  gap: 0;
240
- height: calc(24px * var(--anti-zoom, 1));
241
- min-height: calc(24px * var(--anti-zoom, 1));
242
- max-height: calc(24px * var(--anti-zoom, 1));
241
+ height: 30px;
243
242
  flex-shrink: 0;
244
- box-sizing: border-box;
245
- padding: 0;
246
- background: var(--accent);
247
- border-top: 0;
243
+ padding: 0 2px 0 0;
244
+ /* Negative bottom margin cancels the tab-panel's gap to the
245
+ session-pane underneath, so the strip sits flush against the
246
+ terminal. Negative horizontal margin cancels .main's padding so
247
+ the strip is full-bleed like the terminal underneath. */
248
+ margin: 0 calc(-1 * var(--s-4)) calc(-1 * var(--s-4));
249
+ background: #2d2a26;
250
+ border-bottom: 0;
251
+ }
252
+ .session-tabs-list {
253
+ display: flex;
254
+ align-items: stretch;
255
+ gap: 0;
256
+ flex: 1;
257
+ min-width: 0;
258
+ overflow: hidden;
248
259
  }
249
- .session-actions .action {
250
- display: inline-flex;
260
+ .session-tabs-right {
261
+ display: flex;
251
262
  align-items: center;
252
- gap: 5px;
253
- height: 100%;
254
- padding: 0 10px;
255
- font-size: 11px;
256
- font-weight: 500;
257
- line-height: 1;
258
- color: rgba(255, 255, 255, 0.85);
259
- background: transparent;
263
+ flex-shrink: 0;
264
+ padding-right: 2px;
265
+ }
266
+ /* Close the gap to the page-title-bar above — only when there IS one.
267
+ In standalone PWA the title-bar is display:none and .session-tabs is
268
+ the first tab-panel child; a negative margin-top there would push
269
+ the strip up over the OS title-bar border. */
270
+ .page-title-bar + .session-tabs {
271
+ margin-top: calc(-1 * var(--s-4));
272
+ }
273
+ .session-tab {
274
+ appearance: none;
275
+ background: #423d37;
260
276
  border: 0;
261
- border-radius: 0;
277
+ border-bottom: 2px solid transparent;
278
+ margin-bottom: -1px; /* overlap container border-bottom */
279
+ padding: 0 10px;
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: 6px;
283
+ font: inherit;
284
+ font-size: 12px;
285
+ color: rgba(255, 255, 255, 0.65);
262
286
  cursor: pointer;
287
+ max-width: 200px;
288
+ min-width: 0;
263
289
  transition: background-color .12s, color .12s;
264
290
  }
265
- .session-actions .action:hover {
291
+ .session-tab:hover { background: #4f4942; color: #fff; }
292
+ .session-tab.is-active {
293
+ background: var(--ink);
266
294
  color: #fff;
267
- background: rgba(255, 255, 255, 0.14);
295
+ border-bottom-color: var(--ink);
268
296
  }
269
- .session-actions .action.danger:hover {
297
+ .session-tab-icon { display: inline-flex; flex-shrink: 0; }
298
+ .session-tab-icon svg { width: 14px; height: 14px; }
299
+ .session-tab-icon img { width: 14px; height: 14px; }
300
+ .session-tab-label {
301
+ white-space: nowrap;
302
+ overflow: hidden;
303
+ text-overflow: ellipsis;
304
+ min-width: 0;
305
+ }
306
+ .session-tab-meta { color: rgba(255, 255, 255, 0.5); font-size: 11px; }
307
+ .session-tab.is-active .session-tab-meta { color: rgba(255, 255, 255, 0.6); }
308
+ .session-tab-add {
309
+ background: transparent;
310
+ max-width: none;
311
+ padding: 0 8px;
270
312
  color: #fff;
271
- background: rgba(0, 0, 0, 0.22);
272
313
  }
273
- .session-actions .action svg {
274
- width: 13px;
275
- height: 13px;
276
- stroke-width: 1.75;
314
+ .session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
315
+ .session-tab-add svg { width: 14px; height: 14px; }
316
+
317
+ /* Kebab in the page-title-bar (top-right). Compact 24px square so it
318
+ doesn't dominate the masthead. In WCO mode the title-bar already
319
+ reserves padding-right for OS controls, so this slides cleanly to
320
+ the left of them. */
321
+ .session-menu-btn {
322
+ appearance: none;
323
+ background: transparent;
324
+ border: 0;
325
+ width: 26px;
326
+ height: 26px;
327
+ border-radius: 5px;
328
+ display: inline-flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ color: #fff;
332
+ cursor: pointer;
333
+ flex-shrink: 0;
334
+ transition: background-color .12s, color .12s;
335
+ }
336
+ .session-menu-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
337
+ .session-menu-btn svg { width: 16px; height: 16px; }
338
+
339
+ .session-menu {
340
+ background: var(--bg-elev);
341
+ border: 1px solid var(--border);
342
+ border-radius: 6px;
343
+ padding: 4px;
344
+ box-shadow: var(--shadow-md, 0 4px 16px rgba(0,0,0,0.08));
345
+ display: flex;
346
+ flex-direction: column;
347
+ gap: 2px;
277
348
  }
349
+ .session-menu-item {
350
+ appearance: none;
351
+ background: transparent;
352
+ border: 0;
353
+ padding: 7px 10px;
354
+ border-radius: 4px;
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 8px;
358
+ font: inherit;
359
+ font-size: 13px;
360
+ color: var(--ink);
361
+ cursor: pointer;
362
+ text-align: left;
363
+ }
364
+ .session-menu-item:hover { background: var(--bg); }
365
+ .session-menu-item.danger { color: var(--danger, #b73f3f); }
366
+ .session-menu-item.danger:hover { background: rgba(183, 63, 63, 0.08); }
367
+ .session-menu-item svg { width: 14px; height: 14px; }
368
+
278
369
  .session-pane-head {
279
370
  display: flex;
280
371
  align-items: center;
@@ -80,16 +80,34 @@ body.is-app:not(.is-wco) .sidebar-top {
80
80
  body.is-app:not(.is-wco) .page-title-bar {
81
81
  display: none;
82
82
  }
83
+ /* terminals.css uses `.page-title-bar + .session-tabs { margin-top: -s-4 }`
84
+ to flush the tab strip against the in-page title-bar. Adjacent-sibling
85
+ selectors match by DOM order regardless of display, so the rule still
86
+ pulls session-tabs up by 16px even when the title-bar is hidden — the
87
+ top half of the tabs ends up clipped above the viewport / under the OS
88
+ title bar. Reset to 0 in standalone PWA. */
89
+ body.is-app:not(.is-wco) .page-title-bar + .session-tabs {
90
+ margin-top: 0;
91
+ }
83
92
  body.is-app:not(.is-wco) .app {
84
- /* Hairline separator under the OS title bar. Painted as an inset
85
- box-shadow rather than a border so it doesn't change .app's box
86
- model a real border-top added 1px to .app's height, and the
87
- grid's implicit row auto-sized off content (.main reporting 720)
88
- rather than the (now 719px) container, so .main overflowed the
89
- viewport by exactly 1px and put a permanent vertical scrollbar
90
- down the right of the terminal page. Inset shadow draws the same
91
- line with zero layout cost. */
92
- box-shadow: inset 0 1px 0 var(--border);
93
+ /* Hairline separator under the OS title bar. Has to sit ABOVE the
94
+ sidebar's own background an inset box-shadow on .app gets covered
95
+ by .sidebar (which paints var(--ui-bg) across its full area inside
96
+ .app), so the line was invisible. An absolutely-positioned ::before
97
+ paints over both columns; it's outside the grid track so it doesn't
98
+ reintroduce the 1px overflow that a real `border-top: 1px` did. */
99
+ position: relative;
100
+ }
101
+ body.is-app:not(.is-wco) .app::before {
102
+ content: "";
103
+ position: absolute;
104
+ top: 0;
105
+ left: 0;
106
+ right: 0;
107
+ height: 1px;
108
+ background: var(--border);
109
+ z-index: 10;
110
+ pointer-events: none;
93
111
  }
94
112
  /* With page-title-bar hidden, session-pane's `margin-top: calc(-1 *
95
113
  var(--s-4))` — designed to flush it against the (now invisible)
@@ -101,13 +119,20 @@ body.is-app:not(.is-wco) .session-pane {
101
119
  margin-top: 0;
102
120
  height: auto;
103
121
  }
104
- /* Settings page · with the title-bar hidden in standalone its content
105
- would otherwise butt straight against the OS title-bar border. Give
106
- it back a little breathing room (Sessions / Launch / About don't
107
- need this they have their own top chrome or flush-to-edge intent). */
108
- body.is-app:not(.is-wco) [data-panel="configure"] {
122
+ /* Settings + Launch · with the in-page title-bar hidden in standalone
123
+ mode, their content would otherwise butt straight against the OS
124
+ title-bar border. Give a little breathing room. Sessions has its
125
+ own full-bleed terminal pane; About has its own hero header. */
126
+ body.is-app:not(.is-wco) [data-panel="configure"],
127
+ body.is-app:not(.is-wco) [data-panel="launch"] {
109
128
  padding-top: var(--s-4);
110
129
  }
130
+ /* Sidebar nav rows (New Session / Settings) also need a top gap in
131
+ standalone — body.is-app zeros .sidebar's padding-top so the in-page
132
+ title-bar can sit flush, but with that title-bar gone in standalone
133
+ mode the nav buttons end up jammed against the OS title-bar border.
134
+ Restore a small inset so they read as a real nav, not as overflow. */
135
+ body.is-app:not(.is-wco) .sidebar { padding-top: var(--s-3); }
111
136
 
112
137
  /* WCO mode only: the browser has hidden its own title bar and floats OS
113
138
  controls (min/max/close) over our content top-right. Our 34px top band
@@ -124,16 +149,39 @@ body.is-wco .page-title-bar {
124
149
  }
125
150
  body.is-wco .page-title-bar,
126
151
  body.is-wco .sidebar-top {
127
- height: calc(34px * var(--anti-zoom, 1));
128
- min-height: calc(34px * var(--anti-zoom, 1));
129
- max-height: calc(34px * var(--anti-zoom, 1));
152
+ /* env(titlebar-area-height) is Chromium's reported safe-area height, but
153
+ in practice Edge on Windows often paints the OS controls overlay a few
154
+ px taller than that value — so sizing our top band to exactly env()
155
+ leaves a sliver of the strip BELOW (session-tabs) still under the
156
+ overlay and the kebab gets clipped. Take the max with 40px so we
157
+ always reserve at least the default page-title-bar height regardless
158
+ of what env() reports. Matches non-WCO modes too. */
159
+ height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
160
+ min-height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
161
+ max-height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
130
162
  }
131
163
  body.is-wco .sidebar-brand,
132
164
  body.is-wco .sidebar-brand-button,
133
165
  body.is-wco .collapse-toggle {
134
- height: 34px;
135
- min-height: 34px;
166
+ height: max(40px, env(titlebar-area-height, 40px));
167
+ min-height: max(40px, env(titlebar-area-height, 40px));
136
168
  }
169
+ /* terminals.css uses the .tab-panel's gap (s-4) plus a -s-4 margin-top on
170
+ .session-tabs to close that gap, so the tab strip visually flushes
171
+ against the page-title-bar above. In WCO the negative margin pulls the
172
+ tabs row UP by 16px — i.e. its top edge lands ABOVE the OS overlay's
173
+ bottom edge, and the kebab inside gets clipped by the floating window
174
+ controls. Cancel both the gap and the negative margin here so the tabs
175
+ row sits at exactly y=titlebar-area-height (flush below the overlay)
176
+ without losing flushness against the title-bar. */
177
+ body.is-wco .tab-panel { gap: 0; }
178
+ body.is-wco .page-title-bar + .session-tabs { margin-top: 0; }
179
+ /* terminals.css also gives .session-tabs `margin-bottom: -s-4` to close
180
+ the gap to session-pane below — but with tab-panel gap now 0 in WCO
181
+ that negative margin pulls session-pane UP 16px instead, overlapping
182
+ the bottom of the tab labels (kebab + tab text get clipped from
183
+ below). Zero it out too. */
184
+ body.is-wco .session-tabs { margin-bottom: 0; }
137
185
 
138
186
  @media (display-mode: window-controls-overlay) {
139
187
  body.is-wco .page-title-bar {
@@ -125,6 +125,11 @@ export const IconBranch = ic('0 0 24 24', html`
125
125
  <circle cx="6" cy="18" r="3"/>
126
126
  <path d="M18 9a9 9 0 0 1-9 9"/>
127
127
  `, 18);
128
+ export const IconMoreVert = ic('0 0 24 24', html`
129
+ <circle cx="12" cy="5" r="1.6" fill="currentColor" stroke="none"/>
130
+ <circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/>
131
+ <circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
132
+ `, 16);
128
133
 
129
134
  // Brand-colored CLI marks. These use external SVG assets (full color),
130
135
  // rendered as <img> so the gradients / fills in the file are preserved.
package/public/js/main.js CHANGED
@@ -92,8 +92,7 @@ function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
92
92
  applyNarrow();
93
93
  narrowMq.addEventListener('change', applyNarrow);
94
94
 
95
- // Counter-zoom for chrome bars (page-title-bar, session-actions). Browser
96
- // page zoom (Ctrl+wheel) scales every CSS px including our header heights;
95
+ // Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
97
96
  // without this, the header gets visually taller at 150%+ which the user
98
97
  // usually doesn't want. We detect zoom via outerWidth/innerWidth and write
99
98
  // 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
@@ -1,19 +1,79 @@
1
1
  // Sessions page · the main pane. Shows the terminal for the currently
2
2
  // selected session (activeSessionId), with a thin header providing
3
- // session metadata + rename/delete actions. When a session is selected
4
- // but not running we auto-resume it no manual button.
3
+ // session metadata + a session-tabs strip (future multi-tab support)
4
+ // and a kebab menu top-right for per-session actions. When a session is
5
+ // selected but not running we auto-resume it — no manual button.
5
6
 
6
7
  import { html } from '../html.js';
7
- import { useEffect, useState } from 'preact/hooks';
8
+ import { useEffect, useRef, useState } from 'preact/hooks';
8
9
  import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
9
10
  import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle } from '../api.js';
10
11
  import { setToast } from '../toast.js';
11
12
  import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
12
13
  import { TerminalView } from '../components/TerminalView.js';
13
14
  import { PageTitleBar } from '../components/PageTitleBar.js';
14
- import { IconPencil, IconClose, IconBranch } from '../icons.js';
15
+ import { Popover } from '../components/Popover.js';
16
+ import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal } from '../icons.js';
15
17
  import { fmtAgo } from '../util.js';
16
18
 
19
+ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
20
+ // For now we only show the currently active session as a single tab —
21
+ // other open sessions are hidden, and the "+ new" affordance is parked
22
+ // until multi-tab UX lands.
23
+ const active = activeId ? sessions.value.find((s) => s.id === activeId) : null;
24
+ if (!active) return null;
25
+ const open = [active];
26
+ return html`
27
+ <div class="session-tabs" role="tablist">
28
+ <div class="session-tabs-list">
29
+ ${open.map((s) => {
30
+ const cli = (config.value?.clis || []).find((c) => c.id === s.cliId);
31
+ const Icon = IconForCliType(cli?.type) || IconTerminal;
32
+ const t = s.title || s.workspace || s.id.slice(0, 12);
33
+ const isActive = s.id === activeId;
34
+ return html`
35
+ <button key=${s.id}
36
+ role="tab"
37
+ aria-selected=${isActive}
38
+ class=${`session-tab${isActive ? ' is-active' : ''}`}
39
+ onClick=${() => onActivate(s.id)}
40
+ title=${`${t} · ${s.cwd}`}>
41
+ <span class="session-tab-icon"><${Icon} /></span>
42
+ <span class="session-tab-label">${t}</span>
43
+ ${s.status !== 'running' ? html`<span class="session-tab-meta">·</span>` : null}
44
+ </button>`;
45
+ })}
46
+ ${/* <button class="session-tab session-tab-add" onClick=${onNew} title="New session">
47
+ <${IconPlus} />
48
+ </button> */ null}
49
+ </div>
50
+ ${kebab ? html`<div class="session-tabs-right">${kebab}</div>` : null}
51
+ </div>`;
52
+ }
53
+
54
+ function SessionMenu({ session, onRename, onDelete }) {
55
+ const [open, setOpen] = useState(false);
56
+ const anchor = useRef(null);
57
+ return html`
58
+ <button class="session-menu-btn" ref=${anchor}
59
+ aria-label="Session actions" title="Session actions"
60
+ onClick=${() => setOpen((v) => !v)}>
61
+ <${IconMoreVert} />
62
+ </button>
63
+ ${open ? html`
64
+ <${Popover} anchor=${anchor} align="right" width=${180}
65
+ onClose=${() => setOpen(false)}>
66
+ <div class="session-menu">
67
+ <button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
68
+ <${IconPencil} /> Rename
69
+ </button>
70
+ <button class="session-menu-item danger" onClick=${() => { setOpen(false); onDelete(); }}>
71
+ <${IconClose} /> Delete
72
+ </button>
73
+ </div>
74
+ </${Popover}>` : null}`;
75
+ }
76
+
17
77
  export function SessionsPage() {
18
78
  clockTick.value; // resubscribe fmtAgo
19
79
  const id = activeSessionId.value;
@@ -49,6 +109,11 @@ export function SessionsPage() {
49
109
  const running = session.status === 'running';
50
110
  const title = session.title || session.workspace || session.id.slice(0, 12);
51
111
 
112
+ const onRetry = () => {
113
+ clearResumeFailure(session.id);
114
+ setResumeError(null);
115
+ setRetryNonce((n) => n + 1);
116
+ };
52
117
  const onRename = async () => {
53
118
  const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
54
119
  if (next === null) return;
@@ -64,12 +129,6 @@ export function SessionsPage() {
64
129
  activeSessionId.value = null;
65
130
  } catch (e) { setToast(e.message, 'error'); }
66
131
  };
67
- const onRetry = () => {
68
- clearResumeFailure(session.id);
69
- setResumeError(null);
70
- setRetryNonce((n) => n + 1);
71
- };
72
- const onFork = () => { setToast('Fork is not wired up yet'); };
73
132
 
74
133
  return html`
75
134
  <${PageTitleBar} title=${html`
@@ -82,8 +141,12 @@ export function SessionsPage() {
82
141
  <span>·</span>
83
142
  <span>${running ? 'running' : (resumeError ? 'resume failed' : 'resuming…')}</span>
84
143
  </span>
85
- `}>
86
- </${PageTitleBar}>
144
+ `} />
145
+ <${SessionTabs}
146
+ activeId=${session.id}
147
+ onActivate=${(sid) => selectSession(sid)}
148
+ onNew=${() => selectTab('launch')}
149
+ kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} />`} />
87
150
  <div class="session-pane">
88
151
  <div class="session-pane-body">
89
152
  ${running
@@ -98,21 +161,5 @@ export function SessionsPage() {
98
161
  `}
99
162
  </div>`}
100
163
  </div>
101
- <div class="session-actions">
102
- ${/* Fork button — wired but disabled; turn back on once the
103
- --fork-session / codex fork / copilot fs-copy integrations
104
- are in place. See discussion 2026-05-27. */ ''}
105
- ${false ? html`
106
- <button class="action subtle" onClick=${onFork} title="Fork session">
107
- <${IconBranch} /> Fork
108
- </button>
109
- ` : null}
110
- <button class="action subtle" onClick=${onRename} title="Rename session">
111
- <${IconPencil} /> Rename
112
- </button>
113
- <button class="action subtle danger" onClick=${onDelete} title="Delete session">
114
- <${IconClose} /> Delete
115
- </button>
116
- </div>
117
164
  </div>`;
118
165
  }