feat: v0.9.0 conversation search, plan usage meters, quiet phone at the desk
build-deb / Lint, test, package (push) Successful in 3m6s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- devdesk attach --search (grep + jq over ~/.claude/projects, six files at
  a time) and --resume-convo NAME ID [fork]; devdesk-claude-run passes
  --fork-session once. Search dialog with Resume / Fork / Open.
- statusLine snapshots rate_limits and pr; footer/menu meters with a 90 %
  warning; PR chip on session rows.
- CLAUDE_CLIENT_PRESENCE_FILE per tmux session id; --presence on|off with a
  self-expiring marker; the page heartbeats it while visible.

Closes #24
Closes #25
Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:45:26 -04:00
co-authored by Claude Opus 5.5
parent f82c13612d
commit 73e77aca73
14 changed files with 497 additions and 9 deletions
+135
View File
@@ -0,0 +1,135 @@
// 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;
},
});
}