mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
+158
-31
@@ -1628,7 +1628,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
pin.title = 'Pinned';
|
||||
titleSpan.appendChild(pin);
|
||||
}
|
||||
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
|
||||
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
|
||||
|
||||
// Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill.
|
||||
const badgeRow = document.createElement('div');
|
||||
@@ -1954,7 +1954,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
_HISTORY_INITIAL_COUNT: 10,
|
||||
|
||||
/** How many past sessions the home screen loads (also the filter/sort corpus). */
|
||||
_HISTORY_FETCH_LIMIT: 60,
|
||||
|
||||
async loadHistorySessions() {
|
||||
const container = document.getElementById('historySessions');
|
||||
@@ -1968,7 +1971,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
? Promise.resolve(this.cases)
|
||||
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
|
||||
const [allSessions, cases] = await Promise.all([
|
||||
this._fetchUnifiedSessions(60),
|
||||
this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT),
|
||||
casesPromise,
|
||||
]);
|
||||
if (allSessions.length === 0) {
|
||||
@@ -1976,27 +1979,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
list.replaceChildren();
|
||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||
|
||||
// Render initial items
|
||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||
list.appendChild(this._buildHistoryItem(allSessions[i], cases));
|
||||
}
|
||||
|
||||
// Add "Show More" button if there are more items
|
||||
if (allSessions.length > initialCount) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
for (let i = initialCount; i < allSessions.length; i++) {
|
||||
list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn);
|
||||
}
|
||||
moreBtn.remove();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
// Keep the corpus around: filtering and sorting (issue #260) work on this
|
||||
// array, so a re-render costs no request. Expansion survives the periodic
|
||||
// refresh in panels-ui.js — collapsing the list under the user's cursor
|
||||
// every few seconds would be worse than the original 4-item cap.
|
||||
this._historyAll = allSessions;
|
||||
this._historyCases = cases;
|
||||
this._wireHistoryControls();
|
||||
this._renderHistoryList();
|
||||
|
||||
container.style.display = '';
|
||||
} catch (err) {
|
||||
@@ -2005,6 +1995,131 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** Wire the filter box and sort select once; both re-render from the cached corpus. */
|
||||
_wireHistoryControls() {
|
||||
if (this._historyControlsWired) return;
|
||||
const filter = document.getElementById('historyFilter');
|
||||
const sort = document.getElementById('historySort');
|
||||
if (!filter && !sort) return;
|
||||
this._historyControlsWired = true;
|
||||
|
||||
if (filter) {
|
||||
filter.addEventListener('input', () => this._renderHistoryList());
|
||||
filter.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && filter.value) {
|
||||
// Swallow it: Escape at the welcome screen otherwise closes overlays.
|
||||
ev.stopPropagation();
|
||||
filter.value = '';
|
||||
this._renderHistoryList();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (sort) sort.addEventListener('change', () => this._renderHistoryList());
|
||||
},
|
||||
|
||||
/** True when a past-session row matches the filter text (name, folder, case, prompt). */
|
||||
_historyRowMatches(s, needle, cases) {
|
||||
const fields = [
|
||||
s.name,
|
||||
s.workingDir,
|
||||
this._resolveCaseLabel(s.workingDir, cases),
|
||||
s.firstPrompt,
|
||||
s.lastPrompt,
|
||||
s.sessionId,
|
||||
];
|
||||
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
|
||||
},
|
||||
|
||||
/**
|
||||
* The text a history row shows as its title. Most transcript-backed rows have
|
||||
* no session name at all, so this falls through to the first prompt and then
|
||||
* to the path — and the A–Z sort keys off the SAME string, or "sort by name"
|
||||
* would silently do nothing for exactly the rows the list is mostly made of.
|
||||
*/
|
||||
_historyRowLabel(s, fallback) {
|
||||
return s.name || s.firstPrompt || fallback || '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Sort past-session rows. 'recent' keeps the backend order (newest first);
|
||||
* the alphabetical modes sort by the visible title or by folder basename.
|
||||
* Pinned rows stay on top in every mode — pinning is an explicit override and
|
||||
* a sort that buried it would read as the pin having been lost.
|
||||
*/
|
||||
_sortHistoryRows(rows, mode) {
|
||||
const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase();
|
||||
const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase();
|
||||
const key = mode === 'name' ? label : folder;
|
||||
const sorted = mode === 'recent' ? rows.slice() : rows.slice().sort((a, b) => key(a).localeCompare(key(b)));
|
||||
const pinned = sorted.filter((s) => s.pinned);
|
||||
return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned));
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the "Resume Conversation" list from the cached corpus, applying the
|
||||
* current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT;
|
||||
* "Show more" expands the list AND the box (the CSS cap is class-driven, since
|
||||
* a fixed 240px box made expansion pointless — issue #260).
|
||||
*/
|
||||
_renderHistoryList() {
|
||||
const list = document.getElementById('historyList');
|
||||
if (!list) return;
|
||||
const all = this._historyAll || [];
|
||||
const cases = this._historyCases || [];
|
||||
const countEl = document.getElementById('historyCount');
|
||||
const needle = (document.getElementById('historyFilter')?.value || '').trim().toLowerCase();
|
||||
const mode = document.getElementById('historySort')?.value || 'recent';
|
||||
|
||||
const matched = needle ? all.filter((s) => this._historyRowMatches(s, needle, cases)) : all;
|
||||
const rows = this._sortHistoryRows(matched, mode);
|
||||
// Filtering is itself an expansion request: hiding matches behind "Show more"
|
||||
// would defeat the point of typing a filter.
|
||||
const expanded = !!this._historyExpanded || needle.length > 0;
|
||||
const visible = expanded ? rows : rows.slice(0, this._HISTORY_INITIAL_COUNT);
|
||||
|
||||
list.replaceChildren();
|
||||
list.classList.toggle('expanded', expanded);
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'history-empty';
|
||||
empty.textContent = `No conversations match "${needle}"`;
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
for (const s of visible) list.appendChild(this._buildHistoryItem(s, cases));
|
||||
|
||||
const hidden = rows.length - visible.length;
|
||||
if (hidden > 0) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${hidden} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
this._historyExpanded = true;
|
||||
this._renderHistoryList();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
} else if (expanded && !needle && rows.length > this._HISTORY_INITIAL_COUNT) {
|
||||
const lessBtn = document.createElement('button');
|
||||
lessBtn.className = 'history-show-more';
|
||||
lessBtn.textContent = 'Show less';
|
||||
lessBtn.addEventListener('click', () => {
|
||||
this._historyExpanded = false;
|
||||
this._renderHistoryList();
|
||||
list.scrollTop = 0;
|
||||
});
|
||||
list.appendChild(lessBtn);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = needle
|
||||
? `${rows.length} of ${all.length}`
|
||||
: rows.length > visible.length
|
||||
? `${visible.length} of ${rows.length}`
|
||||
: String(rows.length);
|
||||
}
|
||||
},
|
||||
|
||||
/** Page size for the folder history modal */
|
||||
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||
|
||||
@@ -3832,13 +3947,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Render the grouped result cards (or empty/loading states). */
|
||||
_renderSearch(data) {
|
||||
const results = document.getElementById('searchResults');
|
||||
const historyTitle = document.getElementById('historyTitle');
|
||||
// The header carries the title plus the filter/sort controls (issue #260) —
|
||||
// hide the whole row, not just the title, or the controls float above the
|
||||
// search results and act on a list that is not on screen.
|
||||
const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle');
|
||||
const historyList = document.getElementById('historyList');
|
||||
if (!results) return;
|
||||
|
||||
const searching = !!data;
|
||||
// Hide the plain "Resume Conversation" history list while a search is active.
|
||||
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
|
||||
if (historyHeader) historyHeader.style.display = searching ? 'none' : '';
|
||||
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||
|
||||
results.innerHTML = '';
|
||||
@@ -3907,9 +4025,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'search-result-top';
|
||||
|
||||
// A past session resumes rather than switches tabs, so it says so on the badge.
|
||||
const isPast = r.jumpTo && r.jumpTo.kind === 'resume-session';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type;
|
||||
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type + (isPast ? ' search-badge-past' : '');
|
||||
badge.textContent = isPast ? 'Resume' : (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'search-result-name';
|
||||
@@ -3941,13 +4061,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* resume-session→ resumeHistorySession(...) (past session — no tab to switch to)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview → openFilePreview(path, sessionId, attachmentId)
|
||||
*/
|
||||
_jumpToSearchResult(r) {
|
||||
const jt = r && r.jumpTo;
|
||||
if (!jt) return;
|
||||
// A past session has to be replayed, not switched to. Do it BEFORE hiding the
|
||||
// welcome overlay: resumeHistorySession() owns that transition itself.
|
||||
if (jt.kind === 'resume-session') {
|
||||
this.resumeHistorySession(jt.claudeSessionId || jt.sessionId, jt.workingDir || '', r.sessionName);
|
||||
return;
|
||||
}
|
||||
// Leaving the welcome overlay so the target surface is visible.
|
||||
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user