@bakapiano/ccsm 0.9.0 → 0.10.1

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 (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. package/public/js/pages/TerminalsPage.js +0 -74
@@ -1,224 +1,428 @@
1
- // Settings form. Edits to inputs mark configDirty until Save / Discard.
2
- // We write to a draft signal to avoid clobbering server-side state mid-edit;
3
- // the draft initialises from config.value the first time configure tab mounts
4
- // and after each successful save.
1
+ // Settings page · summary lists of CLIs / Repos / Folders + General
2
+ // (port / work dir / theme). Each row has Edit + Delete; "+ Add"
3
+ // opens the same modal form used inline-from-launch.
5
4
 
6
5
  import { html } from '../html.js';
7
6
  import { useEffect, useState } from 'preact/hooks';
8
- import { config, terminals, configDirty, accentColor, setAccentColor, ACCENT_DEFAULT } from '../state.js';
9
- import { api, loadWorkspaces } from '../api.js';
7
+ import {
8
+ config, configDirty, accentColor, folders, workspaces,
9
+ setAccentColor, ACCENT_DEFAULT,
10
+ } from '../state.js';
11
+ import {
12
+ api, loadConfig, loadWorkspaces, loadFolders,
13
+ createCli, updateCli, deleteCli, setDefaultCli,
14
+ createRepo, updateRepo, deleteRepo,
15
+ createFolder, renameFolder, deleteFolder, reorderFolders,
16
+ deleteWorkspace,
17
+ } from '../api.js';
10
18
  import { setToast } from '../toast.js';
11
19
  import { ccsmConfirm } from '../dialog.js';
12
20
  import { Card } from '../components/Card.js';
13
- import { ReposEditor, addEmptyRepo } from '../components/ReposEditor.js';
14
-
15
- function defaultsFrom(cfg) {
16
- if (!cfg) return null;
17
- return {
18
- port: cfg.port,
19
- workDir: cfg.workDir,
20
- snapshotIntervalMs: cfg.snapshotIntervalMs,
21
- snapshotHistoryKeep: cfg.snapshotHistoryKeep,
22
- claudeCommand: cfg.claudeCommand || 'claude',
23
- terminal: cfg.terminal,
24
- commandShell: cfg.commandShell || 'pwsh',
25
- defaultTerminalMode: cfg.defaultTerminalMode || 'wt',
26
- autoFocusOnLaunch: cfg.autoFocusOnLaunch !== false,
27
- focusMovesToCenter: cfg.focusMovesToCenter === true,
28
- browserMode: cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'),
29
- finderPrompt: cfg.finderPrompt || '',
30
- };
21
+ import { PageTitleBar } from '../components/PageTitleBar.js';
22
+ import { EntityFormModal } from '../components/EntityFormModal.js';
23
+ import { useDragSort } from '../components/useDragSort.js';
24
+ import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
25
+
26
+ // Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
27
+ // (and command if blank) so users don't need to remember the per-CLI flag.
28
+ const CLI_TYPE_DEFAULTS = {
29
+ claude: { command: 'claude', resumeArgs: '--continue', resumeIdArgs: '--resume <id>' },
30
+ codex: { command: 'codex', resumeArgs: 'resume --last', resumeIdArgs: 'resume <id>' },
31
+ copilot: { command: 'copilot', resumeArgs: '--continue', resumeIdArgs: '--resume <id>' },
32
+ other: { resumeArgs: '', resumeIdArgs: '' },
33
+ };
34
+
35
+ function cliFieldsFor({ creating } = {}) {
36
+ return [
37
+ { key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
38
+ { value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
39
+ { value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
40
+ { value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
41
+ { value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
42
+ ],
43
+ // When user picks a type while creating, prefill command + resumeArgs.
44
+ // For edit mode we don't override what the user already has.
45
+ onChange: creating ? (v, next) => {
46
+ const d = CLI_TYPE_DEFAULTS[v];
47
+ if (!d) return null;
48
+ const patch = { resumeArgs: d.resumeArgs, resumeIdArgs: d.resumeIdArgs };
49
+ if (!next.command || !next.command.trim()) patch.command = d.command || '';
50
+ if (!next.name || !next.name.trim()) {
51
+ patch.name = v === 'claude' ? 'Claude Code'
52
+ : v === 'codex' ? 'OpenAI Codex'
53
+ : v === 'copilot' ? 'GitHub Copilot'
54
+ : '';
55
+ }
56
+ return patch;
57
+ } : undefined,
58
+ },
59
+ { key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
60
+ { key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
61
+ { key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '',
62
+ hint: 'Used on every launch.' },
63
+ { key: 'resumeArgs', label: 'Resume args (fallback)', mono: true, placeholder: '--continue',
64
+ hint: 'Used when ccsm has no captured upstream session id — usually "open last session in cwd".' },
65
+ { key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
66
+ hint: 'Use <id> as the placeholder for the captured upstream session UUID. Leave empty to always use the fallback.' },
67
+ { key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
68
+ { value: 'direct', label: 'direct (real .exe / .cmd)' },
69
+ { value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
70
+ { value: 'cmd', label: 'cmd (doskey)' },
71
+ ] },
72
+ ];
73
+ }
74
+
75
+ function Section({ title, meta, children }) {
76
+ return html`
77
+ <section class="settings-section">
78
+ <header class="settings-section-head">
79
+ <h2 class="settings-section-title">${title}</h2>
80
+ ${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
81
+ </header>
82
+ <div class="settings-section-body">${children}</div>
83
+ </section>`;
31
84
  }
32
85
 
86
+ // ── Field definitions shared with Launch picker ──────────────────────
87
+ // (CLI fields built lazily via cliFieldsFor — see above.)
88
+
89
+ const repoFields = [
90
+ { key: 'name', label: 'Name', placeholder: 'my-repo', autoFocus: true, required: true },
91
+ { key: 'url', label: 'URL', mono: true, placeholder: 'https://github.com/me/foo.git', required: true },
92
+ { key: 'defaultSelected', label: 'Pre-select on launch', type: 'checkbox',
93
+ hint: 'Auto-checked in the Repos picker for new sessions' },
94
+ ];
95
+
96
+ const folderFields = [
97
+ { key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
98
+ ];
99
+
100
+ // ── Page ─────────────────────────────────────────────────────────────
33
101
  export function ConfigurePage() {
34
102
  const cfg = config.value;
35
- const [draft, setDraft] = useState(() => defaultsFrom(cfg));
103
+ const [edit, setEdit] = useState(null); // { kind, payload? }
104
+ const [general, setGeneral] = useState(null);
36
105
  const [savedAt, setSavedAt] = useState('');
37
106
 
38
- // re-init from config whenever a fresh load lands (rare — typically only at boot,
39
- // after Save, or after Discard). We compare a stringified snapshot so re-renders
40
- // from unrelated signals don't reset our in-progress edits.
107
+ const folderDnd = useDragSort(
108
+ folders.value.map((f) => f.id),
109
+ async (nextIds) => {
110
+ try { await reorderFolders(nextIds); }
111
+ catch (e) { setToast(e.message, 'error'); }
112
+ },
113
+ );
114
+
41
115
  useEffect(() => {
42
- if (!cfg) return;
43
- if (!draft) { setDraft(defaultsFrom(cfg)); return; }
116
+ if (cfg && !general) {
117
+ setGeneral({ workDir: cfg.workDir });
118
+ }
44
119
  }, [cfg]);
45
120
 
46
- if (!cfg || !draft) return null;
121
+ // Refresh workspace list when the page mounts so sizes are fresh.
122
+ useEffect(() => { loadWorkspaces().catch(() => {}); }, []);
47
123
 
48
- const update = (patch) => {
49
- setDraft({ ...draft, ...patch });
50
- configDirty.value = true;
51
- };
124
+ if (!cfg || !general) return null;
52
125
 
53
- const reposChanged = () => {
54
- configDirty.value = true;
55
- };
56
-
57
- const onSave = async () => {
58
- const next = {
59
- ...draft,
60
- port: Number(draft.port) || 7777,
61
- snapshotIntervalMs: Math.max(5000, Number(draft.snapshotIntervalMs) || 60000),
62
- snapshotHistoryKeep: Math.max(1, Number(draft.snapshotHistoryKeep) || 30),
63
- claudeCommand: (draft.claudeCommand || 'claude').trim(),
64
- terminal: draft.terminal || 'wt',
65
- commandShell: draft.commandShell || 'pwsh',
66
- defaultTerminalMode: draft.defaultTerminalMode === 'web' ? 'web' : 'wt',
67
- browserMode: draft.browserMode || 'app',
68
- workDir: (draft.workDir || '').trim(),
69
- repos: (cfg.repos || []).filter((r) => r.name && r.url),
70
- };
126
+ const saveGeneral = async (patch) => {
127
+ const merged = { ...general, ...patch };
128
+ setGeneral(merged);
71
129
  try {
72
- const saved = await api('PUT', '/api/config', next);
130
+ const saved = await api('PUT', '/api/config', {
131
+ ...cfg,
132
+ workDir: (merged.workDir || '').trim(),
133
+ });
73
134
  config.value = saved;
74
- setDraft(defaultsFrom(saved));
75
- setSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
76
- configDirty.value = false;
77
- setToast('config saved');
135
+ setToast('saved');
78
136
  await loadWorkspaces();
79
137
  } catch (e) { setToast(e.message, 'error'); }
80
138
  };
81
139
 
82
- const onDiscard = async () => {
83
- const ok = await ccsmConfirm('Discard your unsaved changes?', {
84
- title: 'Discard changes', okLabel: 'Discard', danger: true,
85
- });
86
- if (!ok) return;
87
- const fresh = await api('GET', '/api/config');
88
- config.value = fresh;
89
- setDraft(defaultsFrom(fresh));
90
- configDirty.value = false;
91
- setToast('changes discarded');
92
- };
140
+ const close = () => setEdit(null);
93
141
 
94
142
  return html`
95
- ${configDirty.value ? html`
96
- <div class="dirty-banner">
97
- <span class="dirty-dot"></span>
98
- <span class="dirty-text">You have unsaved changes</span>
99
- <button class="action small primary" onClick=${onSave}>Save now</button>
100
- <button class="action small subtle" onClick=${onDiscard}>Discard</button>
101
- </div>` : null}
102
-
103
- <${Card} title="Settings" meta=${html`Persisted to <code>~/.ccsm/config.json</code>`}>
104
- <div class="config-grid">
105
- <label class="field">
106
- <span class="label">Port</span>
107
- <input type="number" value=${draft.port}
108
- onInput=${(e) => update({ port: e.target.value })} />
109
- <span class="hint">restart server to apply</span>
110
- </label>
111
- <label class="field">
112
- <span class="label">Work directory</span>
113
- <input type="text" value=${draft.workDir}
114
- onInput=${(e) => update({ workDir: e.target.value })} />
115
- </label>
116
- <label class="field">
117
- <span class="label">Snapshot interval (ms)</span>
118
- <input type="number" min="5000" value=${draft.snapshotIntervalMs}
119
- onInput=${(e) => update({ snapshotIntervalMs: e.target.value })} />
120
- </label>
121
- <label class="field">
122
- <span class="label">History kept</span>
123
- <input type="number" min="1" value=${draft.snapshotHistoryKeep}
124
- onInput=${(e) => update({ snapshotHistoryKeep: e.target.value })} />
125
- </label>
126
- <label class="field">
127
- <span class="label">Claude command</span>
128
- <input type="text" placeholder="claude" value=${draft.claudeCommand}
129
- onInput=${(e) => update({ claudeCommand: e.target.value })} />
130
- <span class="hint">alias / function / exe name</span>
131
- </label>
132
- <label class="field">
133
- <span class="label">Default mode <span class="hint inline">(new · resume · continue · finder)</span></span>
134
- <select class="input" value=${draft.defaultTerminalMode}
135
- onChange=${(e) => update({ defaultTerminalMode: e.target.value })}>
136
- <option value="wt">system terminal · open a real ${draft.terminal || 'wt'} window</option>
137
- <option value="web">web · in-page xterm under the Terminals tab</option>
138
- </select>
139
- <span class="hint">web requires node-pty; per-launch radios can override</span>
140
- </label>
141
- <label class="field">
142
- <span class="label">Terminal</span>
143
- <select class="input" value=${draft.terminal}
144
- onChange=${(e) => update({ terminal: e.target.value })}>
145
- ${(terminals.value || []).map((t) => html`
146
- <option key=${t.name} value=${t.name}>${t.name} · ${t.processName}</option>`)}
147
- </select>
148
- </label>
149
- <label class="field">
150
- <span class="label">Command shell <span class="hint inline">(wt only)</span></span>
151
- <select class="input" value=${draft.commandShell}
152
- onChange=${(e) => update({ commandShell: e.target.value })}>
153
- <option value="pwsh">pwsh · PowerShell 7</option>
154
- <option value="powershell">powershell · Windows PowerShell 5.1</option>
155
- <option value="none">none · run command directly</option>
156
- </select>
157
- </label>
158
- <label class="field">
159
- <span class="label">Browser open mode</span>
160
- <select class="input" value=${draft.browserMode}
161
- onChange=${(e) => update({ browserMode: e.target.value })}>
162
- <option value="app">app · Edge/Chrome chromeless</option>
163
- <option value="tab">tab · default browser</option>
164
- <option value="none">off · don't open</option>
165
- </select>
166
- </label>
167
- <label class="field toggle">
168
- <input type="checkbox" checked=${draft.autoFocusOnLaunch}
169
- onChange=${(e) => update({ autoFocusOnLaunch: e.target.checked })} />
170
- <span class="toggle-text">
171
- <span class="label">Auto-focus on launch</span>
172
- <span class="hint">raise newly-launched terminal window</span>
173
- </span>
174
- </label>
175
- <label class="field toggle">
176
- <input type="checkbox" checked=${draft.focusMovesToCenter}
177
- onChange=${(e) => update({ focusMovesToCenter: e.target.checked })} />
178
- <span class="toggle-text">
179
- <span class="label">Move focused window to screen center</span>
180
- <span class="hint">centers the focused window on whichever monitor the cursor is on</span>
181
- </span>
182
- </label>
183
- <label class="field full">
184
- <span class="label">Finder prompt</span>
185
- <textarea rows="3" value=${draft.finderPrompt}
186
- onInput=${(e) => update({ finderPrompt: e.target.value })}></textarea>
187
- <span class="hint">passed as initial prompt to the finder session</span>
188
- </label>
143
+ <${PageTitleBar} title="Settings" />
144
+ <div class="settings-scroll">
189
145
 
146
+ <${Section} title="General">
147
+ <div class="config-grid">
190
148
  <div class="field">
191
149
  <span class="label">Theme accent</span>
192
150
  <${AccentPicker} />
193
- <span class="hint">also tints the OS title bar (theme-color)</span>
194
151
  </div>
152
+ </div>
153
+ </${Section}>
195
154
 
196
- <div class="field full">
197
- <div class="repos-head">
198
- <span class="label">Repositories</span>
199
- <button class="action small" onClick=${() => { addEmptyRepo(reposChanged); }}>+ Add repo</button>
200
- </div>
201
- <${ReposEditor} onChange=${reposChanged} />
202
- </div>
155
+ <${Section} title="CLIs" meta=${html`Built-in entries (<code>claude</code>, <code>codex</code>) auto-probe your PATH.`}>
156
+ <${EntityList}
157
+ kind="cli"
158
+ addLabel="Add CLI"
159
+ items=${(cfg.clis || []).map((c) => {
160
+ const tags = [];
161
+ if (cfg.defaultCliId === c.id) tags.push({ label: 'default', tone: 'accent' });
162
+ if (c.builtin) tags.push({ label: c.installed ? 'installed' : 'not found', tone: c.installed ? 'ok' : 'warn' });
163
+ const Icon = IconForCliType(c.type);
164
+ return {
165
+ id: c.id,
166
+ icon: html`<${Icon} />`,
167
+ primary: c.name,
168
+ secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + c.args.join(' ') : ''}</span>${c.shell && c.shell !== 'direct' ? html` · ${c.shell}` : null}`,
169
+ badges: tags,
170
+ undeletable: c.builtin,
171
+ raw: c,
172
+ };
173
+ })}
174
+ onAdd=${() => setEdit({ kind: 'cli-new' })}
175
+ onEdit=${(it) => setEdit({ kind: 'cli-edit', payload: it.raw })}
176
+ onDelete=${async (it) => {
177
+ if (it.undeletable) return setToast(`"${it.primary}" is built-in and can't be deleted`, 'error');
178
+ if (cfg.clis.length === 1) return setToast('cannot delete the last CLI', 'error');
179
+ const ok = await ccsmConfirm(`Delete CLI "${it.primary}"?`, { okLabel: 'Delete', danger: true });
180
+ if (!ok) return;
181
+ try { await deleteCli(it.id); setToast('deleted'); }
182
+ catch (e) { setToast(e.message, 'error'); }
183
+ }}
184
+ onActivate=${async (it) => {
185
+ if (cfg.defaultCliId === it.id) return;
186
+ try { await setDefaultCli(it.id); setToast(`default · ${it.primary}`); }
187
+ catch (e) { setToast(e.message, 'error'); }
188
+ }}
189
+ emptyHint="No CLIs configured."
190
+ />
191
+ </${Section}>
203
192
 
204
- <div class="form-actions full">
205
- <button class=${`action primary${configDirty.value ? ' is-dirty' : ''}`}
206
- onClick=${onSave}>Save configuration</button>
207
- <span class="muted-text">${savedAt}</span>
208
- </div>
193
+ <${Section} title="Repositories" meta="Available for clone-on-launch into a new workspace.">
194
+ <${EntityList}
195
+ kind="repo"
196
+ addLabel="Add Repo"
197
+ items=${(cfg.repos || []).map((r) => ({
198
+ id: r.name,
199
+ icon: html`<${IconBranch} />`,
200
+ primary: r.name,
201
+ secondary: html`<span class="mono">${r.url}</span>`,
202
+ badge: r.defaultSelected ? 'auto' : null,
203
+ raw: r,
204
+ }))}
205
+ onAdd=${() => setEdit({ kind: 'repo-new' })}
206
+ onEdit=${(it) => setEdit({ kind: 'repo-edit', payload: it.raw })}
207
+ onDelete=${async (it) => {
208
+ const ok = await ccsmConfirm(`Remove repo "${it.primary}" from the list?`, { okLabel: 'Remove', danger: true });
209
+ if (!ok) return;
210
+ try { await deleteRepo(it.id); setToast('removed'); }
211
+ catch (e) { setToast(e.message, 'error'); }
212
+ }}
213
+ emptyHint="No repos configured."
214
+ />
215
+ </${Section}>
216
+
217
+ <${Section} title="Folders" meta="Buckets that group sessions in the sidebar.">
218
+ <${EntityList}
219
+ kind="folder"
220
+ addLabel="Add Folder"
221
+ dnd=${folderDnd}
222
+ items=${folders.value.map((f) => ({
223
+ id: f.id,
224
+ icon: html`<${IconFolder} />`,
225
+ primary: f.name,
226
+ secondary: null,
227
+ raw: f,
228
+ }))}
229
+ onAdd=${() => setEdit({ kind: 'folder-new' })}
230
+ onEdit=${(it) => setEdit({ kind: 'folder-edit', payload: it.raw })}
231
+ onDelete=${async (it) => {
232
+ const ok = await ccsmConfirm(`Delete folder "${it.primary}"? Sessions inside move to Unsorted.`, { okLabel: 'Delete', danger: true });
233
+ if (!ok) return;
234
+ try { await deleteFolder(it.id); setToast('deleted'); }
235
+ catch (e) { setToast(e.message, 'error'); }
236
+ }}
237
+ emptyHint="No folders yet."
238
+ />
239
+ </${Section}>
240
+
241
+ <${Section} title="Workspaces"
242
+ meta=${html`Auto-allocated <code>ws-N</code> folders under the work directory. Each holds one or more repo clones.`}>
243
+ <div class="config-grid">
244
+ <label class="field">
245
+ <span class="label">Work directory</span>
246
+ <input type="text" value=${general.workDir}
247
+ onChange=${(e) => saveGeneral({ workDir: e.target.value })} />
248
+ </label>
209
249
  </div>
210
- </${Card}>`;
250
+ <${WorkspaceList} />
251
+ </${Section}>
252
+
253
+ </div>
254
+
255
+ ${edit?.kind === 'cli-new' ? html`
256
+ <${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
257
+ onClose=${close} submitLabel="Create"
258
+ onSubmit=${async (v) => {
259
+ try { await createCli(v); setToast(`created CLI · ${v.name}`); }
260
+ catch (e) { setToast(e.message, 'error'); throw e; }
261
+ }} />` : null}
262
+
263
+ ${edit?.kind === 'cli-edit' ? html`
264
+ <${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
265
+ readOnlyKeys=${edit.payload.builtin ? ['type', 'command'] : []}
266
+ initial=${{
267
+ ...edit.payload,
268
+ args: (edit.payload.args || []).join(' '),
269
+ resumeArgs: (edit.payload.resumeArgs || []).join(' '),
270
+ resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
271
+ }}
272
+ onClose=${close}
273
+ onSubmit=${async (v) => {
274
+ try {
275
+ const patch = {
276
+ ...v,
277
+ args: typeof v.args === 'string' ? v.args.split(/\s+/).filter(Boolean) : v.args,
278
+ resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
279
+ resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
280
+ };
281
+ // command is locked on builtins — drop any tampered value.
282
+ if (edit.payload.builtin) delete patch.command;
283
+ await updateCli(edit.payload.id, patch);
284
+ setToast('saved');
285
+ } catch (e) { setToast(e.message, 'error'); throw e; }
286
+ }} />` : null}
287
+
288
+ ${edit?.kind === 'repo-new' ? html`
289
+ <${EntityFormModal} title="New repo" fields=${repoFields}
290
+ onClose=${close} submitLabel="Add"
291
+ onSubmit=${async (v) => {
292
+ try { await createRepo(v); setToast(`added repo · ${v.name}`); }
293
+ catch (e) { setToast(e.message, 'error'); throw e; }
294
+ }} />` : null}
295
+
296
+ ${edit?.kind === 'repo-edit' ? html`
297
+ <${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${repoFields}
298
+ initial=${edit.payload}
299
+ onClose=${close}
300
+ onSubmit=${async (v) => {
301
+ try { await updateRepo(edit.payload.name, v); setToast('saved'); }
302
+ catch (e) { setToast(e.message, 'error'); throw e; }
303
+ }} />` : null}
304
+
305
+ ${edit?.kind === 'folder-new' ? html`
306
+ <${EntityFormModal} title="New folder" fields=${folderFields}
307
+ onClose=${close} submitLabel="Create"
308
+ onSubmit=${async (v) => {
309
+ try { await createFolder(v.name); await loadFolders(); setToast(`created folder · ${v.name}`); }
310
+ catch (e) { setToast(e.message, 'error'); throw e; }
311
+ }} />` : null}
312
+
313
+ ${edit?.kind === 'folder-edit' ? html`
314
+ <${EntityFormModal} title=${`Rename ${edit.payload.name}`} fields=${folderFields}
315
+ initial=${edit.payload}
316
+ onClose=${close}
317
+ onSubmit=${async (v) => {
318
+ try { await renameFolder(edit.payload.id, v.name.trim()); await loadFolders(); setToast('renamed'); }
319
+ catch (e) { setToast(e.message, 'error'); throw e; }
320
+ }} />` : null}
321
+ `;
322
+ }
323
+
324
+ // Generic "list of rows + Add button" used by all three sections.
325
+ function EntityList({ items, onAdd, onEdit, onDelete, onActivate, emptyHint, dnd, addLabel = 'Add' }) {
326
+ return html`
327
+ <div class="entity-list">
328
+ ${items.length === 0
329
+ ? html`<div class="entity-empty">${emptyHint}</div>`
330
+ : items.map((it) => {
331
+ const rowProps = dnd ? dnd.rowProps(it.id) : {};
332
+ const handleProps = dnd ? dnd.handleProps(it.id) : {};
333
+ const badges = it.badges || (it.badge ? [{ label: it.badge, tone: 'accent' }] : []);
334
+ return html`
335
+ <div class=${`entity-row${dnd ? ' is-draggable' : ''}`} key=${it.id}
336
+ ...${rowProps} ...${handleProps}>
337
+ ${dnd ? html`<span class="entity-row-grip" aria-hidden="true">⋮⋮</span>` : null}
338
+ <span class="entity-row-icon">${it.icon}</span>
339
+ <span class="entity-row-main">
340
+ <span class="entity-row-primary">
341
+ ${it.primary}
342
+ ${badges.map((b) => html`
343
+ <span class=${`entity-row-badge tone-${b.tone || 'accent'}`}>${b.label}</span>`)}
344
+ </span>
345
+ ${it.secondary ? html`<span class="entity-row-secondary">${it.secondary}</span>` : null}
346
+ </span>
347
+ <span class="entity-row-actions">
348
+ ${onActivate ? html`
349
+ <button class="entity-row-action" title="Set default"
350
+ onClick=${() => onActivate(it)}>★</button>` : null}
351
+ <button class="entity-row-action" title="Edit"
352
+ onClick=${() => onEdit(it)}><${IconPencil} /></button>
353
+ ${it.undeletable ? null : html`
354
+ <button class="entity-row-action danger" title="Delete"
355
+ onClick=${() => onDelete(it)}><${IconClose} /></button>`}
356
+ </span>
357
+ </div>`;
358
+ })}
359
+ <button class="entity-add" type="button" onClick=${onAdd}>
360
+ <span>${addLabel}</span>
361
+ </button>
362
+ </div>`;
363
+ }
364
+
365
+ // ── Workspace list ───────────────────────────────────────────────────
366
+ function fmtBytes(n) {
367
+ if (n == null) return '—';
368
+ if (n < 1024) return `${n} B`;
369
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
370
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
371
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
372
+ }
373
+
374
+ function WorkspaceList() {
375
+ const ws = workspaces.value || [];
376
+ if (ws.length === 0) {
377
+ return html`<div class="entity-empty">No workspaces yet — they're created automatically on launch.</div>`;
378
+ }
379
+ const onDelete = async (w) => {
380
+ if (w.inUse) return setToast(`"${w.name}" is in use by a running session`, 'error');
381
+ const ok = await ccsmConfirm(
382
+ `Delete workspace "${w.name}"? This removes the directory and all repo clones inside (${fmtBytes(w.size)}).`,
383
+ { okLabel: 'Delete', danger: true },
384
+ );
385
+ if (!ok) return;
386
+ try {
387
+ await deleteWorkspace(w.name);
388
+ await loadWorkspaces();
389
+ setToast(`deleted · ${w.name}`);
390
+ } catch (e) { setToast(e.message, 'error'); }
391
+ };
392
+ return html`
393
+ <div class="entity-list">
394
+ ${ws.map((w) => {
395
+ const repoCount = (w.repos || []).filter((r) => r.exists).length;
396
+ return html`
397
+ <div class="entity-row" key=${w.path}>
398
+ <span class="entity-row-icon"><${IconFolder} /></span>
399
+ <span class="entity-row-main">
400
+ <span class="entity-row-primary">
401
+ ${w.name}
402
+ ${w.inUse ? html`<span class="entity-row-badge tone-warn">in use</span>` : null}
403
+ </span>
404
+ <span class="entity-row-secondary">
405
+ <span class="mono">${w.path}</span>
406
+ · ${fmtBytes(w.size)}
407
+ ${repoCount > 0 ? html` · ${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}` : null}
408
+ </span>
409
+ </span>
410
+ <span class="entity-row-actions">
411
+ <button class=${`entity-row-action danger${w.inUse ? ' is-disabled' : ''}`}
412
+ title=${w.inUse ? 'In use by a running session' : 'Delete'}
413
+ disabled=${w.inUse}
414
+ onClick=${() => onDelete(w)}><${IconClose} /></button>
415
+ </span>
416
+ </div>`;
417
+ })}
418
+ </div>`;
211
419
  }
212
420
 
213
- // Curated preset palette + free hex input. Each preset is a hand-picked
214
- // brand color that reads well on the cream surface. Selecting a swatch
215
- // applies immediately via setAccentColor (which writes CSS vars +
216
- // localStorage) — no save button needed since this is a per-browser
217
- // UI preference, not part of the server-side config.
421
+ // ── Accent picker (unchanged) ────────────────────────────────────────
218
422
  const PRESETS = [
219
- { name: 'Claude copper', hex: '#b3614a' }, // default
220
- { name: 'Anthropic ink', hex: '#1a1815' },
221
423
  { name: 'Ocean', hex: '#2f6fa3' },
424
+ { name: 'Claude copper', hex: '#b3614a' },
425
+ { name: 'Anthropic ink', hex: '#1a1815' },
222
426
  { name: 'Forest', hex: '#3f7a4a' },
223
427
  { name: 'Amber', hex: '#c4892b' },
224
428
  { name: 'Berry', hex: '#a44b78' },
@@ -227,39 +431,57 @@ const PRESETS = [
227
431
  ];
228
432
 
229
433
  function AccentPicker() {
230
- const current = accentColor.value;
434
+ const current = (accentColor.value || '').toLowerCase();
435
+ const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
436
+ const [customOpen, setCustomOpen] = useState(!matchedPreset);
231
437
  const [text, setText] = useState(current);
232
- // Keep the text input in sync if the signal changes from elsewhere
233
- // (preset click, reset). useState would otherwise drift on subsequent
234
- // applies. eslint-disable-next-line — intentionally re-syncing on prop change.
235
438
  useEffect(() => { setText(current); }, [current]);
236
439
 
440
+ const pickPreset = (hex) => {
441
+ setAccentColor(hex);
442
+ setCustomOpen(false);
443
+ };
237
444
  const onText = (e) => {
238
445
  const v = e.target.value.trim();
239
446
  setText(v);
240
- // Apply live only when it's a valid hex; otherwise let the user
241
- // keep typing without flicker.
242
447
  if (/^#[0-9a-fA-F]{6}$/.test(v)) setAccentColor(v);
243
448
  };
244
-
245
449
  return html`
246
450
  <div class="accent-picker">
247
- <div class="accent-swatches">
248
- ${PRESETS.map((p) => html`
249
- <button key=${p.hex} class=${`accent-swatch${current.toLowerCase() === p.hex.toLowerCase() ? ' is-active' : ''}`}
250
- style=${`background:${p.hex}`}
251
- title=${`${p.name} · ${p.hex}`}
252
- aria-label=${p.name}
253
- onClick=${() => setAccentColor(p.hex)}></button>`)}
254
- </div>
255
- <div class="accent-custom">
256
- <input type="color" value=${current}
257
- onInput=${(e) => setAccentColor(e.target.value)} />
258
- <input type="text" class="accent-hex" value=${text}
259
- spellcheck="false" maxlength="7"
260
- onInput=${onText} placeholder="#rrggbb" />
261
- <button class="action subtle small"
262
- onClick=${() => setAccentColor(ACCENT_DEFAULT)}>Reset</button>
451
+ <div class="accent-chips">
452
+ ${PRESETS.map((p) => {
453
+ const active = current === p.hex.toLowerCase();
454
+ return html`
455
+ <button key=${p.hex} type="button"
456
+ class=${`accent-chip${active ? ' is-active' : ''}`}
457
+ style=${`--c:${p.hex}`}
458
+ title=${p.hex}
459
+ onClick=${() => pickPreset(p.hex)}>
460
+ <span class="accent-chip-dot" aria-hidden="true"></span>
461
+ <span class="accent-chip-name">${p.name}</span>
462
+ </button>`;
463
+ })}
464
+ <button type="button"
465
+ class=${`accent-chip accent-chip-custom${customOpen ? ' is-open' : ''}${!matchedPreset ? ' is-active' : ''}`}
466
+ style=${!matchedPreset ? `--c:${current}` : ''}
467
+ onClick=${() => setCustomOpen((v) => !v)}>
468
+ ${!matchedPreset
469
+ ? html`<span class="accent-chip-dot" aria-hidden="true"></span>`
470
+ : html`<span class="accent-chip-plus" aria-hidden="true">+</span>`}
471
+ <span class="accent-chip-name">Custom</span>
472
+ </button>
263
473
  </div>
474
+ ${customOpen ? html`
475
+ <div class="accent-custom">
476
+ <input type="color" value=${current}
477
+ onInput=${(e) => setAccentColor(e.target.value)} />
478
+ <input type="text" class="accent-hex mono" value=${text}
479
+ spellcheck="false" maxlength="7"
480
+ onInput=${onText} placeholder="#rrggbb" />
481
+ <button type="button" class="accent-reset"
482
+ onClick=${() => { setAccentColor(ACCENT_DEFAULT); setCustomOpen(false); }}>
483
+ Reset
484
+ </button>
485
+ </div>` : null}
264
486
  </div>`;
265
487
  }