@bakapiano/ccsm 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +78 -80
  3. package/bin/ccsm.js +1 -1
  4. package/lib/cliSessionWatcher.js +249 -0
  5. package/lib/config.js +101 -19
  6. package/lib/folders.js +96 -0
  7. package/lib/localCliSessions.js +177 -0
  8. package/lib/persistedSessions.js +134 -0
  9. package/lib/webTerminal.js +48 -13
  10. package/lib/workspace.js +26 -4
  11. package/package.json +4 -4
  12. package/public/assets/claude-color.svg +1 -0
  13. package/public/assets/codex-color.svg +1 -0
  14. package/public/assets/copilot-color.svg +1 -0
  15. package/public/css/base.css +22 -5
  16. package/public/css/cards.css +37 -3
  17. package/public/css/feedback.css +127 -43
  18. package/public/css/forms.css +133 -10
  19. package/public/css/layout.css +79 -26
  20. package/public/css/modal.css +40 -26
  21. package/public/css/responsive.css +2 -2
  22. package/public/css/sidebar.css +456 -20
  23. package/public/css/terminals.css +182 -0
  24. package/public/css/tokens.css +28 -12
  25. package/public/css/wco.css +47 -19
  26. package/public/css/widgets.css +1177 -6
  27. package/public/index.html +39 -4
  28. package/public/js/api.js +194 -37
  29. package/public/js/components/AdoptModal.js +171 -0
  30. package/public/js/components/App.js +1 -11
  31. package/public/js/components/DirectoryPicker.js +203 -0
  32. package/public/js/components/EntityFormModal.js +105 -0
  33. package/public/js/components/Modal.js +51 -0
  34. package/public/js/components/OfflineBanner.js +29 -23
  35. package/public/js/components/PageTitleBar.js +13 -0
  36. package/public/js/components/Picker.js +179 -0
  37. package/public/js/components/Popover.js +55 -0
  38. package/public/js/components/Sidebar.js +244 -26
  39. package/public/js/components/TerminalView.js +192 -2
  40. package/public/js/components/useDragSort.js +67 -0
  41. package/public/js/dialog.js +10 -2
  42. package/public/js/icons.js +66 -3
  43. package/public/js/main.js +54 -3
  44. package/public/js/pages/AboutPage.js +81 -1
  45. package/public/js/pages/ConfigurePage.js +452 -159
  46. package/public/js/pages/LaunchPage.js +328 -76
  47. package/public/js/pages/SessionsPage.js +91 -41
  48. package/public/js/state.js +179 -35
  49. package/public/manifest.webmanifest +2 -2
  50. package/scripts/install.js +1 -1
  51. package/server.js +763 -407
  52. package/lib/favorites.js +0 -51
  53. package/lib/focus.js +0 -369
  54. package/lib/labels.js +0 -29
  55. package/lib/launcher.js +0 -219
  56. package/lib/sessions.js +0 -272
  57. package/lib/snapshot.js +0 -141
  58. package/public/js/actions.js +0 -87
  59. package/public/js/components/Fab.js +0 -11
  60. package/public/js/components/FavoritesTable.js +0 -81
  61. package/public/js/components/Footer.js +0 -12
  62. package/public/js/components/NewSessionModal.js +0 -142
  63. package/public/js/components/PageHead.js +0 -33
  64. package/public/js/components/Pagination.js +0 -27
  65. package/public/js/components/RecentTable.js +0 -68
  66. package/public/js/components/SessionsTable.js +0 -71
  67. package/public/js/components/SnapshotPanel.js +0 -77
  68. package/public/js/components/TitleCell.js +0 -40
  69. package/public/js/components/WorkspacesGrid.js +0 -41
  70. package/public/js/pages/TerminalsPage.js +0 -74
@@ -1,117 +1,369 @@
1
+ // Launch page. ChatGPT-style centered composer with custom popover
2
+ // pickers for CLI / Folder / Repos. Each picker shares the unified
3
+ // PickerPanel component and can inline-create new entries.
4
+
1
5
  import { html } from '../html.js';
