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