@bakapiano/ccsm 0.8.3 → 0.9.0

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/README.md CHANGED
@@ -4,11 +4,11 @@ A single pane over every live Claude Code session on your machine.
4
4
  Hosted web UI + tiny local Node daemon. Windows-first; cross-platform
5
5
  in progress.
6
6
 
7
- [![open](https://img.shields.io/badge/open-bakapiano.github.io%2Fcssm%2Fv1-1a1815?style=flat-square)](https://bakapiano.github.io/cssm/v1/)
7
+ [![open](https://img.shields.io/badge/open-bakapiano.github.io%2Fccsm%2Fv1-1a1815?style=flat-square)](https://bakapiano.github.io/ccsm/v1/)
8
8
 
9
9
  ```
10
10
  ┌── browser ─────────────────────────┐
11
- │ https://bakapiano.github.io/cssm/v1/ ← static frontend
11
+ │ https://bakapiano.github.io/ccsm/v1/ ← static frontend
12
12
  └────────────┬───────────────────────┘
13
13
  │ fetch /api/* (CORS)
14
14
  │ ws://localhost:7777/ws/*
@@ -51,7 +51,7 @@ gets registered.
51
51
  ccsm # starts the backend, opens the frontend
52
52
  ```
53
53
 
54
- Or just visit **https://bakapiano.github.io/cssm/v1/** in any browser.
54
+ Or just visit **https://bakapiano.github.io/ccsm/v1/** in any browser.
55
55
  If the backend isn't running, you'll see a "Backend not running" banner
56
56
  with a **Start ccsm** button — click it, Windows asks once whether to
57
57
  open the `ccsm://` handler (check "Always allow"), and the backend
@@ -119,7 +119,7 @@ ccsm/
119
119
 
120
120
  ## How "wake on click" works
121
121
 
122
- The hosted frontend (https://bakapiano.github.io/cssm/v1/) lives entirely
122
+ The hosted frontend (https://bakapiano.github.io/ccsm/v1/) lives entirely
123
123
  in the browser sandbox — it cannot spawn processes. So when the backend
124
124
  is down, the OfflineBanner's **Start ccsm** is a plain
125
125
  `<a href="ccsm://start">`. The OS hands that off to a per-user URL
@@ -151,7 +151,7 @@ Every gracefulShutdown saves a final snapshot before exit.
151
151
  ## Dev
152
152
 
153
153
  ```bash
154
- git clone https://github.com/bakapiano/cssm
154
+ git clone https://github.com/bakapiano/ccsm
155
155
  cd cssm
156
156
  npm install
157
157
  node server.js
@@ -175,8 +175,8 @@ build feature-detects via `/api/capabilities`, so a slightly older
175
175
  backend still works as long as it advertises the needed feature.
176
176
 
177
177
  ```
178
- https://bakapiano.github.io/cssm/v1/ ← current
179
- https://bakapiano.github.io/cssm/v2/ ← future, when /api breaking-changes
178
+ https://bakapiano.github.io/ccsm/v1/ ← current
179
+ https://bakapiano.github.io/ccsm/v2/ ← future, when /api breaking-changes
180
180
  ```
181
181
 
182
182
  Installed PWAs are pinned to whichever path they were installed from.
package/bin/ccsm.js CHANGED
@@ -176,7 +176,7 @@ function isSameVersion(running) {
176
176
  }
177
177
  console.log(`ccsm started · v${ready.version}`);
178
178
  console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
179
- console.log(`frontend: https://bakapiano.github.io/cssm/v1/`);
179
+ console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
180
180
  console.log(`logs: ${LOG}`);
181
181
 
182
182
  // First-run hint — printed once, then a marker file makes us quiet.
package/lib/config.js CHANGED
@@ -21,6 +21,11 @@ const DEFAULTS = {
21
21
  claudeCommand: 'claude',
22
22
  terminal: 'wt',
23
23
  commandShell: 'pwsh',
24
+ // 'wt' — open a new Windows Terminal window (or whatever `terminal` is set to)
25
+ // 'web' — spawn in-process PTY, attach via xterm.js in the Terminals tab
26
+ // Used as the default for new sessions, resume, continue, finder.
27
+ // Per-launch radio in the UI can still override.
28
+ defaultTerminalMode: 'wt',
24
29
  autoFocusOnLaunch: true,
25
30
  focusMovesToCenter: false,
26
31
  // 'app' — Edge/Chrome --app=<url> chromeless window (looks like a desktop app)
@@ -33,6 +33,8 @@ function genId() {
33
33
  return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
34
34
  }
35
35
 
36
+
37
+
36
38
  // Spawn a new PTY. `command` and `args` are passed straight to node-pty.
37
39
  // `meta` is whatever the caller wants surfaced to the UI (title, cwd, etc).
38
40
  // Throws if node-pty isn't available.
@@ -43,12 +45,25 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}
43
45
  throw err;
44
46
  }
45
47
  const id = genId();
46
- const proc = pty.spawn(command, args, {
48
+ // useConpty: new ConPTY API (Win10 1809+). node-pty defaults this true on
49
+ // Windows, but spell it out so we know we're on the modern path.
50
+ // useConptyDll: opt-in to the newest, separately-versioned conpty.dll
51
+ // (node-pty 1.0+, Windows 10 1809+ if the dll is present). This is the
52
+ // path VSCode uses (see vscode/src/vs/platform/terminal/node/terminalProcess.ts)
53
+ // — it has a larger stdin buffer and doesn't split bracketed-paste
54
+ // payloads across multiple child-process reads, so claude code's
55
+ // [Pasted text] chip detection actually fires.
56
+ const ptyOpts = {
47
57
  name: 'xterm-256color',
48
58
  cols, rows,
49
59
  cwd: cwd ? path.resolve(cwd) : process.cwd(),
50
60
  env: { ...process.env, ...(env || {}) },
51
- });
61
+ };
62
+ if (process.platform === 'win32') {
63
+ ptyOpts.useConpty = true;
64
+ ptyOpts.useConptyDll = true;
65
+ }
66
+ const proc = pty.spawn(command, args, ptyOpts);
52
67
  const entry = {
53
68
  id,
54
69
  pty: proc,
@@ -110,7 +125,14 @@ function attach(id, ws) {
110
125
  if (entry.exitedAt) return; // PTY is dead, ignore further input
111
126
  switch (event.type) {
112
127
  case 'input':
113
- if (typeof event.data === 'string') entry.pty.write(event.data);
128
+ if (typeof event.data === 'string') {
129
+ if (process.env.CCSM_DEBUG_PASTE === '1') {
130
+ const d = event.data;
131
+ const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
132
+ console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
133
+ }
134
+ entry.pty.write(event.data);
135
+ }
114
136
  break;
115
137
  case 'resize':
116
138
  if (Number(event.cols) > 0 && Number(event.rows) > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
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",
@@ -44,12 +44,12 @@
44
44
  ],
45
45
  "repository": {
46
46
  "type": "git",
47
- "url": "git+https://github.com/bakapiano/cssm.git"
47
+ "url": "git+https://github.com/bakapiano/ccsm.git"
48
48
  },
49
49
  "bugs": {
50
- "url": "https://github.com/bakapiano/cssm/issues"
50
+ "url": "https://github.com/bakapiano/ccsm/issues"
51
51
  },
52
- "homepage": "https://github.com/bakapiano/cssm#readme",
52
+ "homepage": "https://github.com/bakapiano/ccsm#readme",
53
53
  "publishConfig": {
54
54
  "access": "public",
55
55
  "registry": "https://registry.npmjs.org/"
@@ -280,3 +280,54 @@ input[type="checkbox"]:checked::after {
280
280
  .repos-table thead th:last-child,
281
281
  .repos-table tbody td:last-child { padding-right: var(--s-2); }
282
282
  .repos-table tbody td input { font-size: 12px; max-width: none; }
283
+
284
+ /* Theme accent picker (Configure tab). 8 swatches + native color picker
285
+ + hex input + reset. Swatches are filled circles; the active one wears
286
+ a subtle ring in --ink so it pops on any accent. */
287
+ .accent-picker {
288
+ display: flex;
289
+ flex-direction: column;
290
+ gap: var(--s-3);
291
+ }
292
+ .accent-swatches {
293
+ display: flex;
294
+ flex-wrap: wrap;
295
+ gap: var(--s-2);
296
+ }
297
+ .accent-swatch {
298
+ appearance: none;
299
+ width: 24px;
300
+ height: 24px;
301
+ border-radius: 999px;
302
+ border: 1px solid rgba(0, 0, 0, 0.1);
303
+ cursor: pointer;
304
+ padding: 0;
305
+ transition: transform .12s ease, box-shadow .12s ease;
306
+ }
307
+ .accent-swatch:hover { transform: scale(1.08); }
308
+ .accent-swatch.is-active {
309
+ box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink);
310
+ }
311
+ .accent-custom {
312
+ display: flex;
313
+ align-items: center;
314
+ gap: var(--s-2);
315
+ }
316
+ .accent-custom input[type="color"] {
317
+ width: 36px;
318
+ height: 28px;
319
+ padding: 0;
320
+ border: 1px solid var(--border);
321
+ border-radius: var(--r-sm);
322
+ background: var(--bg-elev);
323
+ cursor: pointer;
324
+ }
325
+ .accent-hex {
326
+ font-family: var(--mono);
327
+ font-size: 12px;
328
+ width: 96px;
329
+ padding: 4px 8px;
330
+ border: 1px solid var(--border);
331
+ border-radius: var(--r-sm);
332
+ background: var(--bg-elev);
333
+ }
@@ -9,6 +9,11 @@
9
9
  .app:has(.sidebar[data-collapsed="true"]) {
10
10
  grid-template-columns: var(--sidebar-w-collapsed) 1fr;
11
11
  }
12
+ /* While dragging the sidebar resize handle, kill the transition so the
13
+ grid tracks pointermove pixel-for-pixel instead of lagging behind. */
14
+ body.is-resizing-sidebar .app {
15
+ transition: none;
16
+ }
12
17
 
13
18
  .main {
14
19
  display: flex;
@@ -9,7 +9,10 @@
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  padding: var(--s-4) var(--s-3);
12
- overflow: hidden;
12
+ /* visible: let the resize handle (positioned at right: -3px) overflow the
13
+ sidebar's right edge. We instead clip the things that USED to need
14
+ this via the .sidebar-nav scroll region. */
15
+ overflow: visible;
13
16
  transition: padding .25s cubic-bezier(.4, 0, .2, 1);
14
17
  }
15
18
  .sidebar[data-collapsed="true"] {
@@ -163,3 +166,37 @@
163
166
  .sidebar[data-collapsed="true"] .collapse-toggle .nav-icon {
164
167
  transform: rotate(180deg);
165
168
  }
169
+
170
+ /* Drag-to-resize handle. Sits absolutely against the sidebar's right
171
+ border so the cursor target spans the full height. 6px wide hit area
172
+ centered on the visible 1px border — easy to grab without bumping
173
+ into adjacent layout. */
174
+ .sidebar-resize-handle {
175
+ position: absolute;
176
+ top: 0;
177
+ right: -3px;
178
+ width: 6px;
179
+ height: 100%;
180
+ cursor: col-resize;
181
+ z-index: 5;
182
+ touch-action: none;
183
+ /* Subtle hover indicator: deepens the border-right color on hover so the
184
+ user knows the edge is interactive. */
185
+ background: transparent;
186
+ transition: background .12s ease;
187
+ }
188
+ .sidebar-resize-handle:hover,
189
+ body.is-resizing-sidebar .sidebar-resize-handle {
190
+ background: linear-gradient(to right, transparent 0, transparent 2px,
191
+ var(--ink-mid) 2px, var(--ink-mid) 3px,
192
+ transparent 3px);
193
+ }
194
+ /* While dragging, freeze global cursor + suppress text selection so the
195
+ whole page tracks resize cleanly even if pointer leaves the handle. */
196
+ body.is-resizing-sidebar {
197
+ cursor: col-resize !important;
198
+ user-select: none;
199
+ }
200
+ body.is-resizing-sidebar * {
201
+ cursor: col-resize !important;
202
+ }
@@ -91,6 +91,50 @@
91
91
  flex: 1;
92
92
  min-height: 0;
93
93
  width: 100%;
94
+ /* IME composition (Chinese/Japanese pinyin) lives in absolutely-positioned
95
+ .xterm-helper-textarea + .composition-view that grow with the composed
96
+ string. Near the right edge they overflow the viewport and trigger a
97
+ horizontal scrollbar that visually "pushes" the layout. Clip here so
98
+ the overflow is silently absorbed instead of expanding the page.
99
+ Do NOT touch the textarea/composition-view's own text properties —
100
+ xterm relies on their single-line behaviour to keep IME events firing
101
+ correctly (forcing pre-wrap / break-all eats compositionupdate events
102
+ in Chromium and Chinese input stops working entirely). */
103
+ overflow: hidden;
104
+ contain: layout;
105
+ }
106
+ /* While the user is composing (IME), pin the helper textarea to the right
107
+ edge of the terminal so it grows leftward instead of pushing the layout
108
+ rightward. Only touches positioning — NOT width / wrap / max-width, which
109
+ would break Chromium's compositionupdate event flow and stop Chinese
110
+ input from working. The class is toggled by TerminalView.js. */
111
+ .terminal-host.is-composing .xterm-helper-textarea {
112
+ left: auto !important;
113
+ right: 0 !important;
114
+ text-align: right;
115
+ /* xterm un-hides the textarea during composition so the user can see the
116
+ composed string inline. We've moved it to the right edge to stop it
117
+ pushing layout — but that means the composed pinyin would now visibly
118
+ appear on the right. Hide its glyphs (caret + text) so the user only
119
+ sees the OS-native IME candidate popup, which floats independently
120
+ and is unaffected. */
121
+ color: transparent !important;
122
+ caret-color: transparent !important;
123
+ background: transparent !important;
124
+ text-shadow: none !important;
125
+ }
126
+ /* xterm also overlays a .composition-view (a small box at the cursor with
127
+ the in-progress string + a gold caret using THEME.cursor). We can't
128
+ display:none it — Chromium needs it in the layout tree to keep the IME
129
+ compositionupdate events flowing — but we can make it visually invisible
130
+ while leaving it laid out. */
131
+ .terminal-host .composition-view {
132
+ opacity: 0 !important;
133
+ color: transparent !important;
134
+ background: transparent !important;
135
+ border-color: transparent !important;
136
+ box-shadow: none !important;
137
+ pointer-events: none;
94
138
  }
95
139
  /* Don't override xterm's background — its renderer (canvas/WebGL) assumes
96
140
  an opaque surface and ghosts on scroll if we force transparent. The
@@ -58,6 +58,34 @@ body.is-app .sidebar-brand {
58
58
  }
59
59
  body.is-app .page-head {
60
60
  padding-top: var(--s-8); /* 32px = old main.pt */
61
+ /* Reserve space on the right for the OS window controls (close /
62
+ maximize / minimize) that float over the top-right of every
63
+ chromeless app window — both --app= (display-mode: standalone)
64
+ and PWA. Without this, the server-status pill + Refresh button
65
+ slide under those controls and stop being clickable. ~150px covers
66
+ three Windows controls + a little breathing room; macOS traffic
67
+ lights are on the left so this is harmless there. The WCO override
68
+ below uses the precise env() value when the API is available. */
69
+ padding-right: 150px;
70
+ }
71
+
72
+ /* In app/PWA mode, hide the in-page title/subtitle entirely. The sidebar
73
+ nav already tells the user which section they're on, and the OS title
74
+ bar (or the floating WCO controls) is the only chrome we want. This
75
+ reclaims a chunky 60+ px of vertical real estate at the top of every
76
+ tab — really noticeable in the Terminals tab where xterm benefits
77
+ from every pixel. The page-head wrapper survives because it still
78
+ hosts the status pill + Refresh button on the right and remains
79
+ draggable. */
80
+ body.is-app .page-title,
81
+ body.is-app .page-subtitle {
82
+ display: none;
83
+ }
84
+ body.is-app .page-head {
85
+ padding-bottom: 0;
86
+ border-bottom: 0;
87
+ align-items: center;
88
+ min-height: 28px;
61
89
  }
62
90
 
63
91
  /* WCO-only · the OS title bar is fully gone, so right-pad the page-head
@@ -66,5 +94,6 @@ body.is-app .page-head {
66
94
  @media (display-mode: window-controls-overlay) {
67
95
  .page-head {
68
96
  padding-right: calc(var(--s-10) + 100vw - env(titlebar-area-width, 100vw));
97
+ min-height: env(titlebar-area-height, 32px);
69
98
  }
70
99
  }
package/public/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <title>CCSM — Claude CLI Sessions Manager</title>
12
12
  <!-- All asset paths are RELATIVE so the same index.html works when
13
13
  served from localhost:7777/ (backend bundle) AND from
14
- https://bakapiano.github.io/cssm/v1/ (GH Pages hosted). -->
14
+ https://bakapiano.github.io/ccsm/v1/ (GH Pages hosted). -->
15
15
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
16
16
  <link rel="manifest" href="./manifest.webmanifest" />
17
17
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -43,7 +43,9 @@
43
43
  "htm": "https://esm.sh/htm@3.1.1",
44
44
  "@xterm/xterm": "https://esm.sh/@xterm/xterm@5.5.0",
45
45
  "@xterm/addon-fit": "https://esm.sh/@xterm/addon-fit@0.10.0?deps=@xterm/xterm@5.5.0",
46
- "@xterm/addon-web-links": "https://esm.sh/@xterm/addon-web-links@0.11.0?deps=@xterm/xterm@5.5.0"
46
+ "@xterm/addon-web-links": "https://esm.sh/@xterm/addon-web-links@0.11.0?deps=@xterm/xterm@5.5.0",
47
+ "@xterm/addon-clipboard": "https://esm.sh/@xterm/addon-clipboard@0.1.0?deps=@xterm/xterm@5.5.0",
48
+ "@xterm/addon-webgl": "https://esm.sh/@xterm/addon-webgl@0.18.0?deps=@xterm/xterm@5.5.0"
47
49
  }
48
50
  }
49
51
  </script>
@@ -1,8 +1,8 @@
1
1
  // Mutation actions shared by SessionsPage, FavoritesTable etc. — each
2
2
  // optimistically updates the relevant signal and rolls back on error.
3
3
 
4
- import { favorites, labels, sessions, recent } from './state.js';
5
- import { api, loadSessions, loadRecent } from './api.js';
4
+ import { favorites, labels, sessions, recent, config, capabilities, activeTerminalId, selectTab } from './state.js';
5
+ import { api, loadSessions, loadRecent, loadWebTerminals } from './api.js';
6
6
  import { setToast } from './toast.js';
7
7
  import { ccsmPrompt } from './dialog.js';
8
8
 
@@ -68,10 +68,20 @@ export async function focusSession(sessionId) {
68
68
 
69
69
  export async function resumeSession(sessionId, cwd, { kind = 'resume' } = {}) {
70
70
  if (!cwd) return setToast('no cwd for this session', 'error');
71
+ const wantWeb = capabilities.value?.webTerminal
72
+ && (config.value?.defaultTerminalMode || 'wt') === 'web';
73
+ const terminal = wantWeb ? 'web' : 'wt';
71
74
  try {
72
- await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
73
- const verb = kind === 'continue' ? 'continuing' : 'opening wt';
74
- setToast(`${verb} · ${sessionId.slice(0, 8)}…`);
75
+ const r = await api('POST', `/api/sessions/${sessionId}/resume`, { cwd, terminal });
76
+ if (r.launched?.mode === 'web') {
77
+ setToast(`${kind === 'continue' ? 'continuing' : 'resuming'} in web · ${sessionId.slice(0, 8)}…`);
78
+ await loadWebTerminals();
79
+ if (r.launched.id) activeTerminalId.value = r.launched.id;
80
+ selectTab('terminals');
81
+ } else {
82
+ const verb = kind === 'continue' ? 'continuing' : 'opening wt';
83
+ setToast(`${verb} · ${sessionId.slice(0, 8)}…`);
84
+ }
75
85
  if (kind === 'continue') {
76
86
  setTimeout(() => loadSessions().catch(() => {}), 3000);
77
87
  setTimeout(() => loadRecent().catch(() => {}), 4000);
@@ -80,8 +90,18 @@ export async function resumeSession(sessionId, cwd, { kind = 'resume' } = {}) {
80
90
  }
81
91
 
82
92
  export async function runFinder() {
93
+ const wantWeb = capabilities.value?.webTerminal
94
+ && (config.value?.defaultTerminalMode || 'wt') === 'web';
95
+ const terminal = wantWeb ? 'web' : 'wt';
83
96
  try {
84
- await api('POST', '/api/sessions/finder');
85
- setToast('finder session launching in a new wt window');
97
+ const r = await api('POST', '/api/sessions/finder', { terminal });
98
+ if (r.launched?.mode === 'web') {
99
+ await loadWebTerminals();
100
+ if (r.launched.id) activeTerminalId.value = r.launched.id;
101
+ selectTab('terminals');
102
+ setToast('finder launching in web terminal');
103
+ } else {
104
+ setToast('finder session launching in a new wt window');
105
+ }
86
106
  } catch (e) { setToast(e.message, 'error'); }
87
107
  }
@@ -5,8 +5,8 @@
5
5
  import { html } from '../html.js';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
  import { signal } from '@preact/signals';
8
- import { modalOpen, config } from '../state.js';
9
- import { api, loadWorkspaces } from '../api.js';
8
+ import { modalOpen, config, capabilities, activeTerminalId, selectTab } from '../state.js';
9
+ import { api, loadWorkspaces, loadWebTerminals } from '../api.js';
10
10
  import { setToast } from '../toast.js';
11
11
  import { streamNewSession, resetProgress } from '../streaming.js';
12
12
  import { IconClose } from '../icons.js';
@@ -51,29 +51,40 @@ function ModalBody() {
51
51
 
52
52
  const onLaunch = async () => {
53
53
  const repos = [...modalSelected.value];
54
- if (repos.length === 0) return setToast('select at least one repo', 'error');
55
54
  setBusy(true);
56
55
  setResult('');
57
56
  resetProgress(repos, ROOT_ID);
57
+ const wantWeb = capabilities.value?.webTerminal
58
+ && (config.value?.defaultTerminalMode || 'wt') === 'web';
59
+ const terminal = wantWeb ? 'web' : 'wt';
58
60
  try {
59
61
  const final = await streamNewSession(
60
- { repos, workspace: workspace || undefined },
62
+ { repos, workspace: workspace || undefined, terminal },
61
63
  {
62
64
  progressRootId: ROOT_ID,
63
65
  onMeta: (ev) => {
64
66
  if (ev.type === 'workspace') {
65
67
  setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
66
68
  } else if (ev.type === 'launched') {
67
- setResult(`terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`);
69
+ const l = ev.launched || {};
70
+ if (l.mode === 'web') setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
71
+ else setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
68
72
  }
69
73
  },
70
74
  },
71
75
  );
72
76
  if (final.success) {
73
77
  const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
74
- setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} — ${summary}`);
78
+ setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''}${summary ? ' ' + summary : ''}`);
75
79
  setToast(`launched · ${final.workspace.name}`);
76
- setTimeout(close, 1500);
80
+ if (terminal === 'web' && final.launched?.id) {
81
+ activeTerminalId.value = final.launched.id;
82
+ await loadWebTerminals();
83
+ selectTab('terminals');
84
+ modalOpen.value = false;
85
+ } else {
86
+ setTimeout(close, 1500);
87
+ }
77
88
  } else {
78
89
  setResult(`error: ${final.error}`);
79
90
  setToast(final.error || 'new session failed', 'error');
@@ -1,5 +1,5 @@
1
1
  // Shown when the backend probe fails. The hosted frontend (running at
2
- // https://bakapiano.github.io/cssm/v1/) can't spawn processes directly,
2
+ // https://bakapiano.github.io/ccsm/v1/) can't spawn processes directly,
3
3
  // so we surface a ccsm://start link instead. Windows / OS will hand that
4
4
  // off to the registered protocol handler (ccsm.cmd), which spawns the
5
5
  // backend silently. Our health probe picks it up on the next tick and
@@ -1,7 +1,7 @@
1
1
  import { html } from '../html.js';
2
2
  import {
3
3
  activeTab, sidebarCollapsed, configDirty, sessions, webTerminals, capabilities,
4
- selectTab, toggleSidebar,
4
+ selectTab, toggleSidebar, setSidebarWidth, SIDEBAR_MIN, SIDEBAR_MAX,
5
5
  } from '../state.js';
6
6
  import {
7
7
  IconSessions, IconLaunch, IconTerminal, IconConfigure, IconInfo,
@@ -23,6 +23,29 @@ function NavItem({ tab, icon, label, badge, dirty }) {
23
23
  export function Sidebar() {
24
24
  const collapsed = sidebarCollapsed.value;
25
25
 
26
+ // Drag-to-resize handle. Pointer events let one handler cover mouse,
27
+ // touch, pen uniformly + setPointerCapture means dragging continues
28
+ // even if cursor leaves the 4px-wide handle. Collapsed sidebars don't
29
+ // expose a handle — Collapse-toggle is the only way out/in.
30
+ const onResizeStart = (ev) => {
31
+ if (collapsed) return;
32
+ ev.preventDefault();
33
+ const el = ev.currentTarget;
34
+ el.setPointerCapture(ev.pointerId);
35
+ document.body.classList.add('is-resizing-sidebar');
36
+ const move = (e) => setSidebarWidth(e.clientX);
37
+ const up = (e) => {
38
+ el.releasePointerCapture(ev.pointerId);
39
+ document.body.classList.remove('is-resizing-sidebar');
40
+ el.removeEventListener('pointermove', move);
41
+ el.removeEventListener('pointerup', up);
42
+ el.removeEventListener('pointercancel', up);
43
+ };
44
+ el.addEventListener('pointermove', move);
45
+ el.addEventListener('pointerup', up);
46
+ el.addEventListener('pointercancel', up);
47
+ };
48
+
26
49
  return html`
27
50
  <aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
28
51
  <div class="sidebar-brand">
@@ -48,5 +71,13 @@ export function Sidebar() {
48
71
  <span class="nav-label">Collapse</span>
49
72
  </button>
50
73
  </div>
74
+
75
+ ${!collapsed ? html`
76
+ <div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
77
+ aria-label="resize sidebar"
78
+ title="drag to resize · double-click to reset"
79
+ onPointerDown=${onResizeStart}
80
+ onDblClick=${() => setSidebarWidth(232)}></div>
81
+ ` : null}
51
82
  </aside>`;
52
83
  }