From 5d42f64393acc06213a54266ee4122cee2ac6510 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:02:11 +0200 Subject: [PATCH 1/3] fix(web): usable past-conversation list, and search that finds past sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../history-list-and-past-session-search.md | 24 ++ CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 + src/search-service.ts | 27 +- src/types/search.ts | 20 +- src/web/public/index.html | 22 +- src/web/public/styles.css | 86 +++++- src/web/public/terminal-ui.js | 189 ++++++++++-- src/web/routes/search-routes.ts | 35 ++- src/web/routes/session-routes.ts | 78 ++++- src/web/session-history-index.ts | 166 ++++++++++ test/history-list-controls.test.ts | 291 ++++++++++++++++++ test/routes/search-routes.test.ts | 97 +++++- test/search-service.test.ts | 58 ++++ test/session-history-index.test.ts | 161 ++++++++++ 15 files changed, 1206 insertions(+), 54 deletions(-) create mode 100644 .changeset/history-list-and-past-session-search.md create mode 100644 src/web/session-history-index.ts create mode 100644 test/history-list-controls.test.ts create mode 100644 test/session-history-index.test.ts diff --git a/.changeset/history-list-and-past-session-search.md b/.changeset/history-list-and-past-session-search.md new file mode 100644 index 00000000..8869f0f8 --- /dev/null +++ b/.changeset/history-list-and-past-session-search.md @@ -0,0 +1,24 @@ +--- +'aicodeman': patch +--- + +Home screen: make the past-conversation list usable, and let search find past sessions. + +- **#260** — "Resume Conversation" showed 4 rows and then dumped every remaining + one into a fixed 240px box, with no ordering or filtering. The list now opens + with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the + height cap is class-driven instead of fixed), and the header carries a filter + box (matches name, folder, `#case` label and the conversation's prompts), a + sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a + shown-of-total count. Filtering implies expansion, so every match is visible. +- **#261** — the search box could not match a past project by folder name: its + session corpus was the live in-memory map, while past sessions come from + `/api/sessions/unified`. Search now also harvests a bounded snapshot of that + unified list, refreshed OUTSIDE the request path (published by + `/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the + search path keeps its no-filesystem-reads property. Results for a closed + session resume the conversation instead of trying to select a tab that no + longer exists, and are badged `RESUME`. In multi-user mode the snapshot is + re-scoped per row on read, matching what `/api/sessions/unified` exposes. + +Reported by @jordan8037310. diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..478317e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c ` (and ANY `::` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) -**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) +**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget) — that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` @@ -256,6 +256,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. +**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`) — most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. + **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 831bb534..55241d9d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,6 +157,8 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a **Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`. +**Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable — searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free — it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()` — **fire-and-forget, single-flight, TTL-guarded (60s)** — kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row — the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`. + ### Away digest **Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range. diff --git a/src/search-service.ts b/src/search-service.ts index 620fad9c..c16c1f5d 100644 --- a/src/search-service.ts +++ b/src/search-service.ts @@ -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 }, }); } } diff --git a/src/types/search.ts b/src/types/search.ts index 418aa236..d0e3e157 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -22,15 +22,19 @@ export type SearchSourceType = 'session' | 'event' | 'file'; /** Where the frontend should jump when a result card is activated. */ export interface SearchJumpTarget { - /** Kind of navigation target. */ - kind: 'session' | 'run-summary' | 'file-preview'; + /** + * Kind of navigation target. `resume-session` marks a session that is no longer + * running: selecting it has to REPLAY the conversation rather than switch to a + * tab that does not exist. + */ + kind: 'session' | 'run-summary' | 'file-preview' | 'resume-session'; /** Owning Codeman session id (always present — every result is session-scoped). */ sessionId: string; /** * Secondary identifier for the target: * - kind 'run-summary': the run-summary event id * - kind 'file-preview': the attachment history item id - * - kind 'session': undefined (the sessionId is sufficient) + * - kind 'session' / 'resume-session': undefined (the sessionId is sufficient) */ targetId?: string; /** @@ -38,6 +42,16 @@ export interface SearchJumpTarget { * server-private external paths are intentionally omitted to avoid leakage. */ relativePath?: string; + /** + * `resume-session` only: the Claude conversation UUID to resume, when it differs + * from the Codeman session id (resumed and `/clear`-respawned sessions). + */ + claudeSessionId?: string; + /** + * `resume-session` only: the directory to resume in. Already visible in the + * result snippet for session rows, so this exposes nothing new. + */ + workingDir?: string; } /** A single typed search result card. */ diff --git a/src/web/public/index.html b/src/web/public/index.html index 6ec66057..16a53791 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -397,7 +397,27 @@ -

Resume Conversation

+
+

Resume Conversation

+ +
+ + +
+

Or click Run to start

diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..a06de0d3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3666,6 +3666,13 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #95e6b3; } +/* Past session (issue #261): activating the card resumes the conversation + rather than switching to a tab, so the badge says so. */ +.search-badge-past { + background: rgba(245, 158, 11, 0.18); + color: #f0c073; +} + .search-result-name { flex: 1; min-width: 0; @@ -3715,24 +3722,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .search-select { max-width: 7rem; } + /* Tablet/narrow: let the controls drop under the title instead of squeezing it. */ + .history-header { + flex-wrap: wrap; + } + .history-controls { + width: 100%; + margin-left: 0; + } + .history-filter { + flex: 1; + width: auto; + } +} + +/* Title row for the past-session list: label + count on the left, filter and + sort on the right (issue #260 — 35 conversations in a 4-row box). */ +.history-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; } .history-title { font-size: 0.85rem; color: var(--text-dim); - margin-bottom: 0.5rem; font-weight: 500; text-align: left; } +.history-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.history-controls { + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; +} + +.history-filter { + width: 8.5rem; + box-sizing: border-box; + padding: 0.28rem 0.5rem; + font-size: 0.68rem; + color: var(--text); + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + outline: none; + transition: border-color var(--transition-smooth), background var(--transition-smooth); +} + +.history-filter:focus { + border-color: rgba(59, 130, 246, 0.5); + background: rgba(255, 255, 255, 0.06); +} + +.history-filter::placeholder { + color: var(--text-dim); +} + +.history-sort { + max-width: 7.5rem; +} + +.history-empty { + padding: 0.75rem 0.5rem; + font-size: 0.75rem; + color: var(--text-dim); + text-align: center; +} + +/* Collapsed height fits the initial page of rows; expanding the LIST has to + expand the BOX too, or "Show more" just deepens a scroll well. */ .history-list { display: flex; flex-direction: column; gap: 0.35rem; - max-height: 240px; + max-height: min(42vh, 360px); overflow-y: auto; } +.history-list.expanded { + max-height: min(64vh, 660px); +} + .history-item { display: flex; flex-direction: column; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1508c32b..d28c0bc7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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(); diff --git a/src/web/routes/search-routes.ts b/src/web/routes/search-routes.ts index 82fb19f6..b98831a8 100644 --- a/src/web/routes/search-routes.ts +++ b/src/web/routes/search-routes.ts @@ -3,7 +3,9 @@ * * Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search * across three v1 sources, returned in the standard ApiResponse envelope: - * 1. sessions/cases — name, working directory, session id + * 1. sessions/cases — name, working directory, session id, for LIVE sessions + * plus the past-session snapshot in `session-history-index.ts` (issue #261: + * the live map alone made every closed session unfindable by folder name) * 2. run-summary events — event title/details (from the live run-summary trackers) * 3. file paths — per-session attachment history (workspace-relative paths only) * @@ -34,6 +36,7 @@ import { } from '../../search-service.js'; import type { SearchSourceType } from '../../types/search.js'; import type { SessionPort, InfraPort } from '../ports/index.js'; +import { ensureHistorySessionIndexFresh, getHistorySessionIndex } from '../session-history-index.js'; /** * Per-source harvest caps. These bound how much in-memory data we hand to the @@ -61,11 +64,17 @@ interface SessionLike { /** * Harvest the three source arrays from the live in-memory stores. Reads only * bounded, already-loaded data — no disk I/O, no terminal buffers. + * + * Past sessions come from the `session-history-index` snapshot, which is built + * outside the request path for exactly that reason. Live rows are harvested + * first and win the dedupe, so a session that is both live and in the snapshot + * keeps its live jump-to (switch to the tab) instead of a resume. */ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources { const sessions: SessionSearchInput[] = []; const events: EventSearchInput[] = []; const files: FileSearchInput[] = []; + const seenSessionIds = new Set(); for (const raw of ctx.sessions.values()) { const s = raw as unknown as SessionLike & { owner?: string }; @@ -73,6 +82,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) const sessionName = s.name ?? ''; const timestamp = s.lastActivityAt ?? s.createdAt ?? 0; + seenSessionIds.add(s.id); sessions.push({ sessionId: s.id, sessionName, @@ -95,6 +105,24 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) } } + // Past sessions: the out-of-band snapshot of the unified list. Unscoped on + // disk, so every row goes through the same ownership check as a live one — + // host-wide transcript rows carry no owner and are therefore admin-only in + // multi-user mode, matching GET /api/sessions/unified. + for (const item of getHistorySessionIndex().items) { + if (seenSessionIds.has(item.sessionId)) continue; + if (canSee && !canSee(item.owner)) continue; + seenSessionIds.add(item.sessionId); + sessions.push({ + sessionId: item.sessionId, + sessionName: item.name, + workingDir: item.workingDir, + timestamp: item.timestamp, + history: true, + claudeSessionId: item.claudeSessionId, + }); + } + // Events: from the live run-summary trackers, keyed by session id. for (const [sessionId, tracker] of ctx.runSummaryTrackers) { const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined; @@ -134,6 +162,11 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In ) : null; + // Fire-and-forget: a stale past-session snapshot is rebuilt in the + // background. This query still answers from whatever is already in memory, + // which is what keeps the request path free of disk I/O. + ensureHistorySessionIndexFresh(); + const sources = harvestSources(ctx, canSee); // Apply the optional source-type filter before searching so excluded diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 49b80c1f..6ddf0649 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -94,7 +94,13 @@ import { type LifecycleInput, type HistoryInput, type MuxStatInput, + type UnifiedSessionItem, } from '../../services/unified-session-service.js'; +import { + buildHistorySessionIndexItems, + setHistoryIndexRefresher, + setHistorySessionIndex, +} from '../session-history-index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { RunSummaryTracker } from '../../run-summary.js'; @@ -3539,16 +3545,20 @@ export function registerSessionRoutes( return { sessions: results.slice(0, 50) }; }); - // Unified, read-only session list: merges live + persisted + lifecycle + - // transcript history + mux stats into one de-duplicated, searchable list - // (COD-121). Pure merge/filter logic lives in unified-session-service.ts. - app.get('/api/sessions/unified', async (req) => { - const query = req.query as { q?: string; offset?: string; limit?: string }; - - if (ctx.testMode) { - return { sessions: [], total: 0 }; - } - + /** + * Gather the four read-only views the unified list is merged from, plus mux + * stats. This is the expensive half (the lifecycle log and a scan of every + * Claude transcript), factored out of the route handler because the + * past-session search index rebuilds itself from the very same inputs — off + * the request path, see session-history-index.ts. + */ + async function gatherUnifiedInputs(): Promise<{ + live: LiveSessionInput[]; + persisted: PersistedSessionInput[]; + lifecycle: LifecycleInput[]; + history: HistoryInput[]; + mux: MuxStatInput[]; + }> { // Live (in-memory) sessions. const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => { const st = s.toState(); @@ -3649,14 +3659,54 @@ export function registerSessionRoutes( // Mux stats are optional. } + return { live, persisted, lifecycle, history, mux }; + } + + /** + * Publish a merged unified list as the past-session search index (issue #261). + * The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be + * built from an unscoped merge — `harvestSources()` in search-routes re-applies + * the ownership check on read. + */ + function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void { + const ownerById = new Map(); + const stored = ctx.store.getState().sessions as Record; + for (const p of Object.values(stored)) ownerById.set(p.id, p.owner); + // Live wins: a session's owner on disk can lag the running one. + for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner); + const liveIds = new Set(ctx.sessions.keys()); + setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds)); + } + + // Rebuild hook for the search route: it kicks this (fire-and-forget) when the + // snapshot goes stale, so a search never pays for the scan itself. + setHistoryIndexRefresher(async () => { + if (ctx.testMode) return; + publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs())); + }); + + // Unified, read-only session list: merges live + persisted + lifecycle + + // transcript history + mux stats into one de-duplicated, searchable list + // (COD-121). Pure merge/filter logic lives in unified-session-service.ts. + app.get('/api/sessions/unified', async (req) => { + const query = req.query as { q?: string; offset?: string; limit?: string }; + + if (ctx.testMode) { + return { sessions: [], total: 0 }; + } + + const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs(); + // Multi-user: a non-admin only sees their own sessions; host-wide transcript // history (not tied to an owned session) is admin-only. let sLive = live; let sPersisted = persisted; let sLifecycle = lifecycle; let sHistory = history; + let scoped = false; const uUser = getAuthUser(req); if (isMultiUserMode() && uUser.role !== 'admin') { + scoped = true; const ownedLive = new Set( [...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id) ); @@ -3680,6 +3730,14 @@ export function registerSessionRoutes( history: sHistory, mux, }); + + // Refresh the search index off the back of this request — the home screen + // fetches this endpoint whenever it opens, which is the same screen the + // search box lives on, so the snapshot is warm before anyone types. A scoped + // merge is a per-user subset and would corrupt the shared snapshot, so that + // path re-merges unscoped instead (multi-user is opt-in and rarely hit). + publishHistorySessionIndex(scoped ? mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }) : merged); + const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined; const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined; return filterAndPaginate(merged, { diff --git a/src/web/session-history-index.ts b/src/web/session-history-index.ts new file mode 100644 index 00000000..734154a4 --- /dev/null +++ b/src/web/session-history-index.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview Bounded in-memory index of PAST sessions, harvested by `GET /api/search`. + * + * `GET /api/search` used to build its session corpus from the live in-memory + * session map alone, so a folder sitting in the home screen's "Resume + * Conversation" list matched nothing (issue #261). The corpus that list renders + * comes from `GET /api/sessions/unified`, which reads the lifecycle log and every + * Claude transcript file — disk I/O the search path deliberately does not do + * (its no-fs property is what keeps a per-keystroke query cheap and traversal-free). + * + * This module is the seam between the two: a capped snapshot of the unified list + * that the search route reads synchronously, refreshed OUT of the request path. + * Two things fill it: + * 1. `/api/sessions/unified` writes it as a side effect (free — it just merged + * that list). The home screen calls that endpoint whenever it opens, which + * is the same screen the search box lives on, so it is warm in practice. + * 2. `ensureHistorySessionIndexFresh()` — fire-and-forget, single-flight, + * TTL-guarded — kicks the registered refresher when a search finds the + * snapshot stale. The caller never awaits it: the current query answers from + * the existing snapshot and the next one sees fresh data. + * + * OWNERSHIP: each item carries the `owner` of the session it came from, and rows + * not tied to any live/persisted session (host-wide transcript history) carry + * `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule + * exactly — in multi-user mode a non-admin sees neither other users' sessions nor + * unowned host-wide history, and in single-user mode every check short-circuits + * true. The snapshot is written UNSCOPED, so it must never be returned unfiltered. + * + * Key exports: + * - setHistorySessionIndex / getHistorySessionIndex — the snapshot accessors. + * - buildHistorySessionIndexItems — pure merged-list → index-item projection. + * - setHistoryIndexRefresher / ensureHistorySessionIndexFresh — the refresh hook. + */ + +/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */ +export interface HistorySessionIndexItem { + /** Codeman session id (the search result's session id and dedupe key). */ + sessionId: string; + /** Display name, may be empty for a transcript-only row. */ + name: string; + /** Absolute working directory — the field issue #261 is about matching. */ + workingDir: string; + /** Claude conversation UUID, when known: what a resume actually replays. */ + claudeSessionId?: string; + /** Recency timestamp (lastActivityAt, else createdAt). */ + timestamp: number; + /** + * Owning user, when the row is tied to a live or persisted session. `undefined` + * means host-wide transcript history, which only admins (or single-user mode) + * may see — the same rule `/api/sessions/unified` applies. + */ + owner?: string; + /** True when the session is still in the live map (search harvests those directly). */ + live: boolean; +} + +/** Hard cap on snapshot size, so a host with thousands of transcripts stays bounded. */ +export const HISTORY_INDEX_MAX_ITEMS = 400; + +/** How long a snapshot is considered fresh before a search triggers a background refresh. */ +export const HISTORY_INDEX_TTL_MS = 60_000; + +interface HistorySessionIndexSnapshot { + items: HistorySessionIndexItem[]; + /** Epoch ms of the last write; 0 when never populated. */ + updatedAt: number; +} + +let snapshot: HistorySessionIndexSnapshot = { items: [], updatedAt: 0 }; +let refresher: (() => Promise) | null = null; +let refreshInFlight = false; + +/** The merged-list shape this module projects from (a subset of `UnifiedSessionItem`). */ +export interface MergedSessionLike { + sessionId: string; + name?: string; + workingDir?: string; + claudeSessionId?: string; + createdAt?: number; + lastActivityAt?: number; +} + +/** + * Project a merged unified list into index items. PURE — the caller supplies the + * owner lookup and the live-id set it already has in hand. + * + * Rows with no working directory AND no name are dropped: they can never match a + * query in a useful way and would only consume the cap. + * + * @param merged unified-list items, newest-first (the order the merge returns) + * @param ownerById owner of a session id, for rows tied to a live/persisted session + * @param liveIds session ids currently in the live map + */ +export function buildHistorySessionIndexItems( + merged: MergedSessionLike[], + ownerById: Map, + liveIds: Set +): HistorySessionIndexItem[] { + const items: HistorySessionIndexItem[] = []; + for (const m of merged) { + if (items.length >= HISTORY_INDEX_MAX_ITEMS) break; + const name = m.name ?? ''; + const workingDir = m.workingDir ?? ''; + if (!name && !workingDir) continue; + items.push({ + sessionId: m.sessionId, + name, + workingDir, + claudeSessionId: m.claudeSessionId, + timestamp: m.lastActivityAt ?? m.createdAt ?? 0, + owner: ownerById.get(m.sessionId), + live: liveIds.has(m.sessionId), + }); + } + return items; +} + +/** Replace the snapshot. Items are capped defensively even if the caller already did. */ +export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = Date.now()): void { + snapshot = { items: items.slice(0, HISTORY_INDEX_MAX_ITEMS), updatedAt: now }; +} + +/** + * Read the snapshot. The returned array is UNSCOPED — callers must apply the + * per-item ownership check before exposing any of it. + */ +export function getHistorySessionIndex(): HistorySessionIndexSnapshot { + return snapshot; +} + +/** True when the snapshot has never been written, or is older than the TTL. */ +export function isHistorySessionIndexStale(now = Date.now(), ttlMs = HISTORY_INDEX_TTL_MS): boolean { + return snapshot.updatedAt === 0 || now - snapshot.updatedAt > ttlMs; +} + +/** + * Register the rebuild function. Called once by the session routes, which own the + * transcript scanner and the stores the unified list is merged from. + */ +export function setHistoryIndexRefresher(fn: (() => Promise) | null): void { + refresher = fn; +} + +/** + * Kick a background rebuild if the snapshot is stale. Returns immediately — + * NEVER await this from a request handler, that is the whole point: the search + * path answers from the current snapshot and stays free of disk I/O. + */ +export function ensureHistorySessionIndexFresh(now = Date.now()): void { + if (refreshInFlight || !refresher || !isHistorySessionIndexStale(now)) return; + refreshInFlight = true; + void refresher() + .catch(() => { + // A failed rebuild leaves the previous snapshot in place; the next search retries. + }) + .finally(() => { + refreshInFlight = false; + }); +} + +/** Test hook: drop the snapshot and any registered refresher. */ +export function resetHistorySessionIndex(): void { + snapshot = { items: [], updatedAt: 0 }; + refresher = null; + refreshInFlight = false; +} diff --git a/test/history-list-controls.test.ts b/test/history-list-controls.test.ts new file mode 100644 index 00000000..515c6d69 --- /dev/null +++ b/test/history-list-controls.test.ts @@ -0,0 +1,291 @@ +/** + * @fileoverview Issue #260 — the home screen's "Resume Conversation" list. + * + * With ~35 past sessions the list showed 4 rows, then a button that dumped every + * remaining row into a fixed 240px box, with no way to sort or filter. The fix + * moved rendering into `_renderHistoryList()` over a cached corpus, so what is + * worth pinning is the model, not the pixels: + * 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4, + * 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is + * class-driven — without the class, expanding just deepens a scroll well), + * 3. filtering matches name / folder / case label / prompt, and implies + * expansion (hiding matches behind "Show more" defeats typing a filter), + * 4. sorting is alphabetical by name or folder, with pinned rows still on top. + * + * Loaded via `vm` against a stub CodemanApp with a fake DOM — same harness as + * resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get + * rendered and in what order, not how one row looks. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +interface FakeEl { + id: string; + value: string; + textContent: string; + scrollTop: number; + className: string; + children: FakeEl[]; + classes: Set; + listeners: Record void)[]>; + classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean }; + replaceChildren: () => void; + appendChild: (child: FakeEl) => FakeEl; + addEventListener: (type: string, fn: (ev: unknown) => void) => void; + style: Record; +} + +function fakeEl(id: string): FakeEl { + const el = { + id, + value: '', + textContent: '', + scrollTop: 0, + className: '', + children: [] as FakeEl[], + classes: new Set(), + listeners: {} as Record void)[]>, + style: {} as Record, + } as FakeEl; + el.classList = { + toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)), + contains: (c: string) => el.classes.has(c), + }; + el.replaceChildren = () => { + el.children = []; + }; + el.appendChild = (child: FakeEl) => { + el.children.push(child); + return child; + }; + el.addEventListener = (type: string, fn: (ev: unknown) => void) => { + (el.listeners[type] ||= []).push(fn); + }; + return el; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * The element map the vm's `document.getElementById` resolves against. Swapped + * per test — the closure is defined in THIS realm, so the shipping code inside + * the vm reads whatever the current test installed. + */ +let currentEls: Record = {}; + +function loadTerminalUiPrototype(): Record { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { + addEventListener: vi.fn(), + getElementById: (id: string) => currentEls[id] ?? null, + createElement: () => fakeEl('created'), + }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as unknown as { __proto: Record }).__proto; +} + +const proto = loadTerminalUiPrototype(); + +type Row = { + sessionId: string; + name?: string; + workingDir?: string; + firstPrompt?: string; + pinned?: boolean; + lastActivityAt?: number; +}; + +/** Host object carrying the real render/filter/sort methods over a fake DOM. */ +function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) { + const els: Record = { + historyList: fakeEl('historyList'), + historyFilter: fakeEl('historyFilter'), + historySort: fakeEl('historySort'), + historyCount: fakeEl('historyCount'), + }; + els.historySort.value = 'recent'; + + const app: any = { + _HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT, + _historyAll: rows, + _historyCases: cases, + _renderHistoryList: proto._renderHistoryList, + _historyRowMatches: proto._historyRowMatches, + _sortHistoryRows: proto._sortHistoryRows, + _historyRowLabel: proto._historyRowLabel, + _resolveCaseLabel: proto._resolveCaseLabel, + _shortenHomePath: proto._shortenHomePath, + // One fake node per row, tagged so assertions can read back the order. + _buildHistoryItem: (s: Row) => { + const el = fakeEl('item'); + el.textContent = s.sessionId; + return el; + }, + els, + /** Rendered row ids, excluding the show-more/less button and empty state. */ + renderedIds(): string[] { + return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent); + }, + button(): FakeEl | undefined { + return els.historyList.children.find((c) => c.id === 'created'); + }, + }; + + // Point the vm's document at this app's elements, then run the shipping method. + app._render = () => { + currentEls = els; + app._renderHistoryList(); + }; + return app; +} + +function rows(n: number, overrides: Partial = {}): Row[] { + return Array.from({ length: n }, (_, i) => ({ + sessionId: `s${i}`, + name: `w${i}-project${i}`, + workingDir: `/home/u/project${i}`, + lastActivityAt: 1000 - i, + ...overrides, + })); +} + +describe('issue #260 — collapsed page size', () => { + it('shows more than the old 4 rows before "Show more"', () => { + expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8); + }); + + it('renders the initial page and a "Show more" button for the rest', () => { + const app = makeApp(rows(35)); + app._render(); + expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT); + expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`); + expect(app.els.historyList.classList.contains('expanded')).toBe(false); + }); + + it('expanding renders every row AND marks the box expanded', () => { + const app = makeApp(rows(35)); + app._historyExpanded = true; + app._render(); + expect(app.renderedIds()).toHaveLength(35); + // Without this class the CSS max-height stays at the collapsed cap and the + // extra rows land in a four-row scroll well — the original bug. + expect(app.els.historyList.classList.contains('expanded')).toBe(true); + expect(app.button()?.textContent).toBe('Show less'); + }); + + it('shows no button at all when everything fits', () => { + const app = makeApp(rows(3)); + app._render(); + expect(app.renderedIds()).toHaveLength(3); + expect(app.button()).toBeUndefined(); + }); +}); + +describe('issue #260 — filter', () => { + it('matches on folder name and shows every match without expanding first', () => { + const app = makeApp([ + ...rows(30), + { sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 }, + { sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 }, + ]); + app.els.historyFilter.value = 'invoices'; + app._render(); + expect(app.renderedIds().sort()).toEqual(['x1', 'x2']); + expect(app.els.historyList.classList.contains('expanded')).toBe(true); + expect(app.els.historyCount.textContent).toBe('2 of 32'); + }); + + it('matches on the case label and on a prompt', () => { + const app = makeApp( + [ + { sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 }, + { + sessionId: 'p1', + name: 'w2-y', + workingDir: '/home/u/other', + firstPrompt: 'fix the CSV export', + lastActivityAt: 2, + }, + ], + [{ name: 'billing', path: '/home/u/cases/billing' }] + ); + app.els.historyFilter.value = '#billing'; + app._render(); + expect(app.renderedIds()).toEqual(['c1']); + + app.els.historyFilter.value = 'csv export'; + app._render(); + expect(app.renderedIds()).toEqual(['p1']); + }); + + it('renders an empty state when nothing matches', () => { + const app = makeApp(rows(5)); + app.els.historyFilter.value = 'zzzz'; + app._render(); + expect(app.renderedIds()).toEqual([]); + expect(app.els.historyList.children[0].textContent).toContain('No conversations match'); + }); +}); + +describe('issue #260 — sort', () => { + const unsorted: Row[] = [ + { sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 }, + { sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 }, + { sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 }, + ]; + + it('recent keeps the backend order', () => { + const app = makeApp(unsorted); + app._render(); + expect(app.renderedIds()).toEqual(['b', 'a', 'c']); + }); + + it('sorts by name', () => { + const app = makeApp(unsorted); + app.els.historySort.value = 'name'; + app._render(); + expect(app.renderedIds()).toEqual(['a', 'b', 'c']); + }); + + it('sorts by folder basename', () => { + const app = makeApp(unsorted); + app.els.historySort.value = 'folder'; + app._render(); + expect(app.renderedIds()).toEqual(['c', 'a', 'b']); + }); + + it('sorts transcript rows (no session name) by the prompt shown as their title', () => { + // Most past rows come from a transcript and have no name at all. Keying the + // A–Z sort off `name` alone made "Name A–Z" a no-op for them. + const app = makeApp([ + { sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' }, + { sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' }, + { sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' }, + ]); + app.els.historySort.value = 'name'; + app._render(); + expect(app.renderedIds()).toEqual(['a', 'm', 'z']); + }); + + it('keeps pinned rows on top in every sort mode', () => { + const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]); + for (const mode of ['recent', 'name', 'folder']) { + app.els.historySort.value = mode; + app._render(); + expect(app.renderedIds()[0]).toBe('p'); + } + }); +}); diff --git a/test/routes/search-routes.test.ts b/test/routes/search-routes.test.ts index 99b59c42..9d63c292 100644 --- a/test/routes/search-routes.test.ts +++ b/test/routes/search-routes.test.ts @@ -9,12 +9,13 @@ * (sessions + events) return results. Source data is injected via the mock * route context (sessions map, runSummaryTrackers map, attachment history). */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import { registerSearchRoutes } from '../../src/web/routes/search-routes.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { createMockRouteContext } from '../mocks/index.js'; import { RunSummaryTracker } from '../../src/run-summary.js'; +import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js'; type Ctx = ReturnType; @@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => { expect(types).toEqual(['event']); }); }); + +// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's +// folder name matched nothing — the corpus was the live session map alone. Past +// sessions now arrive from the out-of-band history index snapshot. +describe('GET /api/search — past sessions (history index)', () => { + beforeEach(() => { + resetHistorySessionIndex(); + }); + + afterEach(() => { + resetHistorySessionIndex(); + delete process.env.CODEMAN_MULTIUSER; + }); + + it('matches a past session by folder name with no live session at all', async () => { + const { app } = await harness(); + setHistorySessionIndex([ + { + sessionId: 'cod-9', + name: 'w4-needlework', + workingDir: '/home/u/projects/needlework', + claudeSessionId: 'claude-uuid', + timestamp: 1000, + live: false, + }, + ]); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.totalResults).toBe(1); + expect(body.data.groups[0].results[0].jumpTo).toMatchObject({ + kind: 'resume-session', + sessionId: 'cod-9', + claudeSessionId: 'claude-uuid', + }); + }); + + it('does not duplicate a session that is both live and in the snapshot', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 'dup', + fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never + ); + }); + setHistorySessionIndex([ + { sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true }, + ]); + const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body); + expect(body.data.totalResults).toBe(1); + // The live harvest wins, so the card still switches to the open tab. + expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session'); + }); + + it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const app = Fastify({ logger: false }); + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' }; + }); + const ctx = createMockRouteContext(); + ctx.sessions.clear(); + ctx.runSummaryTrackers.clear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerSearchRoutes(app, ctx as any); + installRouteErrorHandler(app); + await app.ready(); + + setHistorySessionIndex([ + { + sessionId: 'mine', + name: 'needle-bob', + workingDir: '/home/u/needle-bob', + timestamp: 3, + owner: 'bob', + live: false, + }, + { + sessionId: 'hers', + name: 'needle-alice', + workingDir: '/home/u/needle-alice', + timestamp: 2, + owner: 'alice', + live: false, + }, + // Host-wide transcript row: no owning session, so admin-only — the same + // rule GET /api/sessions/unified applies when it drops history for non-admins. + { sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false }, + ]); + + const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body); + expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']); + await app.close(); + }); +}); diff --git a/test/search-service.test.ts b/test/search-service.test.ts index 2f71c061..f7b89878 100644 --- a/test/search-service.test.ts +++ b/test/search-service.test.ts @@ -233,3 +233,61 @@ describe('searchSources — result card shape & path safety', () => { expect(searchSources('', data).totalResults).toBe(0); }); }); + +// Past sessions (issue #261). The corpus used to be the live session map alone, +// so a folder in the home screen's Resume list matched nothing. History rows now +// arrive marked, and a card for one has to RESUME the conversation — selecting a +// tab that no longer exists is a no-op the user reads as a broken result. +describe('searchSources — past (history) sessions', () => { + it('matches a past session by folder name and returns a resume jump target', () => { + const data = sources({ + sessions: [ + { + sessionId: 'cod-1', + sessionName: 'w3-invoices', + workingDir: '/home/u/projects/invoices', + timestamp: 500, + history: true, + claudeSessionId: 'claude-uuid-1', + }, + ], + }); + const res = searchSources('invoices', data); + expect(res.totalResults).toBe(1); + expect(res.groups[0].results[0].jumpTo).toEqual({ + kind: 'resume-session', + sessionId: 'cod-1', + claudeSessionId: 'claude-uuid-1', + workingDir: '/home/u/projects/invoices', + }); + }); + + it('keeps a live session on the plain session jump target', () => { + const data = sources({ + sessions: [{ sessionId: 'live-1', sessionName: 'w1-invoices', workingDir: '/home/u/invoices', timestamp: 1 }], + }); + expect(searchSources('invoices', data).groups[0].results[0].jumpTo).toEqual({ + kind: 'session', + sessionId: 'live-1', + }); + }); + + it('does not offer a resume for a history row with no working directory', () => { + const data = sources({ + sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }], + }); + // Nothing to resume INTO — a resume card here would always fail. + expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session'); + }); + + it('falls back to the folder basename when a transcript row has no name', () => { + const data = sources({ + sessions: [ + { sessionId: 'cod-3', sessionName: '', workingDir: '/home/u/proj/needle-app', timestamp: 1, history: true }, + ], + }); + const r = searchSources('needle', data).groups[0].results[0]; + expect(r.sessionName).toBe('needle-app'); + expect(r.snippet).toContain('/home/u/proj/needle-app'); + }); +}); diff --git a/test/session-history-index.test.ts b/test/session-history-index.test.ts new file mode 100644 index 00000000..935d6e71 --- /dev/null +++ b/test/session-history-index.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for the past-session search index (issue #261). + * + * The index is the seam that lets `GET /api/search` match sessions that are no + * longer running WITHOUT doing disk I/O per keystroke. Three properties matter + * and are pinned here: the snapshot stays bounded, the refresh never happens on + * the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the + * stored rows carry the owner needed to re-apply multi-user scoping on read — + * the snapshot is written unscoped, so losing that field would leak one user's + * folders into another user's search. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + buildHistorySessionIndexItems, + ensureHistorySessionIndexFresh, + getHistorySessionIndex, + isHistorySessionIndexStale, + resetHistorySessionIndex, + setHistoryIndexRefresher, + setHistorySessionIndex, + HISTORY_INDEX_MAX_ITEMS, + HISTORY_INDEX_TTL_MS, + type MergedSessionLike, +} from '../src/web/session-history-index.js'; + +beforeEach(() => { + resetHistorySessionIndex(); +}); + +describe('buildHistorySessionIndexItems', () => { + const merged: MergedSessionLike[] = [ + { sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 }, + { sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 }, + { sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 }, + ]; + + it('projects name, dir, timestamp, owner and liveness', () => { + const items = buildHistorySessionIndexItems( + merged, + new Map([ + ['a', 'alice'], + ['b', undefined], + ]), + new Set(['a']) + ); + expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']); + expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 }); + // Transcript-only row: no owner (host-wide) and not live. + expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' }); + }); + + it('drops rows with neither a name nor a working directory', () => { + const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set()); + expect(items.some((i) => i.sessionId === 'empty')).toBe(false); + }); + + it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => { + const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({ + sessionId: `s${i}`, + name: `session ${i}`, + workingDir: `/home/u/p${i}`, + lastActivityAt: i, + })); + expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS); + }); +}); + +describe('snapshot storage', () => { + it('starts empty and stale', () => { + expect(getHistorySessionIndex().items).toEqual([]); + expect(isHistorySessionIndexStale()).toBe(true); + }); + + it('caps on write even when the caller did not', () => { + const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({ + sessionId: `s${i}`, + name: 'x', + workingDir: '/x', + timestamp: i, + live: false, + })); + setHistorySessionIndex(items); + expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS); + }); + + it('goes stale again once the TTL elapses', () => { + const t0 = 1_000_000; + setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0); + expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false); + expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true); + }); +}); + +describe('ensureHistorySessionIndexFresh', () => { + it('returns synchronously — the rebuild must never be on the request path', async () => { + let resolveRefresh: () => void = () => {}; + const refresher = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + // Called, but the caller is already past it while the rebuild is pending. + expect(refresher).toHaveBeenCalledTimes(1); + expect(getHistorySessionIndex().items).toEqual([]); + resolveRefresh(); + await Promise.resolve(); + }); + + it('is single-flight: a second call while a rebuild is pending is a no-op', async () => { + let resolveRefresh: () => void = () => {}; + const refresher = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + ensureHistorySessionIndexFresh(); + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(1); + + resolveRefresh(); + await new Promise((r) => setTimeout(r, 0)); + // Snapshot still stale (the fake refresher wrote nothing) → next call runs again. + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(2); + }); + + it('does not rebuild while the snapshot is fresh', () => { + const refresher = vi.fn(async () => {}); + setHistoryIndexRefresher(refresher); + setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]); + ensureHistorySessionIndexFresh(); + expect(refresher).not.toHaveBeenCalled(); + }); + + it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => { + setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1); + const refresher = vi.fn(async () => { + throw new Error('scan failed'); + }); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + await new Promise((r) => setTimeout(r, 0)); + expect(getHistorySessionIndex().items[0].sessionId).toBe('keep'); + + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(2); + }); + + it('is a no-op when no refresher is registered', () => { + expect(() => ensureHistorySessionIndexFresh()).not.toThrow(); + }); +}); From 053a6d238df36a5fc4472019e9a0099455b6bc04 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:12:02 +0200 Subject: [PATCH 2/3] fix(web): adopt #263's fetch ceiling, persisted sort and numeric collation @jordan8037310 opened #263 against the same two issues while this branch was in flight. Three details there are better than what this had, so they are folded in with credit: - the Resume list pulls 200 unified sessions instead of 60, so the filter can reach a real backlog rather than stopping at an arbitrary ceiling (the endpoint clamps at 500), - the sort choice persists per device in localStorage, like `codeman:skin` and the other display keys that stay out of the synced schema, - alphabetical sorts collate with `{sensitivity:'base', numeric:true}`, so w2- sorts before w10- and case never splits one project's rows apart. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/terminal-ui.js | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d28c0bc7..0b9f4218 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1956,8 +1956,16 @@ Object.assign(CodemanApp.prototype, { /** Number of history items shown before "Show More" */ _HISTORY_INITIAL_COUNT: 10, - /** How many past sessions the home screen loads (also the filter/sort corpus). */ - _HISTORY_FETCH_LIMIT: 60, + /** + * How many past sessions the home screen loads (also the filter/sort corpus). + * 200, not the old 60, so the filter can reach a real backlog — an install with + * 35+ conversations would otherwise hit the ceiling before the filter is useful + * (raised in @jordan8037310's #263; the endpoint clamps at 500). + */ + _HISTORY_FETCH_LIMIT: 200, + + /** localStorage key for the per-device sort choice (#263). */ + _HISTORY_SORT_KEY: 'codeman:historySort', async loadHistorySessions() { const container = document.getElementById('historySessions'); @@ -1995,7 +2003,12 @@ Object.assign(CodemanApp.prototype, { } }, - /** Wire the filter box and sort select once; both re-render from the cached corpus. */ + /** + * Wire the filter box and sort select once; both re-render from the cached + * corpus. The sort choice is restored from (and saved to) localStorage — it is + * a per-device display preference, so it stays out of the synced settings + * schema, same as `codeman:skin`. + */ _wireHistoryControls() { if (this._historyControlsWired) return; const filter = document.getElementById('historyFilter'); @@ -2003,6 +2016,15 @@ Object.assign(CodemanApp.prototype, { if (!filter && !sort) return; this._historyControlsWired = true; + if (sort) { + try { + const saved = localStorage.getItem(this._HISTORY_SORT_KEY); + if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved; + } catch { + /* private mode — the order just won't persist */ + } + } + if (filter) { filter.addEventListener('input', () => this._renderHistoryList()); filter.addEventListener('keydown', (ev) => { @@ -2014,7 +2036,16 @@ Object.assign(CodemanApp.prototype, { } }); } - if (sort) sort.addEventListener('change', () => this._renderHistoryList()); + if (sort) { + sort.addEventListener('change', () => { + try { + localStorage.setItem(this._HISTORY_SORT_KEY, sort.value); + } catch { + /* private mode — the order just won't persist */ + } + this._renderHistoryList(); + }); + } }, /** True when a past-session row matches the filter text (name, folder, case, prompt). */ @@ -2050,7 +2081,14 @@ Object.assign(CodemanApp.prototype, { 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))); + // numeric collation so w2-… sorts before w10-…, and base sensitivity so case + // does not split a project's rows apart (from @jordan8037310's #263). + const sorted = + mode === 'recent' + ? rows.slice() + : rows + .slice() + .sort((a, b) => key(a).localeCompare(key(b), undefined, { sensitivity: 'base', numeric: true })); const pinned = sorted.filter((s) => s.pinned); return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned)); }, From c13b3c55d3c7665b3726f1b77e7320c2bba28a1a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:15:13 +0200 Subject: [PATCH 3/3] style: drop em-dashes from the prose added in this branch Co-Authored-By: Claude Opus 5 (1M context) --- .../history-list-and-past-session-search.md | 4 +-- CLAUDE.md | 6 ++-- docs/architecture-invariants.md | 2 +- src/search-service.ts | 2 +- src/web/public/home-sessions.js | 2 +- src/web/public/styles.css | 2 +- src/web/public/terminal-ui.js | 20 ++++++------- src/web/routes/search-routes.ts | 4 +-- src/web/routes/session-routes.ts | 6 ++-- src/web/session-history-index.ts | 28 +++++++++---------- test/history-list-controls.test.ts | 16 +++++------ test/routes/search-routes.test.ts | 6 ++-- test/search-service.test.ts | 6 ++-- test/session-history-index.test.ts | 4 +-- 14 files changed, 54 insertions(+), 54 deletions(-) diff --git a/.changeset/history-list-and-past-session-search.md b/.changeset/history-list-and-past-session-search.md index 8869f0f8..4f746be6 100644 --- a/.changeset/history-list-and-past-session-search.md +++ b/.changeset/history-list-and-past-session-search.md @@ -4,14 +4,14 @@ Home screen: make the past-conversation list usable, and let search find past sessions. -- **#260** — "Resume Conversation" showed 4 rows and then dumped every remaining +- **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining one into a fixed 240px box, with no ordering or filtering. The list now opens with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the height cap is class-driven instead of fixed), and the header carries a filter box (matches name, folder, `#case` label and the conversation's prompts), a sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a shown-of-total count. Filtering implies expansion, so every match is visible. -- **#261** — the search box could not match a past project by folder name: its +- **#261**: the search box could not match a past project by folder name: its session corpus was the live in-memory map, while past sessions come from `/api/sessions/unified`. Search now also harvests a bounded snapshot of that unified list, refreshed OUTSIDE the request path (published by diff --git a/CLAUDE.md b/CLAUDE.md index 478317e7..79134962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c ` (and ANY `::` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) -**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget) — that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) +**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` @@ -254,9 +254,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). -**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. +**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places**, `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. -**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`) — most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. +**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 55241d9d..4d8fb1bf 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,7 +157,7 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a **Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`. -**Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable — searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free — it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()` — **fire-and-forget, single-flight, TTL-guarded (60s)** — kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row — the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`. +**Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable, searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free, it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()`, **fire-and-forget, single-flight, TTL-guarded (60s)**, kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row, the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`. ### Away digest diff --git a/src/search-service.ts b/src/search-service.ts index c16c1f5d..2fcf9a6c 100644 --- a/src/search-service.ts +++ b/src/search-service.ts @@ -44,7 +44,7 @@ export interface SessionSearchInput { /** Recency timestamp (e.g. lastActivityAt or createdAt). */ timestamp: number; /** - * True for a session that is no longer running (issue #261 — past sessions come + * 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. */ diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 032596da..fdeddbd2 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -34,7 +34,7 @@ /** * Narrowest window that gets the column. The welcome content is 560px wide and - * centered, so at 1180px each gutter is 310px — enough for the 256px column plus + * centered, so at 1180px each gutter is 310px, enough for the 256px column plus * its 20px offset and still a visible gap. Anything narrower would overlap the * search panel, which is why this is a width gate and not a device-type gate. */ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a06de0d3..6f0da286 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3737,7 +3737,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { } /* Title row for the past-session list: label + count on the left, filter and - sort on the right (issue #260 — 35 conversations in a 4-row box). */ + sort on the right (issue #260, 35 conversations in a 4-row box). */ .history-header { display: flex; align-items: center; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0b9f4218..721259d6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1958,7 +1958,7 @@ Object.assign(CodemanApp.prototype, { /** * How many past sessions the home screen loads (also the filter/sort corpus). - * 200, not the old 60, so the filter can reach a real backlog — an install with + * 200, not the old 60, so the filter can reach a real backlog, an install with * 35+ conversations would otherwise hit the ceiling before the filter is useful * (raised in @jordan8037310's #263; the endpoint clamps at 500). */ @@ -1989,7 +1989,7 @@ Object.assign(CodemanApp.prototype, { // 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 + // 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; @@ -2005,7 +2005,7 @@ Object.assign(CodemanApp.prototype, { /** * Wire the filter box and sort select once; both re-render from the cached - * corpus. The sort choice is restored from (and saved to) localStorage — it is + * corpus. The sort choice is restored from (and saved to) localStorage, it is * a per-device display preference, so it stays out of the synced settings * schema, same as `codeman:skin`. */ @@ -2021,7 +2021,7 @@ Object.assign(CodemanApp.prototype, { const saved = localStorage.getItem(this._HISTORY_SORT_KEY); if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved; } catch { - /* private mode — the order just won't persist */ + /* private mode, the order just won't persist */ } } @@ -2041,7 +2041,7 @@ Object.assign(CodemanApp.prototype, { try { localStorage.setItem(this._HISTORY_SORT_KEY, sort.value); } catch { - /* private mode — the order just won't persist */ + /* private mode, the order just won't persist */ } this._renderHistoryList(); }); @@ -2064,7 +2064,7 @@ Object.assign(CodemanApp.prototype, { /** * 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" + * 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) { @@ -2074,7 +2074,7 @@ Object.assign(CodemanApp.prototype, { /** * 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 + * 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) { @@ -2097,7 +2097,7 @@ Object.assign(CodemanApp.prototype, { * 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). + * a fixed 240px box made expansion pointless, issue #260). */ _renderHistoryList() { const list = document.getElementById('historyList'); @@ -3985,7 +3985,7 @@ Object.assign(CodemanApp.prototype, { /** Render the grouped result cards (or empty/loading states). */ _renderSearch(data) { const results = document.getElementById('searchResults'); - // The header carries the title plus the filter/sort controls (issue #260) — + // 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'); @@ -4100,7 +4100,7 @@ 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) - * resume-session→ resumeHistorySession(...) (past session — no tab to switch to) + * resume-session→ resumeHistorySession(...) (past session, no tab to switch to) * run-summary → openRunSummary(sessionId) (session options → summary tab) * file-preview → openFilePreview(path, sessionId, attachmentId) */ diff --git a/src/web/routes/search-routes.ts b/src/web/routes/search-routes.ts index b98831a8..6123d23f 100644 --- a/src/web/routes/search-routes.ts +++ b/src/web/routes/search-routes.ts @@ -3,7 +3,7 @@ * * Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search * across three v1 sources, returned in the standard ApiResponse envelope: - * 1. sessions/cases — name, working directory, session id, for LIVE sessions + * 1. sessions/cases, name, working directory, session id, for LIVE sessions * plus the past-session snapshot in `session-history-index.ts` (issue #261: * the live map alone made every closed session unfindable by folder name) * 2. run-summary events — event title/details (from the live run-summary trackers) @@ -106,7 +106,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) } // Past sessions: the out-of-band snapshot of the unified list. Unscoped on - // disk, so every row goes through the same ownership check as a live one — + // disk, so every row goes through the same ownership check as a live one, // host-wide transcript rows carry no owner and are therefore admin-only in // multi-user mode, matching GET /api/sessions/unified. for (const item of getHistorySessionIndex().items) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6ddf0649..fa44d2dc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3549,7 +3549,7 @@ export function registerSessionRoutes( * Gather the four read-only views the unified list is merged from, plus mux * stats. This is the expensive half (the lifecycle log and a scan of every * Claude transcript), factored out of the route handler because the - * past-session search index rebuilds itself from the very same inputs — off + * past-session search index rebuilds itself from the very same inputs, off * the request path, see session-history-index.ts. */ async function gatherUnifiedInputs(): Promise<{ @@ -3665,7 +3665,7 @@ export function registerSessionRoutes( /** * Publish a merged unified list as the past-session search index (issue #261). * The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be - * built from an unscoped merge — `harvestSources()` in search-routes re-applies + * built from an unscoped merge, `harvestSources()` in search-routes re-applies * the ownership check on read. */ function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void { @@ -3731,7 +3731,7 @@ export function registerSessionRoutes( mux, }); - // Refresh the search index off the back of this request — the home screen + // Refresh the search index off the back of this request, the home screen // fetches this endpoint whenever it opens, which is the same screen the // search box lives on, so the snapshot is warm before anyone types. A scoped // merge is a per-user subset and would corrupt the shared snapshot, so that diff --git a/src/web/session-history-index.ts b/src/web/session-history-index.ts index 734154a4..cf84bbed 100644 --- a/src/web/session-history-index.ts +++ b/src/web/session-history-index.ts @@ -5,31 +5,31 @@ * session map alone, so a folder sitting in the home screen's "Resume * Conversation" list matched nothing (issue #261). The corpus that list renders * comes from `GET /api/sessions/unified`, which reads the lifecycle log and every - * Claude transcript file — disk I/O the search path deliberately does not do - * (its no-fs property is what keeps a per-keystroke query cheap and traversal-free). + * Claude transcript file: disk I/O the search path deliberately does not do (its + * no-fs property is what keeps a per-keystroke query cheap and traversal-free). * * This module is the seam between the two: a capped snapshot of the unified list * that the search route reads synchronously, refreshed OUT of the request path. * Two things fill it: - * 1. `/api/sessions/unified` writes it as a side effect (free — it just merged + * 1. `/api/sessions/unified` writes it as a side effect (free, it just merged * that list). The home screen calls that endpoint whenever it opens, which * is the same screen the search box lives on, so it is warm in practice. - * 2. `ensureHistorySessionIndexFresh()` — fire-and-forget, single-flight, - * TTL-guarded — kicks the registered refresher when a search finds the + * 2. `ensureHistorySessionIndexFresh()`, fire-and-forget, single-flight, + * TTL-guarded, kicks the registered refresher when a search finds the * snapshot stale. The caller never awaits it: the current query answers from * the existing snapshot and the next one sees fresh data. * * OWNERSHIP: each item carries the `owner` of the session it came from, and rows * not tied to any live/persisted session (host-wide transcript history) carry * `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule - * exactly — in multi-user mode a non-admin sees neither other users' sessions nor + * exactly, in multi-user mode a non-admin sees neither other users' sessions nor * unowned host-wide history, and in single-user mode every check short-circuits * true. The snapshot is written UNSCOPED, so it must never be returned unfiltered. * * Key exports: - * - setHistorySessionIndex / getHistorySessionIndex — the snapshot accessors. - * - buildHistorySessionIndexItems — pure merged-list → index-item projection. - * - setHistoryIndexRefresher / ensureHistorySessionIndexFresh — the refresh hook. + * - setHistorySessionIndex / getHistorySessionIndex: the snapshot accessors. + * - buildHistorySessionIndexItems: pure merged-list → index-item projection. + * - setHistoryIndexRefresher / ensureHistorySessionIndexFresh: the refresh hook. */ /** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */ @@ -38,7 +38,7 @@ export interface HistorySessionIndexItem { sessionId: string; /** Display name, may be empty for a transcript-only row. */ name: string; - /** Absolute working directory — the field issue #261 is about matching. */ + /** Absolute working directory, the field issue #261 is about matching. */ workingDir: string; /** Claude conversation UUID, when known: what a resume actually replays. */ claudeSessionId?: string; @@ -47,7 +47,7 @@ export interface HistorySessionIndexItem { /** * Owning user, when the row is tied to a live or persisted session. `undefined` * means host-wide transcript history, which only admins (or single-user mode) - * may see — the same rule `/api/sessions/unified` applies. + * may see, the same rule `/api/sessions/unified` applies. */ owner?: string; /** True when the session is still in the live map (search harvests those directly). */ @@ -81,7 +81,7 @@ export interface MergedSessionLike { } /** - * Project a merged unified list into index items. PURE — the caller supplies the + * Project a merged unified list into index items. PURE, the caller supplies the * owner lookup and the live-id set it already has in hand. * * Rows with no working directory AND no name are dropped: they can never match a @@ -121,7 +121,7 @@ export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = D } /** - * Read the snapshot. The returned array is UNSCOPED — callers must apply the + * Read the snapshot. The returned array is UNSCOPED, callers must apply the * per-item ownership check before exposing any of it. */ export function getHistorySessionIndex(): HistorySessionIndexSnapshot { @@ -142,7 +142,7 @@ export function setHistoryIndexRefresher(fn: (() => Promise) | null): void } /** - * Kick a background rebuild if the snapshot is stale. Returns immediately — + * Kick a background rebuild if the snapshot is stale. Returns immediately, * NEVER await this from a request handler, that is the whole point: the search * path answers from the current snapshot and stays free of disk I/O. */ diff --git a/test/history-list-controls.test.ts b/test/history-list-controls.test.ts index 515c6d69..54494853 100644 --- a/test/history-list-controls.test.ts +++ b/test/history-list-controls.test.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Issue #260 — the home screen's "Resume Conversation" list. + * @fileoverview Issue #260, the home screen's "Resume Conversation" list. * * With ~35 past sessions the list showed 4 rows, then a button that dumped every * remaining row into a fixed 240px box, with no way to sort or filter. The fix @@ -7,12 +7,12 @@ * worth pinning is the model, not the pixels: * 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4, * 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is - * class-driven — without the class, expanding just deepens a scroll well), + * class-driven, without the class, expanding just deepens a scroll well), * 3. filtering matches name / folder / case label / prompt, and implies * expansion (hiding matches behind "Show more" defeats typing a filter), * 4. sorting is alphabetical by name or folder, with pinned rows still on top. * - * Loaded via `vm` against a stub CodemanApp with a fake DOM — same harness as + * Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as * resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get * rendered and in what order, not how one row looks. */ @@ -71,7 +71,7 @@ function fakeEl(id: string): FakeEl { /** * The element map the vm's `document.getElementById` resolves against. Swapped - * per test — the closure is defined in THIS realm, so the shipping code inside + * per test, the closure is defined in THIS realm, so the shipping code inside * the vm reads whatever the current test installed. */ let currentEls: Record = {}; @@ -162,7 +162,7 @@ function rows(n: number, overrides: Partial = {}): Row[] { })); } -describe('issue #260 — collapsed page size', () => { +describe('issue #260: collapsed page size', () => { it('shows more than the old 4 rows before "Show more"', () => { expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8); }); @@ -181,7 +181,7 @@ describe('issue #260 — collapsed page size', () => { app._render(); expect(app.renderedIds()).toHaveLength(35); // Without this class the CSS max-height stays at the collapsed cap and the - // extra rows land in a four-row scroll well — the original bug. + // extra rows land in a four-row scroll well, the original bug. expect(app.els.historyList.classList.contains('expanded')).toBe(true); expect(app.button()?.textContent).toBe('Show less'); }); @@ -194,7 +194,7 @@ describe('issue #260 — collapsed page size', () => { }); }); -describe('issue #260 — filter', () => { +describe('issue #260: filter', () => { it('matches on folder name and shows every match without expanding first', () => { const app = makeApp([ ...rows(30), @@ -240,7 +240,7 @@ describe('issue #260 — filter', () => { }); }); -describe('issue #260 — sort', () => { +describe('issue #260: sort', () => { const unsorted: Row[] = [ { sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 }, { sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 }, diff --git a/test/routes/search-routes.test.ts b/test/routes/search-routes.test.ts index 9d63c292..38d97d7b 100644 --- a/test/routes/search-routes.test.ts +++ b/test/routes/search-routes.test.ts @@ -209,9 +209,9 @@ describe('GET /api/search — caps & filters', () => { }); // Issue #261: with 3 live sessions and ~35 past ones, searching a past project's -// folder name matched nothing — the corpus was the live session map alone. Past +// folder name matched nothing, the corpus was the live session map alone. Past // sessions now arrive from the out-of-band history index snapshot. -describe('GET /api/search — past sessions (history index)', () => { +describe('GET /api/search: past sessions (history index)', () => { beforeEach(() => { resetHistorySessionIndex(); }); @@ -291,7 +291,7 @@ describe('GET /api/search — past sessions (history index)', () => { owner: 'alice', live: false, }, - // Host-wide transcript row: no owning session, so admin-only — the same + // Host-wide transcript row: no owning session, so admin-only, the same // rule GET /api/sessions/unified applies when it drops history for non-admins. { sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false }, ]); diff --git a/test/search-service.test.ts b/test/search-service.test.ts index f7b89878..9bf5a8bc 100644 --- a/test/search-service.test.ts +++ b/test/search-service.test.ts @@ -236,9 +236,9 @@ describe('searchSources — result card shape & path safety', () => { // Past sessions (issue #261). The corpus used to be the live session map alone, // so a folder in the home screen's Resume list matched nothing. History rows now -// arrive marked, and a card for one has to RESUME the conversation — selecting a +// arrive marked, and a card for one has to RESUME the conversation, selecting a // tab that no longer exists is a no-op the user reads as a broken result. -describe('searchSources — past (history) sessions', () => { +describe('searchSources: past (history) sessions', () => { it('matches a past session by folder name and returns a resume jump target', () => { const data = sources({ sessions: [ @@ -276,7 +276,7 @@ describe('searchSources — past (history) sessions', () => { const data = sources({ sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }], }); - // Nothing to resume INTO — a resume card here would always fail. + // Nothing to resume INTO, a resume card here would always fail. expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session'); }); diff --git a/test/session-history-index.test.ts b/test/session-history-index.test.ts index 935d6e71..d3447780 100644 --- a/test/session-history-index.test.ts +++ b/test/session-history-index.test.ts @@ -5,7 +5,7 @@ * longer running WITHOUT doing disk I/O per keystroke. Three properties matter * and are pinned here: the snapshot stays bounded, the refresh never happens on * the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the - * stored rows carry the owner needed to re-apply multi-user scoping on read — + * stored rows carry the owner needed to re-apply multi-user scoping on read, * the snapshot is written unscoped, so losing that field would leak one user's * folders into another user's search. */ @@ -92,7 +92,7 @@ describe('snapshot storage', () => { }); describe('ensureHistorySessionIndexFresh', () => { - it('returns synchronously — the rebuild must never be on the request path', async () => { + it('returns synchronously, the rebuild must never be on the request path', async () => { let resolveRefresh: () => void = () => {}; const refresher = vi.fn( () =>