@bakapiano/ccsm 0.22.7 → 0.22.8

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.
@@ -1,261 +0,0 @@
1
- // "Import existing session" modal. Browses sessions discovered on disk for
2
- // claude / codex / copilot, lets the user pick one, choose which configured
3
- // CLI it should be tied to, and adopts it — a ccsm persistedSessions record
4
- // is created with the upstream session id pre-filled so clicking it later
5
- // runs `<cli> --resume <id>` (via cli.resumeIdArgs).
6
- //
7
- // Layout (matches the app's modal pattern): pinned head (tabs + "import as"
8
- // CLI pill + search), a scrolling list of session cards, and a pinned footer
9
- // with ‹ Prev · X–Y of Z · Next › pagination. Each tab paginates server-side
10
- // (PAGE_SIZE per page); typing a query loads the whole tab and filters it.
11
-
12
- import { html } from '../html.js';
13
- import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
14
- import { Modal } from './Modal.js';
15
- import { Popover } from './Popover.js';
16
- import { PickerPanel } from './Picker.js';
17
- import { config } from '../state.js';
18
- import { listLocalCliSessions, adoptSession } from '../api.js';
19
- import { setToast } from '../toast.js';
20
- import {
21
- IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor,
22
- IconSearch, IconClose, IconChevronDown, IconChevronLeft, IconChevronRight, IconRefresh,
23
- } from '../icons.js';
24
-
25
- const TABS = [
26
- { type: 'claude', label: 'Claude', Icon: IconClaudeColor },
27
- { type: 'codex', label: 'Codex', Icon: IconCodexColor },
28
- { type: 'copilot', label: 'Copilot', Icon: IconCopilotColor },
29
- ];
30
-
31
- const PAGE_SIZE = 20;
32
- const SEARCH_LIMIT = 1000; // typing a query loads the whole tab to filter
33
-
34
- export function AdoptModal({ onClose, onAdopted }) {
35
- const [tab, setTab] = useState('claude');
36
- const [page, setPage] = useState(0);
37
- const [query, setQuery] = useState('');
38
- const [view, setView] = useState({ loading: true, error: null, sessions: [], total: 0 });
39
- const [totals, setTotals] = useState({}); // per-tab total → tab badges
40
- const [adopting, setAdopting] = useState(null);
41
- const [pickerOpen, setPickerOpen] = useState(false);
42
- const [cliOverride, setCliOverride] = useState({});
43
- const [reloadTick, setReloadTick] = useState(0); // Rescan bumps this
44
- const cliAnchorRef = useRef(null);
45
-
46
- const searching = !!query.trim();
47
-
48
- // Fetch the active view (tab × page × query). When searching we pull the
49
- // whole tab and filter client-side; otherwise just the requested page.
50
- useEffect(() => {
51
- let cancelled = false;
52
- setView((v) => ({ ...v, loading: true, error: null }));
53
- (async () => {
54
- try {
55
- const offset = searching ? 0 : page * PAGE_SIZE;
56
- const limit = searching ? SEARCH_LIMIT : PAGE_SIZE;
57
- const r = await listLocalCliSessions(tab, { offset, limit });
58
- if (cancelled) return;
59
- const total = r.total ?? ((r.totalActive || 0) + (r.totalNonActive || 0));
60
- setView({ loading: false, error: null, sessions: r.sessions || [], total });
61
- setTotals((t) => (t[tab] === total ? t : { ...t, [tab]: total }));
62
- } catch (e) {
63
- if (!cancelled) setView({ loading: false, error: e.message, sessions: [], total: 0 });
64
- }
65
- })();
66
- return () => { cancelled = true; };
67
- }, [tab, page, searching, reloadTick]);
68
-
69
- // Snap back to page 0 when the tab changes or search toggles on/off.
70
- useEffect(() => { setPage(0); }, [tab]);
71
- useEffect(() => { setPage(0); }, [searching]);
72
-
73
- const cfg = config.value || {};
74
- const clis = cfg.clis || [];
75
- // CLIs of the same upstream type as the active tab — the ones whose
76
- // `--resume <id>` template will actually work with these sessions.
77
- const matchingClis = useMemo(() => clis.filter((c) => c.type === tab), [clis, tab]);
78
- const effectiveCliId =
79
- cliOverride[tab] || matchingClis[0]?.id || cfg.defaultCliId || clis[0]?.id || '';
80
- const effectiveCli = clis.find((c) => c.id === effectiveCliId) || null;
81
-
82
- const pickerItems = useMemo(() => {
83
- const Icon = IconForCliType(tab);
84
- const top = matchingClis.map((c) => ({ id: c.id, icon: html`<${Icon} />`, label: c.name, meta: c.command }));
85
- const others = clis.filter((c) => c.type !== tab).map((c) => {
86
- const I = IconForCliType(c.type);
87
- return { id: c.id, icon: html`<${I} />`, label: c.name, meta: `(non-${tab})` };
88
- });
89
- return [...top, ...others];
90
- }, [clis, matchingClis, tab]);
91
-
92
- // Search filters the loaded set (the whole tab while searching, else the page).
93
- const rows = useMemo(() => {
94
- const q = query.trim().toLowerCase();
95
- if (!q) return view.sessions;
96
- return view.sessions.filter((it) =>
97
- `${it.summary || ''} ${it.cwd || ''} ${it.cliSessionId}`.toLowerCase().includes(q));
98
- }, [view.sessions, query]);
99
-
100
- const adopt = async (item) => {
101
- if (!effectiveCliId) { setToast('configure a CLI first', 'error'); return; }
102
- setAdopting(item.cliSessionId);
103
- try {
104
- const r = await adoptSession({
105
- cliId: effectiveCliId,
106
- cliSessionId: item.cliSessionId,
107
- cwd: item.cwd,
108
- title: item.summary || '',
109
- });
110
- if (r.alreadyAdopted) setToast('already in ccsm — opened existing record');
111
- else setToast(`imported · ${item.cliSessionId.slice(0, 8)}…`);
112
- setView((v) => ({
113
- ...v,
114
- sessions: v.sessions.map((x) => x.cliSessionId === item.cliSessionId ? { ...x, adopted: true } : x),
115
- }));
116
- onAdopted?.(r.session?.id);
117
- } catch (e) {
118
- setToast(e.message, 'error');
119
- } finally {
120
- setAdopting(null);
121
- }
122
- };
123
-
124
- // ── pagination footer ──
125
- const pageCount = Math.max(1, Math.ceil(view.total / PAGE_SIZE));
126
- const from = view.total === 0 ? 0 : page * PAGE_SIZE + 1;
127
- const to = Math.min(view.total, page * PAGE_SIZE + view.sessions.length);
128
- const footer = searching
129
- ? html`<span class="adopt-pager-info">
130
- ${rows.length} match${rows.length === 1 ? '' : 'es'} · clear search to browse all
131
- </span>`
132
- : html`
133
- <button type="button" class="action subtle small adopt-pager-btn"
134
- disabled=${page === 0 || view.loading}
135
- onClick=${() => setPage((p) => Math.max(0, p - 1))}>
136
- <${IconChevronLeft} /> Prev
137
- </button>
138
- <span class="adopt-pager-info">
139
- ${view.total === 0 ? 'No sessions' : `${from}–${to} of ${view.total}`}
140
- </span>
141
- <button type="button" class="action subtle small adopt-pager-btn"
142
- disabled=${(page + 1) >= pageCount || view.loading}
143
- onClick=${() => setPage((p) => p + 1)}>
144
- Next <${IconChevronRight} />
145
- </button>`;
146
-
147
- return html`
148
- <${Modal} title="Import existing session" onClose=${onClose} width=${680} footer=${footer}>
149
- <div class="adopt">
150
- <div class="adopt-head">
151
- <div class="adopt-tabs">
152
- ${TABS.map((t) => html`
153
- <button type="button" key=${t.type}
154
- class=${`adopt-tab${tab === t.type ? ' is-active' : ''}`}
155
- onClick=${() => setTab(t.type)}>
156
- <span class="adopt-tab-icon"><${t.Icon} /></span>
157
- <span class="adopt-tab-label">${t.label}</span>
158
- ${typeof totals[t.type] === 'number' && totals[t.type] > 0 ? html`
159
- <span class="adopt-tab-count">${totals[t.type]}</span>` : null}
160
- </button>`)}
161
- <button type="button" class="adopt-rescan" title="Rescan disk"
162
- onClick=${() => setReloadTick((n) => n + 1)}>
163
- <${IconRefresh} />
164
- </button>
165
- </div>
166
-
167
- <div class="adopt-tools">
168
- <button type="button" ref=${cliAnchorRef}
169
- class=${`adopt-cli-pill${pickerOpen ? ' is-open' : ''}`}
170
- onClick=${() => setPickerOpen((v) => !v)}>
171
- <span class="adopt-cli-pill-prefix">Import as</span>
172
- <span class="adopt-cli-pill-icon">
173
- ${effectiveCli ? html`${(() => { const I = IconForCliType(effectiveCli.type); return html`<${I} />`; })()}` : null}
174
- </span>
175
- <span class="adopt-cli-pill-name">${effectiveCli?.name || 'choose CLI'}</span>
176
- <${IconChevronDown} />
177
- </button>
178
- ${pickerOpen ? html`
179
- <${Popover} anchor=${cliAnchorRef} onClose=${() => setPickerOpen(false)} width=${300}>
180
- <${PickerPanel}
181
- title=${`CLI for ${tab} sessions`}
182
- items=${pickerItems}
183
- selectedId=${effectiveCliId}
184
- showSearch=${pickerItems.length > 6}
185
- emptyHint=${`No configured CLIs match ${tab}.`}
186
- onSelect=${(id) => { setCliOverride((m) => ({ ...m, [tab]: id })); }}
187
- onClose=${() => setPickerOpen(false)} />
188
- </${Popover}>` : null}
189
-
190
- <div class="adopt-search">
191
- <span class="adopt-search-icon"><${IconSearch} /></span>
192
- <input class="adopt-search-input"
193
- placeholder=${`Search ${tab} sessions…`}
194
- value=${query}
195
- onInput=${(e) => setQuery(e.target.value)} />
196
- ${query ? html`
197
- <button class="adopt-search-clear" type="button" title="Clear"
198
- onClick=${() => setQuery('')}><${IconClose} /></button>` : null}
199
- </div>
200
- </div>
201
- </div>
202
-
203
- <div class="adopt-list">
204
- ${view.loading ? html`
205
- <div class="adopt-empty"><span class="adopt-empty-spinner"></span> Scanning…</div>
206
- ` : view.error ? html`
207
- <div class="adopt-empty adopt-error">${view.error}</div>
208
- ` : rows.length === 0 ? html`
209
- <div class="adopt-empty">
210
- <div class="adopt-empty-mark">∅</div>
211
- ${searching ? html`No matches for "${query}".` : html`No ${tab} sessions found on this machine.`}
212
- </div>
213
- ` : html`
214
- <ul class="adopt-rows">
215
- ${rows.map((it) => {
216
- const Icon = IconForCliType(tab);
217
- return html`
218
- <li class=${`adopt-row${it.adopted ? ' is-adopted' : ''}${it.active ? ' is-active' : ''}`}
219
- key=${it.cliSessionId}>
220
- <span class="adopt-row-icon"><${Icon} /></span>
221
- <div class="adopt-row-main">
222
- <div class="adopt-row-title">
223
- ${it.active ? html`<span class="adopt-row-live" title="A CLI process has this session open right now">live</span>` : null}
224
- ${it.summary || html`<span class="adopt-row-untitled">untitled session</span>`}
225
- </div>
226
- <div class="adopt-row-meta">
227
- <span class="adopt-row-path mono" title=${it.cwd || ''}>${it.cwd || '—'}</span>
228
- <span class="adopt-row-dot">·</span>
229
- <span>${relTime(it.mtime)}</span>
230
- <span class="adopt-row-dot">·</span>
231
- <span class="adopt-row-id mono">${it.cliSessionId.slice(0, 8)}</span>
232
- </div>
233
- </div>
234
- <div class="adopt-row-actions">
235
- ${it.adopted ? html`
236
- <span class="adopt-row-badge">Imported</span>
237
- ` : html`
238
- <button type="button" class="action primary small adopt-row-btn"
239
- disabled=${adopting === it.cliSessionId || !effectiveCliId}
240
- onClick=${() => adopt(it)}>
241
- ${adopting === it.cliSessionId ? 'Importing…' : 'Import'}
242
- </button>`}
243
- </div>
244
- </li>`;
245
- })}
246
- </ul>`}
247
- </div>
248
- </div>
249
- </${Modal}>`;
250
- }
251
-
252
- function relTime(ms) {
253
- if (!ms) return '';
254
- const s = Math.round((Date.now() - ms) / 1000);
255
- if (s < 60) return `${s}s ago`;
256
- const m = Math.round(s / 60);
257
- if (m < 60) return `${m}m ago`;
258
- const h = Math.round(m / 60);
259
- if (h < 48) return `${h}h ago`;
260
- return `${Math.round(h / 24)}d ago`;
261
- }