@bakapiano/ccsm 0.22.6 → 0.22.7

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 (58) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +279 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +154 -154
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +546 -546
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +28 -0
  42. package/public/js/components/useDragSort.js +67 -67
  43. package/public/js/dialog.js +67 -67
  44. package/public/js/icons.js +212 -212
  45. package/public/js/main.js +296 -296
  46. package/public/js/pages/AboutPage.js +90 -90
  47. package/public/js/pages/ConfigurePage.js +728 -713
  48. package/public/js/pages/LaunchPage.js +421 -421
  49. package/public/js/pages/RemotePage.js +743 -743
  50. package/public/js/pages/SessionsPage.js +53 -53
  51. package/public/js/state.js +335 -335
  52. package/scripts/dev.js +149 -149
  53. package/scripts/install.js +153 -153
  54. package/scripts/restart-helper.js +96 -96
  55. package/scripts/upgrade-helper.js +687 -687
  56. package/server.js +1820 -1807
  57. package/public/manifest.webmanifest +0 -25
  58. package/public/setup/index.html +0 -567
@@ -1,386 +1,386 @@
1
- import { html } from '../html.js';
2
- import { signal } from '@preact/signals';
3
- import {
4
- activeTab, sidebarCollapsed, sidebarForcedCollapsed, isMobile, 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, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
- import { isRemoteAccess } from '../backend.js';
10
- import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
11
- import { setToast } from '../toast.js';
12
- import { fmtAgo } from '../util.js';
13
- import { clockTick } from '../state.js';
14
- import { useDragSort } from './useDragSort.js';
15
- import {
16
- IconLaunch, IconConfigure, IconRemote,
17
- IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
18
- } from '../icons.js';
19
-
20
- // Module-level drag state for session → folder moves. Lives outside the
21
- // useDragSort hook (which handles same-list folder reorder) so the two
22
- // don't interfere — session drags and folder drags use disjoint state.
23
- // Folder key: folder.id for real folders, the literal string 'unsorted'
24
- // for the implicit top-level Unsorted bucket.
25
- const draggingSessionId = signal(null);
26
- const dragOverFolderKey = signal(null);
27
- const folderKey = (folder) => folder ? folder.id : 'unsorted';
28
-
29
- function NavItem({ tab, icon, label, dirty }) {
30
- const selected = activeTab.value === tab;
31
- return html`
32
- <button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
33
- role="tab" aria-selected=${selected ? 'true' : 'false'}
34
- onClick=${() => selectTab(tab)}>
35
- <span class="nav-icon">${icon}</span>
36
- <span class="nav-label">${label}</span>
37
- </button>`;
38
- }
39
-
40
- // Module-level: the SessionRow currently being hovered as a reorder
41
- // drop target. Set on dragOver, cleared on dragLeave/end. Drives the
42
- // "above this row" insert-line indicator.
43
- const reorderOverSessionId = signal(null);
44
-
45
- // One row in the session tree. Click → open in main pane. Drag-to-folder
46
- // is handled by FolderGroup's drop zone; same-folder reorder is handled
47
- // here: the row is a drop target when an in-folder sibling is dragged.
48
- function SessionRow({ s, folderId, siblingIds }) {
49
- clockTick.value; // subscribe for fmtAgo refresh
50
- const isActive = activeSessionId.value === s.id;
51
- const running = s.status === 'running';
52
- const title = s.title || s.workspace || s.id.slice(0, 12);
53
-
54
- const onClick = async (ev) => {
55
- ev.preventDefault();
56
- selectSession(s.id);
1
+ import { html } from '../html.js';
2
+ import { signal } from '@preact/signals';
3
+ import {
4
+ activeTab, sidebarCollapsed, sidebarForcedCollapsed, isMobile, 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, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
+ import { isRemoteAccess } from '../backend.js';
10
+ import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
11
+ import { setToast } from '../toast.js';
12
+ import { fmtAgo } from '../util.js';
13
+ import { clockTick } from '../state.js';
14
+ import { useDragSort } from './useDragSort.js';
15
+ import {
16
+ IconLaunch, IconConfigure, IconRemote,
17
+ IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
18
+ } from '../icons.js';
19
+
20
+ // Module-level drag state for session → folder moves. Lives outside the
21
+ // useDragSort hook (which handles same-list folder reorder) so the two
22
+ // don't interfere — session drags and folder drags use disjoint state.
23
+ // Folder key: folder.id for real folders, the literal string 'unsorted'
24
+ // for the implicit top-level Unsorted bucket.
25
+ const draggingSessionId = signal(null);
26
+ const dragOverFolderKey = signal(null);
27
+ const folderKey = (folder) => folder ? folder.id : 'unsorted';
28
+
29
+ function NavItem({ tab, icon, label, dirty }) {
30
+ const selected = activeTab.value === tab;
31
+ return html`
32
+ <button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
33
+ role="tab" aria-selected=${selected ? 'true' : 'false'}
34
+ onClick=${() => selectTab(tab)}>
35
+ <span class="nav-icon">${icon}</span>
36
+ <span class="nav-label">${label}</span>
37
+ </button>`;
38
+ }
39
+
40
+ // Module-level: the SessionRow currently being hovered as a reorder
41
+ // drop target. Set on dragOver, cleared on dragLeave/end. Drives the
42
+ // "above this row" insert-line indicator.
43
+ const reorderOverSessionId = signal(null);
44
+
45
+ // One row in the session tree. Click → open in main pane. Drag-to-folder
46
+ // is handled by FolderGroup's drop zone; same-folder reorder is handled
47
+ // here: the row is a drop target when an in-folder sibling is dragged.
48
+ function SessionRow({ s, folderId, siblingIds }) {
49
+ clockTick.value; // subscribe for fmtAgo refresh
50
+ const isActive = activeSessionId.value === s.id;
51
+ const running = s.status === 'running';
52
+ const title = s.title || s.workspace || s.id.slice(0, 12);
53
+
54
+ const onClick = async (ev) => {
55
+ ev.preventDefault();
56
+ selectSession(s.id);
57
57
  // Auto-resume on click if the session stopped on its own. Explicitly
58
58
  // stopped sessions stay stopped until the user presses Resume.
59
59
  if (s.status !== 'running' && !s.manualStopped) {
60
60
  try { await resumeSession(s.id); }
61
61
  catch (e) { setToast(e.message, 'error'); }
62
62
  }
63
- };
64
-
65
- const onRenameClick = async (ev) => {
66
- ev.preventDefault();
67
- ev.stopPropagation();
68
- const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
69
- if (next === null) return;
70
- try { await setSessionTitle(s.id, next.trim()); }
71
- catch (e) { setToast(e.message, 'error'); }
72
- };
73
-
74
- const onDeleteClick = async (ev) => {
75
- ev.preventDefault();
76
- ev.stopPropagation();
77
- const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
78
- title: 'Delete session', okLabel: 'Delete', danger: true });
79
- if (!ok) return;
80
- try {
81
- await deleteSession(s.id);
82
- if (activeSessionId.value === s.id) activeSessionId.value = null;
83
- } catch (e) { setToast(e.message, 'error'); }
84
- };
85
-
86
- const onDragStart = (ev) => {
87
- draggingSessionId.value = s.id;
88
- ev.dataTransfer.effectAllowed = 'move';
89
- try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
90
- };
91
- const onDragEnd = () => {
92
- draggingSessionId.value = null;
93
- dragOverFolderKey.value = null;
94
- reorderOverSessionId.value = null;
95
- };
96
-
97
- // Drop on a session row → place the dragged session at THIS row's
98
- // position. Same folder = pure reorder. Different folder = move +
99
- // position in one shot (reorderSessions sets both folderId and
100
- // order in one backend call). stopPropagation so .tree-folder
101
- // doesn't also fire its "drop into folder" handler — landing on a
102
- // row is the more specific intent.
103
- const draggedId = draggingSessionId.value;
104
- const acceptDrop = !!draggedId && draggedId !== s.id;
105
- const showInsertLine = acceptDrop && reorderOverSessionId.value === s.id;
106
-
107
- const onRowDragOver = (ev) => {
108
- if (!acceptDrop) return;
109
- ev.preventDefault();
110
- ev.stopPropagation();
111
- ev.dataTransfer.dropEffect = 'move';
112
- if (reorderOverSessionId.value !== s.id) reorderOverSessionId.value = s.id;
113
- // Also clear the parent folder's drop-target highlight — we're
114
- // overriding to "drop on this row" semantics.
115
- if (dragOverFolderKey.value) dragOverFolderKey.value = null;
116
- };
117
- const onRowDragLeave = (ev) => {
118
- if (!acceptDrop) return;
119
- const rt = ev.relatedTarget;
120
- if (rt && ev.currentTarget.contains(rt)) return;
121
- if (reorderOverSessionId.value === s.id) reorderOverSessionId.value = null;
122
- };
123
- const onRowDrop = (ev) => {
124
- if (!acceptDrop) return;
125
- ev.preventDefault();
126
- ev.stopPropagation();
127
- const draggedSid = draggingSessionId.value;
128
- draggingSessionId.value = null;
129
- reorderOverSessionId.value = null;
130
- dragOverFolderKey.value = null;
131
- if (!draggedSid || !siblingIds) return;
132
- // Build the new sibling sequence: remove dragged (in case it was
133
- // already in this folder) then insert at this row's slot.
134
- const next = siblingIds.filter((id) => id !== draggedSid);
135
- const targetIdx = next.indexOf(s.id);
136
- if (targetIdx < 0) return;
137
- next.splice(targetIdx, 0, draggedSid);
138
- reorderSessions(folderId || null, next)
139
- .catch((e) => setToast(e.message, 'error'));
140
- };
141
-
142
- // Skip the HTML5 drag affordance on touch devices — `draggable=true`
143
- // makes mobile browsers interpret the first tap as a drag-start
144
- // gesture, swallowing the click event entirely. The user then needs
145
- // a second tap to navigate. Touch users don't reorder sessions by
146
- // drag anyway; we'd add a dedicated "move to folder" affordance if
147
- // anyone asked.
148
- const touchDevice = isMobile.value || (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches);
149
- return html`
150
- <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}${showInsertLine ? ' is-reorder-target' : ''}`}
151
- draggable=${!touchDevice}
152
- onDragStart=${onDragStart}
153
- onDragEnd=${onDragEnd}
154
- onDragOver=${onRowDragOver}
155
- onDragLeave=${onRowDragLeave}
156
- onDrop=${onRowDrop}
157
- onClick=${onClick}
158
- title=${`${title}\n${s.cwd}\n${running ? (s.activity === 'working' ? 'working' : 'idle') : 'stopped'} · ${s.cliId}`}>
159
- <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}></span>
160
- <span class="tree-label">${title}</span>
161
- <span class="tree-session-actions">
162
- <button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
163
- <button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
164
- </span>
165
- <span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
166
- </div>`;
167
- }
168
-
169
- function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
170
- // folder is now always set — backend materializes a synthetic
171
- // {id:'unsorted', name:'Unsorted', builtin:true} entry alongside the
172
- // user folders. The bucket can be drag-reordered like any other but
173
- // Rename / Delete are hidden, and drops set folderId=null so existing
174
- // sessions don't need a data migration.
175
- const isUnsorted = folder?.id === 'unsorted' || folder?.builtin;
176
- const key = folder ? folder.id : 'unsorted';
177
- const collapsed = !!foldersCollapsed.value[key];
178
- const name = folder ? folder.name : 'Unsorted';
179
- const onToggle = () => toggleFolder(folder ? folder.id : null);
180
-
181
- const onRename = async (ev) => {
182
- ev.stopPropagation();
183
- if (!folder || isUnsorted) return;
184
- const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
185
- if (next === null || !next.trim()) return;
186
- try { await renameFolder(folder.id, next.trim()); }
187
- catch (e) { setToast(e.message, 'error'); }
188
- };
189
-
190
- const onDelete = async (ev) => {
191
- ev.stopPropagation();
192
- if (!folder || isUnsorted) return;
193
- const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
194
- title: 'Delete folder', okLabel: 'Delete', danger: true });
195
- if (!ok) return;
196
- try { await deleteFolder(folder.id); }
197
- catch (e) { setToast(e.message, 'error'); }
198
- };
199
-
200
- // Session-into-folder drop target. We don't go through useDragSort
201
- // because that one is wired for folder-reorder. Folder reorder's
202
- // handlers (in dndRow) short-circuit when no folder is being dragged,
203
- // and our handlers below short-circuit when no session is being
204
- // dragged — so composing both is safe.
205
- // When the dragged session lands on the Unsorted bucket, we persist
206
- // it with folderId=null (matches the existing data model — sessions
207
- // with no folder are null, not 'unsorted'). Same for the sameFolder
208
- // guard below.
209
- const dropFolderId = isUnsorted ? null : (folder ? folder.id : null);
210
- const draggedSession = draggingSessionId.value
211
- ? sessions.value.find((s) => s.id === draggingSessionId.value)
212
- : null;
213
- const sameFolder = draggedSession
214
- && (draggedSession.folderId || null) === dropFolderId;
215
- const isOver = !sameFolder && dragOverFolderKey.value === key;
216
-
217
- const onSessionDragOver = (ev) => {
218
- if (!draggingSessionId.value || sameFolder) return;
219
- ev.preventDefault();
220
- ev.dataTransfer.dropEffect = 'move';
221
- if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
222
- };
223
- const onSessionDragLeave = (ev) => {
224
- if (!draggingSessionId.value) return;
225
- const rt = ev.relatedTarget;
226
- if (rt && ev.currentTarget.contains(rt)) return;
227
- if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
228
- };
229
- const onSessionDrop = (ev) => {
230
- const sid = draggingSessionId.value;
231
- draggingSessionId.value = null;
232
- dragOverFolderKey.value = null;
233
- if (!sid || sameFolder) return;
234
- ev.preventDefault();
235
- ev.stopPropagation();
236
- setSessionFolder(sid, dropFolderId)
237
- .then(() => setToast(`moved to ${name}`))
238
- .catch((e) => setToast(e.message, 'error'));
239
- };
240
-
241
- // Spread folder-reorder row handlers first, then compose our
242
- // session-drop handlers on top so both fire.
243
- const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
244
- const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
245
- const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
246
- const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
247
-
248
- return html`
249
- <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
250
- ...${rowAttrs}
251
- onDragOver=${composedOver}
252
- onDragLeave=${composedLeave}
253
- onDrop=${composedDrop}>
254
- <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
255
- ...${dndHandle || {}}>
256
- <span class="tree-folder-icon">
257
- ${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
258
- </span>
259
- <span class="tree-folder-name">${name}</span>
260
- ${folder && !isUnsorted ? html`
261
- <span class="tree-folder-actions">
262
- <button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
263
- <button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
264
- </span>` : null}
265
- </button>
266
- ${!collapsed ? html`
267
- <div class="tree-folder-body">
268
- ${sessionList.length === 0
269
- ? html`<div class="tree-empty">no sessions</div>`
270
- : (() => {
271
- // siblingIds captured once per render so each row sees a
272
- // consistent snapshot for splice math.
273
- const siblingIds = sessionList.map((x) => x.id);
274
- return sessionList.map((s) => html`
275
- <${SessionRow} key=${s.id} s=${s}
276
- folderId=${dropFolderId}
277
- siblingIds=${siblingIds} />`);
278
- })()}
279
- </div>
280
- ` : null}
281
- </div>`;
282
- }
283
-
284
- function SessionTree() {
285
- const grouped = sessionsByFolder.value;
286
- const orderedFolders = folders.value;
287
- const dnd = useDragSort(
288
- orderedFolders.map((f) => f.id),
289
- async (nextIds) => {
290
- try { await reorderFolders(nextIds); }
291
- catch (e) { setToast(e.message, 'error'); }
292
- },
293
- );
294
-
295
- const onNewFolder = async () => {
296
- const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
297
- if (!name || !name.trim()) return;
298
- try { await createFolder(name.trim()); }
299
- catch (e) { setToast(e.message, 'error'); }
300
- };
301
-
302
- return html`
303
- <div class="tree">
304
- <div class="tree-head">
305
- <span class="tree-head-label">Sessions</span>
306
- <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
307
- <${IconPlus} />
308
- </button>
309
- </div>
310
- ${orderedFolders.map((f) => html`
311
- <${FolderGroup} key=${f.id} folder=${f}
312
- sessionList=${grouped.get(f.id) || []}
313
- dndHandle=${dnd.handleProps(f.id)}
314
- dndRow=${dnd.rowProps(f.id)} />`)}
315
- </div>`;
316
- }
317
-
318
- export function Sidebar() {
319
- // On phones the sidebar is rendered inside a full-screen drawer
320
- // (App applies .is-mobile + .drawer-open classes). It should always
321
- // appear in EXPANDED form there — full labels + sessions tree.
322
- // Desktop/tablet keeps the original collapse behaviour.
323
- const mobile = isMobile.value;
324
- const collapsed = !mobile && (sidebarCollapsed.value || sidebarForcedCollapsed.value);
325
- const forced = !mobile && sidebarForcedCollapsed.value;
326
-
327
- const onResizeStart = (ev) => {
328
- if (collapsed) return;
329
- ev.preventDefault();
330
- const el = ev.currentTarget;
331
- el.setPointerCapture(ev.pointerId);
332
- document.body.classList.add('is-resizing-sidebar');
333
- const move = (e) => setSidebarWidth(e.clientX);
334
- const up = () => {
335
- try { el.releasePointerCapture(ev.pointerId); } catch {}
336
- document.body.classList.remove('is-resizing-sidebar');
337
- el.removeEventListener('pointermove', move);
338
- el.removeEventListener('pointerup', up);
339
- el.removeEventListener('pointercancel', up);
340
- };
341
- el.addEventListener('pointermove', move);
342
- el.addEventListener('pointerup', up);
343
- el.addEventListener('pointercancel', up);
344
- };
345
-
346
- return html`
347
- <aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
348
- <div class="sidebar-top">
349
- <button class="sidebar-brand sidebar-brand-button"
350
- role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
351
- title="About"
352
- onClick=${() => selectTab('about')}>
353
- <span class="brand-mark"><${BrandMark} /></span>
354
- <span class="brand-name">CCSM</span>
355
- </button>
356
- </div>
357
-
358
- <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
359
- <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
360
- ${!isRemoteAccess() ? html`
361
- <${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
362
- ` : null}
363
- <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
364
- </nav>
365
-
366
- ${!collapsed ? html`<${SessionTree} />` : null}
367
-
368
- <div class="sidebar-foot">
369
- ${!forced ? html`
370
- <button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
371
- title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
372
- onClick=${toggleSidebar}>
373
- <span class="nav-icon"><${IconSidebarToggle} /></span>
374
- </button>
375
- ` : null}
376
- </div>
377
-
378
- ${!collapsed ? html`
379
- <div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
380
- aria-label="resize sidebar"
381
- title="drag to resize · double-click to reset"
382
- onPointerDown=${onResizeStart}
383
- onDblClick=${() => setSidebarWidth(232)}></div>
384
- ` : null}
385
- </aside>`;
386
- }
63
+ };
64
+
65
+ const onRenameClick = async (ev) => {
66
+ ev.preventDefault();
67
+ ev.stopPropagation();
68
+ const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
69
+ if (next === null) return;
70
+ try { await setSessionTitle(s.id, next.trim()); }
71
+ catch (e) { setToast(e.message, 'error'); }
72
+ };
73
+
74
+ const onDeleteClick = async (ev) => {
75
+ ev.preventDefault();
76
+ ev.stopPropagation();
77
+ const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
78
+ title: 'Delete session', okLabel: 'Delete', danger: true });
79
+ if (!ok) return;
80
+ try {
81
+ await deleteSession(s.id);
82
+ if (activeSessionId.value === s.id) activeSessionId.value = null;
83
+ } catch (e) { setToast(e.message, 'error'); }
84
+ };
85
+
86
+ const onDragStart = (ev) => {
87
+ draggingSessionId.value = s.id;
88
+ ev.dataTransfer.effectAllowed = 'move';
89
+ try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
90
+ };
91
+ const onDragEnd = () => {
92
+ draggingSessionId.value = null;
93
+ dragOverFolderKey.value = null;
94
+ reorderOverSessionId.value = null;
95
+ };
96
+
97
+ // Drop on a session row → place the dragged session at THIS row's
98
+ // position. Same folder = pure reorder. Different folder = move +
99
+ // position in one shot (reorderSessions sets both folderId and
100
+ // order in one backend call). stopPropagation so .tree-folder
101
+ // doesn't also fire its "drop into folder" handler — landing on a
102
+ // row is the more specific intent.
103
+ const draggedId = draggingSessionId.value;
104
+ const acceptDrop = !!draggedId && draggedId !== s.id;
105
+ const showInsertLine = acceptDrop && reorderOverSessionId.value === s.id;
106
+
107
+ const onRowDragOver = (ev) => {
108
+ if (!acceptDrop) return;
109
+ ev.preventDefault();
110
+ ev.stopPropagation();
111
+ ev.dataTransfer.dropEffect = 'move';
112
+ if (reorderOverSessionId.value !== s.id) reorderOverSessionId.value = s.id;
113
+ // Also clear the parent folder's drop-target highlight — we're
114
+ // overriding to "drop on this row" semantics.
115
+ if (dragOverFolderKey.value) dragOverFolderKey.value = null;
116
+ };
117
+ const onRowDragLeave = (ev) => {
118
+ if (!acceptDrop) return;
119
+ const rt = ev.relatedTarget;
120
+ if (rt && ev.currentTarget.contains(rt)) return;
121
+ if (reorderOverSessionId.value === s.id) reorderOverSessionId.value = null;
122
+ };
123
+ const onRowDrop = (ev) => {
124
+ if (!acceptDrop) return;
125
+ ev.preventDefault();
126
+ ev.stopPropagation();
127
+ const draggedSid = draggingSessionId.value;
128
+ draggingSessionId.value = null;
129
+ reorderOverSessionId.value = null;
130
+ dragOverFolderKey.value = null;
131
+ if (!draggedSid || !siblingIds) return;
132
+ // Build the new sibling sequence: remove dragged (in case it was
133
+ // already in this folder) then insert at this row's slot.
134
+ const next = siblingIds.filter((id) => id !== draggedSid);
135
+ const targetIdx = next.indexOf(s.id);
136
+ if (targetIdx < 0) return;
137
+ next.splice(targetIdx, 0, draggedSid);
138
+ reorderSessions(folderId || null, next)
139
+ .catch((e) => setToast(e.message, 'error'));
140
+ };
141
+
142
+ // Skip the HTML5 drag affordance on touch devices — `draggable=true`
143
+ // makes mobile browsers interpret the first tap as a drag-start
144
+ // gesture, swallowing the click event entirely. The user then needs
145
+ // a second tap to navigate. Touch users don't reorder sessions by
146
+ // drag anyway; we'd add a dedicated "move to folder" affordance if
147
+ // anyone asked.
148
+ const touchDevice = isMobile.value || (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches);
149
+ return html`
150
+ <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}${showInsertLine ? ' is-reorder-target' : ''}`}
151
+ draggable=${!touchDevice}
152
+ onDragStart=${onDragStart}
153
+ onDragEnd=${onDragEnd}
154
+ onDragOver=${onRowDragOver}
155
+ onDragLeave=${onRowDragLeave}
156
+ onDrop=${onRowDrop}
157
+ onClick=${onClick}
158
+ title=${`${title}\n${s.cwd}\n${running ? (s.activity === 'working' ? 'working' : 'idle') : 'stopped'} · ${s.cliId}`}>
159
+ <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}></span>
160
+ <span class="tree-label">${title}</span>
161
+ <span class="tree-session-actions">
162
+ <button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
163
+ <button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
164
+ </span>
165
+ <span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
166
+ </div>`;
167
+ }
168
+
169
+ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
170
+ // folder is now always set — backend materializes a synthetic
171
+ // {id:'unsorted', name:'Unsorted', builtin:true} entry alongside the
172
+ // user folders. The bucket can be drag-reordered like any other but
173
+ // Rename / Delete are hidden, and drops set folderId=null so existing
174
+ // sessions don't need a data migration.
175
+ const isUnsorted = folder?.id === 'unsorted' || folder?.builtin;
176
+ const key = folder ? folder.id : 'unsorted';
177
+ const collapsed = !!foldersCollapsed.value[key];
178
+ const name = folder ? folder.name : 'Unsorted';
179
+ const onToggle = () => toggleFolder(folder ? folder.id : null);
180
+
181
+ const onRename = async (ev) => {
182
+ ev.stopPropagation();
183
+ if (!folder || isUnsorted) return;
184
+ const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
185
+ if (next === null || !next.trim()) return;
186
+ try { await renameFolder(folder.id, next.trim()); }
187
+ catch (e) { setToast(e.message, 'error'); }
188
+ };
189
+
190
+ const onDelete = async (ev) => {
191
+ ev.stopPropagation();
192
+ if (!folder || isUnsorted) return;
193
+ const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
194
+ title: 'Delete folder', okLabel: 'Delete', danger: true });
195
+ if (!ok) return;
196
+ try { await deleteFolder(folder.id); }
197
+ catch (e) { setToast(e.message, 'error'); }
198
+ };
199
+
200
+ // Session-into-folder drop target. We don't go through useDragSort
201
+ // because that one is wired for folder-reorder. Folder reorder's
202
+ // handlers (in dndRow) short-circuit when no folder is being dragged,
203
+ // and our handlers below short-circuit when no session is being
204
+ // dragged — so composing both is safe.
205
+ // When the dragged session lands on the Unsorted bucket, we persist
206
+ // it with folderId=null (matches the existing data model — sessions
207
+ // with no folder are null, not 'unsorted'). Same for the sameFolder
208
+ // guard below.
209
+ const dropFolderId = isUnsorted ? null : (folder ? folder.id : null);
210
+ const draggedSession = draggingSessionId.value
211
+ ? sessions.value.find((s) => s.id === draggingSessionId.value)
212
+ : null;
213
+ const sameFolder = draggedSession
214
+ && (draggedSession.folderId || null) === dropFolderId;
215
+ const isOver = !sameFolder && dragOverFolderKey.value === key;
216
+
217
+ const onSessionDragOver = (ev) => {
218
+ if (!draggingSessionId.value || sameFolder) return;
219
+ ev.preventDefault();
220
+ ev.dataTransfer.dropEffect = 'move';
221
+ if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
222
+ };
223
+ const onSessionDragLeave = (ev) => {
224
+ if (!draggingSessionId.value) return;
225
+ const rt = ev.relatedTarget;
226
+ if (rt && ev.currentTarget.contains(rt)) return;
227
+ if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
228
+ };
229
+ const onSessionDrop = (ev) => {
230
+ const sid = draggingSessionId.value;
231
+ draggingSessionId.value = null;
232
+ dragOverFolderKey.value = null;
233
+ if (!sid || sameFolder) return;
234
+ ev.preventDefault();
235
+ ev.stopPropagation();
236
+ setSessionFolder(sid, dropFolderId)
237
+ .then(() => setToast(`moved to ${name}`))
238
+ .catch((e) => setToast(e.message, 'error'));
239
+ };
240
+
241
+ // Spread folder-reorder row handlers first, then compose our
242
+ // session-drop handlers on top so both fire.
243
+ const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
244
+ const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
245
+ const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
246
+ const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
247
+
248
+ return html`
249
+ <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
250
+ ...${rowAttrs}
251
+ onDragOver=${composedOver}
252
+ onDragLeave=${composedLeave}
253
+ onDrop=${composedDrop}>
254
+ <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
255
+ ...${dndHandle || {}}>
256
+ <span class="tree-folder-icon">
257
+ ${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
258
+ </span>
259
+ <span class="tree-folder-name">${name}</span>
260
+ ${folder && !isUnsorted ? html`
261
+ <span class="tree-folder-actions">
262
+ <button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
263
+ <button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
264
+ </span>` : null}
265
+ </button>
266
+ ${!collapsed ? html`
267
+ <div class="tree-folder-body">
268
+ ${sessionList.length === 0
269
+ ? html`<div class="tree-empty">no sessions</div>`
270
+ : (() => {
271
+ // siblingIds captured once per render so each row sees a
272
+ // consistent snapshot for splice math.
273
+ const siblingIds = sessionList.map((x) => x.id);
274
+ return sessionList.map((s) => html`
275
+ <${SessionRow} key=${s.id} s=${s}
276
+ folderId=${dropFolderId}
277
+ siblingIds=${siblingIds} />`);
278
+ })()}
279
+ </div>
280
+ ` : null}
281
+ </div>`;
282
+ }
283
+
284
+ function SessionTree() {
285
+ const grouped = sessionsByFolder.value;
286
+ const orderedFolders = folders.value;
287
+ const dnd = useDragSort(
288
+ orderedFolders.map((f) => f.id),
289
+ async (nextIds) => {
290
+ try { await reorderFolders(nextIds); }
291
+ catch (e) { setToast(e.message, 'error'); }
292
+ },
293
+ );
294
+
295
+ const onNewFolder = async () => {
296
+ const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
297
+ if (!name || !name.trim()) return;
298
+ try { await createFolder(name.trim()); }
299
+ catch (e) { setToast(e.message, 'error'); }
300
+ };
301
+
302
+ return html`
303
+ <div class="tree">
304
+ <div class="tree-head">
305
+ <span class="tree-head-label">Sessions</span>
306
+ <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
307
+ <${IconPlus} />
308
+ </button>
309
+ </div>
310
+ ${orderedFolders.map((f) => html`
311
+ <${FolderGroup} key=${f.id} folder=${f}
312
+ sessionList=${grouped.get(f.id) || []}
313
+ dndHandle=${dnd.handleProps(f.id)}
314
+ dndRow=${dnd.rowProps(f.id)} />`)}
315
+ </div>`;
316
+ }
317
+
318
+ export function Sidebar() {
319
+ // On phones the sidebar is rendered inside a full-screen drawer
320
+ // (App applies .is-mobile + .drawer-open classes). It should always
321
+ // appear in EXPANDED form there — full labels + sessions tree.
322
+ // Desktop/tablet keeps the original collapse behaviour.
323
+ const mobile = isMobile.value;
324
+ const collapsed = !mobile && (sidebarCollapsed.value || sidebarForcedCollapsed.value);
325
+ const forced = !mobile && sidebarForcedCollapsed.value;
326
+
327
+ const onResizeStart = (ev) => {
328
+ if (collapsed) return;
329
+ ev.preventDefault();
330
+ const el = ev.currentTarget;
331
+ el.setPointerCapture(ev.pointerId);
332
+ document.body.classList.add('is-resizing-sidebar');
333
+ const move = (e) => setSidebarWidth(e.clientX);
334
+ const up = () => {
335
+ try { el.releasePointerCapture(ev.pointerId); } catch {}
336
+ document.body.classList.remove('is-resizing-sidebar');
337
+ el.removeEventListener('pointermove', move);
338
+ el.removeEventListener('pointerup', up);
339
+ el.removeEventListener('pointercancel', up);
340
+ };
341
+ el.addEventListener('pointermove', move);
342
+ el.addEventListener('pointerup', up);
343
+ el.addEventListener('pointercancel', up);
344
+ };
345
+
346
+ return html`
347
+ <aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
348
+ <div class="sidebar-top">
349
+ <button class="sidebar-brand sidebar-brand-button"
350
+ role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
351
+ title="About"
352
+ onClick=${() => selectTab('about')}>
353
+ <span class="brand-mark"><${BrandMark} /></span>
354
+ <span class="brand-name">CCSM</span>
355
+ </button>
356
+ </div>
357
+
358
+ <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
359
+ <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
360
+ ${!isRemoteAccess() ? html`
361
+ <${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
362
+ ` : null}
363
+ <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
364
+ </nav>
365
+
366
+ ${!collapsed ? html`<${SessionTree} />` : null}
367
+
368
+ <div class="sidebar-foot">
369
+ ${!forced ? html`
370
+ <button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
371
+ title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
372
+ onClick=${toggleSidebar}>
373
+ <span class="nav-icon"><${IconSidebarToggle} /></span>
374
+ </button>
375
+ ` : null}
376
+ </div>
377
+
378
+ ${!collapsed ? html`
379
+ <div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
380
+ aria-label="resize sidebar"
381
+ title="drag to resize · double-click to reset"
382
+ onPointerDown=${onResizeStart}
383
+ onDblClick=${() => setSidebarWidth(232)}></div>
384
+ ` : null}
385
+ </aside>`;
386
+ }