@bakapiano/ccsm 0.15.4 → 0.17.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.
@@ -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
- // 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 }) {
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
- const key = folderKey(folder);
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) === (folder ? folder.id : 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, folder ? folder.id : null)
165
- .then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
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
- : sessionList.map((s) => html`<${SessionRow} key=${s.id} s=${s} />`)}
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
 
@@ -0,0 +1,182 @@
1
+ // Global keyboard shortcuts.
2
+ //
3
+ // Stored in localStorage under `ccsm.keybindings` as `{ action: combo }`.
4
+ // Combos are strings like "Ctrl+Alt+ArrowDown" — order doesn't matter
5
+ // at parse time but we normalize when emitting so the UI shows a stable
6
+ // representation.
7
+ //
8
+ // The terminal grabs most keys for itself; we use Ctrl+Alt as the
9
+ // default modifier set because xterm.js / claude / pwsh basically
10
+ // never bind those combos.
11
+
12
+ import { signal } from '@preact/signals';
13
+ import { activeSessionId, sessions, folders, sessionsByFolder, selectSession, UNSORTED_KEY } from './state.js';
14
+ import { reorderSessions } from './api.js';
15
+ import { setToast } from './toast.js';
16
+
17
+ export const ACTIONS = {
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' },
22
+ };
23
+
24
+ const LS_KEY = 'ccsm.keybindings';
25
+
26
+ function defaults() {
27
+ const out = {};
28
+ for (const [id, def] of Object.entries(ACTIONS)) out[id] = def.defaultCombo;
29
+ return out;
30
+ }
31
+
32
+ function load() {
33
+ try {
34
+ const raw = localStorage.getItem(LS_KEY);
35
+ if (!raw) return defaults();
36
+ const saved = JSON.parse(raw);
37
+ return { ...defaults(), ...saved };
38
+ } catch { return defaults(); }
39
+ }
40
+
41
+ export const keybindings = signal(load());
42
+
43
+ export function setBinding(actionId, combo) {
44
+ if (!ACTIONS[actionId]) return;
45
+ const next = { ...keybindings.value, [actionId]: combo };
46
+ keybindings.value = next;
47
+ try { localStorage.setItem(LS_KEY, JSON.stringify(next)); } catch {}
48
+ }
49
+
50
+ export function resetBinding(actionId) {
51
+ const def = ACTIONS[actionId]?.defaultCombo;
52
+ if (!def) return;
53
+ setBinding(actionId, def);
54
+ }
55
+
56
+ // Normalize a keydown event into a canonical combo string. Order:
57
+ // Ctrl, Alt, Shift, Meta — then the key. Letter keys are uppercased.
58
+ // Arrow keys retain "ArrowUp" / "ArrowDown" form.
59
+ export function comboFromEvent(ev) {
60
+ if (!ev) return '';
61
+ const parts = [];
62
+ if (ev.ctrlKey) parts.push('Ctrl');
63
+ if (ev.altKey) parts.push('Alt');
64
+ if (ev.shiftKey) parts.push('Shift');
65
+ if (ev.metaKey) parts.push('Meta');
66
+ // Skip pure-modifier keydowns — those happen when the user presses
67
+ // just Ctrl/Alt/Shift/Meta on its own and we'd record a useless
68
+ // "Ctrl" combo. The recorder UI uses this; the matcher doesn't.
69
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return '';
70
+ let key = ev.key;
71
+ if (/^[a-z]$/.test(key)) key = key.toUpperCase();
72
+ parts.push(key);
73
+ return parts.join('+');
74
+ }
75
+
76
+ // Build a flat list of sessions in the order they appear in the
77
+ // sidebar — folder order first, then sessions within each folder,
78
+ // then the unsorted bucket. Mirrors Sidebar's render order so
79
+ // Next/Prev moves "down then up" visually.
80
+ function flatSidebarOrder() {
81
+ const grouped = sessionsByFolder.value;
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.
85
+ for (const f of folders.value) {
86
+ const list = grouped.get(f.id) || [];
87
+ for (const s of list) out.push(s.id);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function moveSelection(delta) {
93
+ const ids = flatSidebarOrder();
94
+ if (ids.length === 0) return;
95
+ const cur = activeSessionId.value;
96
+ const idx = cur ? ids.indexOf(cur) : -1;
97
+ let next;
98
+ if (idx < 0) {
99
+ next = delta > 0 ? ids[0] : ids[ids.length - 1];
100
+ } else {
101
+ next = ids[(idx + delta + ids.length) % ids.length];
102
+ }
103
+ if (next) selectSession(next);
104
+ }
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
+
129
+ const HANDLERS = {
130
+ 'session-next': () => moveSelection(+1),
131
+ 'session-prev': () => moveSelection(-1),
132
+ 'session-move-down': () => moveActiveSessionInFolder(+1),
133
+ 'session-move-up': () => moveActiveSessionInFolder(-1),
134
+ };
135
+
136
+ // Should we suppress shortcut handling because the user is typing into
137
+ // an input / textarea? Terminal's xterm hidden textarea counts too,
138
+ // but we deliberately let our Ctrl+Alt combos through there because
139
+ // the terminal child process never uses them.
140
+ function shouldSuppress(target) {
141
+ if (!target) return false;
142
+ const tag = (target.tagName || '').toLowerCase();
143
+ if (tag === 'input' || tag === 'textarea') {
144
+ // Exception: xterm's helper textarea is a hidden input — we want
145
+ // shortcuts to fire there.
146
+ if (target.classList?.contains('xterm-helper-textarea')) return false;
147
+ return true;
148
+ }
149
+ if (target.isContentEditable) return true;
150
+ return false;
151
+ }
152
+
153
+ let installed = false;
154
+ export function installGlobalKeybindings() {
155
+ if (installed) return;
156
+ installed = true;
157
+ window.addEventListener('keydown', (ev) => {
158
+ if (shouldSuppress(ev.target)) return;
159
+ const combo = comboFromEvent(ev);
160
+ if (!combo) return;
161
+ const map = keybindings.value;
162
+ for (const [action, expected] of Object.entries(map)) {
163
+ if (expected === combo && HANDLERS[action]) {
164
+ ev.preventDefault();
165
+ ev.stopPropagation();
166
+ HANDLERS[action]();
167
+ return;
168
+ }
169
+ }
170
+ }, true);
171
+ }
172
+
173
+ // Pretty-print a combo for display in Settings. Replace verbose key
174
+ // names with shorter symbols where it helps.
175
+ export function formatCombo(combo) {
176
+ if (!combo) return '(unset)';
177
+ return combo
178
+ .replace(/ArrowUp/g, '↑')
179
+ .replace(/ArrowDown/g, '↓')
180
+ .replace(/ArrowLeft/g, '←')
181
+ .replace(/ArrowRight/g, '→');
182
+ }
package/public/js/main.js CHANGED
@@ -9,8 +9,10 @@ import { httpBase } from './backend.js';
9
9
  import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
10
10
  import { setToast } from './toast.js';
11
11
  import { App } from './components/App.js';
12
+ import { installGlobalKeybindings } from './keybindings.js';
12
13
 
13
14
  loadPersisted();
15
+ installGlobalKeybindings();
14
16
  // Window/tab title pinned to "CCSM". A MutationObserver guards against
15
17
  // Chromium standalone builds that occasionally try to inject the URL
16
18
  // into the title bar.
@@ -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?.closeFrontend) {
71
- // Backend will respawn with a fresh browser window — close this
72
- // one so the user isn't stuck on the OfflineBanner during the
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
- // No "finally" reset — the server is about to shut down, and the
83
- // OfflineBanner takes over UI. When the router reroutes us to the new
84
- // version's frontend, this component re-mounts fresh.
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,11 +17,13 @@ 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 } from '../keybindings.js';
21
+ import { KeybindingRecorder } from '../components/KeybindingRecorder.js';
20
22
  import { Card } from '../components/Card.js';
21
23
  import { PageTitleBar } from '../components/PageTitleBar.js';
22
24
  import { EntityFormModal } from '../components/EntityFormModal.js';
23
25
  import { useDragSort } from '../components/useDragSort.js';
24
- import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
26
+ import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
25
27
 
26
28
  // Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
27
29
  // (and command if blank) so users don't need to remember the per-CLI flag.
@@ -251,6 +253,11 @@ export function ConfigurePage() {
251
253
  <${WorkspaceList} />
252
254
  </${Section}>
253
255
 
256
+ <${Section} title="Keyboard shortcuts"
257
+ meta="Click a binding to record a new combo. Press Esc to cancel.">
258
+ <${KeybindingsList} />
259
+ </${Section}>
260
+
254
261
  </div>
255
262
 
256
263
  ${edit?.kind === 'cli-new' ? html`
@@ -511,3 +518,51 @@ function AccentPicker() {
511
518
  </div>` : null}
512
519
  </div>`;
513
520
  }
521
+
522
+
523
+ // ── Keyboard shortcuts ───────────────────────────────────────────────
524
+ const ACTION_ICONS = {
525
+ 'session-next': IconChevronDown,
526
+ 'session-prev': IconChevronUp,
527
+ 'session-move-down': IconChevronDown,
528
+ 'session-move-up': IconChevronUp,
529
+ };
530
+
531
+ function KeybindingsList() {
532
+ const map = keybindings.value;
533
+ const [recording, setRecording] = useState(null); // actionId or null
534
+
535
+ return html`
536
+ <div class="entity-list">
537
+ ${Object.entries(ACTIONS).map(([id, def]) => {
538
+ const combo = map[id];
539
+ const isCustom = combo !== def.defaultCombo;
540
+ const Icon = ACTION_ICONS[id] || IconTerminal;
541
+ return html`
542
+ <div class="entity-row" key=${id}>
543
+ <span class="entity-row-icon"><${Icon} /></span>
544
+ <span class="entity-row-main">
545
+ <span class="entity-row-primary">
546
+ ${def.label}
547
+ <span class="entity-row-badge tone-accent">${formatCombo(combo)}</span>
548
+ </span>
549
+ <span class="entity-row-secondary">
550
+ <span class="mono">${id}</span> · default <span class="mono">${formatCombo(def.defaultCombo)}</span>
551
+ </span>
552
+ </span>
553
+ <span class="entity-row-actions">
554
+ <button class="entity-row-action" title="Rebind"
555
+ onClick=${() => setRecording(id)}><${IconPencil} /></button>
556
+ ${isCustom ? html`
557
+ <button class="entity-row-action" title="Reset to default"
558
+ onClick=${() => resetBinding(id)}><${IconRefresh} /></button>` : null}
559
+ </span>
560
+ </div>`;
561
+ })}
562
+ </div>
563
+ ${recording ? html`
564
+ <${KeybindingRecorder}
565
+ actionLabel=${ACTIONS[recording]?.label || recording}
566
+ onCommit=${(combo) => { setBinding(recording, combo); setRecording(null); }}
567
+ onCancel=${() => setRecording(null)} />` : null}`;
568
+ }
@@ -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(null, []);
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 || null;
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
- list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
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
  });