mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #264 from Ark0N/fix/history-search-260-261
fix(web): usable past-conversation list (#260) and search that finds past sessions (#261)
This commit is contained in:
@@ -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.
|
||||
@@ -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 <cmd>` (and ANY `<name>::<payload>` 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 `<base>` (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,7 +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-<backend>` / `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`.
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+23
-4
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -397,7 +397,27 @@
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<div class="history-header" id="historyHeader">
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<span class="history-count" id="historyCount" data-i18n-skip></span>
|
||||
<div class="history-controls">
|
||||
<input
|
||||
type="search"
|
||||
id="historyFilter"
|
||||
class="history-filter"
|
||||
placeholder="Filter…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="100"
|
||||
aria-label="Filter past conversations"
|
||||
/>
|
||||
<select id="historySort" class="search-select history-sort" aria-label="Sort past conversations">
|
||||
<option value="recent">Recent</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="folder">Folder A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+194
-29
@@ -1646,7 +1646,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');
|
||||
@@ -1984,7 +1984,18 @@ 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).
|
||||
* 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');
|
||||
@@ -1998,7 +2009,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) {
|
||||
@@ -2006,27 +2017,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) {
|
||||
@@ -2035,6 +2033,161 @@ 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
|
||||
* 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');
|
||||
const sort = document.getElementById('historySort');
|
||||
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) => {
|
||||
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', () => {
|
||||
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). */
|
||||
_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;
|
||||
// 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));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -3862,13 +4015,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 = '';
|
||||
@@ -3937,9 +4093,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';
|
||||
@@ -3972,12 +4130,19 @@ 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)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||
* 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();
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3649,16 +3655,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();
|
||||
@@ -3762,14 +3772,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<string, string | undefined>();
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
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)
|
||||
);
|
||||
@@ -3793,6 +3843,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, {
|
||||
|
||||
@@ -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<void>) | 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<string, string | undefined>,
|
||||
liveIds: Set<string>
|
||||
): 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<void>) | 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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
listeners: Record<string, ((ev: unknown) => 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<string, string>;
|
||||
}
|
||||
|
||||
function fakeEl(id: string): FakeEl {
|
||||
const el = {
|
||||
id,
|
||||
value: '',
|
||||
textContent: '',
|
||||
scrollTop: 0,
|
||||
className: '',
|
||||
children: [] as FakeEl[],
|
||||
classes: new Set<string>(),
|
||||
listeners: {} as Record<string, ((ev: unknown) => void)[]>,
|
||||
style: {} as Record<string, string>,
|
||||
} 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<string, FakeEl> = {};
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
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<string, any> }).__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<string, FakeEl> = {
|
||||
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> = {}): 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMockRouteContext>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>((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<void>((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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user