@bakapiano/ccsm 0.16.0 → 0.17.2
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/bin/ccsm.js +33 -0
- package/lib/config.js +20 -1
- package/lib/folders.js +23 -4
- package/package.json +1 -1
- package/public/css/feedback.css +115 -0
- package/public/css/sidebar.css +17 -0
- package/public/js/api.js +15 -2
- package/public/js/components/App.js +2 -2
- package/public/js/components/EntityFormModal.js +22 -10
- package/public/js/components/HealthOverlay.js +91 -0
- package/public/js/components/KeybindingRecorder.js +138 -0
- package/public/js/components/Sidebar.js +87 -15
- package/public/js/keybindings.js +36 -6
- package/public/js/pages/AboutPage.js +33 -9
- package/public/js/pages/ConfigurePage.js +49 -49
- package/public/js/state.js +22 -3
- package/scripts/dev.js +46 -0
- package/scripts/install.js +14 -19
- package/scripts/upgrade-helper.js +551 -62
- package/server.js +43 -4
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
6
6
|
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
7
7
|
} from '../state.js';
|
|
8
|
-
import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, deleteSession, resumeSession, setSessionTitle } from '../api.js';
|
|
8
|
+
import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
|
|
9
9
|
import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
|
|
10
10
|
import { setToast } from '../toast.js';
|
|
11
11
|
import { fmtAgo } from '../util.js';
|
|
@@ -36,9 +36,15 @@ function NavItem({ tab, icon, label, dirty }) {
|
|
|
36
36
|
</button>`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
39
|
+
// Module-level: the SessionRow currently being hovered as a reorder
|
|
40
|
+
// drop target. Set on dragOver, cleared on dragLeave/end. Drives the
|
|
41
|
+
// "above this row" insert-line indicator.
|
|
42
|
+
const reorderOverSessionId = signal(null);
|
|
43
|
+
|
|
44
|
+
// One row in the session tree. Click → open in main pane. Drag-to-folder
|
|
45
|
+
// is handled by FolderGroup's drop zone; same-folder reorder is handled
|
|
46
|
+
// here: the row is a drop target when an in-folder sibling is dragged.
|
|
47
|
+
function SessionRow({ s, folderId, siblingIds }) {
|
|
42
48
|
clockTick.value; // subscribe for fmtAgo refresh
|
|
43
49
|
const isActive = activeSessionId.value === s.id;
|
|
44
50
|
const running = s.status === 'running';
|
|
@@ -80,19 +86,67 @@ function SessionRow({ s }) {
|
|
|
80
86
|
const onDragStart = (ev) => {
|
|
81
87
|
draggingSessionId.value = s.id;
|
|
82
88
|
ev.dataTransfer.effectAllowed = 'move';
|
|
83
|
-
// Firefox refuses to start a drag without some data set.
|
|
84
89
|
try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
|
|
85
90
|
};
|
|
86
91
|
const onDragEnd = () => {
|
|
87
92
|
draggingSessionId.value = null;
|
|
88
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'));
|
|
89
140
|
};
|
|
90
141
|
|
|
91
142
|
return html`
|
|
92
|
-
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}
|
|
143
|
+
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}${showInsertLine ? ' is-reorder-target' : ''}`}
|
|
93
144
|
draggable=${true}
|
|
94
145
|
onDragStart=${onDragStart}
|
|
95
146
|
onDragEnd=${onDragEnd}
|
|
147
|
+
onDragOver=${onRowDragOver}
|
|
148
|
+
onDragLeave=${onRowDragLeave}
|
|
149
|
+
onDrop=${onRowDrop}
|
|
96
150
|
onClick=${onClick}
|
|
97
151
|
title=${`${title}\n${s.cwd}\n${running ? (s.activity === 'working' ? 'working' : 'idle') : 'stopped'} · ${s.cliId}`}>
|
|
98
152
|
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}`}></span>
|
|
@@ -106,14 +160,20 @@ function SessionRow({ s }) {
|
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
109
|
-
|
|
163
|
+
// folder is now always set — backend materializes a synthetic
|
|
164
|
+
// {id:'unsorted', name:'Unsorted', builtin:true} entry alongside the
|
|
165
|
+
// user folders. The bucket can be drag-reordered like any other but
|
|
166
|
+
// Rename / Delete are hidden, and drops set folderId=null so existing
|
|
167
|
+
// sessions don't need a data migration.
|
|
168
|
+
const isUnsorted = folder?.id === 'unsorted' || folder?.builtin;
|
|
169
|
+
const key = folder ? folder.id : 'unsorted';
|
|
110
170
|
const collapsed = !!foldersCollapsed.value[key];
|
|
111
171
|
const name = folder ? folder.name : 'Unsorted';
|
|
112
172
|
const onToggle = () => toggleFolder(folder ? folder.id : null);
|
|
113
173
|
|
|
114
174
|
const onRename = async (ev) => {
|
|
115
175
|
ev.stopPropagation();
|
|
116
|
-
if (!folder) return;
|
|
176
|
+
if (!folder || isUnsorted) return;
|
|
117
177
|
const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
|
|
118
178
|
if (next === null || !next.trim()) return;
|
|
119
179
|
try { await renameFolder(folder.id, next.trim()); }
|
|
@@ -122,7 +182,7 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
122
182
|
|
|
123
183
|
const onDelete = async (ev) => {
|
|
124
184
|
ev.stopPropagation();
|
|
125
|
-
if (!folder) return;
|
|
185
|
+
if (!folder || isUnsorted) return;
|
|
126
186
|
const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
|
|
127
187
|
title: 'Delete folder', okLabel: 'Delete', danger: true });
|
|
128
188
|
if (!ok) return;
|
|
@@ -135,11 +195,16 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
135
195
|
// handlers (in dndRow) short-circuit when no folder is being dragged,
|
|
136
196
|
// and our handlers below short-circuit when no session is being
|
|
137
197
|
// dragged — so composing both is safe.
|
|
198
|
+
// When the dragged session lands on the Unsorted bucket, we persist
|
|
199
|
+
// it with folderId=null (matches the existing data model — sessions
|
|
200
|
+
// with no folder are null, not 'unsorted'). Same for the sameFolder
|
|
201
|
+
// guard below.
|
|
202
|
+
const dropFolderId = isUnsorted ? null : (folder ? folder.id : null);
|
|
138
203
|
const draggedSession = draggingSessionId.value
|
|
139
204
|
? sessions.value.find((s) => s.id === draggingSessionId.value)
|
|
140
205
|
: null;
|
|
141
206
|
const sameFolder = draggedSession
|
|
142
|
-
&& (draggedSession.folderId || null) ===
|
|
207
|
+
&& (draggedSession.folderId || null) === dropFolderId;
|
|
143
208
|
const isOver = !sameFolder && dragOverFolderKey.value === key;
|
|
144
209
|
|
|
145
210
|
const onSessionDragOver = (ev) => {
|
|
@@ -161,8 +226,8 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
161
226
|
if (!sid || sameFolder) return;
|
|
162
227
|
ev.preventDefault();
|
|
163
228
|
ev.stopPropagation();
|
|
164
|
-
setSessionFolder(sid,
|
|
165
|
-
.then(() => setToast(
|
|
229
|
+
setSessionFolder(sid, dropFolderId)
|
|
230
|
+
.then(() => setToast(`moved to ${name}`))
|
|
166
231
|
.catch((e) => setToast(e.message, 'error'));
|
|
167
232
|
};
|
|
168
233
|
|
|
@@ -185,7 +250,7 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
185
250
|
${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
|
|
186
251
|
</span>
|
|
187
252
|
<span class="tree-folder-name">${name}</span>
|
|
188
|
-
${folder ? html`
|
|
253
|
+
${folder && !isUnsorted ? html`
|
|
189
254
|
<span class="tree-folder-actions">
|
|
190
255
|
<button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
|
|
191
256
|
<button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
|
|
@@ -195,7 +260,15 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
195
260
|
<div class="tree-folder-body">
|
|
196
261
|
${sessionList.length === 0
|
|
197
262
|
? html`<div class="tree-empty">no sessions</div>`
|
|
198
|
-
:
|
|
263
|
+
: (() => {
|
|
264
|
+
// siblingIds captured once per render so each row sees a
|
|
265
|
+
// consistent snapshot for splice math.
|
|
266
|
+
const siblingIds = sessionList.map((x) => x.id);
|
|
267
|
+
return sessionList.map((s) => html`
|
|
268
|
+
<${SessionRow} key=${s.id} s=${s}
|
|
269
|
+
folderId=${dropFolderId}
|
|
270
|
+
siblingIds=${siblingIds} />`);
|
|
271
|
+
})()}
|
|
199
272
|
</div>
|
|
200
273
|
` : null}
|
|
201
274
|
</div>`;
|
|
@@ -232,7 +305,6 @@ function SessionTree() {
|
|
|
232
305
|
sessionList=${grouped.get(f.id) || []}
|
|
233
306
|
dndHandle=${dnd.handleProps(f.id)}
|
|
234
307
|
dndRow=${dnd.rowProps(f.id)} />`)}
|
|
235
|
-
<${FolderGroup} folder=${null} sessionList=${grouped.get(null) || []} />
|
|
236
308
|
</div>`;
|
|
237
309
|
}
|
|
238
310
|
|
package/public/js/keybindings.js
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
// never bind those combos.
|
|
11
11
|
|
|
12
12
|
import { signal } from '@preact/signals';
|
|
13
|
-
import { activeSessionId, sessions, folders, sessionsByFolder, selectSession } from './state.js';
|
|
13
|
+
import { activeSessionId, sessions, folders, sessionsByFolder, selectSession, UNSORTED_KEY } from './state.js';
|
|
14
|
+
import { reorderSessions } from './api.js';
|
|
15
|
+
import { setToast } from './toast.js';
|
|
14
16
|
|
|
15
17
|
export const ACTIONS = {
|
|
16
|
-
'session-next':
|
|
17
|
-
'session-prev':
|
|
18
|
+
'session-next': { label: 'Next session', defaultCombo: 'Ctrl+Alt+ArrowDown' },
|
|
19
|
+
'session-prev': { label: 'Previous session', defaultCombo: 'Ctrl+Alt+ArrowUp' },
|
|
20
|
+
'session-move-down': { label: 'Move session down', defaultCombo: 'Ctrl+Alt+Shift+ArrowDown' },
|
|
21
|
+
'session-move-up': { label: 'Move session up', defaultCombo: 'Ctrl+Alt+Shift+ArrowUp' },
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
const LS_KEY = 'ccsm.keybindings';
|
|
@@ -76,11 +80,12 @@ export function comboFromEvent(ev) {
|
|
|
76
80
|
function flatSidebarOrder() {
|
|
77
81
|
const grouped = sessionsByFolder.value;
|
|
78
82
|
const out = [];
|
|
83
|
+
// folders.value now includes the synthetic Unsorted entry inline at
|
|
84
|
+
// its own user-set order — no need to special-case the bucket here.
|
|
79
85
|
for (const f of folders.value) {
|
|
80
86
|
const list = grouped.get(f.id) || [];
|
|
81
87
|
for (const s of list) out.push(s.id);
|
|
82
88
|
}
|
|
83
|
-
for (const s of grouped.get(null) || []) out.push(s.id);
|
|
84
89
|
return out;
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -98,9 +103,34 @@ function moveSelection(delta) {
|
|
|
98
103
|
if (next) selectSession(next);
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
// Move the active session one slot up/down within its current folder.
|
|
107
|
+
// Clamps at folder boundaries (doesn't cross folders — that's what
|
|
108
|
+
// drag-and-drop is for). No-op when there's no active session, or
|
|
109
|
+
// when the session is already at the folder edge.
|
|
110
|
+
function moveActiveSessionInFolder(delta) {
|
|
111
|
+
const cur = activeSessionId.value;
|
|
112
|
+
if (!cur) return;
|
|
113
|
+
const s = sessions.value.find((x) => x.id === cur);
|
|
114
|
+
if (!s) return;
|
|
115
|
+
const folderId = s.folderId || null;
|
|
116
|
+
const folderKey = folderId || UNSORTED_KEY;
|
|
117
|
+
const siblings = sessionsByFolder.value.get(folderKey) || [];
|
|
118
|
+
const ids = siblings.map((x) => x.id);
|
|
119
|
+
const idx = ids.indexOf(cur);
|
|
120
|
+
if (idx < 0) return;
|
|
121
|
+
const target = idx + delta;
|
|
122
|
+
if (target < 0 || target >= ids.length) return;
|
|
123
|
+
const next = ids.slice();
|
|
124
|
+
next.splice(idx, 1);
|
|
125
|
+
next.splice(target, 0, cur);
|
|
126
|
+
reorderSessions(folderId, next).catch((e) => setToast(e.message, 'error'));
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
const HANDLERS = {
|
|
102
|
-
'session-next':
|
|
103
|
-
'session-prev':
|
|
130
|
+
'session-next': () => moveSelection(+1),
|
|
131
|
+
'session-prev': () => moveSelection(-1),
|
|
132
|
+
'session-move-down': () => moveActiveSessionInFolder(+1),
|
|
133
|
+
'session-move-up': () => moveActiveSessionInFolder(-1),
|
|
104
134
|
};
|
|
105
135
|
|
|
106
136
|
// Should we suppress shortcut handling because the user is typing into
|
|
@@ -67,21 +67,39 @@ function UpgradeCard() {
|
|
|
67
67
|
try {
|
|
68
68
|
const r = await api('POST', '/api/upgrade', { target: 'latest' });
|
|
69
69
|
setToast(`upgrading to v${info.latest} · backend will restart`);
|
|
70
|
-
if (r?.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// upgrade window. window.close() only works when the window was
|
|
74
|
-
// script-opened (Edge --app=, our spawned browser); regular tabs
|
|
75
|
-
// ignore it silently, which is fine (OfflineBanner takes over).
|
|
70
|
+
if (r?.helperUrl) {
|
|
71
|
+
setTimeout(() => { location.href = r.helperUrl; }, 300);
|
|
72
|
+
} else if (r?.closeFrontend) {
|
|
76
73
|
setTimeout(() => { try { window.close(); } catch {} }, 400);
|
|
77
74
|
}
|
|
78
75
|
} catch (e) {
|
|
79
76
|
setUpgrading(false);
|
|
80
77
|
setToast(e.message, 'error');
|
|
81
78
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Dev-only sandbox test path: reinstall the SAME version into a
|
|
82
|
+
// throwaway prefix under ~/.ccsm-dev/test-install. Exercises the
|
|
83
|
+
// whole helper UI + SSE + lockfile flow without touching the user's
|
|
84
|
+
// global install. respawn=false keeps the helper showing "done"
|
|
85
|
+
// until it self-exits.
|
|
86
|
+
const onTestUpgrade = async () => {
|
|
87
|
+
if (!info?.current) return;
|
|
88
|
+
setUpgrading(true);
|
|
89
|
+
try {
|
|
90
|
+
const r = await api('POST', '/api/upgrade', {
|
|
91
|
+
target: info.current,
|
|
92
|
+
installPrefix: 'C:\\Users\\jiannanli\\.ccsm-dev\\test-install',
|
|
93
|
+
respawn: false,
|
|
94
|
+
});
|
|
95
|
+
setToast(`test upgrade · reinstalling v${info.current} to sandbox`);
|
|
96
|
+
if (r?.helperUrl) {
|
|
97
|
+
setTimeout(() => { location.href = r.helperUrl; }, 300);
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
setUpgrading(false);
|
|
101
|
+
setToast(e.message, 'error');
|
|
102
|
+
}
|
|
85
103
|
};
|
|
86
104
|
|
|
87
105
|
const current = info?.current || serverHealth.value.version || '';
|
|
@@ -116,6 +134,12 @@ function UpgradeCard() {
|
|
|
116
134
|
${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
|
|
117
135
|
</button>
|
|
118
136
|
` : null}
|
|
137
|
+
${info?.devMode && !updateAvailable ? html`
|
|
138
|
+
<button class="action subtle" onClick=${onTestUpgrade} disabled=${upgrading}
|
|
139
|
+
title="Reinstall to a sandbox prefix to exercise the updater UI without touching prod">
|
|
140
|
+
${upgrading ? 'Testing…' : 'Test upgrade flow'}
|
|
141
|
+
</button>
|
|
142
|
+
` : null}
|
|
119
143
|
</div>
|
|
120
144
|
</div>
|
|
121
145
|
${upgrading ? html`
|
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
} from '../api.js';
|
|
18
18
|
import { setToast } from '../toast.js';
|
|
19
19
|
import { ccsmConfirm } from '../dialog.js';
|
|
20
|
-
import { keybindings, setBinding, resetBinding, ACTIONS, formatCombo
|
|
20
|
+
import { keybindings, setBinding, resetBinding, ACTIONS, formatCombo } from '../keybindings.js';
|
|
21
|
+
import { KeybindingRecorder } from '../components/KeybindingRecorder.js';
|
|
21
22
|
import { Card } from '../components/Card.js';
|
|
22
23
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
23
24
|
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
@@ -41,30 +42,51 @@ function cliFieldsFor({ creating } = {}) {
|
|
|
41
42
|
{ value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
|
|
42
43
|
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
43
44
|
],
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
45
|
+
// Type-change side effects. For known types we force the
|
|
46
|
+
// integration args (newSessionIdArgs / resumeIdArgs) to the
|
|
47
|
+
// canonical template — those fields are locked anyway so
|
|
48
|
+
// there's no value in leaving stale strings around. For
|
|
49
|
+
// type='other' we leave existing args alone so the user can
|
|
50
|
+
// keep editing them. Name + command are only prefilled when
|
|
51
|
+
// creating (don't clobber a saved CLI's name on edit).
|
|
52
|
+
onChange: (v, next) => {
|
|
47
53
|
const d = CLI_TYPE_DEFAULTS[v];
|
|
48
54
|
if (!d) return null;
|
|
49
|
-
const patch = {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
patch.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const patch = {};
|
|
56
|
+
if (v !== 'other') {
|
|
57
|
+
patch.resumeIdArgs = d.resumeIdArgs;
|
|
58
|
+
patch.newSessionIdArgs = d.newSessionIdArgs;
|
|
59
|
+
}
|
|
60
|
+
if (creating) {
|
|
61
|
+
if (!next.command || !next.command.trim()) patch.command = d.command || '';
|
|
62
|
+
if (!next.name || !next.name.trim()) {
|
|
63
|
+
patch.name = v === 'claude' ? 'Claude Code'
|
|
64
|
+
: v === 'codex' ? 'OpenAI Codex'
|
|
65
|
+
: v === 'copilot' ? 'GitHub Copilot'
|
|
66
|
+
: '';
|
|
67
|
+
}
|
|
56
68
|
}
|
|
57
69
|
return patch;
|
|
58
|
-
}
|
|
70
|
+
},
|
|
59
71
|
},
|
|
60
72
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
61
73
|
{ key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
|
|
62
74
|
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '',
|
|
63
75
|
hint: 'Used on every launch.' },
|
|
64
76
|
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
65
|
-
|
|
77
|
+
// Lock for known types — those args are an integration contract
|
|
78
|
+
// with the upstream CLI, not a user knob. Only Type=Other allows
|
|
79
|
+
// a custom value (for hand-rolled CLIs ccsm doesn't ship a
|
|
80
|
+
// template for).
|
|
81
|
+
readOnly: (d) => d.type && d.type !== 'other',
|
|
82
|
+
hint: (d) => d.type && d.type !== 'other'
|
|
83
|
+
? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
|
|
84
|
+
: 'ccsm pre-generates a UUID and substitutes it for <id> on first launch — the upstream CLI session id is known immediately.' },
|
|
66
85
|
{ key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
|
|
67
|
-
|
|
86
|
+
readOnly: (d) => d.type && d.type !== 'other',
|
|
87
|
+
hint: (d) => d.type && d.type !== 'other'
|
|
88
|
+
? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
|
|
89
|
+
: 'Used on every resume. Substitutes <id> with the captured session UUID.' },
|
|
68
90
|
{ key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
|
|
69
91
|
{ value: 'direct', label: 'direct (real .exe / .cmd)' },
|
|
70
92
|
{ value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
|
|
@@ -521,69 +543,47 @@ function AccentPicker() {
|
|
|
521
543
|
|
|
522
544
|
// ── Keyboard shortcuts ───────────────────────────────────────────────
|
|
523
545
|
const ACTION_ICONS = {
|
|
524
|
-
'session-next':
|
|
525
|
-
'session-prev':
|
|
546
|
+
'session-next': IconChevronDown,
|
|
547
|
+
'session-prev': IconChevronUp,
|
|
548
|
+
'session-move-down': IconChevronDown,
|
|
549
|
+
'session-move-up': IconChevronUp,
|
|
526
550
|
};
|
|
527
551
|
|
|
528
552
|
function KeybindingsList() {
|
|
529
553
|
const map = keybindings.value;
|
|
530
554
|
const [recording, setRecording] = useState(null); // actionId or null
|
|
531
555
|
|
|
532
|
-
// While recording, swallow every keydown globally and feed it into
|
|
533
|
-
// comboFromEvent. We use the capture phase so the global shortcut
|
|
534
|
-
// listener never sees the keys and never fires actions mid-record.
|
|
535
|
-
useEffect(() => {
|
|
536
|
-
if (!recording) return;
|
|
537
|
-
const onKey = (ev) => {
|
|
538
|
-
if (ev.key === 'Escape') {
|
|
539
|
-
ev.preventDefault();
|
|
540
|
-
ev.stopPropagation();
|
|
541
|
-
setRecording(null);
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
const combo = comboFromEvent(ev);
|
|
545
|
-
if (!combo) return; // pure-modifier keydown, keep listening
|
|
546
|
-
ev.preventDefault();
|
|
547
|
-
ev.stopPropagation();
|
|
548
|
-
setBinding(recording, combo);
|
|
549
|
-
setRecording(null);
|
|
550
|
-
};
|
|
551
|
-
window.addEventListener('keydown', onKey, true);
|
|
552
|
-
return () => window.removeEventListener('keydown', onKey, true);
|
|
553
|
-
}, [recording]);
|
|
554
|
-
|
|
555
556
|
return html`
|
|
556
557
|
<div class="entity-list">
|
|
557
558
|
${Object.entries(ACTIONS).map(([id, def]) => {
|
|
558
559
|
const combo = map[id];
|
|
559
|
-
const isRec = recording === id;
|
|
560
560
|
const isCustom = combo !== def.defaultCombo;
|
|
561
561
|
const Icon = ACTION_ICONS[id] || IconTerminal;
|
|
562
|
-
const badges = [{
|
|
563
|
-
label: isRec ? 'press keys…' : formatCombo(combo),
|
|
564
|
-
tone: isRec ? 'warn' : 'accent',
|
|
565
|
-
}];
|
|
566
562
|
return html`
|
|
567
563
|
<div class="entity-row" key=${id}>
|
|
568
564
|
<span class="entity-row-icon"><${Icon} /></span>
|
|
569
565
|
<span class="entity-row-main">
|
|
570
566
|
<span class="entity-row-primary">
|
|
571
567
|
${def.label}
|
|
572
|
-
|
|
573
|
-
<span class=${`entity-row-badge tone-${b.tone}`}>${b.label}</span>`)}
|
|
568
|
+
<span class="entity-row-badge tone-accent">${formatCombo(combo)}</span>
|
|
574
569
|
</span>
|
|
575
570
|
<span class="entity-row-secondary">
|
|
576
571
|
<span class="mono">${id}</span> · default <span class="mono">${formatCombo(def.defaultCombo)}</span>
|
|
577
572
|
</span>
|
|
578
573
|
</span>
|
|
579
574
|
<span class="entity-row-actions">
|
|
580
|
-
<button class="entity-row-action" title
|
|
581
|
-
onClick=${() => setRecording(
|
|
575
|
+
<button class="entity-row-action" title="Rebind"
|
|
576
|
+
onClick=${() => setRecording(id)}><${IconPencil} /></button>
|
|
582
577
|
${isCustom ? html`
|
|
583
578
|
<button class="entity-row-action" title="Reset to default"
|
|
584
579
|
onClick=${() => resetBinding(id)}><${IconRefresh} /></button>` : null}
|
|
585
580
|
</span>
|
|
586
581
|
</div>`;
|
|
587
582
|
})}
|
|
588
|
-
</div
|
|
583
|
+
</div>
|
|
584
|
+
${recording ? html`
|
|
585
|
+
<${KeybindingRecorder}
|
|
586
|
+
actionLabel=${ACTIONS[recording]?.label || recording}
|
|
587
|
+
onCommit=${(combo) => { setBinding(recording, combo); setRecording(null); }}
|
|
588
|
+
onCancel=${() => setRecording(null)} />` : null}`;
|
|
589
589
|
}
|
package/public/js/state.js
CHANGED
|
@@ -13,6 +13,10 @@ export const sessions = signal([]);
|
|
|
13
13
|
export const folders = signal([]); // [{id,name,order,createdAt}]
|
|
14
14
|
export const workspaces = signal([]);
|
|
15
15
|
export const serverHealth = signal({ state: 'connecting' });
|
|
16
|
+
// Flips true the first time we successfully reach the backend in this
|
|
17
|
+
// frontend session. Gates UI (HealthOverlay) so it doesn't pop on the
|
|
18
|
+
// very first boot probe while the page is still wiring up.
|
|
19
|
+
export const hasBootedOnline = signal(false);
|
|
16
20
|
|
|
17
21
|
// ── ui state (persisted in localStorage where noted) ───────────
|
|
18
22
|
export const activeTab = signal('sessions');
|
|
@@ -48,17 +52,32 @@ export const isInstalledPwa = signal(false); // running inside an in
|
|
|
48
52
|
// matching folder hasn't loaded yet — that way on first paint sessions
|
|
49
53
|
// don't all collapse into Unsorted and then snap back into their real
|
|
50
54
|
// folder a few ms later when /api/folders resolves.
|
|
55
|
+
// "Unsorted" is keyed as 'unsorted' (not null) so it can be looked up
|
|
56
|
+
// alongside real folders by Sidebar/keybindings iterating folders.value
|
|
57
|
+
// — backend exposes a synthetic folder with id='unsorted' that's always
|
|
58
|
+
// present, drag-reorderable like real folders.
|
|
59
|
+
export const UNSORTED_KEY = 'unsorted';
|
|
51
60
|
export const sessionsByFolder = computed(() => {
|
|
52
61
|
const groups = new Map();
|
|
53
|
-
groups.set(
|
|
62
|
+
groups.set(UNSORTED_KEY, []);
|
|
54
63
|
for (const f of folders.value) groups.set(f.id, []);
|
|
55
64
|
for (const s of sessions.value) {
|
|
56
|
-
const key = s.folderId ||
|
|
65
|
+
const key = s.folderId || UNSORTED_KEY;
|
|
57
66
|
if (!groups.has(key)) groups.set(key, []);
|
|
58
67
|
groups.get(key).push(s);
|
|
59
68
|
}
|
|
60
69
|
for (const list of groups.values()) {
|
|
61
|
-
|
|
70
|
+
// Stable sort: explicit `order` field first (set by user drag), then
|
|
71
|
+
// createdAt desc as fallback. Sessions without `order` fall to the
|
|
72
|
+
// top (newer-first) which is the legacy behavior.
|
|
73
|
+
list.sort((a, b) => {
|
|
74
|
+
const oa = typeof a.order === 'number' ? a.order : null;
|
|
75
|
+
const ob = typeof b.order === 'number' ? b.order : null;
|
|
76
|
+
if (oa !== null && ob !== null) return oa - ob;
|
|
77
|
+
if (oa !== null) return -1;
|
|
78
|
+
if (ob !== null) return 1;
|
|
79
|
+
return (b.createdAt || 0) - (a.createdAt || 0);
|
|
80
|
+
});
|
|
62
81
|
}
|
|
63
82
|
return groups;
|
|
64
83
|
});
|
package/scripts/dev.js
CHANGED
|
@@ -38,6 +38,52 @@ if (!fs.existsSync(configPath)) {
|
|
|
38
38
|
}, null, 2));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Mirror pages-root assets into public/ so the dev server can serve
|
|
42
|
+
// them at the URL paths the deployed site uses (manifest at
|
|
43
|
+
// /manifest.webmanifest, setup page at /setup/). Both mirror files
|
|
44
|
+
// are .gitignored — they exist only for local preview.
|
|
45
|
+
//
|
|
46
|
+
// The manifest is rewritten with a "ccsm-dev" identity so the PWA
|
|
47
|
+
// installed from dev shows up separately in Chrome's installed-apps
|
|
48
|
+
// list and Start Menu, instead of conflicting with the prod CCSM
|
|
49
|
+
// install.
|
|
50
|
+
const REPO_ROOT = path.join(__dirname, '..');
|
|
51
|
+
const PAGES_ROOT = path.join(REPO_ROOT, 'pages-root');
|
|
52
|
+
const PUBLIC_DIR = path.join(REPO_ROOT, 'public');
|
|
53
|
+
|
|
54
|
+
function mirrorSetup() {
|
|
55
|
+
try {
|
|
56
|
+
const src = path.join(PAGES_ROOT, 'setup');
|
|
57
|
+
const dst = path.join(PUBLIC_DIR, 'setup');
|
|
58
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
59
|
+
for (const f of fs.readdirSync(src)) {
|
|
60
|
+
fs.copyFileSync(path.join(src, f), path.join(dst, f));
|
|
61
|
+
}
|
|
62
|
+
} catch (e) { console.warn('[dev] setup mirror failed:', e.message); }
|
|
63
|
+
}
|
|
64
|
+
function writeDevManifest() {
|
|
65
|
+
try {
|
|
66
|
+
const src = path.join(PAGES_ROOT, 'manifest.webmanifest');
|
|
67
|
+
const m = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
68
|
+
m.id = '/?ccsm-dev';
|
|
69
|
+
m.name = 'CCSM dev';
|
|
70
|
+
m.short_name = 'CCSM dev';
|
|
71
|
+
// Dev runs at host root (localhost:7788/), so scope + start_url
|
|
72
|
+
// anchor at `/` not `./` (which would resolve relative to the
|
|
73
|
+
// manifest URL — same result here, but explicit is clearer).
|
|
74
|
+
m.scope = '/';
|
|
75
|
+
m.start_url = '/';
|
|
76
|
+
// Drop related_applications self-reference — its URL points at
|
|
77
|
+
// the prod GH Pages manifest, not this dev one. Leaving it in
|
|
78
|
+
// would let prod's getInstalledRelatedApps() detect dev installs
|
|
79
|
+
// as if they were prod, which is the opposite of what we want.
|
|
80
|
+
delete m.related_applications;
|
|
81
|
+
fs.writeFileSync(path.join(PUBLIC_DIR, 'manifest.webmanifest'), JSON.stringify(m, null, 2));
|
|
82
|
+
} catch (e) { console.warn('[dev] manifest mirror failed:', e.message); }
|
|
83
|
+
}
|
|
84
|
+
mirrorSetup();
|
|
85
|
+
writeDevManifest();
|
|
86
|
+
|
|
41
87
|
const env = {
|
|
42
88
|
...process.env,
|
|
43
89
|
CCSM_HOME: DEV_HOME,
|
package/scripts/install.js
CHANGED
|
@@ -130,29 +130,24 @@ try {
|
|
|
130
130
|
warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
133
|
+
// Open the hosted setup guide. The page walks the user through the
|
|
134
|
+
// remaining one-time setup (allow ccsm:// protocol, firewall, install
|
|
135
|
+
// as PWA) and Step 1's "Try ccsm://start" button doubles as ccsm
|
|
136
|
+
// auto-launch — so we don't need a separate spawn here. Set
|
|
137
|
+
// CCSM_NO_AUTOLAUNCH=1 to skip (CI, headless setups).
|
|
137
138
|
if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
|
|
138
139
|
try {
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
detached: true,
|
|
148
|
-
stdio: 'ignore',
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
});
|
|
151
|
-
child.unref();
|
|
152
|
-
log('launching ccsm now · check for the chromeless window');
|
|
140
|
+
// `start` on Windows opens the default browser without attaching a
|
|
141
|
+
// console. Run via cmd.exe /c since `start` is a cmd builtin.
|
|
142
|
+
require('node:child_process').spawn(
|
|
143
|
+
'cmd.exe',
|
|
144
|
+
['/d', '/s', '/c', 'start', '', 'https://bakapiano.github.io/ccsm/setup/'],
|
|
145
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }
|
|
146
|
+
).unref();
|
|
147
|
+
log('opened setup guide · https://bakapiano.github.io/ccsm/setup/');
|
|
153
148
|
log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
|
|
154
149
|
} catch (e) {
|
|
155
|
-
warn(`
|
|
150
|
+
warn(`setup guide open failed · ${e.message}`);
|
|
156
151
|
warn('run `ccsm` manually to start.');
|
|
157
152
|
}
|
|
158
153
|
}
|