style: drop em-dashes from the prose added in this branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:15:13 +02:00
parent 053a6d238d
commit c13b3c55d3
14 changed files with 54 additions and 54 deletions
@@ -4,14 +4,14 @@
Home screen: make the past-conversation list usable, and let search find past sessions. Home screen: make the past-conversation list usable, and let search find past sessions.
- **#260** — "Resume Conversation" showed 4 rows and then dumped every remaining - **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining
one into a fixed 240px box, with no ordering or filtering. The list now opens 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 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 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 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 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. shown-of-total count. Filtering implies expansion, so every match is visible.
- **#261** — the search box could not match a past project by folder name: its - **#261**: the search box could not match a past project by folder name: its
session corpus was the live in-memory map, while past sessions come from 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 `/api/sessions/unified`. Search now also harvests a bounded snapshot of that
unified list, refreshed OUTSIDE the request path (published by unified list, refreshed OUTSIDE the request path (published by
+3 -3
View File
@@ -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. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget) — that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<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`
@@ -254,9 +254,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. **Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places**, `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`) — most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. **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)
+1 -1
View File
@@ -157,7 +157,7 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a
**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`. **Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
**Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable — searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free — it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()` — **fire-and-forget, single-flight, TTL-guarded (60s)** — kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row — the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`. **Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable, searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free, it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()`, **fire-and-forget, single-flight, TTL-guarded (60s)**, kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row, the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`.
### Away digest ### Away digest
+1 -1
View File
@@ -44,7 +44,7 @@ export interface SessionSearchInput {
/** 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 * 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 * from the history index, not the live map). Such a result resumes the
* conversation instead of switching to a tab that no longer exists. * conversation instead of switching to a tab that no longer exists.
*/ */
+1 -1
View File
@@ -34,7 +34,7 @@
/** /**
* Narrowest window that gets the column. The welcome content is 560px wide and * Narrowest window that gets the column. The welcome content is 560px wide and
* centered, so at 1180px each gutter is 310px — enough for the 256px column plus * centered, so at 1180px each gutter is 310px, enough for the 256px column plus
* its 20px offset and still a visible gap. Anything narrower would overlap the * its 20px offset and still a visible gap. Anything narrower would overlap the
* search panel, which is why this is a width gate and not a device-type gate. * search panel, which is why this is a width gate and not a device-type gate.
*/ */
+1 -1
View File
@@ -3737,7 +3737,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
} }
/* Title row for the past-session list: label + count on the left, filter and /* Title row for the past-session list: label + count on the left, filter and
sort on the right (issue #260 — 35 conversations in a 4-row box). */ sort on the right (issue #260, 35 conversations in a 4-row box). */
.history-header { .history-header {
display: flex; display: flex;
align-items: center; align-items: center;
+10 -10
View File
@@ -1958,7 +1958,7 @@ Object.assign(CodemanApp.prototype, {
/** /**
* How many past sessions the home screen loads (also the filter/sort corpus). * How many past sessions the home screen loads (also the filter/sort corpus).
* 200, not the old 60, so the filter can reach a real backlog — an install with * 200, not the old 60, so the filter can reach a real backlog, an install with
* 35+ conversations would otherwise hit the ceiling before the filter is useful * 35+ conversations would otherwise hit the ceiling before the filter is useful
* (raised in @jordan8037310's #263; the endpoint clamps at 500). * (raised in @jordan8037310's #263; the endpoint clamps at 500).
*/ */
@@ -1989,7 +1989,7 @@ Object.assign(CodemanApp.prototype, {
// Keep the corpus around: filtering and sorting (issue #260) work on this // Keep the corpus around: filtering and sorting (issue #260) work on this
// array, so a re-render costs no request. Expansion survives the periodic // array, so a re-render costs no request. Expansion survives the periodic
// refresh in panels-ui.js — collapsing the list under the user's cursor // refresh in panels-ui.js, collapsing the list under the user's cursor
// every few seconds would be worse than the original 4-item cap. // every few seconds would be worse than the original 4-item cap.
this._historyAll = allSessions; this._historyAll = allSessions;
this._historyCases = cases; this._historyCases = cases;
@@ -2005,7 +2005,7 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Wire the filter box and sort select once; both re-render from the cached * Wire the filter box and sort select once; both re-render from the cached
* corpus. The sort choice is restored from (and saved to) localStorage — it is * corpus. The sort choice is restored from (and saved to) localStorage, it is
* a per-device display preference, so it stays out of the synced settings * a per-device display preference, so it stays out of the synced settings
* schema, same as `codeman:skin`. * schema, same as `codeman:skin`.
*/ */
@@ -2021,7 +2021,7 @@ Object.assign(CodemanApp.prototype, {
const saved = localStorage.getItem(this._HISTORY_SORT_KEY); const saved = localStorage.getItem(this._HISTORY_SORT_KEY);
if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved; if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved;
} catch { } catch {
/* private mode — the order just won't persist */ /* private mode, the order just won't persist */
} }
} }
@@ -2041,7 +2041,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
localStorage.setItem(this._HISTORY_SORT_KEY, sort.value); localStorage.setItem(this._HISTORY_SORT_KEY, sort.value);
} catch { } catch {
/* private mode — the order just won't persist */ /* private mode, the order just won't persist */
} }
this._renderHistoryList(); this._renderHistoryList();
}); });
@@ -2064,7 +2064,7 @@ Object.assign(CodemanApp.prototype, {
/** /**
* The text a history row shows as its title. Most transcript-backed rows have * 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 * no session name at all, so this falls through to the first prompt and then
* to the path — and the A–Z sort keys off the SAME string, or "sort by name" * to the path, and the A–Z sort keys off the SAME string, or "sort by name"
* would silently do nothing for exactly the rows the list is mostly made of. * would silently do nothing for exactly the rows the list is mostly made of.
*/ */
_historyRowLabel(s, fallback) { _historyRowLabel(s, fallback) {
@@ -2074,7 +2074,7 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Sort past-session rows. 'recent' keeps the backend order (newest first); * Sort past-session rows. 'recent' keeps the backend order (newest first);
* the alphabetical modes sort by the visible title or by folder basename. * the alphabetical modes sort by the visible title or by folder basename.
* Pinned rows stay on top in every mode — pinning is an explicit override and * Pinned rows stay on top in every mode, pinning is an explicit override and
* a sort that buried it would read as the pin having been lost. * a sort that buried it would read as the pin having been lost.
*/ */
_sortHistoryRows(rows, mode) { _sortHistoryRows(rows, mode) {
@@ -2097,7 +2097,7 @@ Object.assign(CodemanApp.prototype, {
* Render the "Resume Conversation" list from the cached corpus, applying the * Render the "Resume Conversation" list from the cached corpus, applying the
* current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT; * 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 * "Show more" expands the list AND the box (the CSS cap is class-driven, since
* a fixed 240px box made expansion pointless — issue #260). * a fixed 240px box made expansion pointless, issue #260).
*/ */
_renderHistoryList() { _renderHistoryList() {
const list = document.getElementById('historyList'); const list = document.getElementById('historyList');
@@ -3985,7 +3985,7 @@ 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');
// The header carries the title plus the filter/sort controls (issue #260) — // The header carries the title plus the filter/sort controls (issue #260),
// hide the whole row, not just the title, or the controls float above the // 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. // search results and act on a list that is not on screen.
const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle'); const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle');
@@ -4100,7 +4100,7 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Navigate to a search result by jumpTo.kind, reusing the existing app methods: * 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) * 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)
*/ */
+2 -2
View File
@@ -3,7 +3,7 @@
* *
* 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, for LIVE sessions * 1. sessions/cases, name, working directory, session id, for LIVE sessions
* plus the past-session snapshot in `session-history-index.ts` (issue #261: * plus the past-session snapshot in `session-history-index.ts` (issue #261:
* the live map alone made every closed session unfindable by folder name) * 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)
@@ -106,7 +106,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string)
} }
// Past sessions: the out-of-band snapshot of the unified list. Unscoped on // Past sessions: the out-of-band snapshot of the unified list. Unscoped on
// disk, so every row goes through the same ownership check as a live one — // disk, so every row goes through the same ownership check as a live one,
// host-wide transcript rows carry no owner and are therefore admin-only in // host-wide transcript rows carry no owner and are therefore admin-only in
// multi-user mode, matching GET /api/sessions/unified. // multi-user mode, matching GET /api/sessions/unified.
for (const item of getHistorySessionIndex().items) { for (const item of getHistorySessionIndex().items) {
+3 -3
View File
@@ -3549,7 +3549,7 @@ export function registerSessionRoutes(
* Gather the four read-only views the unified list is merged from, plus mux * Gather the four read-only views the unified list is merged from, plus mux
* stats. This is the expensive half (the lifecycle log and a scan of every * stats. This is the expensive half (the lifecycle log and a scan of every
* Claude transcript), factored out of the route handler because the * Claude transcript), factored out of the route handler because the
* past-session search index rebuilds itself from the very same inputs — off * past-session search index rebuilds itself from the very same inputs, off
* the request path, see session-history-index.ts. * the request path, see session-history-index.ts.
*/ */
async function gatherUnifiedInputs(): Promise<{ async function gatherUnifiedInputs(): Promise<{
@@ -3665,7 +3665,7 @@ export function registerSessionRoutes(
/** /**
* Publish a merged unified list as the past-session search index (issue #261). * 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 * The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be
* built from an unscoped merge — `harvestSources()` in search-routes re-applies * built from an unscoped merge, `harvestSources()` in search-routes re-applies
* the ownership check on read. * the ownership check on read.
*/ */
function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void { function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void {
@@ -3731,7 +3731,7 @@ export function registerSessionRoutes(
mux, mux,
}); });
// Refresh the search index off the back of this request — the home screen // Refresh the search index off the back of this request, the home screen
// fetches this endpoint whenever it opens, which is the same screen the // 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 // 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 // merge is a per-user subset and would corrupt the shared snapshot, so that
+14 -14
View File
@@ -5,31 +5,31 @@
* session map alone, so a folder sitting in the home screen's "Resume * session map alone, so a folder sitting in the home screen's "Resume
* Conversation" list matched nothing (issue #261). The corpus that list renders * Conversation" list matched nothing (issue #261). The corpus that list renders
* comes from `GET /api/sessions/unified`, which reads the lifecycle log and every * 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 * Claude transcript file: disk I/O the search path deliberately does not do (its
* (its no-fs property is what keeps a per-keystroke query cheap and traversal-free). * 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 * 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. * that the search route reads synchronously, refreshed OUT of the request path.
* Two things fill it: * Two things fill it:
* 1. `/api/sessions/unified` writes it as a side effect (free — it just merged * 1. `/api/sessions/unified` writes it as a side effect (free, it just merged
* that list). The home screen calls that endpoint whenever it opens, which * 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. * is the same screen the search box lives on, so it is warm in practice.
* 2. `ensureHistorySessionIndexFresh()` — fire-and-forget, single-flight, * 2. `ensureHistorySessionIndexFresh()`, fire-and-forget, single-flight,
* TTL-guarded — kicks the registered refresher when a search finds the * TTL-guarded, kicks the registered refresher when a search finds the
* snapshot stale. The caller never awaits it: the current query answers from * snapshot stale. The caller never awaits it: the current query answers from
* the existing snapshot and the next one sees fresh data. * the existing snapshot and the next one sees fresh data.
* *
* OWNERSHIP: each item carries the `owner` of the session it came from, and rows * 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 * not tied to any live/persisted session (host-wide transcript history) carry
* `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule * `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule
* exactly — in multi-user mode a non-admin sees neither other users' sessions nor * exactly, in multi-user mode a non-admin sees neither other users' sessions nor
* unowned host-wide history, and in single-user mode every check short-circuits * 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. * true. The snapshot is written UNSCOPED, so it must never be returned unfiltered.
* *
* Key exports: * Key exports:
* - setHistorySessionIndex / getHistorySessionIndex — the snapshot accessors. * - setHistorySessionIndex / getHistorySessionIndex: the snapshot accessors.
* - buildHistorySessionIndexItems — pure merged-list → index-item projection. * - buildHistorySessionIndexItems: pure merged-list → index-item projection.
* - setHistoryIndexRefresher / ensureHistorySessionIndexFresh — the refresh hook. * - setHistoryIndexRefresher / ensureHistorySessionIndexFresh: the refresh hook.
*/ */
/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */ /** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */
@@ -38,7 +38,7 @@ export interface HistorySessionIndexItem {
sessionId: string; sessionId: string;
/** Display name, may be empty for a transcript-only row. */ /** Display name, may be empty for a transcript-only row. */
name: string; name: string;
/** Absolute working directory — the field issue #261 is about matching. */ /** Absolute working directory, the field issue #261 is about matching. */
workingDir: string; workingDir: string;
/** Claude conversation UUID, when known: what a resume actually replays. */ /** Claude conversation UUID, when known: what a resume actually replays. */
claudeSessionId?: string; claudeSessionId?: string;
@@ -47,7 +47,7 @@ export interface HistorySessionIndexItem {
/** /**
* Owning user, when the row is tied to a live or persisted session. `undefined` * 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) * means host-wide transcript history, which only admins (or single-user mode)
* may see — the same rule `/api/sessions/unified` applies. * may see, the same rule `/api/sessions/unified` applies.
*/ */
owner?: string; owner?: string;
/** True when the session is still in the live map (search harvests those directly). */ /** True when the session is still in the live map (search harvests those directly). */
@@ -81,7 +81,7 @@ export interface MergedSessionLike {
} }
/** /**
* Project a merged unified list into index items. PURE — the caller supplies the * Project a merged unified list into index items. PURE, the caller supplies the
* owner lookup and the live-id set it already has in hand. * 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 * Rows with no working directory AND no name are dropped: they can never match a
@@ -121,7 +121,7 @@ export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = D
} }
/** /**
* Read the snapshot. The returned array is UNSCOPED — callers must apply the * Read the snapshot. The returned array is UNSCOPED, callers must apply the
* per-item ownership check before exposing any of it. * per-item ownership check before exposing any of it.
*/ */
export function getHistorySessionIndex(): HistorySessionIndexSnapshot { export function getHistorySessionIndex(): HistorySessionIndexSnapshot {
@@ -142,7 +142,7 @@ export function setHistoryIndexRefresher(fn: (() => Promise<void>) | null): void
} }
/** /**
* Kick a background rebuild if the snapshot is stale. Returns immediately — * Kick a background rebuild if the snapshot is stale. Returns immediately,
* NEVER await this from a request handler, that is the whole point: the search * 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. * path answers from the current snapshot and stays free of disk I/O.
*/ */
+8 -8
View File
@@ -1,5 +1,5 @@
/** /**
* @fileoverview Issue #260 — the home screen's "Resume Conversation" list. * @fileoverview Issue #260, the home screen's "Resume Conversation" list.
* *
* With ~35 past sessions the list showed 4 rows, then a button that dumped every * 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 * remaining row into a fixed 240px box, with no way to sort or filter. The fix
@@ -7,12 +7,12 @@
* worth pinning is the model, not the pixels: * worth pinning is the model, not the pixels:
* 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4, * 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 * 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is
* class-driven — without the class, expanding just deepens a scroll well), * class-driven, without the class, expanding just deepens a scroll well),
* 3. filtering matches name / folder / case label / prompt, and implies * 3. filtering matches name / folder / case label / prompt, and implies
* expansion (hiding matches behind "Show more" defeats typing a filter), * expansion (hiding matches behind "Show more" defeats typing a filter),
* 4. sorting is alphabetical by name or folder, with pinned rows still on top. * 4. sorting is alphabetical by name or folder, with pinned rows still on top.
* *
* Loaded via `vm` against a stub CodemanApp with a fake DOM — same harness as * Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
* resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get * resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get
* rendered and in what order, not how one row looks. * rendered and in what order, not how one row looks.
*/ */
@@ -71,7 +71,7 @@ function fakeEl(id: string): FakeEl {
/** /**
* The element map the vm's `document.getElementById` resolves against. Swapped * The element map the vm's `document.getElementById` resolves against. Swapped
* per test — the closure is defined in THIS realm, so the shipping code inside * per test, the closure is defined in THIS realm, so the shipping code inside
* the vm reads whatever the current test installed. * the vm reads whatever the current test installed.
*/ */
let currentEls: Record<string, FakeEl> = {}; let currentEls: Record<string, FakeEl> = {};
@@ -162,7 +162,7 @@ function rows(n: number, overrides: Partial<Row> = {}): Row[] {
})); }));
} }
describe('issue #260 — collapsed page size', () => { describe('issue #260: collapsed page size', () => {
it('shows more than the old 4 rows before "Show more"', () => { it('shows more than the old 4 rows before "Show more"', () => {
expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8); expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8);
}); });
@@ -181,7 +181,7 @@ describe('issue #260 — collapsed page size', () => {
app._render(); app._render();
expect(app.renderedIds()).toHaveLength(35); expect(app.renderedIds()).toHaveLength(35);
// Without this class the CSS max-height stays at the collapsed cap and the // Without this class the CSS max-height stays at the collapsed cap and the
// extra rows land in a four-row scroll well — the original bug. // extra rows land in a four-row scroll well, the original bug.
expect(app.els.historyList.classList.contains('expanded')).toBe(true); expect(app.els.historyList.classList.contains('expanded')).toBe(true);
expect(app.button()?.textContent).toBe('Show less'); expect(app.button()?.textContent).toBe('Show less');
}); });
@@ -194,7 +194,7 @@ describe('issue #260 — collapsed page size', () => {
}); });
}); });
describe('issue #260 — filter', () => { describe('issue #260: filter', () => {
it('matches on folder name and shows every match without expanding first', () => { it('matches on folder name and shows every match without expanding first', () => {
const app = makeApp([ const app = makeApp([
...rows(30), ...rows(30),
@@ -240,7 +240,7 @@ describe('issue #260 — filter', () => {
}); });
}); });
describe('issue #260 — sort', () => { describe('issue #260: sort', () => {
const unsorted: Row[] = [ const unsorted: Row[] = [
{ sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 }, { sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 },
{ sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 }, { sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 },
+3 -3
View File
@@ -209,9 +209,9 @@ describe('GET /api/search — caps & filters', () => {
}); });
// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's // Issue #261: with 3 live sessions and ~35 past ones, searching a past project's
// folder name matched nothing — the corpus was the live session map alone. Past // folder name matched nothing, the corpus was the live session map alone. Past
// sessions now arrive from the out-of-band history index snapshot. // sessions now arrive from the out-of-band history index snapshot.
describe('GET /api/search — past sessions (history index)', () => { describe('GET /api/search: past sessions (history index)', () => {
beforeEach(() => { beforeEach(() => {
resetHistorySessionIndex(); resetHistorySessionIndex();
}); });
@@ -291,7 +291,7 @@ describe('GET /api/search — past sessions (history index)', () => {
owner: 'alice', owner: 'alice',
live: false, live: false,
}, },
// Host-wide transcript row: no owning session, so admin-only — the same // Host-wide transcript row: no owning session, so admin-only, the same
// rule GET /api/sessions/unified applies when it drops history for non-admins. // 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 }, { sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false },
]); ]);
+3 -3
View File
@@ -236,9 +236,9 @@ describe('searchSources — result card shape & path safety', () => {
// Past sessions (issue #261). The corpus used to be the live session map alone, // 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 // so a folder in the home screen's Resume list matched nothing. History rows now
// arrive marked, and a card for one has to RESUME the conversation — selecting a // arrive marked, and a card for one has to RESUME the conversation, selecting a
// tab that no longer exists is a no-op the user reads as a broken result. // tab that no longer exists is a no-op the user reads as a broken result.
describe('searchSources — past (history) sessions', () => { describe('searchSources: past (history) sessions', () => {
it('matches a past session by folder name and returns a resume jump target', () => { it('matches a past session by folder name and returns a resume jump target', () => {
const data = sources({ const data = sources({
sessions: [ sessions: [
@@ -276,7 +276,7 @@ describe('searchSources — past (history) sessions', () => {
const data = sources({ const data = sources({
sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }], sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }],
}); });
// Nothing to resume INTO — a resume card here would always fail. // Nothing to resume INTO, a resume card here would always fail.
expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session'); expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session');
}); });
+2 -2
View File
@@ -5,7 +5,7 @@
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter * 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 * 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 * the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
* stored rows carry the owner needed to re-apply multi-user scoping on read — * stored rows carry the owner needed to re-apply multi-user scoping on read,
* the snapshot is written unscoped, so losing that field would leak one user's * the snapshot is written unscoped, so losing that field would leak one user's
* folders into another user's search. * folders into another user's search.
*/ */
@@ -92,7 +92,7 @@ describe('snapshot storage', () => {
}); });
describe('ensureHistorySessionIndexFresh', () => { describe('ensureHistorySessionIndexFresh', () => {
it('returns synchronously — the rebuild must never be on the request path', async () => { it('returns synchronously, the rebuild must never be on the request path', async () => {
let resolveRefresh: () => void = () => {}; let resolveRefresh: () => void = () => {};
const refresher = vi.fn( const refresher = vi.fn(
() => () =>