@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,129 +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, config } from '../state.js';
5
- import { api, 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 { ReposEditor, addEmptyRepo } from '../components/ReposEditor.js';
11
- import { WorkspacePicker } from '../components/WorkspacePicker.js';
12
+ import { PageTitleBar } from '../components/PageTitleBar.js';
12
13
  import { ProgressList } from '../components/ProgressList.js';
13
- import { WorkspacesGrid, WorkspacesHeader } from '../components/WorkspacesGrid.js';
14
- 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';
15
20
 
16
21
  const ROOT_ID = 'newSessionProgress';
17
- const inlineSelected = signal(new Set());
22
+ const selectedRepos = signal(new Set());
18
23
 
19
- function NewSessionCard() {
20
- const [workspace, setWorkspace] = useState('');
21
- const [result, setResult] = useState('');
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'
22
39
  const [busy, setBusy] = useState(false);
23
- const [reposSavedAt, setReposSavedAt] = useState('');
24
- const repos = config.value?.repos || [];
25
- const hasRepos = repos.length > 0;
26
- // Always follow the global Configure → "Default mode" setting. The
27
- // per-launch picker was removed; users who want a one-off override
28
- // change the global setting first. This keeps "new" / "resume" /
29
- // "continue" / "finder" all consistent.
30
- const cfgDefault = config.value?.defaultTerminalMode || 'wt';
31
- const terminal = capabilities.value.webTerminal ? cfgDefault : 'wt';
32
-
33
- const onSaveRepos = async () => {
34
- try {
35
- const cfg = await api('PUT', '/api/config', config.value);
36
- config.value = cfg;
37
- setReposSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
38
- setToast('repos saved');
39
- } catch (e) { setToast(e.message, 'error'); }
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;
40
71
  };
41
72
 
42
73
  const onLaunch = async () => {
43
- const repos = [...inlineSelected.value];
44
- // Allow zero-repo launches: workspace is created empty, claude opens there.
74
+ const useCwd = mode === 'cwd' && cwd;
75
+ const chosen = useCwd ? [] : [...selectedRepos.value];
45
76
  setBusy(true);
46
77
  setResult('');
47
- resetProgress(repos, ROOT_ID);
78
+ resetProgress(chosen, ROOT_ID);
48
79
  try {
49
80
  const final = await streamNewSession(
50
- { repos, workspace: workspace || undefined, terminal },
81
+ {
82
+ repos: chosen,
83
+ cwd: useCwd ? cwd : undefined,
84
+ cliId: cliId || undefined,
85
+ folderId: folderId || undefined,
86
+ },
51
87
  {
52
88
  progressRootId: ROOT_ID,
53
89
  onMeta: (ev) => {
54
90
  if (ev.type === 'workspace') {
55
- setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
91
+ setResult(`workspace · ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
56
92
  } else if (ev.type === 'launched') {
57
- const l = ev.launched || {};
58
- if (l.mode === 'web') {
59
- setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
60
- } else {
61
- setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
62
- }
93
+ setResult(`launched · session ${ev.launched.id}`);
63
94
  }
64
95
  },
65
96
  },
66
97
  );
67
- if (final.success) {
68
- const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
69
- setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} — ${summary}`);
98
+ if (final.success && final.launched) {
70
99
  setToast(`launched · ${final.workspace.name}`);
71
- // For web mode, hop to the Terminals tab and open the new session.
72
- if (terminal === 'web' && final.launched?.id) {
73
- activeTerminalId.value = final.launched.id;
74
- await loadWebTerminals();
75
- selectTab('terminals');
76
- }
77
- } else {
78
- setResult(`error: ${final.error}`);
79
- 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');
80
106
  }
81
- await loadWorkspaces();
82
107
  } catch (e) {
83
- setResult(`error: ${e.message}`);
108
+ setResult(`error · ${e.message}`);
84
109
  setToast(e.message, 'error');
85
110
  } finally {
86
111
  setBusy(false);
87
112
  }
88
113
  };
89
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
+
90
196
  return html`
91
- <${Card} title="New session"
92
- meta=${html`Picks an unused workspace, clones missing repos, opens <code>claude</code> in a fresh terminal.`}>
93
- <div class="form-row">
94
- <span class="form-label">Repos</span>
95
- ${hasRepos
96
- ? html`<${RepoPicker} selectedSig=${inlineSelected} />`
97
- : html`<span class="muted-text">no repos configured · add one below, or launch with no repos for an empty workspace</span>`}
197
+ <div class="launch-hero">
198
+ <div class="launch-brand">
199
+ <span class="launch-brand-mark"><${BrandMark} /></span>
98
200
  </div>
99
- <details class="repos-inline-config" open=${!hasRepos}>
100
- <summary>Manage repos</summary>
101
- <div class="repos-inline-body">
102
- <${ReposEditor} />
103
- <div class="repos-inline-actions">
104
- <button class="action small" onClick=${() => addEmptyRepo()}>+ Add repo</button>
105
- <button class="action small primary" onClick=${onSaveRepos}>Save changes</button>
106
- <span class="muted-text">${reposSavedAt}</span>
107
- </div>
108
- </div>
109
- </details>
110
- <div class="form-row">
111
- <label class="form-label">Workspace</label>
112
- <${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
113
- <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}
114
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
+
115
341
  <${ProgressList} rootId=${ROOT_ID} />
116
- <div class="post-result">${result}</div>
117
- </${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);
118
363
  }
119
364
 
120
365
  export function LaunchPage() {
121
366
  return html`
122
- <${NewSessionCard} />
123
- <${Card} title="Snapshot & restore" meta=${html`<${SnapshotMeta} />`}>
124
- <${SnapshotPanel} />
125
- </${Card}>
126
- <${Card} title="Workspaces on disk" meta=${html`<${WorkspacesHeader} />`}>
127
- <${WorkspacesGrid} />
128
- </${Card}>`;
367
+ <${PageTitleBar} title="New session" />
368
+ <${LaunchHero} />`;
129
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
  }