mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(web): usable past-conversation list, and search that finds past sessions
Two home-screen reports from @jordan8037310, both about history that is present but unreachable. #260 — "Resume Conversation" rendered 4 rows, then a button that appended every remaining row into a `max-height: 240px` box, so 35 conversations landed in a four-row scroll well with no ordering or filtering. Rendering now goes through `_renderHistoryList()` over a cached corpus: 10 rows to start, Show more/Show less that grows and shrinks the box (the height cap is class-driven, `.history-list.expanded`), plus a filter box (name, folder, #case label, prompts), a sort control (recent / name / folder, pinned rows still first) and a shown-of-total count. A filter implies expansion, so every match is visible, and the whole header hides as one unit while a federated search is active. The A-Z sort keys off the same string the row renders, since most rows are transcript-backed and carry no session name at all. #261 — the search box could not match a past project by folder name: `harvestSources()` built its session corpus from the live in-memory map, while past sessions come from `/api/sessions/unified` (lifecycle log + transcript scan). Folding that scan into the request path would have cost the search its no-filesystem-reads property, so the corpus arrives via a bounded snapshot instead: `session-history-index.ts` is published as a side effect of `/api/sessions/unified` (the home screen fetches it on open, which is the same screen the search box lives on) and rebuilt fire-and-forget, single-flight and TTL-guarded when a search finds it stale. A result for a closed session now resumes the conversation rather than selecting a tab that no longer exists, and is badged RESUME. The snapshot is stored unscoped with a per-row owner and re-filtered through canAccessOwned() on read, so multi-user sees exactly what /api/sessions/unified exposes: own sessions only, host-wide transcript history admin-only. Live rows are harvested first and win the dedupe. Verified end-to-end against a real instance with 60 past sessions: cold process answers its first search without history and its second with it; folder-name queries return resume targets; clicking one posts the right resumeSessionId + workingDir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
**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`
|
**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`
|
||||||
|
|
||||||
@@ -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.
|
**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)
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -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`.
|
**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
|
||||||
|
|
||||||
**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.
|
**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. */
|
/** Maximum characters in a result snippet. */
|
||||||
export const SEARCH_SNIPPET_MAX = 200;
|
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 {
|
export interface SessionSearchInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
||||||
timestamp: number;
|
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. */
|
/** 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[] = [];
|
const sessionRows: SearchResult[] = [];
|
||||||
for (const s of sources.sessions) {
|
for (const s of sources.sessions) {
|
||||||
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
||||||
|
const label = s.sessionName || s.workingDir.split('/').pop() || s.sessionId;
|
||||||
sessionRows.push({
|
sessionRows.push({
|
||||||
type: 'session',
|
type: 'session',
|
||||||
sessionId: s.sessionId,
|
sessionId: s.sessionId,
|
||||||
sessionName: s.sessionName,
|
sessionName: label,
|
||||||
timestamp: s.timestamp,
|
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),
|
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. */
|
/** Where the frontend should jump when a result card is activated. */
|
||||||
export interface SearchJumpTarget {
|
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). */
|
/** Owning Codeman session id (always present — every result is session-scoped). */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/**
|
/**
|
||||||
* Secondary identifier for the target:
|
* Secondary identifier for the target:
|
||||||
* - kind 'run-summary': the run-summary event id
|
* - kind 'run-summary': the run-summary event id
|
||||||
* - kind 'file-preview': the attachment history item 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;
|
targetId?: string;
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +42,16 @@ export interface SearchJumpTarget {
|
|||||||
* server-private external paths are intentionally omitted to avoid leakage.
|
* server-private external paths are intentionally omitted to avoid leakage.
|
||||||
*/
|
*/
|
||||||
relativePath?: string;
|
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. */
|
/** A single typed search result card. */
|
||||||
|
|||||||
@@ -397,7 +397,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-results" id="searchResults" hidden></div>
|
<div class="search-results" id="searchResults" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="history-header" id="historyHeader">
|
||||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
<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 class="history-list" id="historyList"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-hint">Or click Run to start</p>
|
<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;
|
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 {
|
.search-result-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3715,24 +3722,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
.search-select {
|
.search-select {
|
||||||
max-width: 7rem;
|
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 {
|
.history-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
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 {
|
.history-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
max-height: 240px;
|
max-height: min(42vh, 360px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-list.expanded {
|
||||||
|
max-height: min(64vh, 660px);
|
||||||
|
}
|
||||||
|
|
||||||
.history-item {
|
.history-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+155
-28
@@ -1628,7 +1628,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
pin.title = 'Pinned';
|
pin.title = 'Pinned';
|
||||||
titleSpan.appendChild(pin);
|
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.
|
// Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill.
|
||||||
const badgeRow = document.createElement('div');
|
const badgeRow = document.createElement('div');
|
||||||
@@ -1954,7 +1954,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Number of history items shown before "Show More" */
|
/** 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() {
|
async loadHistorySessions() {
|
||||||
const container = document.getElementById('historySessions');
|
const container = document.getElementById('historySessions');
|
||||||
@@ -1968,7 +1971,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
? Promise.resolve(this.cases)
|
? Promise.resolve(this.cases)
|
||||||
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
|
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
|
||||||
const [allSessions, cases] = await Promise.all([
|
const [allSessions, cases] = await Promise.all([
|
||||||
this._fetchUnifiedSessions(60),
|
this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT),
|
||||||
casesPromise,
|
casesPromise,
|
||||||
]);
|
]);
|
||||||
if (allSessions.length === 0) {
|
if (allSessions.length === 0) {
|
||||||
@@ -1976,27 +1979,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.replaceChildren();
|
// Keep the corpus around: filtering and sorting (issue #260) work on this
|
||||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
// 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
|
||||||
// Render initial items
|
// every few seconds would be worse than the original 4-item cap.
|
||||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
this._historyAll = allSessions;
|
||||||
list.appendChild(this._buildHistoryItem(allSessions[i], cases));
|
this._historyCases = cases;
|
||||||
}
|
this._wireHistoryControls();
|
||||||
|
this._renderHistoryList();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.style.display = '';
|
container.style.display = '';
|
||||||
} catch (err) {
|
} 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 */
|
/** Page size for the folder history modal */
|
||||||
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||||
|
|
||||||
@@ -3832,13 +3947,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/** Render the grouped result cards (or empty/loading states). */
|
/** Render the grouped result cards (or empty/loading states). */
|
||||||
_renderSearch(data) {
|
_renderSearch(data) {
|
||||||
const results = document.getElementById('searchResults');
|
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');
|
const historyList = document.getElementById('historyList');
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
|
|
||||||
const searching = !!data;
|
const searching = !!data;
|
||||||
// Hide the plain "Resume Conversation" history list while a search is active.
|
// 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' : '';
|
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||||
|
|
||||||
results.innerHTML = '';
|
results.innerHTML = '';
|
||||||
@@ -3907,9 +4025,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const topRow = document.createElement('div');
|
const topRow = document.createElement('div');
|
||||||
topRow.className = 'search-result-top';
|
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');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'search-result-badge search-badge-' + r.type;
|
badge.className = 'search-result-badge search-badge-' + r.type + (isPast ? ' search-badge-past' : '');
|
||||||
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
badge.textContent = isPast ? 'Resume' : (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'search-result-name';
|
name.className = 'search-result-name';
|
||||||
@@ -3942,12 +4062,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/**
|
/**
|
||||||
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||||
* session → selectSession(sessionId) (open/switch to the session)
|
* 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)
|
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||||
* file-preview → openFilePreview(path, sessionId, attachmentId)
|
* file-preview → openFilePreview(path, sessionId, attachmentId)
|
||||||
*/
|
*/
|
||||||
_jumpToSearchResult(r) {
|
_jumpToSearchResult(r) {
|
||||||
const jt = r && r.jumpTo;
|
const jt = r && r.jumpTo;
|
||||||
if (!jt) return;
|
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.
|
// Leaving the welcome overlay so the target surface is visible.
|
||||||
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
||||||
* across three v1 sources, returned in the standard ApiResponse envelope:
|
* 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)
|
* 2. run-summary events — event title/details (from the live run-summary trackers)
|
||||||
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
||||||
*
|
*
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
} from '../../search-service.js';
|
} from '../../search-service.js';
|
||||||
import type { SearchSourceType } from '../../types/search.js';
|
import type { SearchSourceType } from '../../types/search.js';
|
||||||
import type { SessionPort, InfraPort } from '../ports/index.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
|
* 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
|
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
* 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 {
|
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
|
||||||
const sessions: SessionSearchInput[] = [];
|
const sessions: SessionSearchInput[] = [];
|
||||||
const events: EventSearchInput[] = [];
|
const events: EventSearchInput[] = [];
|
||||||
const files: FileSearchInput[] = [];
|
const files: FileSearchInput[] = [];
|
||||||
|
const seenSessionIds = new Set<string>();
|
||||||
|
|
||||||
for (const raw of ctx.sessions.values()) {
|
for (const raw of ctx.sessions.values()) {
|
||||||
const s = raw as unknown as SessionLike & { owner?: string };
|
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 sessionName = s.name ?? '';
|
||||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||||
|
|
||||||
|
seenSessionIds.add(s.id);
|
||||||
sessions.push({
|
sessions.push({
|
||||||
sessionId: s.id,
|
sessionId: s.id,
|
||||||
sessionName,
|
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.
|
// Events: from the live run-summary trackers, keyed by session id.
|
||||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||||
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
|
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;
|
: 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);
|
const sources = harvestSources(ctx, canSee);
|
||||||
|
|
||||||
// Apply the optional source-type filter before searching so excluded
|
// Apply the optional source-type filter before searching so excluded
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ import {
|
|||||||
type LifecycleInput,
|
type LifecycleInput,
|
||||||
type HistoryInput,
|
type HistoryInput,
|
||||||
type MuxStatInput,
|
type MuxStatInput,
|
||||||
|
type UnifiedSessionItem,
|
||||||
} from '../../services/unified-session-service.js';
|
} 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 type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { RunSummaryTracker } from '../../run-summary.js';
|
import { RunSummaryTracker } from '../../run-summary.js';
|
||||||
|
|
||||||
@@ -3539,16 +3545,20 @@ export function registerSessionRoutes(
|
|||||||
return { sessions: results.slice(0, 50) };
|
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
|
* Gather the four read-only views the unified list is merged from, plus mux
|
||||||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
* stats. This is the expensive half (the lifecycle log and a scan of every
|
||||||
app.get('/api/sessions/unified', async (req) => {
|
* Claude transcript), factored out of the route handler because the
|
||||||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
* past-session search index rebuilds itself from the very same inputs — off
|
||||||
|
* the request path, see session-history-index.ts.
|
||||||
if (ctx.testMode) {
|
*/
|
||||||
return { sessions: [], total: 0 };
|
async function gatherUnifiedInputs(): Promise<{
|
||||||
}
|
live: LiveSessionInput[];
|
||||||
|
persisted: PersistedSessionInput[];
|
||||||
|
lifecycle: LifecycleInput[];
|
||||||
|
history: HistoryInput[];
|
||||||
|
mux: MuxStatInput[];
|
||||||
|
}> {
|
||||||
// Live (in-memory) sessions.
|
// Live (in-memory) sessions.
|
||||||
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
|
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
|
||||||
const st = s.toState();
|
const st = s.toState();
|
||||||
@@ -3649,14 +3659,54 @@ export function registerSessionRoutes(
|
|||||||
// Mux stats are optional.
|
// 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
|
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||||
// history (not tied to an owned session) is admin-only.
|
// history (not tied to an owned session) is admin-only.
|
||||||
let sLive = live;
|
let sLive = live;
|
||||||
let sPersisted = persisted;
|
let sPersisted = persisted;
|
||||||
let sLifecycle = lifecycle;
|
let sLifecycle = lifecycle;
|
||||||
let sHistory = history;
|
let sHistory = history;
|
||||||
|
let scoped = false;
|
||||||
const uUser = getAuthUser(req);
|
const uUser = getAuthUser(req);
|
||||||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||||||
|
scoped = true;
|
||||||
const ownedLive = new Set(
|
const ownedLive = new Set(
|
||||||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||||||
);
|
);
|
||||||
@@ -3680,6 +3730,14 @@ export function registerSessionRoutes(
|
|||||||
history: sHistory,
|
history: sHistory,
|
||||||
mux,
|
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 offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||||
return filterAndPaginate(merged, {
|
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
|
* (sessions + events) return results. Source data is injected via the mock
|
||||||
* route context (sessions map, runSummaryTrackers map, attachment history).
|
* 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 Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { createMockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext } from '../mocks/index.js';
|
||||||
import { RunSummaryTracker } from '../../src/run-summary.js';
|
import { RunSummaryTracker } from '../../src/run-summary.js';
|
||||||
|
import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js';
|
||||||
|
|
||||||
type Ctx = ReturnType<typeof createMockRouteContext>;
|
type Ctx = ReturnType<typeof createMockRouteContext>;
|
||||||
|
|
||||||
@@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => {
|
|||||||
expect(types).toEqual(['event']);
|
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);
|
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