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;
},
});
}
+67 -1
View File
@@ -2,7 +2,7 @@
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
TtydConnection, fetchAlerts, fetchRepo, fetchToken, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
@@ -14,6 +14,7 @@ import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { registerPathLinks } from './links.js';
import { openHistory } from './history.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -180,6 +181,7 @@ function renderPanelsBadge() {
}
function onSessionData({ fetched = false } = {}) {
renderUsage();
renderSessionChip();
renderAttention(fetched);
if (!ui.switcher.hidden) renderSwitcher();
@@ -359,6 +361,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
setWsState('connected', 'live');
if (session) rememberSession(session);
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
presenceTick();
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
@@ -624,6 +627,65 @@ function askInSession(name, text) {
setTimeout(tick, 1500);
}
// ---------------------------------------------------------------- history & usage
function showHistory() {
closeMenus();
openHistory({
sessions: () => sessionsPanel?.data?.sessions ?? [],
open: (name) => connect(name, { existing: true }),
refresh: () => sessionsPanel.reload(),
});
}
// Plan usage (devdesk#25): the 5-hour and weekly windows, from the newest
// statusLine snapshot of any session (they're account-wide). A toast when the
// 5-hour window passes 90 %, once per window.
let usageWarned = 0;
function renderUsage() {
const snapshots = (sessionsPanel?.data?.sessions ?? []).map((s) => s.context).filter((c) => c?.rate_limits);
const latest = snapshots.sort((a, b) => (b.updated || 0) - (a.updated || 0))[0]?.rate_limits;
const footer = $('usage-footer');
const menuRow = $('usage-row');
if (!latest || (!latest.five_hour && !latest.seven_day)) {
footer.hidden = true;
menuRow.hidden = true;
return;
}
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
const meter = (label, w) => {
if (!w || w.pct === null || w.pct === undefined) return null;
const pct = Math.round(w.pct);
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
el('span', { class: 'dd-meter-label' }, label),
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
`${pct}%`);
};
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
footer.replaceChildren(...parts());
$('usage-menu').replaceChildren(...parts());
footer.hidden = false;
menuRow.hidden = false;
const five = latest.five_hour;
if (five?.pct >= 90 && five.resets_at !== usageWarned) {
usageWarned = five.resets_at;
toast.warning(`Plan usage: ${Math.round(five.pct)}% of the 5-hour window (resets ${at(five.resets_at)})`, 10000);
}
}
// Remote Control presence (devdesk#26): while this tab shows a session and is
// visible, its Claude knows a client is present and holds back phone pushes.
let presenceFor = null;
function presence(on) {
const session = state.session;
if (presenceFor && presenceFor !== session) sessionAction('presence', presenceFor, 'off').catch(() => {});
presenceFor = on ? session : null;
if (session) sessionAction('presence', session, on ? 'on' : 'off').catch(() => {});
}
function presenceTick() {
presence(Boolean(state.session && state.conn?.isOpen && document.visibilityState === 'visible'));
}
// ---------------------------------------------------------------- palette & help
const sendKeys = (keys) => state.conn?.sendInput(keys);
@@ -670,6 +732,7 @@ function buildCommands() {
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
}
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
if (state.session && state.conn?.isOpen) {
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
@@ -887,6 +950,9 @@ function wireChrome() {
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
$('history-menu').addEventListener('click', showHistory);
setInterval(presenceTick, 45 * 1000);
document.addEventListener('visibilitychange', presenceTick);
$('palette-menu').addEventListener('click', () => {
closeMenus();
palette.open();
+7 -1
View File
@@ -286,7 +286,13 @@ export class SessionsPanel {
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-session-name' }, s.name,
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
// The pull request of this branch (devdesk#25)
s.context?.pr?.number ? el('a', {
class: `dd-pr is-${s.context.pr.review_state || 'open'}`, href: s.context.pr.url, target: '_blank', rel: 'noopener',
title: `PR #${s.context.pr.number}${s.context.pr.review_state ? ` · ${s.context.pr.review_state.replace(/_/g, ' ')}` : ''}`,
onclick: (event) => event.stopPropagation(),
}, `PR #${s.context.pr.number}${{ approved: ' ✓', changes_requested: ' ✗' }[s.context.pr.review_state] ?? ''}`) : null),
el('span', { class: 'dd-session-meta' }, meta),
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),