@bakapiano/ccsm 0.14.0 → 0.15.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.
- package/CLAUDE.md +474 -475
- package/README.md +189 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliActivity.js +118 -0
- package/lib/codexSeed.js +147 -0
- package/lib/config.js +211 -188
- package/lib/folders.js +105 -105
- package/lib/localCliSessions.js +489 -489
- package/lib/persistedSessions.js +144 -142
- package/lib/webTerminal.js +224 -224
- package/lib/workspace.js +230 -230
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +613 -608
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1628 -1628
- package/public/index.html +111 -105
- package/public/js/api.js +296 -280
- package/public/js/components/AdoptModal.js +343 -343
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +141 -141
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +299 -299
- package/public/js/components/TerminalView.js +314 -314
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +132 -132
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +505 -475
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +101 -97
- package/public/js/state.js +231 -231
- package/scripts/dev.js +44 -11
- package/scripts/install.js +158 -158
- package/scripts/restart-helper.js +91 -0
- package/server.js +1278 -1254
- package/lib/cliSessionWatcher.js +0 -275
- package/public/manifest.webmanifest +0 -15
|
@@ -1,343 +1,343 @@
|
|
|
1
|
-
// "Import existing session" modal. Browses sessions discovered on disk
|
|
2
|
-
// for claude / codex / copilot, lets the user pick one, choose which
|
|
3
|
-
// configured CLI it should be tied to, and adopts it — a ccsm
|
|
4
|
-
// persistedSessions record is created with the upstream session id
|
|
5
|
-
// pre-filled so clicking it later runs `<cli> --resume <id>` (via
|
|
6
|
-
// cli.resumeIdArgs).
|
|
7
|
-
//
|
|
8
|
-
// Props:
|
|
9
|
-
// onClose() — close request
|
|
10
|
-
// onAdopted(sessionId) — fires after a successful adopt with
|
|
11
|
-
// the new (or pre-existing) record id
|
|
12
|
-
//
|
|
13
|
-
// Tabs across the top switch the upstream type. Below the tabs, an
|
|
14
|
-
// "Adopt as <CLI ▾>" chip filters the configured CLIs by matching
|
|
15
|
-
// `type` and reuses the global PickerPanel popover. A search box
|
|
16
|
-
// filters rows by title + cwd. Each row is a card with the prompt
|
|
17
|
-
// summary, cwd, age, and an Import button.
|
|
18
|
-
|
|
19
|
-
import { html } from '../html.js';
|
|
20
|
-
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
|
21
|
-
import { Modal } from './Modal.js';
|
|
22
|
-
import { Popover } from './Popover.js';
|
|
23
|
-
import { PickerPanel } from './Picker.js';
|
|
24
|
-
import { config } from '../state.js';
|
|
25
|
-
import { listLocalCliSessions, adoptSession } from '../api.js';
|
|
26
|
-
import { setToast } from '../toast.js';
|
|
27
|
-
import {
|
|
28
|
-
IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor,
|
|
29
|
-
IconSearch, IconClose, IconChevronDown, IconBranch,
|
|
30
|
-
} from '../icons.js';
|
|
31
|
-
|
|
32
|
-
const TABS = [
|
|
33
|
-
{ type: 'claude', label: 'Claude', Icon: IconClaudeColor },
|
|
34
|
-
{ type: 'codex', label: 'Codex', Icon: IconCodexColor },
|
|
35
|
-
{ type: 'copilot', label: 'Copilot', Icon: IconCopilotColor },
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
const PAGE_SIZE = 30;
|
|
39
|
-
|
|
40
|
-
export function AdoptModal({ onClose, onAdopted }) {
|
|
41
|
-
const [tab, setTab] = useState('claude');
|
|
42
|
-
// cache shape per tab: { loading, loadingMore, error, items, offset,
|
|
43
|
-
// hasMore, totalActive, totalNonActive }
|
|
44
|
-
const [cache, setCache] = useState({});
|
|
45
|
-
const [adopting, setAdopting] = useState(null);
|
|
46
|
-
const [query, setQuery] = useState('');
|
|
47
|
-
const [pickerOpen, setPickerOpen] = useState(false);
|
|
48
|
-
const [cliOverride, setCliOverride] = useState({});
|
|
49
|
-
const cliAnchorRef = useRef(null);
|
|
50
|
-
|
|
51
|
-
const load = async (type, { force = false } = {}) => {
|
|
52
|
-
const existing = cache[type];
|
|
53
|
-
if (!force && existing && !existing.error && existing.items.length) return;
|
|
54
|
-
setCache((c) => ({
|
|
55
|
-
...c,
|
|
56
|
-
[type]: { loading: true, loadingMore: false, items: [], error: null,
|
|
57
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
58
|
-
}));
|
|
59
|
-
try {
|
|
60
|
-
const r = await listLocalCliSessions(type, { offset: 0, limit: PAGE_SIZE });
|
|
61
|
-
setCache((c) => ({
|
|
62
|
-
...c,
|
|
63
|
-
[type]: {
|
|
64
|
-
loading: false, loadingMore: false, error: null,
|
|
65
|
-
items: r.sessions,
|
|
66
|
-
offset: r.offset + r.sessions.filter((s) => !s.active).length, // advance past hydrated non-active
|
|
67
|
-
hasMore: r.hasMore,
|
|
68
|
-
totalActive: r.totalActive,
|
|
69
|
-
totalNonActive: r.totalNonActive,
|
|
70
|
-
},
|
|
71
|
-
}));
|
|
72
|
-
} catch (e) {
|
|
73
|
-
setCache((c) => ({
|
|
74
|
-
...c,
|
|
75
|
-
[type]: { loading: false, loadingMore: false, items: [], error: e.message,
|
|
76
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
77
|
-
}));
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const loadMore = async () => {
|
|
82
|
-
const cur = cache[tab];
|
|
83
|
-
if (!cur || cur.loadingMore || !cur.hasMore) return;
|
|
84
|
-
setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: true } }));
|
|
85
|
-
try {
|
|
86
|
-
const r = await listLocalCliSessions(tab, { offset: cur.offset, limit: PAGE_SIZE });
|
|
87
|
-
setCache((c) => {
|
|
88
|
-
const entry = c[tab];
|
|
89
|
-
const existingIds = new Set(entry.items.map((x) => x.cliSessionId));
|
|
90
|
-
const additions = r.sessions.filter((s) => !existingIds.has(s.cliSessionId));
|
|
91
|
-
return {
|
|
92
|
-
...c,
|
|
93
|
-
[tab]: {
|
|
94
|
-
...entry,
|
|
95
|
-
loadingMore: false,
|
|
96
|
-
items: [...entry.items, ...additions],
|
|
97
|
-
offset: cur.offset + additions.filter((s) => !s.active).length,
|
|
98
|
-
hasMore: r.hasMore,
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
});
|
|
102
|
-
} catch (e) {
|
|
103
|
-
setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: false, error: e.message } }));
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
useEffect(() => { load(tab); /* eslint-disable-next-line */ }, [tab]);
|
|
108
|
-
// Clear search when switching tabs
|
|
109
|
-
useEffect(() => { setQuery(''); }, [tab]);
|
|
110
|
-
|
|
111
|
-
const cfg = config.value || {};
|
|
112
|
-
const clis = cfg.clis || [];
|
|
113
|
-
// CLIs of the same upstream `type` as the active tab — these are the
|
|
114
|
-
// ones the row's `--resume <id>` template will actually work with.
|
|
115
|
-
const matchingClis = useMemo(
|
|
116
|
-
() => clis.filter((c) => c.type === tab),
|
|
117
|
-
[clis, tab],
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Effective CLI for the current tab: user override → first matching
|
|
121
|
-
// → configured default → first cli.
|
|
122
|
-
const effectiveCliId =
|
|
123
|
-
cliOverride[tab]
|
|
124
|
-
|| matchingClis[0]?.id
|
|
125
|
-
|| cfg.defaultCliId
|
|
126
|
-
|| clis[0]?.id
|
|
127
|
-
|| '';
|
|
128
|
-
const effectiveCli = clis.find((c) => c.id === effectiveCliId) || null;
|
|
129
|
-
|
|
130
|
-
// Items the picker shows — prefer same-type CLIs at top, then dim others.
|
|
131
|
-
const pickerItems = useMemo(() => {
|
|
132
|
-
const Icon = IconForCliType(tab);
|
|
133
|
-
const top = matchingClis.map((c) => ({
|
|
134
|
-
id: c.id,
|
|
135
|
-
icon: html`<${Icon} />`,
|
|
136
|
-
label: c.name,
|
|
137
|
-
meta: c.command,
|
|
138
|
-
}));
|
|
139
|
-
const others = clis
|
|
140
|
-
.filter((c) => c.type !== tab)
|
|
141
|
-
.map((c) => {
|
|
142
|
-
const I = IconForCliType(c.type);
|
|
143
|
-
return {
|
|
144
|
-
id: c.id,
|
|
145
|
-
icon: html`<${I} />`,
|
|
146
|
-
label: c.name,
|
|
147
|
-
meta: `(non-${tab})`,
|
|
148
|
-
};
|
|
149
|
-
});
|
|
150
|
-
return [...top, ...others];
|
|
151
|
-
}, [clis, matchingClis, tab]);
|
|
152
|
-
|
|
153
|
-
const state = cache[tab] || {
|
|
154
|
-
loading: true, loadingMore: false, items: [], error: null,
|
|
155
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0,
|
|
156
|
-
};
|
|
157
|
-
const items = useMemo(() => {
|
|
158
|
-
const q = query.trim().toLowerCase();
|
|
159
|
-
if (!q) return state.items;
|
|
160
|
-
return state.items.filter((it) => {
|
|
161
|
-
const hay = `${it.summary || ''} ${it.cwd || ''} ${it.cliSessionId}`.toLowerCase();
|
|
162
|
-
return hay.includes(q);
|
|
163
|
-
});
|
|
164
|
-
}, [state.items, query]);
|
|
165
|
-
|
|
166
|
-
const totalKnown = state.totalActive + state.totalNonActive;
|
|
167
|
-
const unimportedCount = state.items.filter((it) => !it.adopted).length;
|
|
168
|
-
|
|
169
|
-
const adopt = async (item) => {
|
|
170
|
-
const cliId = effectiveCliId;
|
|
171
|
-
if (!cliId) { setToast('configure a CLI first', 'error'); return; }
|
|
172
|
-
setAdopting(item.cliSessionId);
|
|
173
|
-
try {
|
|
174
|
-
const r = await adoptSession({
|
|
175
|
-
cliId,
|
|
176
|
-
cliSessionId: item.cliSessionId,
|
|
177
|
-
cwd: item.cwd,
|
|
178
|
-
title: item.summary || '',
|
|
179
|
-
});
|
|
180
|
-
if (r.alreadyAdopted) setToast('already in ccsm — opened existing record');
|
|
181
|
-
else setToast(`imported · ${item.cliSessionId.slice(0, 8)}…`);
|
|
182
|
-
setCache((c) => ({
|
|
183
|
-
...c,
|
|
184
|
-
[tab]: c[tab] ? {
|
|
185
|
-
...c[tab],
|
|
186
|
-
items: c[tab].items.map((x) => x.cliSessionId === item.cliSessionId
|
|
187
|
-
? { ...x, adopted: true } : x),
|
|
188
|
-
} : c[tab],
|
|
189
|
-
}));
|
|
190
|
-
onAdopted?.(r.session?.id);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
setToast(e.message, 'error');
|
|
193
|
-
} finally {
|
|
194
|
-
setAdopting(null);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
return html`
|
|
199
|
-
<${Modal} title="Import existing session" onClose=${onClose} width=${680}>
|
|
200
|
-
<div class="adopt">
|
|
201
|
-
<!-- Tabs row -->
|
|
202
|
-
<div class="adopt-tabs">
|
|
203
|
-
${TABS.map((t) => {
|
|
204
|
-
const cnt = cache[t.type]?.items?.filter((x) => !x.adopted).length;
|
|
205
|
-
return html`
|
|
206
|
-
<button type="button" key=${t.type}
|
|
207
|
-
class=${`adopt-tab${tab === t.type ? ' is-active' : ''}`}
|
|
208
|
-
onClick=${() => setTab(t.type)}>
|
|
209
|
-
<span class="adopt-tab-icon"><${t.Icon} /></span>
|
|
210
|
-
<span>${t.label}</span>
|
|
211
|
-
${typeof cnt === 'number' && cnt > 0 ? html`
|
|
212
|
-
<span class="adopt-tab-count">${cnt}</span>
|
|
213
|
-
` : null}
|
|
214
|
-
</button>`;
|
|
215
|
-
})}
|
|
216
|
-
<button type="button" class="adopt-icon-btn" title="Rescan"
|
|
217
|
-
onClick=${() => load(tab, { force: true })}>
|
|
218
|
-
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
|
219
|
-
<path d="M2 8a6 6 0 0 1 10.3-4.2L14 2v4h-4l1.5-1.5A4.5 4.5 0 1 0 12.5 8H14a6 6 0 1 1-12 0z"
|
|
220
|
-
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
|
221
|
-
</svg>
|
|
222
|
-
</button>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
<!-- Tools row: CLI picker + search -->
|
|
226
|
-
<div class="adopt-tools">
|
|
227
|
-
<button type="button" ref=${cliAnchorRef}
|
|
228
|
-
class=${`adopt-cli-pill${pickerOpen ? ' is-open' : ''}`}
|
|
229
|
-
onClick=${() => setPickerOpen((v) => !v)}>
|
|
230
|
-
<span class="adopt-cli-pill-prefix">Adopt as</span>
|
|
231
|
-
<span class="adopt-cli-pill-icon">
|
|
232
|
-
${effectiveCli ? html`${(() => {
|
|
233
|
-
const I = IconForCliType(effectiveCli.type);
|
|
234
|
-
return html`<${I} />`;
|
|
235
|
-
})()}` : null}
|
|
236
|
-
</span>
|
|
237
|
-
<span class="adopt-cli-pill-name">${effectiveCli?.name || 'choose CLI'}</span>
|
|
238
|
-
<${IconChevronDown} />
|
|
239
|
-
</button>
|
|
240
|
-
${pickerOpen ? html`
|
|
241
|
-
<${Popover} anchor=${cliAnchorRef} onClose=${() => setPickerOpen(false)} width=${300}>
|
|
242
|
-
<${PickerPanel}
|
|
243
|
-
title=${`CLI for ${tab} sessions`}
|
|
244
|
-
items=${pickerItems}
|
|
245
|
-
selectedId=${effectiveCliId}
|
|
246
|
-
showSearch=${pickerItems.length > 6}
|
|
247
|
-
emptyHint=${`No configured CLIs match ${tab}.`}
|
|
248
|
-
onSelect=${(id) => { setCliOverride((m) => ({ ...m, [tab]: id })); }}
|
|
249
|
-
onClose=${() => setPickerOpen(false)} />
|
|
250
|
-
</${Popover}>` : null}
|
|
251
|
-
|
|
252
|
-
<div class="adopt-search">
|
|
253
|
-
<span class="adopt-search-icon"><${IconSearch} /></span>
|
|
254
|
-
<input class="adopt-search-input"
|
|
255
|
-
placeholder=${state.loading ? 'Loading…' : `Search ${unimportedCount} sessions…`}
|
|
256
|
-
value=${query} disabled=${state.loading}
|
|
257
|
-
onInput=${(e) => setQuery(e.target.value)} />
|
|
258
|
-
${query ? html`
|
|
259
|
-
<button class="adopt-search-clear" type="button"
|
|
260
|
-
onClick=${() => setQuery('')} title="Clear">
|
|
261
|
-
<${IconClose} />
|
|
262
|
-
</button>` : null}
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
|
|
266
|
-
<!-- List body -->
|
|
267
|
-
<div class="adopt-body">
|
|
268
|
-
${state.loading ? html`
|
|
269
|
-
<div class="adopt-empty"><span class="adopt-empty-spinner"></span> Scanning…</div>
|
|
270
|
-
` : state.error ? html`
|
|
271
|
-
<div class="adopt-empty adopt-error">${state.error}</div>
|
|
272
|
-
` : state.items.length === 0 ? html`
|
|
273
|
-
<div class="adopt-empty">
|
|
274
|
-
<div class="adopt-empty-mark">∅</div>
|
|
275
|
-
No ${tab} sessions found on this machine.
|
|
276
|
-
</div>
|
|
277
|
-
` : items.length === 0 ? html`
|
|
278
|
-
<div class="adopt-empty">No matches for "${query}".</div>
|
|
279
|
-
` : html`
|
|
280
|
-
<ul class="adopt-list" data-shown=${items.length} data-total=${totalKnown}>
|
|
281
|
-
${items.map((it) => html`
|
|
282
|
-
<li class=${`adopt-row${it.adopted ? ' is-adopted' : ''}${it.active ? ' is-active' : ''}`}
|
|
283
|
-
key=${it.cliSessionId}>
|
|
284
|
-
<div class="adopt-row-main">
|
|
285
|
-
<div class="adopt-row-title">
|
|
286
|
-
${it.active ? html`<span class="adopt-row-live" title="A CLI process has this session open right now">● live</span>` : null}
|
|
287
|
-
${it.summary || html`<span class="adopt-row-untitled">untitled session</span>`}
|
|
288
|
-
</div>
|
|
289
|
-
<div class="adopt-row-meta">
|
|
290
|
-
<span class="adopt-row-path mono" title=${it.cwd || ''}>${it.cwd || '—'}</span>
|
|
291
|
-
<span class="adopt-row-dot">·</span>
|
|
292
|
-
<span>${relTime(it.mtime)}</span>
|
|
293
|
-
<span class="adopt-row-dot">·</span>
|
|
294
|
-
<span class="adopt-row-id mono">${it.cliSessionId.slice(0, 8)}</span>
|
|
295
|
-
</div>
|
|
296
|
-
</div>
|
|
297
|
-
<div class="adopt-row-actions">
|
|
298
|
-
${it.adopted ? html`
|
|
299
|
-
<span class="adopt-row-badge">Imported</span>
|
|
300
|
-
` : html`
|
|
301
|
-
<button type="button" class="action primary adopt-row-btn"
|
|
302
|
-
disabled=${adopting === it.cliSessionId || !effectiveCliId}
|
|
303
|
-
onClick=${() => adopt(it)}>
|
|
304
|
-
${adopting === it.cliSessionId ? 'Importing…' : 'Import'}
|
|
305
|
-
</button>
|
|
306
|
-
`}
|
|
307
|
-
</div>
|
|
308
|
-
</li>`)}
|
|
309
|
-
</ul>
|
|
310
|
-
${state.hasMore && !query ? html`
|
|
311
|
-
<div class="adopt-loadmore">
|
|
312
|
-
<button type="button" class="action subtle"
|
|
313
|
-
disabled=${state.loadingMore}
|
|
314
|
-
onClick=${loadMore}>
|
|
315
|
-
${state.loadingMore ? 'Loading…'
|
|
316
|
-
: `Load ${Math.min(PAGE_SIZE, state.totalNonActive - state.offset)} more · ${state.items.length} / ${totalKnown}`}
|
|
317
|
-
</button>
|
|
318
|
-
</div>` : !query && state.items.length > 0 ? html`
|
|
319
|
-
<div class="adopt-loadmore adopt-loadmore-done">
|
|
320
|
-
All ${totalKnown} sessions loaded
|
|
321
|
-
</div>` : null}
|
|
322
|
-
${query && state.hasMore ? html`
|
|
323
|
-
<div class="adopt-loadmore adopt-loadmore-hint">
|
|
324
|
-
Searching ${state.items.length} loaded · clear search and Load more to see older sessions
|
|
325
|
-
</div>` : null}
|
|
326
|
-
`}
|
|
327
|
-
</div>
|
|
328
|
-
</div>
|
|
329
|
-
</${Modal}>`;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function relTime(ms) {
|
|
333
|
-
if (!ms) return '';
|
|
334
|
-
const d = Date.now() - ms;
|
|
335
|
-
const s = Math.round(d / 1000);
|
|
336
|
-
if (s < 60) return `${s}s ago`;
|
|
337
|
-
const m = Math.round(s / 60);
|
|
338
|
-
if (m < 60) return `${m}m ago`;
|
|
339
|
-
const h = Math.round(m / 60);
|
|
340
|
-
if (h < 48) return `${h}h ago`;
|
|
341
|
-
const days = Math.round(h / 24);
|
|
342
|
-
return `${days}d ago`;
|
|
343
|
-
}
|
|
1
|
+
// "Import existing session" modal. Browses sessions discovered on disk
|
|
2
|
+
// for claude / codex / copilot, lets the user pick one, choose which
|
|
3
|
+
// configured CLI it should be tied to, and adopts it — a ccsm
|
|
4
|
+
// persistedSessions record is created with the upstream session id
|
|
5
|
+
// pre-filled so clicking it later runs `<cli> --resume <id>` (via
|
|
6
|
+
// cli.resumeIdArgs).
|
|
7
|
+
//
|
|
8
|
+
// Props:
|
|
9
|
+
// onClose() — close request
|
|
10
|
+
// onAdopted(sessionId) — fires after a successful adopt with
|
|
11
|
+
// the new (or pre-existing) record id
|
|
12
|
+
//
|
|
13
|
+
// Tabs across the top switch the upstream type. Below the tabs, an
|
|
14
|
+
// "Adopt as <CLI ▾>" chip filters the configured CLIs by matching
|
|
15
|
+
// `type` and reuses the global PickerPanel popover. A search box
|
|
16
|
+
// filters rows by title + cwd. Each row is a card with the prompt
|
|
17
|
+
// summary, cwd, age, and an Import button.
|
|
18
|
+
|
|
19
|
+
import { html } from '../html.js';
|
|
20
|
+
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
|
21
|
+
import { Modal } from './Modal.js';
|
|
22
|
+
import { Popover } from './Popover.js';
|
|
23
|
+
import { PickerPanel } from './Picker.js';
|
|
24
|
+
import { config } from '../state.js';
|
|
25
|
+
import { listLocalCliSessions, adoptSession } from '../api.js';
|
|
26
|
+
import { setToast } from '../toast.js';
|
|
27
|
+
import {
|
|
28
|
+
IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor,
|
|
29
|
+
IconSearch, IconClose, IconChevronDown, IconBranch,
|
|
30
|
+
} from '../icons.js';
|
|
31
|
+
|
|
32
|
+
const TABS = [
|
|
33
|
+
{ type: 'claude', label: 'Claude', Icon: IconClaudeColor },
|
|
34
|
+
{ type: 'codex', label: 'Codex', Icon: IconCodexColor },
|
|
35
|
+
{ type: 'copilot', label: 'Copilot', Icon: IconCopilotColor },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const PAGE_SIZE = 30;
|
|
39
|
+
|
|
40
|
+
export function AdoptModal({ onClose, onAdopted }) {
|
|
41
|
+
const [tab, setTab] = useState('claude');
|
|
42
|
+
// cache shape per tab: { loading, loadingMore, error, items, offset,
|
|
43
|
+
// hasMore, totalActive, totalNonActive }
|
|
44
|
+
const [cache, setCache] = useState({});
|
|
45
|
+
const [adopting, setAdopting] = useState(null);
|
|
46
|
+
const [query, setQuery] = useState('');
|
|
47
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
48
|
+
const [cliOverride, setCliOverride] = useState({});
|
|
49
|
+
const cliAnchorRef = useRef(null);
|
|
50
|
+
|
|
51
|
+
const load = async (type, { force = false } = {}) => {
|
|
52
|
+
const existing = cache[type];
|
|
53
|
+
if (!force && existing && !existing.error && existing.items.length) return;
|
|
54
|
+
setCache((c) => ({
|
|
55
|
+
...c,
|
|
56
|
+
[type]: { loading: true, loadingMore: false, items: [], error: null,
|
|
57
|
+
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
58
|
+
}));
|
|
59
|
+
try {
|
|
60
|
+
const r = await listLocalCliSessions(type, { offset: 0, limit: PAGE_SIZE });
|
|
61
|
+
setCache((c) => ({
|
|
62
|
+
...c,
|
|
63
|
+
[type]: {
|
|
64
|
+
loading: false, loadingMore: false, error: null,
|
|
65
|
+
items: r.sessions,
|
|
66
|
+
offset: r.offset + r.sessions.filter((s) => !s.active).length, // advance past hydrated non-active
|
|
67
|
+
hasMore: r.hasMore,
|
|
68
|
+
totalActive: r.totalActive,
|
|
69
|
+
totalNonActive: r.totalNonActive,
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
setCache((c) => ({
|
|
74
|
+
...c,
|
|
75
|
+
[type]: { loading: false, loadingMore: false, items: [], error: e.message,
|
|
76
|
+
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const loadMore = async () => {
|
|
82
|
+
const cur = cache[tab];
|
|
83
|
+
if (!cur || cur.loadingMore || !cur.hasMore) return;
|
|
84
|
+
setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: true } }));
|
|
85
|
+
try {
|
|
86
|
+
const r = await listLocalCliSessions(tab, { offset: cur.offset, limit: PAGE_SIZE });
|
|
87
|
+
setCache((c) => {
|
|
88
|
+
const entry = c[tab];
|
|
89
|
+
const existingIds = new Set(entry.items.map((x) => x.cliSessionId));
|
|
90
|
+
const additions = r.sessions.filter((s) => !existingIds.has(s.cliSessionId));
|
|
91
|
+
return {
|
|
92
|
+
...c,
|
|
93
|
+
[tab]: {
|
|
94
|
+
...entry,
|
|
95
|
+
loadingMore: false,
|
|
96
|
+
items: [...entry.items, ...additions],
|
|
97
|
+
offset: cur.offset + additions.filter((s) => !s.active).length,
|
|
98
|
+
hasMore: r.hasMore,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: false, error: e.message } }));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
useEffect(() => { load(tab); /* eslint-disable-next-line */ }, [tab]);
|
|
108
|
+
// Clear search when switching tabs
|
|
109
|
+
useEffect(() => { setQuery(''); }, [tab]);
|
|
110
|
+
|
|
111
|
+
const cfg = config.value || {};
|
|
112
|
+
const clis = cfg.clis || [];
|
|
113
|
+
// CLIs of the same upstream `type` as the active tab — these are the
|
|
114
|
+
// ones the row's `--resume <id>` template will actually work with.
|
|
115
|
+
const matchingClis = useMemo(
|
|
116
|
+
() => clis.filter((c) => c.type === tab),
|
|
117
|
+
[clis, tab],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Effective CLI for the current tab: user override → first matching
|
|
121
|
+
// → configured default → first cli.
|
|
122
|
+
const effectiveCliId =
|
|
123
|
+
cliOverride[tab]
|
|
124
|
+
|| matchingClis[0]?.id
|
|
125
|
+
|| cfg.defaultCliId
|
|
126
|
+
|| clis[0]?.id
|
|
127
|
+
|| '';
|
|
128
|
+
const effectiveCli = clis.find((c) => c.id === effectiveCliId) || null;
|
|
129
|
+
|
|
130
|
+
// Items the picker shows — prefer same-type CLIs at top, then dim others.
|
|
131
|
+
const pickerItems = useMemo(() => {
|
|
132
|
+
const Icon = IconForCliType(tab);
|
|
133
|
+
const top = matchingClis.map((c) => ({
|
|
134
|
+
id: c.id,
|
|
135
|
+
icon: html`<${Icon} />`,
|
|
136
|
+
label: c.name,
|
|
137
|
+
meta: c.command,
|
|
138
|
+
}));
|
|
139
|
+
const others = clis
|
|
140
|
+
.filter((c) => c.type !== tab)
|
|
141
|
+
.map((c) => {
|
|
142
|
+
const I = IconForCliType(c.type);
|
|
143
|
+
return {
|
|
144
|
+
id: c.id,
|
|
145
|
+
icon: html`<${I} />`,
|
|
146
|
+
label: c.name,
|
|
147
|
+
meta: `(non-${tab})`,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
return [...top, ...others];
|
|
151
|
+
}, [clis, matchingClis, tab]);
|
|
152
|
+
|
|
153
|
+
const state = cache[tab] || {
|
|
154
|
+
loading: true, loadingMore: false, items: [], error: null,
|
|
155
|
+
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0,
|
|
156
|
+
};
|
|
157
|
+
const items = useMemo(() => {
|
|
158
|
+
const q = query.trim().toLowerCase();
|
|
159
|
+
if (!q) return state.items;
|
|
160
|
+
return state.items.filter((it) => {
|
|
161
|
+
const hay = `${it.summary || ''} ${it.cwd || ''} ${it.cliSessionId}`.toLowerCase();
|
|
162
|
+
return hay.includes(q);
|
|
163
|
+
});
|
|
164
|
+
}, [state.items, query]);
|
|
165
|
+
|
|
166
|
+
const totalKnown = state.totalActive + state.totalNonActive;
|
|
167
|
+
const unimportedCount = state.items.filter((it) => !it.adopted).length;
|
|
168
|
+
|
|
169
|
+
const adopt = async (item) => {
|
|
170
|
+
const cliId = effectiveCliId;
|
|
171
|
+
if (!cliId) { setToast('configure a CLI first', 'error'); return; }
|
|
172
|
+
setAdopting(item.cliSessionId);
|
|
173
|
+
try {
|
|
174
|
+
const r = await adoptSession({
|
|
175
|
+
cliId,
|
|
176
|
+
cliSessionId: item.cliSessionId,
|
|
177
|
+
cwd: item.cwd,
|
|
178
|
+
title: item.summary || '',
|
|
179
|
+
});
|
|
180
|
+
if (r.alreadyAdopted) setToast('already in ccsm — opened existing record');
|
|
181
|
+
else setToast(`imported · ${item.cliSessionId.slice(0, 8)}…`);
|
|
182
|
+
setCache((c) => ({
|
|
183
|
+
...c,
|
|
184
|
+
[tab]: c[tab] ? {
|
|
185
|
+
...c[tab],
|
|
186
|
+
items: c[tab].items.map((x) => x.cliSessionId === item.cliSessionId
|
|
187
|
+
? { ...x, adopted: true } : x),
|
|
188
|
+
} : c[tab],
|
|
189
|
+
}));
|
|
190
|
+
onAdopted?.(r.session?.id);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
setToast(e.message, 'error');
|
|
193
|
+
} finally {
|
|
194
|
+
setAdopting(null);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return html`
|
|
199
|
+
<${Modal} title="Import existing session" onClose=${onClose} width=${680}>
|
|
200
|
+
<div class="adopt">
|
|
201
|
+
<!-- Tabs row -->
|
|
202
|
+
<div class="adopt-tabs">
|
|
203
|
+
${TABS.map((t) => {
|
|
204
|
+
const cnt = cache[t.type]?.items?.filter((x) => !x.adopted).length;
|
|
205
|
+
return html`
|
|
206
|
+
<button type="button" key=${t.type}
|
|
207
|
+
class=${`adopt-tab${tab === t.type ? ' is-active' : ''}`}
|
|
208
|
+
onClick=${() => setTab(t.type)}>
|
|
209
|
+
<span class="adopt-tab-icon"><${t.Icon} /></span>
|
|
210
|
+
<span>${t.label}</span>
|
|
211
|
+
${typeof cnt === 'number' && cnt > 0 ? html`
|
|
212
|
+
<span class="adopt-tab-count">${cnt}</span>
|
|
213
|
+
` : null}
|
|
214
|
+
</button>`;
|
|
215
|
+
})}
|
|
216
|
+
<button type="button" class="adopt-icon-btn" title="Rescan"
|
|
217
|
+
onClick=${() => load(tab, { force: true })}>
|
|
218
|
+
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
|
219
|
+
<path d="M2 8a6 6 0 0 1 10.3-4.2L14 2v4h-4l1.5-1.5A4.5 4.5 0 1 0 12.5 8H14a6 6 0 1 1-12 0z"
|
|
220
|
+
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Tools row: CLI picker + search -->
|
|
226
|
+
<div class="adopt-tools">
|
|
227
|
+
<button type="button" ref=${cliAnchorRef}
|
|
228
|
+
class=${`adopt-cli-pill${pickerOpen ? ' is-open' : ''}`}
|
|
229
|
+
onClick=${() => setPickerOpen((v) => !v)}>
|
|
230
|
+
<span class="adopt-cli-pill-prefix">Adopt as</span>
|
|
231
|
+
<span class="adopt-cli-pill-icon">
|
|
232
|
+
${effectiveCli ? html`${(() => {
|
|
233
|
+
const I = IconForCliType(effectiveCli.type);
|
|
234
|
+
return html`<${I} />`;
|
|
235
|
+
})()}` : null}
|
|
236
|
+
</span>
|
|
237
|
+
<span class="adopt-cli-pill-name">${effectiveCli?.name || 'choose CLI'}</span>
|
|
238
|
+
<${IconChevronDown} />
|
|
239
|
+
</button>
|
|
240
|
+
${pickerOpen ? html`
|
|
241
|
+
<${Popover} anchor=${cliAnchorRef} onClose=${() => setPickerOpen(false)} width=${300}>
|
|
242
|
+
<${PickerPanel}
|
|
243
|
+
title=${`CLI for ${tab} sessions`}
|
|
244
|
+
items=${pickerItems}
|
|
245
|
+
selectedId=${effectiveCliId}
|
|
246
|
+
showSearch=${pickerItems.length > 6}
|
|
247
|
+
emptyHint=${`No configured CLIs match ${tab}.`}
|
|
248
|
+
onSelect=${(id) => { setCliOverride((m) => ({ ...m, [tab]: id })); }}
|
|
249
|
+
onClose=${() => setPickerOpen(false)} />
|
|
250
|
+
</${Popover}>` : null}
|
|
251
|
+
|
|
252
|
+
<div class="adopt-search">
|
|
253
|
+
<span class="adopt-search-icon"><${IconSearch} /></span>
|
|
254
|
+
<input class="adopt-search-input"
|
|
255
|
+
placeholder=${state.loading ? 'Loading…' : `Search ${unimportedCount} sessions…`}
|
|
256
|
+
value=${query} disabled=${state.loading}
|
|
257
|
+
onInput=${(e) => setQuery(e.target.value)} />
|
|
258
|
+
${query ? html`
|
|
259
|
+
<button class="adopt-search-clear" type="button"
|
|
260
|
+
onClick=${() => setQuery('')} title="Clear">
|
|
261
|
+
<${IconClose} />
|
|
262
|
+
</button>` : null}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- List body -->
|
|
267
|
+
<div class="adopt-body">
|
|
268
|
+
${state.loading ? html`
|
|
269
|
+
<div class="adopt-empty"><span class="adopt-empty-spinner"></span> Scanning…</div>
|
|
270
|
+
` : state.error ? html`
|
|
271
|
+
<div class="adopt-empty adopt-error">${state.error}</div>
|
|
272
|
+
` : state.items.length === 0 ? html`
|
|
273
|
+
<div class="adopt-empty">
|
|
274
|
+
<div class="adopt-empty-mark">∅</div>
|
|
275
|
+
No ${tab} sessions found on this machine.
|
|
276
|
+
</div>
|
|
277
|
+
` : items.length === 0 ? html`
|
|
278
|
+
<div class="adopt-empty">No matches for "${query}".</div>
|
|
279
|
+
` : html`
|
|
280
|
+
<ul class="adopt-list" data-shown=${items.length} data-total=${totalKnown}>
|
|
281
|
+
${items.map((it) => html`
|
|
282
|
+
<li class=${`adopt-row${it.adopted ? ' is-adopted' : ''}${it.active ? ' is-active' : ''}`}
|
|
283
|
+
key=${it.cliSessionId}>
|
|
284
|
+
<div class="adopt-row-main">
|
|
285
|
+
<div class="adopt-row-title">
|
|
286
|
+
${it.active ? html`<span class="adopt-row-live" title="A CLI process has this session open right now">● live</span>` : null}
|
|
287
|
+
${it.summary || html`<span class="adopt-row-untitled">untitled session</span>`}
|
|
288
|
+
</div>
|
|
289
|
+
<div class="adopt-row-meta">
|
|
290
|
+
<span class="adopt-row-path mono" title=${it.cwd || ''}>${it.cwd || '—'}</span>
|
|
291
|
+
<span class="adopt-row-dot">·</span>
|
|
292
|
+
<span>${relTime(it.mtime)}</span>
|
|
293
|
+
<span class="adopt-row-dot">·</span>
|
|
294
|
+
<span class="adopt-row-id mono">${it.cliSessionId.slice(0, 8)}</span>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="adopt-row-actions">
|
|
298
|
+
${it.adopted ? html`
|
|
299
|
+
<span class="adopt-row-badge">Imported</span>
|
|
300
|
+
` : html`
|
|
301
|
+
<button type="button" class="action primary adopt-row-btn"
|
|
302
|
+
disabled=${adopting === it.cliSessionId || !effectiveCliId}
|
|
303
|
+
onClick=${() => adopt(it)}>
|
|
304
|
+
${adopting === it.cliSessionId ? 'Importing…' : 'Import'}
|
|
305
|
+
</button>
|
|
306
|
+
`}
|
|
307
|
+
</div>
|
|
308
|
+
</li>`)}
|
|
309
|
+
</ul>
|
|
310
|
+
${state.hasMore && !query ? html`
|
|
311
|
+
<div class="adopt-loadmore">
|
|
312
|
+
<button type="button" class="action subtle"
|
|
313
|
+
disabled=${state.loadingMore}
|
|
314
|
+
onClick=${loadMore}>
|
|
315
|
+
${state.loadingMore ? 'Loading…'
|
|
316
|
+
: `Load ${Math.min(PAGE_SIZE, state.totalNonActive - state.offset)} more · ${state.items.length} / ${totalKnown}`}
|
|
317
|
+
</button>
|
|
318
|
+
</div>` : !query && state.items.length > 0 ? html`
|
|
319
|
+
<div class="adopt-loadmore adopt-loadmore-done">
|
|
320
|
+
All ${totalKnown} sessions loaded
|
|
321
|
+
</div>` : null}
|
|
322
|
+
${query && state.hasMore ? html`
|
|
323
|
+
<div class="adopt-loadmore adopt-loadmore-hint">
|
|
324
|
+
Searching ${state.items.length} loaded · clear search and Load more to see older sessions
|
|
325
|
+
</div>` : null}
|
|
326
|
+
`}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</${Modal}>`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function relTime(ms) {
|
|
333
|
+
if (!ms) return '';
|
|
334
|
+
const d = Date.now() - ms;
|
|
335
|
+
const s = Math.round(d / 1000);
|
|
336
|
+
if (s < 60) return `${s}s ago`;
|
|
337
|
+
const m = Math.round(s / 60);
|
|
338
|
+
if (m < 60) return `${m}m ago`;
|
|
339
|
+
const h = Math.round(m / 60);
|
|
340
|
+
if (h < 48) return `${h}h ago`;
|
|
341
|
+
const days = Math.round(h / 24);
|
|
342
|
+
return `${days}d ago`;
|
|
343
|
+
}
|