2026-10-10 21:45:26 -04:00
// 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' ;
2026-11-03 21:23:29 -05:00
import { confirmDialog , openDialog , toast } from './ui.js' ;
2026-10-10 21:45:26 -04:00
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' )),
2026-11-03 21:23:29 -05:00
}, 'Copy command' ),
live ? null : el ( 'button' , {
type : 'button' , class : 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text' , title : 'Delete this conversation from this machine for good' ,
onclick : async () => {
const ok = await confirmDialog ({
title : 'Delete this conversation?' ,
text : `“ ${ r . title . slice ( 0 , 80 ) } ” ( ${ day ( r . started ) } ) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.` ,
confirmLabel : 'Delete for good' ,
danger : true ,
});
if ( ! ok ) return ;
try {
await sessionAction ( 'delete-convo' , r . id );
item . remove ();
toast . success ( 'Conversation deleted from this machine' , 8000 , [{ label : 'claude.ai/code' , run : () => window . open ( 'https://claude.ai/code' , '_blank' , 'noopener' ) }]);
} catch ( error ) {
toast . error ( error . message );
}
},
}, 'Delete…' ));
2026-10-10 21:45:26 -04:00
fill ();
2026-11-03 21:23:29 -05:00
const item = el ( 'div' , { class : 'dd-history-item' },
2026-10-10 21:45:26 -04:00
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 );
2026-11-03 21:23:29 -05:00
return item ;
2026-10-10 21:45:26 -04:00
}));
};
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 ;
},
});
}