fix(web): usable past-conversation list, and search that finds past sessions

Two home-screen reports from @jordan8037310, both about history that is
present but unreachable.

#260 — "Resume Conversation" rendered 4 rows, then a button that appended
every remaining row into a `max-height: 240px` box, so 35 conversations
landed in a four-row scroll well with no ordering or filtering. Rendering
now goes through `_renderHistoryList()` over a cached corpus: 10 rows to
start, Show more/Show less that grows and shrinks the box (the height cap
is class-driven, `.history-list.expanded`), plus a filter box (name,
folder, #case label, prompts), a sort control (recent / name / folder,
pinned rows still first) and a shown-of-total count. A filter implies
expansion, so every match is visible, and the whole header hides as one
unit while a federated search is active. The A-Z sort keys off the same
string the row renders, since most rows are transcript-backed and carry
no session name at all.

#261 — the search box could not match a past project by folder name:
`harvestSources()` built its session corpus from the live in-memory map,
while past sessions come from `/api/sessions/unified` (lifecycle log +
transcript scan). Folding that scan into the request path would have cost
the search its no-filesystem-reads property, so the corpus arrives via a
bounded snapshot instead: `session-history-index.ts` is published as a
side effect of `/api/sessions/unified` (the home screen fetches it on
open, which is the same screen the search box lives on) and rebuilt
fire-and-forget, single-flight and TTL-guarded when a search finds it
stale. A result for a closed session now resumes the conversation rather
than selecting a tab that no longer exists, and is badged RESUME.

The snapshot is stored unscoped with a per-row owner and re-filtered
through canAccessOwned() on read, so multi-user sees exactly what
/api/sessions/unified exposes: own sessions only, host-wide transcript
history admin-only. Live rows are harvested first and win the dedupe.

Verified end-to-end against a real instance with 60 past sessions: cold
process answers its first search without history and its second with it;
folder-name queries return resume targets; clicking one posts the right
resumeSessionId + workingDir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:02:11 +02:00
parent c942bb5dfb
commit 5d42f64393
15 changed files with 1206 additions and 54 deletions
+23 -4
View File
@@ -36,13 +36,21 @@ export const SEARCH_PER_GROUP_CAP = 25;
/** Maximum characters in a result snippet. */
export const SEARCH_SNIPPET_MAX = 200;
/** A live-session row harvested for the session/case source. */
/** A session row harvested for the session/case source (live or past). */
export interface SessionSearchInput {
sessionId: string;
sessionName: string;
workingDir: string;
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
timestamp: number;
/**
* True for a session that is no longer running (issue #261 — past sessions come
* from the history index, not the live map). Such a result resumes the
* conversation instead of switching to a tab that no longer exists.
*/
history?: boolean;
/** Claude conversation UUID to resume, when it differs from the Codeman id. */
claudeSessionId?: string;
}
/** A run-summary timeline event harvested for the event source. */
@@ -121,14 +129,25 @@ export function searchSources(query: string, sources: SearchSources): SearchResp
const sessionRows: SearchResult[] = [];
for (const s of sources.sessions) {
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
const label = s.sessionName || s.workingDir.split('/').pop() || s.sessionId;
sessionRows.push({
type: 'session',
sessionId: s.sessionId,
sessionName: s.sessionName,
sessionName: label,
timestamp: s.timestamp,
snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName),
snippet: truncate(s.workingDir ? `${label} — ${s.workingDir}` : label),
exactMatch: isExact(s.sessionName),
jumpTo: { kind: 'session', sessionId: s.sessionId },
// A resume needs a directory to run in, so a history row without one
// stays a plain session target rather than an action that cannot work.
jumpTo:
s.history && s.workingDir
? {
kind: 'resume-session',
sessionId: s.sessionId,
claudeSessionId: s.claudeSessionId,
workingDir: s.workingDir,
}
: { kind: 'session', sessionId: s.sessionId },
});
}
}