136 lines
5.9 KiB
JavaScript
136 lines
5.9 KiB
JavaScript
// 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 <mark>.
|
||
|
|
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;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|