@bakapiano/ccsm 0.14.0 → 0.15.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.
Files changed (52) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +211 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +165 -165
  43. package/public/js/pages/ConfigurePage.js +505 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +91 -0
  50. package/server.js +1278 -1254
  51. package/lib/cliSessionWatcher.js +0 -275
  52. package/public/manifest.webmanifest +0 -15
@@ -1,299 +1,299 @@
1
- import { html } from '../html.js';
2
- import { signal } from '@preact/signals';
3
- import {
4
- activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
5
- sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
6
- selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
7
- } from '../state.js';
8
- import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
- import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
10
- import { setToast } from '../toast.js';
11
- import { fmtAgo } from '../util.js';
12
- import { clockTick } from '../state.js';
13
- import { useDragSort } from './useDragSort.js';
14
- import {
15
- IconLaunch, IconConfigure,
16
- IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
17
- } from '../icons.js';
18
-
19
- // Module-level drag state for session → folder moves. Lives outside the
20
- // useDragSort hook (which handles same-list folder reorder) so the two
21
- // don't interfere — session drags and folder drags use disjoint state.
22
- // Folder key: folder.id for real folders, the literal string 'unsorted'
23
- // for the implicit top-level Unsorted bucket.
24
- const draggingSessionId = signal(null);
25
- const dragOverFolderKey = signal(null);
26
- const folderKey = (folder) => folder ? folder.id : 'unsorted';
27
-
28
- function NavItem({ tab, icon, label, dirty }) {
29
- const selected = activeTab.value === tab;
30
- return html`
31
- <button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
32
- role="tab" aria-selected=${selected ? 'true' : 'false'}
33
- onClick=${() => selectTab(tab)}>
34
- <span class="nav-icon">${icon}</span>
35
- <span class="nav-label">${label}</span>
36
- </button>`;
37
- }
38
-
39
- // One row in the session tree. Click → open in main pane. Right-click /
40
- // long-press not implemented; "..." menu via the inline kebab.
41
- function SessionRow({ s }) {
42
- clockTick.value; // subscribe for fmtAgo refresh
43
- const isActive = activeSessionId.value === s.id;
44
- const running = s.status === 'running';
45
- const title = s.title || s.workspace || s.id.slice(0, 12);
46
-
47
- const onClick = async (ev) => {
48
- ev.preventDefault();
49
- selectSession(s.id);
50
- // Auto-resume on click if the session is stopped — saves the user
51
- // from a second click on the "Resume" button in the right pane.
52
- // No-op if already running.
53
- if (s.status !== 'running') {
54
- try { await resumeSession(s.id); }
55
- catch (e) { setToast(e.message, 'error'); }
56
- }
57
- };
58
-
59
- const onRenameClick = async (ev) => {
60
- ev.preventDefault();
61
- ev.stopPropagation();
62
- const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
63
- if (next === null) return;
64
- try { await setSessionTitle(s.id, next.trim()); }
65
- catch (e) { setToast(e.message, 'error'); }
66
- };
67
-
68
- const onDeleteClick = async (ev) => {
69
- ev.preventDefault();
70
- ev.stopPropagation();
71
- const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
72
- title: 'Delete session', okLabel: 'Delete', danger: true });
73
- if (!ok) return;
74
- try {
75
- await deleteSession(s.id);
76
- if (activeSessionId.value === s.id) activeSessionId.value = null;
77
- } catch (e) { setToast(e.message, 'error'); }
78
- };
79
-
80
- const onDragStart = (ev) => {
81
- draggingSessionId.value = s.id;
82
- ev.dataTransfer.effectAllowed = 'move';
83
- // Firefox refuses to start a drag without some data set.
84
- try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
85
- };
86
- const onDragEnd = () => {
87
- draggingSessionId.value = null;
88
- dragOverFolderKey.value = null;
89
- };
90
-
91
- return html`
92
- <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
93
- draggable=${true}
94
- onDragStart=${onDragStart}
95
- onDragEnd=${onDragEnd}
96
- onClick=${onClick}
97
- title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
98
- <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
99
- <span class="tree-label">${title}</span>
100
- <span class="tree-session-actions">
101
- <button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
102
- <button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
103
- </span>
104
- <span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
105
- </div>`;
106
- }
107
-
108
- function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
109
- const key = folderKey(folder);
110
- const collapsed = !!foldersCollapsed.value[key];
111
- const name = folder ? folder.name : 'Unsorted';
112
- const onToggle = () => toggleFolder(folder ? folder.id : null);
113
-
114
- const onRename = async (ev) => {
115
- ev.stopPropagation();
116
- if (!folder) return;
117
- const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
118
- if (next === null || !next.trim()) return;
119
- try { await renameFolder(folder.id, next.trim()); }
120
- catch (e) { setToast(e.message, 'error'); }
121
- };
122
-
123
- const onDelete = async (ev) => {
124
- ev.stopPropagation();
125
- if (!folder) return;
126
- const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
127
- title: 'Delete folder', okLabel: 'Delete', danger: true });
128
- if (!ok) return;
129
- try { await deleteFolder(folder.id); }
130
- catch (e) { setToast(e.message, 'error'); }
131
- };
132
-
133
- // Session-into-folder drop target. We don't go through useDragSort
134
- // because that one is wired for folder-reorder. Folder reorder's
135
- // handlers (in dndRow) short-circuit when no folder is being dragged,
136
- // and our handlers below short-circuit when no session is being
137
- // dragged — so composing both is safe.
138
- const draggedSession = draggingSessionId.value
139
- ? sessions.value.find((s) => s.id === draggingSessionId.value)
140
- : null;
141
- const sameFolder = draggedSession
142
- && (draggedSession.folderId || null) === (folder ? folder.id : null);
143
- const isOver = !sameFolder && dragOverFolderKey.value === key;
144
-
145
- const onSessionDragOver = (ev) => {
146
- if (!draggingSessionId.value || sameFolder) return;
147
- ev.preventDefault();
148
- ev.dataTransfer.dropEffect = 'move';
149
- if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
150
- };
151
- const onSessionDragLeave = (ev) => {
152
- if (!draggingSessionId.value) return;
153
- const rt = ev.relatedTarget;
154
- if (rt && ev.currentTarget.contains(rt)) return;
155
- if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
156
- };
157
- const onSessionDrop = (ev) => {
158
- const sid = draggingSessionId.value;
159
- draggingSessionId.value = null;
160
- dragOverFolderKey.value = null;
161
- if (!sid || sameFolder) return;
162
- ev.preventDefault();
163
- ev.stopPropagation();
164
- setSessionFolder(sid, folder ? folder.id : null)
165
- .then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
166
- .catch((e) => setToast(e.message, 'error'));
167
- };
168
-
169
- // Spread folder-reorder row handlers first, then compose our
170
- // session-drop handlers on top so both fire.
171
- const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
172
- const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
173
- const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
174
- const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
175
-
176
- return html`
177
- <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
178
- ...${rowAttrs}
179
- onDragOver=${composedOver}
180
- onDragLeave=${composedLeave}
181
- onDrop=${composedDrop}>
182
- <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
183
- ...${dndHandle || {}}>
184
- <span class="tree-folder-icon">
185
- ${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
186
- </span>
187
- <span class="tree-folder-name">${name}</span>
188
- ${folder ? html`
189
- <span class="tree-folder-actions">
190
- <button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
191
- <button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
192
- </span>` : null}
193
- </button>
194
- ${!collapsed ? html`
195
- <div class="tree-folder-body">
196
- ${sessionList.length === 0
197
- ? html`<div class="tree-empty">no sessions</div>`
198
- : sessionList.map((s) => html`<${SessionRow} key=${s.id} s=${s} />`)}
199
- </div>
200
- ` : null}
201
- </div>`;
202
- }
203
-
204
- function SessionTree() {
205
- const grouped = sessionsByFolder.value;
206
- const orderedFolders = folders.value;
207
- const dnd = useDragSort(
208
- orderedFolders.map((f) => f.id),
209
- async (nextIds) => {
210
- try { await reorderFolders(nextIds); }
211
- catch (e) { setToast(e.message, 'error'); }
212
- },
213
- );
214
-
215
- const onNewFolder = async () => {
216
- const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
217
- if (!name || !name.trim()) return;
218
- try { await createFolder(name.trim()); }
219
- catch (e) { setToast(e.message, 'error'); }
220
- };
221
-
222
- return html`
223
- <div class="tree">
224
- <div class="tree-head">
225
- <span class="tree-head-label">Sessions</span>
226
- <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
227
- <${IconPlus} />
228
- </button>
229
- </div>
230
- ${orderedFolders.map((f) => html`
231
- <${FolderGroup} key=${f.id} folder=${f}
232
- sessionList=${grouped.get(f.id) || []}
233
- dndHandle=${dnd.handleProps(f.id)}
234
- dndRow=${dnd.rowProps(f.id)} />`)}
235
- <${FolderGroup} folder=${null} sessionList=${grouped.get(null) || []} />
236
- </div>`;
237
- }
238
-
239
- export function Sidebar() {
240
- const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
241
- const forced = sidebarForcedCollapsed.value;
242
-
243
- const onResizeStart = (ev) => {
244
- if (collapsed) return;
245
- ev.preventDefault();
246
- const el = ev.currentTarget;
247
- el.setPointerCapture(ev.pointerId);
248
- document.body.classList.add('is-resizing-sidebar');
249
- const move = (e) => setSidebarWidth(e.clientX);
250
- const up = () => {
251
- try { el.releasePointerCapture(ev.pointerId); } catch {}
252
- document.body.classList.remove('is-resizing-sidebar');
253
- el.removeEventListener('pointermove', move);
254
- el.removeEventListener('pointerup', up);
255
- el.removeEventListener('pointercancel', up);
256
- };
257
- el.addEventListener('pointermove', move);
258
- el.addEventListener('pointerup', up);
259
- el.addEventListener('pointercancel', up);
260
- };
261
-
262
- return html`
263
- <aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
264
- <div class="sidebar-top">
265
- <button class="sidebar-brand sidebar-brand-button"
266
- role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
267
- title="About"
268
- onClick=${() => selectTab('about')}>
269
- <span class="brand-mark"><${BrandMark} /></span>
270
- <span class="brand-name">CCSM<span class="brand-dot">.</span></span>
271
- </button>
272
- </div>
273
-
274
- <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
275
- <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
276
- <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
277
- </nav>
278
-
279
- ${!collapsed ? html`<${SessionTree} />` : null}
280
-
281
- <div class="sidebar-foot">
282
- ${!forced ? html`
283
- <button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
284
- title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
285
- onClick=${toggleSidebar}>
286
- <span class="nav-icon"><${IconSidebarToggle} /></span>
287
- </button>
288
- ` : null}
289
- </div>
290
-
291
- ${!collapsed ? html`
292
- <div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
293
- aria-label="resize sidebar"
294
- title="drag to resize · double-click to reset"
295
- onPointerDown=${onResizeStart}
296
- onDblClick=${() => setSidebarWidth(232)}></div>
297
- ` : null}
298
- </aside>`;
299
- }
1
+ import { html } from '../html.js';
2
+ import { signal } from '@preact/signals';
3
+ import {
4
+ activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
5
+ sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
6
+ selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
7
+ } from '../state.js';
8
+ import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
+ import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
10
+ import { setToast } from '../toast.js';
11
+ import { fmtAgo } from '../util.js';
12
+ import { clockTick } from '../state.js';
13
+ import { useDragSort } from './useDragSort.js';
14
+ import {
15
+ IconLaunch, IconConfigure,
16
+ IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
17
+ } from '../icons.js';
18
+
19
+ // Module-level drag state for session → folder moves. Lives outside the
20
+ // useDragSort hook (which handles same-list folder reorder) so the two
21
+ // don't interfere — session drags and folder drags use disjoint state.
22
+ // Folder key: folder.id for real folders, the literal string 'unsorted'
23
+ // for the implicit top-level Unsorted bucket.
24
+ const draggingSessionId = signal(null);
25
+ const dragOverFolderKey = signal(null);
26
+ const folderKey = (folder) => folder ? folder.id : 'unsorted';
27
+
28
+ function NavItem({ tab, icon, label, dirty }) {
29
+ const selected = activeTab.value === tab;
30
+ return html`
31
+ <button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
32
+ role="tab" aria-selected=${selected ? 'true' : 'false'}
33
+ onClick=${() => selectTab(tab)}>
34
+ <span class="nav-icon">${icon}</span>
35
+ <span class="nav-label">${label}</span>
36
+ </button>`;
37
+ }
38
+
39
+ // One row in the session tree. Click → open in main pane. Right-click /
40
+ // long-press not implemented; "..." menu via the inline kebab.
41
+ function SessionRow({ s }) {
42
+ clockTick.value; // subscribe for fmtAgo refresh
43
+ const isActive = activeSessionId.value === s.id;
44
+ const running = s.status === 'running';
45
+ const title = s.title || s.workspace || s.id.slice(0, 12);
46
+
47
+ const onClick = async (ev) => {
48
+ ev.preventDefault();
49
+ selectSession(s.id);
50
+ // Auto-resume on click if the session is stopped — saves the user
51
+ // from a second click on the "Resume" button in the right pane.
52
+ // No-op if already running.
53
+ if (s.status !== 'running') {
54
+ try { await resumeSession(s.id); }
55
+ catch (e) { setToast(e.message, 'error'); }
56
+ }
57
+ };
58
+
59
+ const onRenameClick = async (ev) => {
60
+ ev.preventDefault();
61
+ ev.stopPropagation();
62
+ const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
63
+ if (next === null) return;
64
+ try { await setSessionTitle(s.id, next.trim()); }
65
+ catch (e) { setToast(e.message, 'error'); }
66
+ };
67
+
68
+ const onDeleteClick = async (ev) => {
69
+ ev.preventDefault();
70
+ ev.stopPropagation();
71
+ const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
72
+ title: 'Delete session', okLabel: 'Delete', danger: true });
73
+ if (!ok) return;
74
+ try {
75
+ await deleteSession(s.id);
76
+ if (activeSessionId.value === s.id) activeSessionId.value = null;
77
+ } catch (e) { setToast(e.message, 'error'); }
78
+ };
79
+
80
+ const onDragStart = (ev) => {
81
+ draggingSessionId.value = s.id;
82
+ ev.dataTransfer.effectAllowed = 'move';
83
+ // Firefox refuses to start a drag without some data set.
84
+ try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
85
+ };
86
+ const onDragEnd = () => {
87
+ draggingSessionId.value = null;
88
+ dragOverFolderKey.value = null;
89
+ };
90
+
91
+ return html`
92
+ <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}
93
+ draggable=${true}
94
+ onDragStart=${onDragStart}
95
+ onDragEnd=${onDragEnd}
96
+ onClick=${onClick}
97
+ title=${`${title}\n${s.cwd}\n${running ? (s.activity === 'working' ? 'working' : 'idle') : 'stopped'} · ${s.cliId}`}>
98
+ <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}></span>
99
+ <span class="tree-label">${title}</span>
100
+ <span class="tree-session-actions">
101
+ <button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
102
+ <button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
103
+ </span>
104
+ <span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
105
+ </div>`;
106
+ }
107
+
108
+ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
109
+ const key = folderKey(folder);
110
+ const collapsed = !!foldersCollapsed.value[key];
111
+ const name = folder ? folder.name : 'Unsorted';
112
+ const onToggle = () => toggleFolder(folder ? folder.id : null);
113
+
114
+ const onRename = async (ev) => {
115
+ ev.stopPropagation();
116
+ if (!folder) return;
117
+ const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
118
+ if (next === null || !next.trim()) return;
119
+ try { await renameFolder(folder.id, next.trim()); }
120
+ catch (e) { setToast(e.message, 'error'); }
121
+ };
122
+
123
+ const onDelete = async (ev) => {
124
+ ev.stopPropagation();
125
+ if (!folder) return;
126
+ const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
127
+ title: 'Delete folder', okLabel: 'Delete', danger: true });
128
+ if (!ok) return;
129
+ try { await deleteFolder(folder.id); }
130
+ catch (e) { setToast(e.message, 'error'); }
131
+ };
132
+
133
+ // Session-into-folder drop target. We don't go through useDragSort
134
+ // because that one is wired for folder-reorder. Folder reorder's
135
+ // handlers (in dndRow) short-circuit when no folder is being dragged,
136
+ // and our handlers below short-circuit when no session is being
137
+ // dragged — so composing both is safe.
138
+ const draggedSession = draggingSessionId.value
139
+ ? sessions.value.find((s) => s.id === draggingSessionId.value)
140
+ : null;
141
+ const sameFolder = draggedSession
142
+ && (draggedSession.folderId || null) === (folder ? folder.id : null);
143
+ const isOver = !sameFolder && dragOverFolderKey.value === key;
144
+
145
+ const onSessionDragOver = (ev) => {
146
+ if (!draggingSessionId.value || sameFolder) return;
147
+ ev.preventDefault();
148
+ ev.dataTransfer.dropEffect = 'move';
149
+ if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
150
+ };
151
+ const onSessionDragLeave = (ev) => {
152
+ if (!draggingSessionId.value) return;
153
+ const rt = ev.relatedTarget;
154
+ if (rt && ev.currentTarget.contains(rt)) return;
155
+ if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
156
+ };
157
+ const onSessionDrop = (ev) => {
158
+ const sid = draggingSessionId.value;
159
+ draggingSessionId.value = null;
160
+ dragOverFolderKey.value = null;
161
+ if (!sid || sameFolder) return;
162
+ ev.preventDefault();
163
+ ev.stopPropagation();
164
+ setSessionFolder(sid, folder ? folder.id : null)
165
+ .then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
166
+ .catch((e) => setToast(e.message, 'error'));
167
+ };
168
+
169
+ // Spread folder-reorder row handlers first, then compose our
170
+ // session-drop handlers on top so both fire.
171
+ const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
172
+ const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
173
+ const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
174
+ const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
175
+
176
+ return html`
177
+ <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
178
+ ...${rowAttrs}
179
+ onDragOver=${composedOver}
180
+ onDragLeave=${composedLeave}
181
+ onDrop=${composedDrop}>
182
+ <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
183
+ ...${dndHandle || {}}>
184
+ <span class="tree-folder-icon">
185
+ ${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
186
+ </span>
187
+ <span class="tree-folder-name">${name}</span>
188
+ ${folder ? html`
189
+ <span class="tree-folder-actions">
190
+ <button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
191
+ <button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
192
+ </span>` : null}
193
+ </button>
194
+ ${!collapsed ? html`
195
+ <div class="tree-folder-body">
196
+ ${sessionList.length === 0
197
+ ? html`<div class="tree-empty">no sessions</div>`
198
+ : sessionList.map((s) => html`<${SessionRow} key=${s.id} s=${s} />`)}
199
+ </div>
200
+ ` : null}
201
+ </div>`;
202
+ }
203
+
204
+ function SessionTree() {
205
+ const grouped = sessionsByFolder.value;
206
+ const orderedFolders = folders.value;
207
+ const dnd = useDragSort(
208
+ orderedFolders.map((f) => f.id),
209
+ async (nextIds) => {
210
+ try { await reorderFolders(nextIds); }
211
+ catch (e) { setToast(e.message, 'error'); }
212
+ },
213
+ );
214
+
215
+ const onNewFolder = async () => {
216
+ const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
217
+ if (!name || !name.trim()) return;
218
+ try { await createFolder(name.trim()); }
219
+ catch (e) { setToast(e.message, 'error'); }
220
+ };
221
+
222
+ return html`
223
+ <div class="tree">
224
+ <div class="tree-head">
225
+ <span class="tree-head-label">Sessions</span>
226
+ <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
227
+ <${IconPlus} />
228
+ </button>
229
+ </div>
230
+ ${orderedFolders.map((f) => html`
231
+ <${FolderGroup} key=${f.id} folder=${f}
232
+ sessionList=${grouped.get(f.id) || []}
233
+ dndHandle=${dnd.handleProps(f.id)}
234
+ dndRow=${dnd.rowProps(f.id)} />`)}
235
+ <${FolderGroup} folder=${null} sessionList=${grouped.get(null) || []} />
236
+ </div>`;
237
+ }
238
+
239
+ export function Sidebar() {
240
+ const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
241
+ const forced = sidebarForcedCollapsed.value;
242
+
243
+ const onResizeStart = (ev) => {
244
+ if (collapsed) return;
245
+ ev.preventDefault();
246
+ const el = ev.currentTarget;
247
+ el.setPointerCapture(ev.pointerId);
248
+ document.body.classList.add('is-resizing-sidebar');
249
+ const move = (e) => setSidebarWidth(e.clientX);
250
+ const up = () => {
251
+ try { el.releasePointerCapture(ev.pointerId); } catch {}
252
+ document.body.classList.remove('is-resizing-sidebar');
253
+ el.removeEventListener('pointermove', move);
254
+ el.removeEventListener('pointerup', up);
255
+ el.removeEventListener('pointercancel', up);
256
+ };
257
+ el.addEventListener('pointermove', move);
258
+ el.addEventListener('pointerup', up);
259
+ el.addEventListener('pointercancel', up);
260
+ };
261
+
262
+ return html`
263
+ <aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
264
+ <div class="sidebar-top">
265
+ <button class="sidebar-brand sidebar-brand-button"
266
+ role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
267
+ title="About"
268
+ onClick=${() => selectTab('about')}>
269
+ <span class="brand-mark"><${BrandMark} /></span>
270
+ <span class="brand-name">CCSM<span class="brand-dot">.</span></span>
271
+ </button>
272
+ </div>
273
+
274
+ <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
275
+ <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
276
+ <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
277
+ </nav>
278
+
279
+ ${!collapsed ? html`<${SessionTree} />` : null}
280
+
281
+ <div class="sidebar-foot">
282
+ ${!forced ? html`
283
+ <button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
284
+ title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
285
+ onClick=${toggleSidebar}>
286
+ <span class="nav-icon"><${IconSidebarToggle} /></span>
287
+ </button>
288
+ ` : null}
289
+ </div>
290
+
291
+ ${!collapsed ? html`
292
+ <div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
293
+ aria-label="resize sidebar"
294
+ title="drag to resize · double-click to reset"
295
+ onPointerDown=${onResizeStart}
296
+ onDblClick=${() => setSidebarWidth(232)}></div>
297
+ ` : null}
298
+ </aside>`;
299
+ }