2
- import { useState } from 'preact/hooks';
6
+ import { useState, useEffect } from 'preact/hooks';
3
7
  import { signal } from '@preact/signals';
4
- import { capabilities, activeTerminalId, selectTab } from '../state.js';
5
- import { loadWorkspaces, loadWebTerminals } from '../api.js';
8
+ import { config, folders, selectSession, selectTab } from '../state.js';
9
+ import { createCli, createFolder, createRepo, reorderFolders, refreshAll } from '../api.js';
6
10
  import { setToast } from '../toast.js';
7
11
  import { streamNewSession, resetProgress } from '../streaming.js';
8
- import { Card } from '../components/Card.js';
9
- import { RepoPicker } from '../components/RepoPicker.js';
10
- import { WorkspacePicker } from '../components/WorkspacePicker.js';
12
+ import { PageTitleBar } from '../components/PageTitleBar.js';
11
13
  import { ProgressList } from '../components/ProgressList.js';
12
- import { WorkspacesGrid, WorkspacesHeader } from '../components/WorkspacesGrid.js';
13
- import { SnapshotPanel, SnapshotMeta } from '../components/SnapshotPanel.js';
14
+ import { Modal } from '../components/Modal.js';
15
+ import { PickerPanel } from '../components/Picker.js';
16
+ import { DirectoryPicker } from '../components/DirectoryPicker.js';
17
+ import { AdoptModal } from '../components/AdoptModal.js';
18
+ import { useDragSort } from '../components/useDragSort.js';
19
+ import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSparkle, IconWorkspace, IconArrowRight } from '../icons.js';
14
20
 
15
21
  const ROOT_ID = 'newSessionProgress';
