claude-simple 0.1.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 (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
@@ -0,0 +1,442 @@
1
+ // shell/session-list.js — session list UI, filters, activity search, memory tab
2
+ import { esc, $ } from '../lib.js';
3
+ import {
4
+ sessionsData, workspacesData, sessionFilter, sessionSearch,
5
+ expandedFolders, collapsedFolders, filteredSessions, projectGroups,
6
+ currentModel, currentAgent,
7
+ sidebarView, agentFilter, timeRange, activityHits, activityLoading,
8
+ sessionMetaMap, LOW_TURN_THRESHOLD, metaKey, sessionDisplayTitle,
9
+ hubMode, selectedMachineId, agentsMeta, currentEffort,
10
+ getModelsForAgent, getDefaultModel, getEffortsForModel,
11
+ addCustomModel, removeCustomModel, getCustomModels, ctx,
12
+ } from '../state.js';
13
+ import { api } from '../api.js';
14
+ import { getCachedSessions, setCachedSessions, getCachedWorkspaces, setCachedWorkspaces } from '../cache.js';
15
+ import { formatTime, agentBadge, agentCountPills, shortPath } from './util.js';
16
+
17
+
18
+ function normalizeSessions(v) {
19
+ if (Array.isArray(v)) return v;
20
+ if (v && Array.isArray(v.results)) return v.results;
21
+ if (v && Array.isArray(v.sessions)) return v.sessions;
22
+ return [];
23
+ }
24
+
25
+ /** @param {{ waitFresh?: boolean }} opts waitFresh=false → paint cache first, refresh in bg */
26
+ export async function loadAllSessions({ waitFresh = true } = {}) {
27
+ const cached = normalizeSessions(await getCachedSessions());
28
+ if (cached.length) {
29
+ // Hub: if we already selected a machine, prefer cache entries for that machine
30
+ const mid = selectedMachineId.peek();
31
+ if (hubMode.peek() && mid) {
32
+ const filtered = cached.filter(s => !s.machineId || s.machineId === mid);
33
+ sessionsData.value = filtered.length ? filtered : cached;
34
+ } else {
35
+ sessionsData.value = cached;
36
+ }
37
+ }
38
+
39
+ const fetchFresh = async () => {
40
+ const fresh = normalizeSessions(await api('GET', '/api/sessions'));
41
+ sessionsData.value = fresh;
42
+ setCachedSessions(fresh);
43
+ return fresh;
44
+ };
45
+
46
+ if (waitFresh || !cached.length) {
47
+ try {
48
+ await fetchFresh();
49
+ } catch (e) {
50
+ if (!cached.length) {
51
+ const el = $('session-list');
52
+ if (el) el.innerHTML = `<div class="px-3 py-3 text-xs text-error">${esc(e.message)}</div>`;
53
+ }
54
+ }
55
+ } else {
56
+ fetchFresh().catch(() => {});
57
+ }
58
+ }
59
+
60
+ export async function loadWorkspaces({ waitFresh = true } = {}) {
61
+ const cached = await getCachedWorkspaces();
62
+ if (cached) workspacesData.value = cached;
63
+ const fetchFresh = async () => {
64
+ const fresh = await api('GET', '/api/workspaces');
65
+ workspacesData.value = fresh;
66
+ setCachedWorkspaces(fresh);
67
+ };
68
+ if (waitFresh || !cached) {
69
+ try { await fetchFresh(); } catch {}
70
+ } else {
71
+ fetchFresh().catch(() => {});
72
+ }
73
+ }
74
+ export function sessionItemHtml(s, { showProject = false } = {}) {
75
+ const active = ctx.sessionId === s.sessionId && (ctx.agent || currentAgent.peek()) === (s.agent || 'claude');
76
+ const meta = sessionMetaMap.peek()?.[metaKey(s.agent, s.sessionId, s.machineId)] || {};
77
+ const renamed = !!(meta.title && String(meta.title).trim());
78
+ const title = s.snippet && sessionSearch.peek()
79
+ ? s.snippet
80
+ : sessionDisplayTitle(s);
81
+ const fav = !!meta.favorite;
82
+ const hasNote = !!(meta.notes && String(meta.notes).trim());
83
+ const turns = Number(s.turnCount) || 0;
84
+ const thin = turns > 0 && turns <= LOW_TURN_THRESHOLD;
85
+ const projectLine = showProject
86
+ ? `<div class="text-[10px] text-base-content/40 font-mono truncate" title="${esc(s.cwd || '')}">${esc(s.projectName || shortPath(s.cwd) || '—')}</div>`
87
+ : '';
88
+ const badges = [
89
+ thin ? `<span class="session-thin-badge" title="Only ${turns} turn${turns === 1 ? '' : 's'}">thin·${turns}</span>` : '',
90
+ renamed ? `<span class="session-renamed-badge" title="Custom name (original: ${esc(s.display || '')})">✎</span>` : '',
91
+ hasNote ? `<span class="session-note-dot" title="${esc(meta.notes)}">📝</span>` : '',
92
+ ].filter(Boolean).join('');
93
+ const machineBadge = s.machineId
94
+ ? `<span class="text-[9px] px-1 py-0.5 rounded bg-base-300 text-base-content/50 font-mono flex-shrink-0" title="Machine">${esc(s.machineId)}</span>`
95
+ : '';
96
+ const tip = renamed
97
+ ? `${title}\n(original: ${s.display || s.sessionId})`
98
+ : (s.display || '');
99
+ return `<div class="session-row px-2.5 py-1.5 cursor-pointer hover:bg-base-300 border-l-2
100
+ ${active ? 'border-primary bg-primary/5' : 'border-transparent'} ${fav ? 'session-fav' : ''} ${thin ? 'session-thin' : ''}"
101
+ data-session-id="${esc(s.sessionId)}"
102
+ data-session-cwd="${esc(s.cwd || '')}"
103
+ data-session-config-dir="${esc(s.configDir || '')}"
104
+ data-session-agent="${esc(s.agent || 'claude')}"
105
+ data-session-machine="${esc(s.machineId || '')}"
106
+ data-session-display="${esc(s.display || '')}">
107
+ <div class="flex items-start gap-1.5">
108
+ <button type="button" class="session-fav-btn flex-shrink-0 leading-none mt-0.5 ${fav ? 'is-fav' : ''}"
109
+ data-fav-toggle="1"
110
+ data-fav-session-id="${esc(s.sessionId)}"
111
+ data-fav-session-agent="${esc(s.agent || 'claude')}"
112
+ data-fav-session-machine="${esc(s.machineId || '')}"
113
+ title="${fav ? 'Unfavorite' : 'Favorite'}">${fav ? '★' : '☆'}</button>
114
+ ${agentBadge(s.agent)}
115
+ <div class="min-w-0 flex-1">
116
+ <div class="session-title-wrap flex items-center gap-1 min-w-0"
117
+ data-session-id="${esc(s.sessionId)}"
118
+ data-rename-session-id="${esc(s.sessionId)}"
119
+ data-rename-session-agent="${esc(s.agent || 'claude')}"
120
+ data-rename-session-machine="${esc(s.machineId || '')}"
121
+ data-rename-session-display="${esc(s.display || '')}">
122
+ <div class="session-title-text text-[11px] leading-snug truncate flex-1 min-w-0 ${active ? 'text-primary font-medium' : 'text-base-content/85'} ${renamed ? 'session-title-renamed' : ''}"
123
+ data-rename-session="1"
124
+ title="${esc(tip)} · click to rename">${esc(title)}</div>
125
+ <button type="button" class="session-rename-btn flex-shrink-0"
126
+ data-rename-session="1"
127
+ title="Rename session">✎</button>
128
+ </div>
129
+ ${projectLine}
130
+ ${badges || machineBadge ? `<div class="flex items-center gap-1 mt-0.5 flex-wrap">${machineBadge}${badges}</div>` : ''}
131
+ </div>
132
+ <span class="text-[9px] text-base-content/30 flex-shrink-0 mt-0.5">${formatTime(s.updatedAt)}</span>
133
+ </div>
134
+ </div>`;
135
+ }
136
+
137
+ /** Load /api/agents model lists into agentsMeta (Codex/Grok caches + Claude settings). */
138
+ export async function loadAgentsMeta() {
139
+ try {
140
+ const data = await api('GET', '/api/agents');
141
+ agentsMeta.value = data;
142
+ // Align current model with discovered list for active agent
143
+ const agent = currentAgent.peek() || 'claude';
144
+ const models = getModelsForAgent(agent);
145
+ const cur = currentModel.peek();
146
+ if (cur && !models.some(m => m.value === cur)) {
147
+ const def = getDefaultModel(agent);
148
+ currentModel.value = def;
149
+ localStorage.setItem('model', def);
150
+ }
151
+ refreshModelSelect();
152
+ return data;
153
+ } catch (e) {
154
+ console.warn('[agents] failed to load model list', e);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ export function refreshEffortSelect() {
160
+ const sel = $('sel-effort');
161
+ if (!sel) return;
162
+ const agent = currentAgent.peek() || 'claude';
163
+ const model = currentModel.peek();
164
+ const efforts = getEffortsForModel(agent, model);
165
+ const cur = currentEffort.peek() || '';
166
+ const opts = [`<option value="">effort: off</option>`]
167
+ .concat(efforts.map(e =>
168
+ `<option value="${esc(e)}" ${e === cur ? 'selected' : ''}>${esc(e)}</option>`
169
+ ));
170
+ sel.innerHTML = opts.join('');
171
+ if (cur && !efforts.includes(cur)) {
172
+ currentEffort.value = '';
173
+ sel.value = '';
174
+ } else {
175
+ sel.value = cur;
176
+ }
177
+ }
178
+
179
+ export function refreshModelSelect() {
180
+ const sel = $('sel-model');
181
+ if (!sel) return;
182
+ const agent = currentAgent.peek() || 'claude';
183
+ let models = getModelsForAgent(agent);
184
+ let cur = currentModel.peek();
185
+ // Keep a free-typed /model value visible even if not in list
186
+ if (cur && !models.some(m => m.value === cur)) {
187
+ models = [{ value: cur, label: `${cur} ★`, custom: true }, ...models];
188
+ }
189
+ sel.innerHTML = models.map(m =>
190
+ `<option value="${esc(m.value)}" ${m.value === cur ? 'selected' : ''}>${esc(m.label)}</option>`
191
+ ).join('');
192
+ if (!models.some(m => m.value === cur)) {
193
+ const def = getDefaultModel(agent) || models[0]?.value;
194
+ currentModel.value = def;
195
+ localStorage.setItem('model', def);
196
+ sel.value = def;
197
+ cur = def;
198
+ } else {
199
+ sel.value = cur;
200
+ }
201
+ const agentSel = $('sel-agent');
202
+ if (agentSel) agentSel.value = agent;
203
+ refreshEffortSelect();
204
+ }
205
+
206
+ /**
207
+ * Prompt user for a model id, save as custom for current agent, select it.
208
+ * @param {{ editCurrent?: boolean }} opts
209
+ */
210
+ export function promptCustomModel({ editCurrent = false } = {}) {
211
+ const agent = currentAgent.peek() || 'claude';
212
+ const initial = editCurrent ? (currentModel.peek() || '') : '';
213
+ const msg = editCurrent
214
+ ? `Edit model id for ${agent}:`
215
+ : `Add model id for ${agent} (saved in this browser):`;
216
+ const raw = window.prompt(msg, initial);
217
+ if (raw == null) return;
218
+ const id = raw.trim();
219
+ if (!id) return;
220
+
221
+ if (editCurrent && initial && initial !== id && getCustomModels(agent).includes(initial)) {
222
+ removeCustomModel(agent, initial);
223
+ }
224
+ addCustomModel(agent, id);
225
+ currentModel.value = id;
226
+ localStorage.setItem('model', id);
227
+ refreshModelSelect();
228
+ }
229
+
230
+ export function renderProjectGroups(groups) {
231
+ if (!Array.isArray(groups) || !groups.length) {
232
+ return `<div class="px-3 py-6 text-center text-xs text-base-content/40">
233
+ No projects in this range.<br>
234
+ <span class="text-base-content/30">Try “All” time or another agent.</span>
235
+ </div>`;
236
+ }
237
+
238
+ const expanded = expandedFolders.value;
239
+ const collapsed = collapsedFolders.value;
240
+ const forceOpen = !!sessionSearch.value.trim();
241
+ // Auto-expand the most recently active project
242
+ const topCwd = groups[0]?.cwd || '(no path)';
243
+
244
+ return groups.map(g => {
245
+ const key = g.cwd || '(no path)';
246
+ let open = false;
247
+ if (collapsed.has(key)) {
248
+ open = false;
249
+ } else if (expanded.has(key)) {
250
+ open = true;
251
+ } else {
252
+ open = forceOpen || key === topCwd || groups.length <= 3;
253
+ }
254
+ const latestTitle = g.latest?.display || g.latest?.snippet || '';
255
+ const machineTag = g.machineId
256
+ ? `<span class="text-[9px] font-mono text-base-content/40 flex-shrink-0">@${esc(g.machineId)}</span>`
257
+ : '';
258
+ const header = `
259
+ <div class="project-header px-2.5 py-2 cursor-pointer hover:bg-base-300/80 select-none border-b border-base-300/40"
260
+ data-folder="${esc(key)}" data-project-cwd="${esc(g.cwd || '')}" data-project-machine="${esc(g.machineId || '')}">
261
+ <div class="flex items-center gap-1.5">
262
+ <span class="text-[10px] text-base-content/40 w-3 flex-shrink-0">${open ? '▾' : '▸'}</span>
263
+ <span class="text-[12px] font-semibold truncate flex-1" title="${esc(g.cwd || '')}">${esc(g.projectName)}</span>
264
+ ${machineTag}
265
+ <span class="text-[9px] text-base-content/30 flex-shrink-0">${formatTime(g.updatedAt)}</span>
266
+ </div>
267
+ <div class="flex items-center gap-1.5 mt-0.5 pl-4">
268
+ <span class="text-[10px] text-base-content/35 font-mono truncate flex-1" title="${esc(g.cwd || '')}">${esc(shortPath(g.cwd))}</span>
269
+ <span class="flex items-center gap-0.5 flex-shrink-0">${agentCountPills(g.agents)}</span>
270
+ </div>
271
+ ${latestTitle && !open ? `<div class="pl-4 mt-0.5 text-[10px] text-base-content/45 truncate" title="${esc(latestTitle)}">↳ ${esc(latestTitle)}</div>` : ''}
272
+ </div>`;
273
+ const sessions = g.sessions.slice(0, 40);
274
+ const more = g.sessions.length > 40
275
+ ? `<div class="px-3 py-1 text-[10px] text-base-content/30">+${g.sessions.length - 40} more</div>` : '';
276
+ const body = open
277
+ ? `<div class="pb-1 border-b border-base-300/30">${sessions.map(s => sessionItemHtml(s)).join('')}${more}</div>`
278
+ : '';
279
+ return header + body;
280
+ }).join('');
281
+ }
282
+
283
+ export function renderTimeline(sessions) {
284
+ if (!Array.isArray(sessions) || !sessions.length) {
285
+ return `<div class="px-3 py-6 text-center text-xs text-base-content/40">
286
+ No recent activity.<br>
287
+ <span class="text-base-content/30">Widen the time range or clear filters.</span>
288
+ </div>`;
289
+ }
290
+
291
+ // Group by day for readability
292
+ const byDay = new Map();
293
+ for (const s of sessions) {
294
+ const d = s.updatedAt ? new Date(s.updatedAt) : new Date();
295
+ const key = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
296
+ if (!byDay.has(key)) byDay.set(key, []);
297
+ byDay.get(key).push(s);
298
+ }
299
+
300
+ return [...byDay.entries()].map(([day, items]) => `
301
+ <div class="px-2.5 pt-2.5 pb-1 text-[10px] uppercase tracking-wide text-base-content/35 font-medium sticky top-0 bg-base-200/95 backdrop-blur-sm z-[1]">${esc(day)}</div>
302
+ ${items.map(s => sessionItemHtml(s, { showProject: true })).join('')}
303
+ `).join('');
304
+ }
305
+
306
+ export function renderSessionList() {
307
+ const view = sidebarView.value;
308
+ const sessions = Array.isArray(filteredSessions.value) ? filteredSessions.value : [];
309
+ const groups = Array.isArray(projectGroups.value) ? projectGroups.value : [];
310
+ if (view === 'timeline') {
311
+ return renderTimeline(sessions);
312
+ }
313
+ return renderProjectGroups(groups);
314
+ }
315
+
316
+ export function syncSidebarChrome() {
317
+ // View tabs
318
+ document.querySelectorAll('[data-view]').forEach(btn => {
319
+ const on = btn.dataset.view === sidebarView.peek();
320
+ btn.classList.toggle('bg-primary/15', on);
321
+ btn.classList.toggle('text-primary', on);
322
+ btn.classList.toggle('font-medium', on);
323
+ btn.classList.toggle('text-base-content/50', !on);
324
+ });
325
+ // Agent pills
326
+ document.querySelectorAll('[data-agent-filter]').forEach(btn => {
327
+ btn.classList.toggle('active', btn.dataset.agentFilter === agentFilter.peek());
328
+ });
329
+ const tr = $('time-range');
330
+ if (tr && tr.value !== timeRange.peek()) tr.value = timeRange.peek();
331
+
332
+ const status = $('search-status');
333
+ if (!status) return;
334
+ const hits = activityHits.peek();
335
+ const hitResults = Array.isArray(hits?.results) ? hits.results : null;
336
+ const q = (sessionSearch.peek() || '').trim();
337
+ if (activityLoading.peek()) {
338
+ status.classList.remove('hidden');
339
+ status.textContent = 'Searching content…';
340
+ } else if (q && hitResults) {
341
+ status.classList.remove('hidden');
342
+ status.textContent = `${hitResults.length} hit${hitResults.length === 1 ? '' : 's'} · ${timeRange.peek() === '0' ? 'all time' : timeRange.peek() + 'd'}${hits.deep === false ? '' : ' (incl. content)'}`;
343
+ } else if (q) {
344
+ status.classList.remove('hidden');
345
+ status.textContent = 'Local filter only — type to deep-search';
346
+ } else {
347
+ const sessions = filteredSessions.peek();
348
+ const groups = projectGroups.peek();
349
+ const n = Array.isArray(sessions) ? sessions.length : 0;
350
+ const pg = Array.isArray(groups) ? groups.length : 0;
351
+ status.classList.remove('hidden');
352
+ status.textContent = sidebarView.peek() === 'projects'
353
+ ? `${pg} project${pg === 1 ? '' : 's'} · ${n} session${n === 1 ? '' : 's'}`
354
+ : `${n} recent session${n === 1 ? '' : 's'}`;
355
+ }
356
+ }
357
+
358
+ // Debounced deep search against /api/activity
359
+ let _searchTimer = null;
360
+ export async function runActivitySearch(q) {
361
+ const query = (q || '').trim();
362
+ if (!query) {
363
+ activityHits.value = null;
364
+ activityLoading.value = false;
365
+ return;
366
+ }
367
+ activityLoading.value = true;
368
+ try {
369
+ const params = new URLSearchParams({
370
+ q: query,
371
+ days: timeRange.peek() || '14',
372
+ limit: '50',
373
+ deep: '1',
374
+ });
375
+ if (agentFilter.peek() && agentFilter.peek() !== 'all') {
376
+ params.set('agent', agentFilter.peek());
377
+ }
378
+ const data = await api('GET', `/api/activity?${params}`);
379
+ // Only apply if query still matches
380
+ if (sessionSearch.peek().trim() === query) {
381
+ activityHits.value = { q: query.toLowerCase(), results: data.results || [], deep: true };
382
+ }
383
+ } catch (e) {
384
+ if (sessionSearch.peek().trim() === query) {
385
+ activityHits.value = { q: query.toLowerCase(), results: [], error: e.message };
386
+ }
387
+ } finally {
388
+ activityLoading.value = false;
389
+ }
390
+ }
391
+
392
+ export function scheduleActivitySearch(q) {
393
+ clearTimeout(_searchTimer);
394
+ const query = (q || '').trim();
395
+ if (!query) {
396
+ activityHits.value = null;
397
+ activityLoading.value = false;
398
+ return;
399
+ }
400
+ _searchTimer = setTimeout(() => runActivitySearch(query), 350);
401
+ }
402
+
403
+ export function mdToHtml(text) {
404
+ if (!text) return '';
405
+ if (typeof marked === 'undefined') return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
406
+ try {
407
+ // Prefer shared renderer if chat has configured marked
408
+ return marked.parse(String(text), { breaks: true, gfm: true });
409
+ } catch {
410
+ return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
411
+ }
412
+ }
413
+
414
+ // Load the persistent memory for the currently open session into the Memory tab.
415
+ export async function loadMemoryTab() {
416
+ const body = $('memory-area');
417
+ if (!body) return;
418
+ if (!ctx.sessionId) {
419
+ body.innerHTML = '<div class="p-4 text-xs text-base-content/40">Open or resume a session to view its memory.</div>';
420
+ return;
421
+ }
422
+ body.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
423
+ try {
424
+ const q = new URLSearchParams({ agent: ctx.agent || currentAgent.peek() || 'claude' });
425
+ const mem = await api('GET', `/api/sessions/${ctx.sessionId}/memory?${q}`);
426
+ const parts = [];
427
+ if (mem.index) {
428
+ parts.push(`<div class="mb-4"><div class="text-[11px] uppercase tracking-wide text-base-content/40 mb-1">Index (MEMORY.md)</div>
429
+ <div class="md">${mdToHtml(mem.index)}</div></div>`);
430
+ }
431
+ for (const f of (mem.files || [])) {
432
+ parts.push(`<div class="mb-3 border border-base-300 rounded-lg overflow-hidden">
433
+ <div class="px-3 py-1.5 bg-base-300/50 text-xs font-mono text-base-content/70">${esc(f.name)}</div>
434
+ <div class="px-3 py-2 md">${mdToHtml(f.content)}</div>
435
+ </div>`);
436
+ }
437
+ if (!parts.length) parts.push('<div class="text-base-content/40 text-xs">No memory saved for this project yet.</div>');
438
+ body.innerHTML = `<div class="p-4">${parts.join('')}</div>`;
439
+ } catch (e) {
440
+ body.innerHTML = `<div class="p-4 text-error text-xs">${esc(e?.message ?? String(e))}</div>`;
441
+ }
442
+ }