// Search past conversations (devdesk#24): every Claude conversation on this host // (DevDesk and code-server), by what you asked, what Claude said, the commands // it ran and their output. Resume one in a new session, or fork it. import { runCommand, sessionAction } from './connection.js'; import { parseListOutput, slugify } from './protocol.js'; import { el } from './dom.js'; import { ago } from './sessions.js'; import { openDialog, toast } from './ui.js'; const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' }; // Text with every case-insensitive occurrence of `query` in . function highlight(text, query) { const nodes = []; const lower = text.toLowerCase(); const q = query.toLowerCase(); let at = 0; for (let i = lower.indexOf(q); i >= 0 && q; i = lower.indexOf(q, at)) { if (i > at) nodes.push(text.slice(at, i)); nodes.push(el('mark', {}, text.slice(i, i + q.length))); at = i + q.length; } nodes.push(text.slice(at)); return nodes; } const day = (iso) => (iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : ''); // hooks: sessions() -> session list, open(name), refresh() export function openHistory(hooks) { const input = el('input', { class: 'lt-input dd-history-input', type: 'search', placeholder: 'What did you work on? e.g. "NFS mount", "pct exec 167", "no_root_squash"', spellcheck: 'false', autocomplete: 'off' }); const status = el('div', { class: 'dd-dialog-hint' }, 'Searches every Claude conversation on this host: your prompts, Claude\'s answers, the commands it ran and their output.'); const results = el('div', { class: 'dd-history-results' }); let seq = 0; let timer = 0; let close = null; const liveSessionFor = (id) => hooks.sessions().find((s) => s.running && s.claude_session_id === id)?.name; // Resume or fork: confirm the new session's name inline, then open it. const start = (result, fork, holder) => { const name = el('input', { class: 'lt-input', value: slugify(`${fork ? 'fork-' : ''}${result.title}`).slice(0, 40) || 'resumed', spellcheck: 'false' }); const go = async () => { const value = slugify(name.value); if (!value) return; try { await sessionAction('resume-convo', value, result.id, ...(fork ? ['fork'] : [])); close?.(); toast.success(`${fork ? 'Forked' : 'Resumed'} “${result.title.slice(0, 40)}” in ${value}`); hooks.refresh(); hooks.open(value); } catch (error) { toast.error(error.message); } }; name.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); go(); } }); holder.replaceChildren(el('span', { class: 'dd-dialog-hint' }, `New session for ${fork ? 'a fork of ' : ''}this conversation:`), name, el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: go }, fork ? 'Fork' : 'Resume')); name.focus(); name.select(); }; const render = (data) => { if (!data.results.length) { results.replaceChildren(el('div', { class: 'dd-files-msg' }, `Nothing found for “${data.query}”.`)); return; } results.replaceChildren(...data.results.map((r) => { const live = liveSessionFor(r.id); const actions = el('div', { class: 'dd-history-actions' }); const fill = () => actions.replaceChildren( live ? el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => { close?.(); hooks.open(live); } }, `Open ${live}`) : el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => start(r, false, actions) }, 'Resume…'), el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: () => start(r, true, actions), title: 'Continue as a new conversation; the original stays as it is' }, 'Fork…'), el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')), }, 'Copy command')); fill(); return el('div', { class: 'dd-history-item' }, el('div', { class: 'dd-history-title' }, r.title), el('div', { class: 'dd-history-meta' }, [r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : ''] .filter(Boolean).join(' · ')), ...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` }, el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))), actions); })); }; const search = async () => { const query = input.value.trim(); if (query.length < 2) return; const mine = ++seq; status.textContent = `Searching for “${query}”…`; try { const data = parseListOutput(await runCommand(['--search', query], 30000)); if (mine !== seq) return; const n = data.results.length; status.textContent = `${n}${n === 25 ? '+' : ''} conversation${n === 1 ? ' mentions' : 's mention'} “${query}”, newest first.`; render(data); } catch (error) { if (mine === seq) status.textContent = error.message; } }; input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 500); }); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); clearTimeout(timer); search(); } }); openDialog({ title: 'Search past conversations', className: 'is-wide', body: el('div', { class: 'dd-history' }, input, status, results), actions: [{ label: 'Close', value: null }], onOpen: (overlay) => { close = () => overlay.querySelector('.lt-modal-close')?.click(); return input; }, }); }