mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user