16
- const inlineSelected = signal(new Set());
17
-
18
- function NewSessionCard() {
19
- const [workspace, setWorkspace] = useState('');
20
- // 'web' = run inside this page (in-process PTY · bridges to xterm.js)
21
- // 'wt' = open a new Windows Terminal window
22
- const initialMode = capabilities.value.webTerminal ? 'web' : 'wt';
23
- const [terminal, setTerminal] = useState(initialMode);
24
- const [result, setResult] = useState('');
22
+ const selectedRepos = signal(new Set());
23
+
24
+ function initRepoSelection(repos) {
25
+ const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
26
+ selectedRepos.value = want;
27
+ }
28
+
29
+ function LaunchHero() {
30
+ const cfg = config.value || {};
31
+ const clis = cfg.clis || [];
32
+ const repos = cfg.repos || [];
33
+ const defaultCli = cfg.defaultCliId || clis[0]?.id || '';
34
+
35
+ const [cliId, setCliId] = useState(defaultCli);
36
+ const [folderId, setFolderId] = useState('');
37
+ const [mode, setMode] = useState('auto'); // 'auto' = workspace + repos, 'cwd' = pick existing dir
38
+ const [cwd, setCwd] = useState(''); // only used when mode === 'cwd'
25
39
  const [busy, setBusy] = useState(false);
40
+ const [result, setResult] = useState('');
41
+ const [openPicker, setOpenPicker] = useState(null); // 'cli' | 'folder' | 'workdir' | null
42
+ const [adoptOpen, setAdoptOpen] = useState(false);
43
+
44
+ // If config arrives after first render (cliId === '') OR the saved
45
+ // cli was removed, snap to the current default.
46
+ useEffect(() => {
47
+ if (!clis.length) return;
48
+ if (!cliId || !clis.find((c) => c.id === cliId)) {
49
+ setCliId(defaultCli);
50
+ }
51
+ }, [defaultCli, clis.length]);
52
+
53
+ const folderDnd = useDragSort(
54
+ folders.value.map((f) => f.id),
55
+ async (nextIds) => {
56
+ try { await reorderFolders(nextIds); }
57
+ catch (e) { setToast(e.message, 'error'); }
58
+ },
59
+ );
60
+
61
+ const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
62
+ useStateOnce(sig, () => initRepoSelection(repos));
63
+
64
+ const cli = clis.find((c) => c.id === cliId) || clis[0];
65
+ const folder = folders.value.find((f) => f.id === folderId);
66
+
67
+ const toggleRepo = (name, on) => {
68
+ const next = new Set(selectedRepos.value);
69
+ if (on) next.add(name); else next.delete(name);
70
+ selectedRepos.value = next;
71
+ };
26
72
 
27
73
  const onLaunch = async () => {
28
- const repos = [...inlineSelected.value];
29
- if (repos.length === 0) return setToast('select at least one repo', 'error');
74
+ const useCwd = mode === 'cwd' && cwd;
75
+ const chosen = useCwd ? [] : [...selectedRepos.value];
30
76
  setBusy(true);
31
77
  setResult('');
32
- resetProgress(repos, ROOT_ID);
78
+ resetProgress(chosen, ROOT_ID);
33
79
  try {
34
80
  const final = await streamNewSession(
35
- { repos, workspace: workspace || undefined, terminal },
81
+ {
82
+ repos: chosen,
83
+ cwd: useCwd ? cwd : undefined,
84
+ cliId: cliId || undefined,
85
+ folderId: folderId || undefined,
86
+ },
36
87
  {
37
88
  progressRootId: ROOT_ID,
38
89
  onMeta: (ev) => {
39
90
  if (ev.type === 'workspace') {
40
- setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
91
+ setResult(`workspace · ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
41
92
  } else if (ev.type === 'launched') {
42
- const l = ev.launched || {};
43
- if (l.mode === 'web') {
44
- setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
45
- } else {
46
- setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
47
- }
93
+ setResult(`launched · session ${ev.launched.id}`);
48
94
  }
49
95
  },
50
96
  },
51
97
  );
52
- if (final.success) {
53
- const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
54
- setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} — ${summary}`);
98
+ if (final.success && final.launched) {
55
99
  setToast(`launched · ${final.workspace.name}`);
56
- // For web mode, hop to the Terminals tab and open the new session.
57
- if (terminal === 'web' && final.launched?.id) {
58
- activeTerminalId.value = final.launched.id;
59
- await loadWebTerminals();
60
- selectTab('terminals');
61
- }
62
- } else {
63
- setResult(`error: ${final.error}`);
64
- setToast(final.error || 'new session failed', 'error');
100
+ await refreshAll();
101
+ selectSession(final.launched.id);
102
+ selectTab('sessions');
103
+ } else if (!final.success) {
104
+ setResult(`error · ${final.error}`);
105
+ setToast(final.error || 'launch failed', 'error');
65
106
  }
66
- await loadWorkspaces();
67
107
  } catch (e) {
68
- setResult(`error: ${e.message}`);
108
+ setResult(`error · ${e.message}`);
69
109
  setToast(e.message, 'error');
70
110
  } finally {
71
111
  setBusy(false);
72
112
  }
73
113
  };
74
114
 
115
+ const close = () => setOpenPicker(null);
116
+
117
+ // --- CLI picker config -----------------------------------------------
118
+ const cliItems = clis.map((c) => {
119
+ const Icon = IconForCliType(c.type);
120
+ return {
121
+ id: c.id,
122
+ icon: html`<${Icon} />`,
123
+ label: c.name,
124
+ meta: `${c.command}${c.shell && c.shell !== 'direct' ? ' · ' + c.shell : ''}`,
125
+ };
126
+ });
127
+ const cliCreateFields = [
128
+ { key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
129
+ { value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
130
+ { value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
131
+ { value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
132
+ { value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
133
+ ],
134
+ onChange: (v, next) => {
135
+ const presets = { claude: { command: 'claude', resumeArgs: '--continue', resumeIdArgs: '--resume <id>', name: 'Claude Code' },
136
+ codex: { command: 'codex', resumeArgs: 'resume --last', resumeIdArgs: 'resume <id>', name: 'OpenAI Codex' },
137
+ copilot: { command: 'copilot', resumeArgs: '--continue', resumeIdArgs: '--resume <id>', name: 'GitHub Copilot' },
138
+ other: {} }[v] || {};
139
+ const patch = {};
140
+ if (presets.resumeArgs != null) patch.resumeArgs = presets.resumeArgs;
141
+ if (presets.resumeIdArgs != null) patch.resumeIdArgs = presets.resumeIdArgs;
142
+ if (!next.command || !next.command.trim()) patch.command = presets.command || '';
143
+ if (!next.name || !next.name.trim()) patch.name = presets.name || '';
144
+ return patch;
145
+ },
146
+ },
147
+ { key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
148
+ { key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
149
+ { key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '' },
150
+ { key: 'resumeArgs', label: 'Resume args (fallback)', mono: true, placeholder: '--continue',
151
+ hint: 'Used when ccsm has no captured upstream session id.' },
152
+ { key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
153
+ hint: 'Use <id> as the placeholder for the captured upstream session UUID.' },
154
+ { key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
155
+ { value: 'direct', label: 'direct (real .exe / .cmd)' },
156
+ { value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
157
+ { value: 'cmd', label: 'cmd (doskey)' },
158
+ ] },
159
+ ];
160
+
161
+ // --- Folder picker config --------------------------------------------
162
+ const folderItems = [
163
+ { id: '', label: 'Unsorted', meta: 'no folder', undraggable: true },
164
+ ...folders.value.map((f) => ({ id: f.id, label: f.name })),
165
+ ];
166
+ const folderCreateFields = [
167
+ { key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
168
+ ];
169
+
170
+ // --- Repo picker config ----------------------------------------------
171
+ const repoItems = repos.map((r) => ({
172
+ id: r.name,
173
+ label: r.name,
174
+ meta: r.url,
175
+ }));
176
+ const repoCreateFields = [
177
+ { key: 'name', label: 'Name', placeholder: 'my-repo', autoFocus: true, required: true },
178
+ { key: 'url', label: 'URL', mono: true, placeholder: 'https://github.com/me/foo.git', required: true },
179
+ ];
180
+
181
+ const selectedRepoCount = selectedRepos.value.size;
182
+
183
+ // Label + title for the unified workdir/repos pill.
184
+ const workdirLabel = (() => {
185
+ if (mode === 'cwd') return cwd ? shortenPath(cwd) : 'Pick folder…';
186
+ if (selectedRepoCount === 0) return 'Auto workspace';
187
+ if (selectedRepoCount === 1) return [...selectedRepos.value][0];
188
+ return `Auto · ${selectedRepoCount} repos`;
189
+ })();
190
+ const workdirTitle = mode === 'cwd'
191
+ ? (cwd ? `Working dir · ${cwd}` : 'Pick an existing folder')
192
+ : (selectedRepoCount === 0
193
+ ? 'Auto: a fresh workspace under workDir (no repos)'
194
+ : `Auto workspace · clone ${selectedRepoCount} repo(s)`);
195
+
75
196
  return html`
76
- <${Card} title="New session"
77
- meta=${html`Picks an unused workspace, clones missing repos, opens <code>claude</code> in a fresh terminal.`}>
78
- <div class="form-row">
79
- <span class="form-label">Repos</span>
80
- <${RepoPicker} selectedSig=${inlineSelected} />
197
+ <div class="launch-hero">
198
+ <div class="launch-brand">
199
+ <span class="launch-brand-mark"><${BrandMark} /></span>
81
200
  </div>
82
- ${capabilities.value.webTerminal ? html`
83
- <div class="form-row">
84
- <span class="form-label">Open in</span>
85
- <div class="radio-row">
86
- <label class=${`radio${terminal === 'web' ? ' is-checked' : ''}`}>
87
- <input type="radio" name="terminal" value="web"
88
- checked=${terminal === 'web'} onChange=${() => setTerminal('web')} />
89
- this page
90
- </label>
91
- <label class=${`radio${terminal === 'wt' ? ' is-checked' : ''}`}>
92
- <input type="radio" name="terminal" value="wt"
93
- checked=${terminal === 'wt'} onChange=${() => setTerminal('wt')} />
94
- wt window
95
- </label>
96
- </div>
97
- </div>` : null}
98
- <div class="form-row">
99
- <label class="form-label">Workspace</label>
100
- <${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
101
- <button class="action primary" disabled=${busy} onClick=${onLaunch}>Launch new session</button>
201
+ <h1 class="launch-tagline">
202
+ One shell. <em>Every CLI.</em>
203
+ </h1>
204
+
205
+ <div class="launch-toolbar">
206
+ <button type="button"
207
+ class=${`pill${openPicker === 'cli' ? ' is-open' : ''}`}
208
+ title="Choose CLI"
209
+ onClick=${() => setOpenPicker(openPicker === 'cli' ? null : 'cli')}>
210
+ <span class="pill-icon">${(() => { const I = IconForCliType(cli?.type); return html`<${I} />`; })()}</span>
211
+ <span class="pill-label">${cli ? cli.name : 'Choose CLI'}</span>
212
+ <span class="pill-chev"><${IconChevronDown} /></span>
213
+ </button>
214
+ ${openPicker === 'cli' ? html`
215
+ <${Modal} title="Choose CLI" onClose=${close} width=${440}>
216
+ <${PickerPanel} items=${cliItems} selectedId=${cliId}
217
+ showSearch=${false}
218
+ onSelect=${(id) => setCliId(id)}
219
+ onCreate=${async (v) => {
220
+ try {
221
+ const id = await createCli(v);
222
+ setToast(`created CLI · ${v.name}`);
223
+ return id;
224
+ } catch (e) { setToast(e.message, 'error'); throw e; }
225
+ }}
226
+ createLabel="New CLI" createFields=${cliCreateFields}
227
+ onClose=${close} />
228
+ </${Modal}>` : null}
229
+
230
+ <button type="button"
231
+ class=${`pill${openPicker === 'workdir' ? ' is-open' : ''}${(mode === 'cwd' && cwd) ? ' is-set' : ''}`}
232
+ title=${workdirTitle}
233
+ onClick=${() => setOpenPicker(openPicker === 'workdir' ? null : 'workdir')}>
234
+ <span class="pill-icon"><${IconWorkspace} /></span>
235
+ <span class="pill-label">${workdirLabel}</span>
236
+ <span class="pill-chev"><${IconChevronDown} /></span>
237
+ </button>
238
+ ${openPicker === 'workdir' ? html`
239
+ <${Modal} title="Working directory" onClose=${close} width=${640}>
240
+ <div class="workdir-modal">
241
+ <div class="workdir-mode-grid">
242
+ <button type="button"
243
+ class=${`workdir-mode-opt${mode === 'auto' ? ' is-active' : ''}`}
244
+ onClick=${() => setMode('auto')}>
245
+ <span class="workdir-mode-icon"><${IconSparkle} /></span>
246
+ <span class="workdir-mode-name">Auto workspace</span>
247
+ <span class="workdir-mode-sub">Fresh <span class="mono">ws-N</span> + clone repos</span>
248
+ </button>
249
+ <button type="button"
250
+ class=${`workdir-mode-opt${mode === 'cwd' ? ' is-active' : ''}`}
251
+ onClick=${() => setMode('cwd')}>
252
+ <span class="workdir-mode-icon"><${IconFolderOpen} /></span>
253
+ <span class="workdir-mode-name">Existing folder</span>
254
+ <span class="workdir-mode-sub">Launch directly · no clone</span>
255
+ </button>
256
+ </div>
257
+ <div class="workdir-detail">
258
+ ${mode === 'auto' ? html`
259
+ <${PickerPanel} items=${repoItems} multi
260
+ showSearch=${false}
261
+ selectedIds=${selectedRepos.value}
262
+ onToggle=${toggleRepo}
263
+ title="Repos to clone"
264
+ emptyHint="No repos configured. Add one below to clone it into the workspace."
265
+ onCreate=${async (v) => {
266
+ try {
267
+ const name = await createRepo(v);
268
+ setToast(`added repo · ${name}`);
269
+ return name;
270
+ } catch (e) { setToast(e.message, 'error'); throw e; }
271
+ }}
272
+ createLabel="New repo" createFields=${repoCreateFields}
273
+ onClose=${close} />
274
+ ` : html`
275
+ <${DirectoryPicker} initialPath=${cwd || ''}
276
+ onPick=${(p) => { setCwd(p); }} />
277
+ `}
278
+ </div>
279
+ <div class="workdir-foot">
280
+ <button type="button" class="action subtle" onClick=${close}>Cancel</button>
281
+ <button type="button" class="action primary"
282
+ disabled=${mode === 'cwd' && !cwd}
283
+ onClick=${close}>
284
+ ${mode === 'cwd' ? 'Use folder' : 'Done'}
285
+ </button>
286
+ </div>
287
+ </div>
288
+ </${Modal}>` : null}
289
+
290
+ <button type="button"
291
+ class=${`pill${openPicker === 'folder' ? ' is-open' : ''}`}
292
+ title="Choose folder"
293
+ onClick=${() => setOpenPicker(openPicker === 'folder' ? null : 'folder')}>
294
+ <span class="pill-icon"><${IconFolder} /></span>
295
+ <span class="pill-label">${folder ? folder.name : 'Unsorted'}</span>
296
+ <span class="pill-chev"><${IconChevronDown} /></span>
297
+ </button>
298
+ ${openPicker === 'folder' ? html`
299
+ <${Modal} title="Choose folder" onClose=${close} width=${400}>
300
+ <${PickerPanel} items=${folderItems} selectedId=${folderId}
301
+ showSearch=${false}
302
+ dnd=${folderDnd}
303
+ onSelect=${(id) => setFolderId(id)}
304
+ onCreate=${async (v) => {
305
+ try {
306
+ const f = await createFolder(v.name);
307
+ setToast(`created folder · ${v.name}`);
308
+ return f?.id;
309
+ } catch (e) { setToast(e.message, 'error'); throw e; }
310
+ }}
311
+ createLabel="New folder" createFields=${folderCreateFields}
312
+ onClose=${close} />
313
+ </${Modal}>` : null}
102
314
  </div>
315
+
316
+ <button class="action primary launch-cta"
317
+ disabled=${busy || !cliId || (mode === 'cwd' && !cwd)}
318
+ onClick=${onLaunch}>
319
+ ${busy ? 'Launching…' : html`Launch <span class="launch-cta-plane" aria-hidden="true">
320
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
321
+ <path d="M22 2 11 13"/>
322
+ <path d="M22 2 15 22l-4-9-9-4Z"/>
323
+ </svg>
324
+ </span>`}
325
+ </button>
326
+
327
+ <button type="button" class="launch-import-link"
328
+ onClick=${() => setAdoptOpen(true)}>
329
+ or import existing<span class="launch-import-arrow" aria-hidden="true"><${IconArrowRight} /></span>
330
+ </button>
331
+
332
+ ${adoptOpen ? html`
333
+ <${AdoptModal} onClose=${() => setAdoptOpen(false)}
334
+ onAdopted=${async (id) => {
335
+ setAdoptOpen(false);
336
+ await refreshAll();
337
+ if (id) selectSession(id);
338
+ selectTab('sessions');
339
+ }} />` : null}
340
+
103
341
  <${ProgressList} rootId=${ROOT_ID} />
104
- <div class="post-result">${result}</div>
105
- </${Card}>`;
342
+ ${result ? html`<div class="launch-status mono">${result}</div>` : null}
343
+ </div>`;
344
+ }
345
+
346
+ let lastKey = null;
347
+ function useStateOnce(key, init) {
348
+ if (key !== lastKey) {
349
+ lastKey = key;
350
+ init();
351
+ }
352
+ }
353
+
354
+ // Truncate a long path so it fits the pill nicely.
355
+ // C:\Users\admin\proj\foo\bar → …\foo\bar
356
+ function shortenPath(p) {
357
+ if (!p) return '';
358
+ if (p.length <= 28) return p;
359
+ const sep = p.includes('\\') ? '\\' : '/';
360
+ const parts = p.split(sep).filter(Boolean);
361
+ if (parts.length <= 2) return p;
362
+ return '…' + sep + parts.slice(-2).join(sep);
106
363
  }
107
364
 
108
365
  export function LaunchPage() {
109
366
  return html`
110
- <${NewSessionCard} />
111
- <${Card} title="Snapshot & restore" meta=${html`<${SnapshotMeta} />`}>
112
- <${SnapshotPanel} />
113
- </${Card}>
114
- <${Card} title="Workspaces on disk" meta=${html`<${WorkspacesHeader} />`}>
115
- <${WorkspacesGrid} />
116
- </${Card}>`;
367
+ <${PageTitleBar} title="New session" />
368
+ <${LaunchHero} />`;
117
369
  }
@@ -1,47 +1,97 @@
1
+ // Sessions page · the main pane. Shows the terminal for the currently
2
+ // selected session (activeSessionId), with a thin header providing
3
+ // session metadata + rename/delete actions. When a session is selected
4
+ // but not running we auto-resume it — no manual button.
5
+
1
6
  import { html } from '../html.js';
2
- import { sessions, recentTotal, favoritesList, clockTick } from '../state.js';
3
- import { Card } from '../components/Card.js';
4
- import { SessionsTable } from '../components/SessionsTable.js';
5
- import { RecentTable } from '../components/RecentTable.js';
6
- import { FavoritesTable } from '../components/FavoritesTable.js';
7
- import { runFinder } from '../actions.js';
8
- import { IconSearch, StarSmallFilled } from '../icons.js';
9
- import { nowClock } from '../util.js';
7
+ import { useEffect, useState } from 'preact/hooks';
8
+ import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
9
+ import { resumeSession, deleteSession, setSessionTitle } from '../api.js';
10
+ import { setToast } from '../toast.js';
11
+ import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
12
+ import { TerminalView } from '../components/TerminalView.js';
13
+ import { PageTitleBar } from '../components/PageTitleBar.js';
14
+ import { IconPencil, IconClose } from '../icons.js';
15
+ import { fmtAgo } from '../util.js';
10
16
 
11
17
  export function SessionsPage() {
12
- void clockTick.value;
13
- const sessCount = sessions.value.length;
14
- const favCount = favoritesList.value.length;
15
-
16
- const sessionsMeta = sessCount
17
- ? `${sessCount} live · refreshed ${nowClock()}`
18
- : 'no live sessions';
19
- const recentMeta = recentTotal.value
20
- ? `${recentTotal.value} total · sorted by jsonl mtime, excluding live`
21
- : 'no recent sessions';
22
- const favMeta = favCount ? `${favCount} pinned` : 'click on any row to pin sessions here';
18
+ clockTick.value; // resubscribe fmtAgo
19
+ const id = activeSessionId.value;
20
+ const list = sessions.value;
21
+ const session = id ? list.find((s) => s.id === id) : null;
22
+ const [resumeError, setResumeError] = useState(null);
23
+ // Bumps to force the auto-resume effect to re-run on Retry without
24
+ // mutating any signal. Primitive in the dep array → identity changes.
25
+ const [retryNonce, setRetryNonce] = useState(0);
26
+
27
+ // No session selected → bounce to the Launch page. Done in an effect so
28
+ // we don't mutate signals during render. Returning null while the bounce
29
+ // is in flight avoids a flash of empty content.
30
+ useEffect(() => {
31
+ if (!session) selectTab('launch');
32
+ }, [session]);
33
+
34
+ // Auto-resume when the active session is exited. resumeSession() in
35
+ // api.js dedups in-flight calls per session id, so simultaneous fires
36
+ // from here and from Sidebar.onClick collapse into one request.
37
+ useEffect(() => {
38
+ if (!session) return;
39
+ if (session.status === 'running') { setResumeError(null); return; }
40
+ setResumeError(null);
41
+ resumeSession(session.id)
42
+ .then((launched) => { if (launched?.id) selectSession(launched.id); })
43
+ .catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
44
+ }, [session?.id, session?.status, retryNonce]);
45
+
46
+ if (!session) return null;
47
+
48
+ const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
49
+ const running = session.status === 'running';
50
+ const title = session.title || session.workspace || session.id.slice(0, 12);
51
+
52
+ const onRename = async () => {
53
+ const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
54
+ if (next === null) return;
55
+ try { await setSessionTitle(session.id, next.trim()); }
56
+ catch (e) { setToast(e.message, 'error'); }
57
+ };
58
+ const onDelete = async () => {
59
+ const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
60
+ title: 'Delete session', okLabel: 'Delete', danger: true });
61
+ if (!ok) return;
62
+ try {
63
+ await deleteSession(session.id);
64
+ activeSessionId.value = null;
65
+ } catch (e) { setToast(e.message, 'error'); }
66
+ };
67
+ const onRetry = () => setRetryNonce((n) => n + 1);
23
68
 
24
69
  return html`
25
- <div class="page-actions">
26
- <span class="page-actions-hint">Looking through your past conversations?</span>
27
- <button class="action primary" onClick=${runFinder}
28
- title="open a Claude session with context on the ccsm data dir">
29
- <${IconSearch} stroke=${2} /> Ask Claude to find a session
30
- </button>
31
- </div>
32
-
33
- <${Card} foldKey="favorites"
34
- title=${html`Favorites <${StarSmallFilled} />`}
35
- meta=${favMeta}
36
- flush=${true}>
37
- <${FavoritesTable} />
38
- </${Card}>
39
-
40
- <${Card} foldKey="sessions" title="Live sessions" meta=${sessionsMeta} flush=${true}>
41
- <${SessionsTable} />
42
- </${Card}>
43
-
44
- <${Card} foldKey="recent" title="Recently closed" meta=${recentMeta} flush=${true}>
45
- <${RecentTable} />
46
- </${Card}>`;
70
+ <${PageTitleBar} title=${html`
71
+ <span class="session-title-text">${title}</span>
72
+ <span class="session-title-meta">
73
+ <span class="mono">${session.cwd}</span>
74
+ <span>·</span>
75
+ <span>${cli ? cli.name : session.cliId}</span>
76
+ ${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
77
+ <span>·</span>
78
+ <span>${running ? 'running' : (resumeError ? 'resume failed' : 'resuming…')}</span>
79
+ </span>
80
+ `}>
81
+ </${PageTitleBar}>
82
+ <div class="session-pane">
83
+ <div class="session-pane-body">
84
+ ${running
85
+ ? html`<${TerminalView} terminalId=${session.id} />`
86
+ : html`
87
+ <div class="terminal-empty">
88
+ ${resumeError ? html`
89
+ <div>Failed to resume: <span class="mono">${resumeError}</span></div>
90
+ <button class="action primary" onClick=${onRetry}>Retry</button>
91
+ ` : html`
92
+ <div>Resuming session…</div>
93
+ `}
94
+ </div>`}
95
+ </div>
96
+ </div>`;
47
97
  }