From 5d42f64393acc06213a54266ee4122cee2ac6510 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:02:11 +0200 Subject: [PATCH 1/7] fix(web): usable past-conversation list, and search that finds past sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two home-screen reports from @jordan8037310, both about history that is present but unreachable. #260 — "Resume Conversation" rendered 4 rows, then a button that appended every remaining row into a `max-height: 240px` box, so 35 conversations landed in a four-row scroll well with no ordering or filtering. Rendering now goes through `_renderHistoryList()` over a cached corpus: 10 rows to start, Show more/Show less that grows and shrinks the box (the height cap is class-driven, `.history-list.expanded`), plus a filter box (name, folder, #case label, prompts), a sort control (recent / name / folder, pinned rows still first) and a shown-of-total count. A filter implies expansion, so every match is visible, and the whole header hides as one unit while a federated search is active. The A-Z sort keys off the same string the row renders, since most rows are transcript-backed and carry no session name at all. #261 — the search box could not match a past project by folder name: `harvestSources()` built its session corpus from the live in-memory map, while past sessions come from `/api/sessions/unified` (lifecycle log + transcript scan). Folding that scan into the request path would have cost the search its no-filesystem-reads property, so the corpus arrives via a bounded snapshot instead: `session-history-index.ts` is published as a side effect of `/api/sessions/unified` (the home screen fetches it on open, which is the same screen the search box lives on) and rebuilt fire-and-forget, single-flight and TTL-guarded when a search finds it stale. A result for a closed session now resumes the conversation rather than selecting a tab that no longer exists, and is badged RESUME. The snapshot is stored unscoped with a per-row owner and re-filtered through canAccessOwned() on read, so multi-user sees exactly what /api/sessions/unified exposes: own sessions only, host-wide transcript history admin-only. Live rows are harvested first and win the dedupe. Verified end-to-end against a real instance with 60 past sessions: cold process answers its first search without history and its second with it; folder-name queries return resume targets; clicking one posts the right resumeSessionId + workingDir. Co-Authored-By: Claude Opus 5 (1M context) --- .../history-list-and-past-session-search.md | 24 ++ CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 + src/search-service.ts | 27 +- src/types/search.ts | 20 +- src/web/public/index.html | 22 +- src/web/public/styles.css | 86 +++++- src/web/public/terminal-ui.js | 189 ++++++++++-- src/web/routes/search-routes.ts | 35 ++- src/web/routes/session-routes.ts | 78 ++++- src/web/session-history-index.ts | 166 ++++++++++ test/history-list-controls.test.ts | 291 ++++++++++++++++++ test/routes/search-routes.test.ts | 97 +++++- test/search-service.test.ts | 58 ++++ test/session-history-index.test.ts | 161 ++++++++++ 15 files changed, 1206 insertions(+), 54 deletions(-) create mode 100644 .changeset/history-list-and-past-session-search.md create mode 100644 src/web/session-history-index.ts create mode 100644 test/history-list-controls.test.ts create mode 100644 test/session-history-index.test.ts diff --git a/.changeset/history-list-and-past-session-search.md b/.changeset/history-list-and-past-session-search.md new file mode 100644 index 00000000..8869f0f8 --- /dev/null +++ b/.changeset/history-list-and-past-session-search.md @@ -0,0 +1,24 @@ +--- +'aicodeman': patch +--- + +Home screen: make the past-conversation list usable, and let search find past sessions. + +- **#260** — "Resume Conversation" showed 4 rows and then dumped every remaining + one into a fixed 240px box, with no ordering or filtering. The list now opens + with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the + height cap is class-driven instead of fixed), and the header carries a filter + box (matches name, folder, `#case` label and the conversation's prompts), a + sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a + shown-of-total count. Filtering implies expansion, so every match is visible. +- **#261** — the search box could not match a past project by folder name: its + session corpus was the live in-memory map, while past sessions come from + `/api/sessions/unified`. Search now also harvests a bounded snapshot of that + unified list, refreshed OUTSIDE the request path (published by + `/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the + search path keeps its no-filesystem-reads property. Results for a closed + session resume the conversation instead of trying to select a tab that no + longer exists, and are badged `RESUME`. In multi-user mode the snapshot is + re-scoped per row on read, matching what `/api/sessions/unified` exposes. + +Reported by @jordan8037310. diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..478317e7 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. → [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` @@ -256,6 +256,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. +**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) **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 831bb534..55241d9d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,6 +157,8 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a **Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`. +**Past sessions in the corpus** (#261): the live session map alone made every CLOSED session unfindable — searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from `src/web/session-history-index.ts`: a capped (`HISTORY_INDEX_MAX_ITEMS` 400) snapshot of the unified list, read synchronously by `harvestSources()`. ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: `/api/sessions/unified` publishes it as a side effect (free — it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and `ensureHistorySessionIndexFresh()` — **fire-and-forget, single-flight, TTL-guarded (60s)** — kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never `await` the refresher from a handler. ⚠️ The snapshot is stored **UNSCOPED** with a per-row `owner` (`undefined` = host-wide transcript history), and `harvestSources()` re-applies `canAccessOwned()` per row — the same rule `/api/sessions/unified` applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps `jumpTo.kind:'session'`; history rows get `'resume-session'` (with `claudeSessionId`/`workingDir`), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: `test/session-history-index.test.ts`, `test/routes/search-routes.test.ts`. + ### Away digest **Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range. diff --git a/src/search-service.ts b/src/search-service.ts index 620fad9c..c16c1f5d 100644 --- a/src/search-service.ts +++ b/src/search-service.ts @@ -36,13 +36,21 @@ export const SEARCH_PER_GROUP_CAP = 25; /** Maximum characters in a result snippet. */ export const SEARCH_SNIPPET_MAX = 200; -/** A live-session row harvested for the session/case source. */ +/** A session row harvested for the session/case source (live or past). */ export interface SessionSearchInput { sessionId: string; sessionName: string; workingDir: string; /** Recency timestamp (e.g. lastActivityAt or createdAt). */ timestamp: number; + /** + * True for a session that is no longer running (issue #261 — past sessions come + * from the history index, not the live map). Such a result resumes the + * conversation instead of switching to a tab that no longer exists. + */ + history?: boolean; + /** Claude conversation UUID to resume, when it differs from the Codeman id. */ + claudeSessionId?: string; } /** A run-summary timeline event harvested for the event source. */ @@ -121,14 +129,25 @@ export function searchSources(query: string, sources: SearchSources): SearchResp const sessionRows: SearchResult[] = []; for (const s of sources.sessions) { if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) { + const label = s.sessionName || s.workingDir.split('/').pop() || s.sessionId; sessionRows.push({ type: 'session', sessionId: s.sessionId, - sessionName: s.sessionName, + sessionName: label, timestamp: s.timestamp, - snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName), + snippet: truncate(s.workingDir ? `${label} — ${s.workingDir}` : label), exactMatch: isExact(s.sessionName), - jumpTo: { kind: 'session', sessionId: s.sessionId }, + // A resume needs a directory to run in, so a history row without one + // stays a plain session target rather than an action that cannot work. + jumpTo: + s.history && s.workingDir + ? { + kind: 'resume-session', + sessionId: s.sessionId, + claudeSessionId: s.claudeSessionId, + workingDir: s.workingDir, + } + : { kind: 'session', sessionId: s.sessionId }, }); } } diff --git a/src/types/search.ts b/src/types/search.ts index 418aa236..d0e3e157 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -22,15 +22,19 @@ export type SearchSourceType = 'session' | 'event' | 'file'; /** Where the frontend should jump when a result card is activated. */ export interface SearchJumpTarget { - /** Kind of navigation target. */ - kind: 'session' | 'run-summary' | 'file-preview'; + /** + * Kind of navigation target. `resume-session` marks a session that is no longer + * running: selecting it has to REPLAY the conversation rather than switch to a + * tab that does not exist. + */ + kind: 'session' | 'run-summary' | 'file-preview' | 'resume-session'; /** Owning Codeman session id (always present — every result is session-scoped). */ sessionId: string; /** * Secondary identifier for the target: * - kind 'run-summary': the run-summary event id * - kind 'file-preview': the attachment history item id - * - kind 'session': undefined (the sessionId is sufficient) + * - kind 'session' / 'resume-session': undefined (the sessionId is sufficient) */ targetId?: string; /** @@ -38,6 +42,16 @@ export interface SearchJumpTarget { * server-private external paths are intentionally omitted to avoid leakage. */ relativePath?: string; + /** + * `resume-session` only: the Claude conversation UUID to resume, when it differs + * from the Codeman session id (resumed and `/clear`-respawned sessions). + */ + claudeSessionId?: string; + /** + * `resume-session` only: the directory to resume in. Already visible in the + * result snippet for session rows, so this exposes nothing new. + */ + workingDir?: string; } /** A single typed search result card. */ diff --git a/src/web/public/index.html b/src/web/public/index.html index 6ec66057..16a53791 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -397,7 +397,27 @@ -

Resume Conversation

+
+

Resume Conversation

+ +
+ + +
+

Or click Run to start

diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..a06de0d3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3666,6 +3666,13 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #95e6b3; } +/* Past session (issue #261): activating the card resumes the conversation + rather than switching to a tab, so the badge says so. */ +.search-badge-past { + background: rgba(245, 158, 11, 0.18); + color: #f0c073; +} + .search-result-name { flex: 1; min-width: 0; @@ -3715,24 +3722,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .search-select { max-width: 7rem; } + /* Tablet/narrow: let the controls drop under the title instead of squeezing it. */ + .history-header { + flex-wrap: wrap; + } + .history-controls { + width: 100%; + margin-left: 0; + } + .history-filter { + flex: 1; + width: auto; + } +} + +/* Title row for the past-session list: label + count on the left, filter and + sort on the right (issue #260 — 35 conversations in a 4-row box). */ +.history-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; } .history-title { font-size: 0.85rem; color: var(--text-dim); - margin-bottom: 0.5rem; font-weight: 500; text-align: left; } +.history-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.history-controls { + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; +} + +.history-filter { + width: 8.5rem; + box-sizing: border-box; + padding: 0.28rem 0.5rem; + font-size: 0.68rem; + color: var(--text); + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + outline: none; + transition: border-color var(--transition-smooth), background var(--transition-smooth); +} + +.history-filter:focus { + border-color: rgba(59, 130, 246, 0.5); + background: rgba(255, 255, 255, 0.06); +} + +.history-filter::placeholder { + color: var(--text-dim); +} + +.history-sort { + max-width: 7.5rem; +} + +.history-empty { + padding: 0.75rem 0.5rem; + font-size: 0.75rem; + color: var(--text-dim); + text-align: center; +} + +/* Collapsed height fits the initial page of rows; expanding the LIST has to + expand the BOX too, or "Show more" just deepens a scroll well. */ .history-list { display: flex; flex-direction: column; gap: 0.35rem; - max-height: 240px; + max-height: min(42vh, 360px); overflow-y: auto; } +.history-list.expanded { + max-height: min(64vh, 660px); +} + .history-item { display: flex; flex-direction: column; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1508c32b..d28c0bc7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1628,7 +1628,7 @@ Object.assign(CodemanApp.prototype, { pin.title = 'Pinned'; titleSpan.appendChild(pin); } - titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir)); + titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir))); // Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill. const badgeRow = document.createElement('div'); @@ -1954,7 +1954,10 @@ Object.assign(CodemanApp.prototype, { }, /** Number of history items shown before "Show More" */ - _HISTORY_INITIAL_COUNT: 4, + _HISTORY_INITIAL_COUNT: 10, + + /** How many past sessions the home screen loads (also the filter/sort corpus). */ + _HISTORY_FETCH_LIMIT: 60, async loadHistorySessions() { const container = document.getElementById('historySessions'); @@ -1968,7 +1971,7 @@ Object.assign(CodemanApp.prototype, { ? Promise.resolve(this.cases) : fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []); const [allSessions, cases] = await Promise.all([ - this._fetchUnifiedSessions(60), + this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT), casesPromise, ]); if (allSessions.length === 0) { @@ -1976,27 +1979,14 @@ Object.assign(CodemanApp.prototype, { return; } - list.replaceChildren(); - const initialCount = this._HISTORY_INITIAL_COUNT; - - // Render initial items - for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) { - list.appendChild(this._buildHistoryItem(allSessions[i], cases)); - } - - // Add "Show More" button if there are more items - if (allSessions.length > initialCount) { - const moreBtn = document.createElement('button'); - moreBtn.className = 'history-show-more'; - moreBtn.textContent = `Show ${allSessions.length - initialCount} more`; - moreBtn.addEventListener('click', () => { - for (let i = initialCount; i < allSessions.length; i++) { - list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn); - } - moreBtn.remove(); - }); - list.appendChild(moreBtn); - } + // Keep the corpus around: filtering and sorting (issue #260) work on this + // array, so a re-render costs no request. Expansion survives the periodic + // refresh in panels-ui.js — collapsing the list under the user's cursor + // every few seconds would be worse than the original 4-item cap. + this._historyAll = allSessions; + this._historyCases = cases; + this._wireHistoryControls(); + this._renderHistoryList(); container.style.display = ''; } catch (err) { @@ -2005,6 +1995,131 @@ Object.assign(CodemanApp.prototype, { } }, + /** Wire the filter box and sort select once; both re-render from the cached corpus. */ + _wireHistoryControls() { + if (this._historyControlsWired) return; + const filter = document.getElementById('historyFilter'); + const sort = document.getElementById('historySort'); + if (!filter && !sort) return; + this._historyControlsWired = true; + + if (filter) { + filter.addEventListener('input', () => this._renderHistoryList()); + filter.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape' && filter.value) { + // Swallow it: Escape at the welcome screen otherwise closes overlays. + ev.stopPropagation(); + filter.value = ''; + this._renderHistoryList(); + } + }); + } + if (sort) sort.addEventListener('change', () => this._renderHistoryList()); + }, + + /** True when a past-session row matches the filter text (name, folder, case, prompt). */ + _historyRowMatches(s, needle, cases) { + const fields = [ + s.name, + s.workingDir, + this._resolveCaseLabel(s.workingDir, cases), + s.firstPrompt, + s.lastPrompt, + s.sessionId, + ]; + return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle)); + }, + + /** + * The text a history row shows as its title. Most transcript-backed rows have + * no session name at all, so this falls through to the first prompt and then + * to the path — and the A–Z sort keys off the SAME string, or "sort by name" + * would silently do nothing for exactly the rows the list is mostly made of. + */ + _historyRowLabel(s, fallback) { + return s.name || s.firstPrompt || fallback || ''; + }, + + /** + * Sort past-session rows. 'recent' keeps the backend order (newest first); + * the alphabetical modes sort by the visible title or by folder basename. + * Pinned rows stay on top in every mode — pinning is an explicit override and + * a sort that buried it would read as the pin having been lost. + */ + _sortHistoryRows(rows, mode) { + const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase(); + const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase(); + const key = mode === 'name' ? label : folder; + const sorted = mode === 'recent' ? rows.slice() : rows.slice().sort((a, b) => key(a).localeCompare(key(b))); + const pinned = sorted.filter((s) => s.pinned); + return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned)); + }, + + /** + * Render the "Resume Conversation" list from the cached corpus, applying the + * current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT; + * "Show more" expands the list AND the box (the CSS cap is class-driven, since + * a fixed 240px box made expansion pointless — issue #260). + */ + _renderHistoryList() { + const list = document.getElementById('historyList'); + if (!list) return; + const all = this._historyAll || []; + const cases = this._historyCases || []; + const countEl = document.getElementById('historyCount'); + const needle = (document.getElementById('historyFilter')?.value || '').trim().toLowerCase(); + const mode = document.getElementById('historySort')?.value || 'recent'; + + const matched = needle ? all.filter((s) => this._historyRowMatches(s, needle, cases)) : all; + const rows = this._sortHistoryRows(matched, mode); + // Filtering is itself an expansion request: hiding matches behind "Show more" + // would defeat the point of typing a filter. + const expanded = !!this._historyExpanded || needle.length > 0; + const visible = expanded ? rows : rows.slice(0, this._HISTORY_INITIAL_COUNT); + + list.replaceChildren(); + list.classList.toggle('expanded', expanded); + + if (rows.length === 0) { + const empty = document.createElement('div'); + empty.className = 'history-empty'; + empty.textContent = `No conversations match "${needle}"`; + list.appendChild(empty); + } + + for (const s of visible) list.appendChild(this._buildHistoryItem(s, cases)); + + const hidden = rows.length - visible.length; + if (hidden > 0) { + const moreBtn = document.createElement('button'); + moreBtn.className = 'history-show-more'; + moreBtn.textContent = `Show ${hidden} more`; + moreBtn.addEventListener('click', () => { + this._historyExpanded = true; + this._renderHistoryList(); + }); + list.appendChild(moreBtn); + } else if (expanded && !needle && rows.length > this._HISTORY_INITIAL_COUNT) { + const lessBtn = document.createElement('button'); + lessBtn.className = 'history-show-more'; + lessBtn.textContent = 'Show less'; + lessBtn.addEventListener('click', () => { + this._historyExpanded = false; + this._renderHistoryList(); + list.scrollTop = 0; + }); + list.appendChild(lessBtn); + } + + if (countEl) { + countEl.textContent = needle + ? `${rows.length} of ${all.length}` + : rows.length > visible.length + ? `${visible.length} of ${rows.length}` + : String(rows.length); + } + }, + /** Page size for the folder history modal */ _FOLDER_HISTORY_PAGE_SIZE: 20, @@ -3832,13 +3947,16 @@ Object.assign(CodemanApp.prototype, { /** Render the grouped result cards (or empty/loading states). */ _renderSearch(data) { const results = document.getElementById('searchResults'); - const historyTitle = document.getElementById('historyTitle'); + // The header carries the title plus the filter/sort controls (issue #260) — + // hide the whole row, not just the title, or the controls float above the + // search results and act on a list that is not on screen. + const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle'); const historyList = document.getElementById('historyList'); if (!results) return; const searching = !!data; // Hide the plain "Resume Conversation" history list while a search is active. - if (historyTitle) historyTitle.style.display = searching ? 'none' : ''; + if (historyHeader) historyHeader.style.display = searching ? 'none' : ''; if (historyList) historyList.style.display = searching ? 'none' : ''; results.innerHTML = ''; @@ -3907,9 +4025,11 @@ Object.assign(CodemanApp.prototype, { const topRow = document.createElement('div'); topRow.className = 'search-result-top'; + // A past session resumes rather than switches tabs, so it says so on the badge. + const isPast = r.jumpTo && r.jumpTo.kind === 'resume-session'; const badge = document.createElement('span'); - badge.className = 'search-result-badge search-badge-' + r.type; - badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, ''); + badge.className = 'search-result-badge search-badge-' + r.type + (isPast ? ' search-badge-past' : ''); + badge.textContent = isPast ? 'Resume' : (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, ''); const name = document.createElement('span'); name.className = 'search-result-name'; @@ -3941,13 +4061,20 @@ 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) - * run-summary → openRunSummary(sessionId) (session options → summary tab) - * file-preview→ openFilePreview(path, sessionId, attachmentId) + * session → selectSession(sessionId) (open/switch to the session) + * resume-session→ resumeHistorySession(...) (past session — no tab to switch to) + * run-summary → openRunSummary(sessionId) (session options → summary tab) + * file-preview → openFilePreview(path, sessionId, attachmentId) */ _jumpToSearchResult(r) { const jt = r && r.jumpTo; if (!jt) return; + // A past session has to be replayed, not switched to. Do it BEFORE hiding the + // welcome overlay: resumeHistorySession() owns that transition itself. + if (jt.kind === 'resume-session') { + this.resumeHistorySession(jt.claudeSessionId || jt.sessionId, jt.workingDir || '', r.sessionName); + return; + } // Leaving the welcome overlay so the target surface is visible. if (typeof this.hideWelcome === 'function') this.hideWelcome(); diff --git a/src/web/routes/search-routes.ts b/src/web/routes/search-routes.ts index 82fb19f6..b98831a8 100644 --- a/src/web/routes/search-routes.ts +++ b/src/web/routes/search-routes.ts @@ -3,7 +3,9 @@ * * Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search * across three v1 sources, returned in the standard ApiResponse envelope: - * 1. sessions/cases — name, working directory, session id + * 1. sessions/cases — name, working directory, session id, for LIVE sessions + * plus the past-session snapshot in `session-history-index.ts` (issue #261: + * the live map alone made every closed session unfindable by folder name) * 2. run-summary events — event title/details (from the live run-summary trackers) * 3. file paths — per-session attachment history (workspace-relative paths only) * @@ -34,6 +36,7 @@ import { } from '../../search-service.js'; import type { SearchSourceType } from '../../types/search.js'; import type { SessionPort, InfraPort } from '../ports/index.js'; +import { ensureHistorySessionIndexFresh, getHistorySessionIndex } from '../session-history-index.js'; /** * Per-source harvest caps. These bound how much in-memory data we hand to the @@ -61,11 +64,17 @@ interface SessionLike { /** * Harvest the three source arrays from the live in-memory stores. Reads only * bounded, already-loaded data — no disk I/O, no terminal buffers. + * + * Past sessions come from the `session-history-index` snapshot, which is built + * outside the request path for exactly that reason. Live rows are harvested + * first and win the dedupe, so a session that is both live and in the snapshot + * keeps its live jump-to (switch to the tab) instead of a resume. */ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources { const sessions: SessionSearchInput[] = []; const events: EventSearchInput[] = []; const files: FileSearchInput[] = []; + const seenSessionIds = new Set(); for (const raw of ctx.sessions.values()) { const s = raw as unknown as SessionLike & { owner?: string }; @@ -73,6 +82,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) const sessionName = s.name ?? ''; const timestamp = s.lastActivityAt ?? s.createdAt ?? 0; + seenSessionIds.add(s.id); sessions.push({ sessionId: s.id, sessionName, @@ -95,6 +105,24 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) } } + // Past sessions: the out-of-band snapshot of the unified list. Unscoped on + // disk, so every row goes through the same ownership check as a live one — + // host-wide transcript rows carry no owner and are therefore admin-only in + // multi-user mode, matching GET /api/sessions/unified. + for (const item of getHistorySessionIndex().items) { + if (seenSessionIds.has(item.sessionId)) continue; + if (canSee && !canSee(item.owner)) continue; + seenSessionIds.add(item.sessionId); + sessions.push({ + sessionId: item.sessionId, + sessionName: item.name, + workingDir: item.workingDir, + timestamp: item.timestamp, + history: true, + claudeSessionId: item.claudeSessionId, + }); + } + // Events: from the live run-summary trackers, keyed by session id. for (const [sessionId, tracker] of ctx.runSummaryTrackers) { const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined; @@ -134,6 +162,11 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In ) : null; + // Fire-and-forget: a stale past-session snapshot is rebuilt in the + // background. This query still answers from whatever is already in memory, + // which is what keeps the request path free of disk I/O. + ensureHistorySessionIndexFresh(); + const sources = harvestSources(ctx, canSee); // Apply the optional source-type filter before searching so excluded diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 49b80c1f..6ddf0649 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -94,7 +94,13 @@ import { type LifecycleInput, type HistoryInput, type MuxStatInput, + type UnifiedSessionItem, } from '../../services/unified-session-service.js'; +import { + buildHistorySessionIndexItems, + setHistoryIndexRefresher, + setHistorySessionIndex, +} from '../session-history-index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { RunSummaryTracker } from '../../run-summary.js'; @@ -3539,16 +3545,20 @@ export function registerSessionRoutes( return { sessions: results.slice(0, 50) }; }); - // Unified, read-only session list: merges live + persisted + lifecycle + - // transcript history + mux stats into one de-duplicated, searchable list - // (COD-121). Pure merge/filter logic lives in unified-session-service.ts. - app.get('/api/sessions/unified', async (req) => { - const query = req.query as { q?: string; offset?: string; limit?: string }; - - if (ctx.testMode) { - return { sessions: [], total: 0 }; - } - + /** + * Gather the four read-only views the unified list is merged from, plus mux + * stats. This is the expensive half (the lifecycle log and a scan of every + * Claude transcript), factored out of the route handler because the + * past-session search index rebuilds itself from the very same inputs — off + * the request path, see session-history-index.ts. + */ + async function gatherUnifiedInputs(): Promise<{ + live: LiveSessionInput[]; + persisted: PersistedSessionInput[]; + lifecycle: LifecycleInput[]; + history: HistoryInput[]; + mux: MuxStatInput[]; + }> { // Live (in-memory) sessions. const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => { const st = s.toState(); @@ -3649,14 +3659,54 @@ export function registerSessionRoutes( // Mux stats are optional. } + return { live, persisted, lifecycle, history, mux }; + } + + /** + * Publish a merged unified list as the past-session search index (issue #261). + * The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be + * built from an unscoped merge — `harvestSources()` in search-routes re-applies + * the ownership check on read. + */ + function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void { + const ownerById = new Map(); + const stored = ctx.store.getState().sessions as Record; + for (const p of Object.values(stored)) ownerById.set(p.id, p.owner); + // Live wins: a session's owner on disk can lag the running one. + for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner); + const liveIds = new Set(ctx.sessions.keys()); + setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds)); + } + + // Rebuild hook for the search route: it kicks this (fire-and-forget) when the + // snapshot goes stale, so a search never pays for the scan itself. + setHistoryIndexRefresher(async () => { + if (ctx.testMode) return; + publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs())); + }); + + // Unified, read-only session list: merges live + persisted + lifecycle + + // transcript history + mux stats into one de-duplicated, searchable list + // (COD-121). Pure merge/filter logic lives in unified-session-service.ts. + app.get('/api/sessions/unified', async (req) => { + const query = req.query as { q?: string; offset?: string; limit?: string }; + + if (ctx.testMode) { + return { sessions: [], total: 0 }; + } + + const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs(); + // Multi-user: a non-admin only sees their own sessions; host-wide transcript // history (not tied to an owned session) is admin-only. let sLive = live; let sPersisted = persisted; let sLifecycle = lifecycle; let sHistory = history; + let scoped = false; const uUser = getAuthUser(req); if (isMultiUserMode() && uUser.role !== 'admin') { + scoped = true; const ownedLive = new Set( [...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id) ); @@ -3680,6 +3730,14 @@ export function registerSessionRoutes( history: sHistory, mux, }); + + // Refresh the search index off the back of this request — the home screen + // fetches this endpoint whenever it opens, which is the same screen the + // search box lives on, so the snapshot is warm before anyone types. A scoped + // merge is a per-user subset and would corrupt the shared snapshot, so that + // path re-merges unscoped instead (multi-user is opt-in and rarely hit). + publishHistorySessionIndex(scoped ? mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }) : merged); + const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined; const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined; return filterAndPaginate(merged, { diff --git a/src/web/session-history-index.ts b/src/web/session-history-index.ts new file mode 100644 index 00000000..734154a4 --- /dev/null +++ b/src/web/session-history-index.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview Bounded in-memory index of PAST sessions, harvested by `GET /api/search`. + * + * `GET /api/search` used to build its session corpus from the live in-memory + * session map alone, so a folder sitting in the home screen's "Resume + * Conversation" list matched nothing (issue #261). The corpus that list renders + * comes from `GET /api/sessions/unified`, which reads the lifecycle log and every + * Claude transcript file — disk I/O the search path deliberately does not do + * (its no-fs property is what keeps a per-keystroke query cheap and traversal-free). + * + * This module is the seam between the two: a capped snapshot of the unified list + * that the search route reads synchronously, refreshed OUT of the request path. + * Two things fill it: + * 1. `/api/sessions/unified` writes it as a side effect (free — it just merged + * that list). The home screen calls that endpoint whenever it opens, which + * is the same screen the search box lives on, so it is warm in practice. + * 2. `ensureHistorySessionIndexFresh()` — fire-and-forget, single-flight, + * TTL-guarded — kicks the registered refresher when a search finds the + * snapshot stale. The caller never awaits it: the current query answers from + * the existing snapshot and the next one sees fresh data. + * + * OWNERSHIP: each item carries the `owner` of the session it came from, and rows + * not tied to any live/persisted session (host-wide transcript history) carry + * `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule + * exactly — in multi-user mode a non-admin sees neither other users' sessions nor + * unowned host-wide history, and in single-user mode every check short-circuits + * true. The snapshot is written UNSCOPED, so it must never be returned unfiltered. + * + * Key exports: + * - setHistorySessionIndex / getHistorySessionIndex — the snapshot accessors. + * - buildHistorySessionIndexItems — pure merged-list → index-item projection. + * - setHistoryIndexRefresher / ensureHistorySessionIndexFresh — the refresh hook. + */ + +/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */ +export interface HistorySessionIndexItem { + /** Codeman session id (the search result's session id and dedupe key). */ + sessionId: string; + /** Display name, may be empty for a transcript-only row. */ + name: string; + /** Absolute working directory — the field issue #261 is about matching. */ + workingDir: string; + /** Claude conversation UUID, when known: what a resume actually replays. */ + claudeSessionId?: string; + /** Recency timestamp (lastActivityAt, else createdAt). */ + timestamp: number; + /** + * Owning user, when the row is tied to a live or persisted session. `undefined` + * means host-wide transcript history, which only admins (or single-user mode) + * may see — the same rule `/api/sessions/unified` applies. + */ + owner?: string; + /** True when the session is still in the live map (search harvests those directly). */ + live: boolean; +} + +/** Hard cap on snapshot size, so a host with thousands of transcripts stays bounded. */ +export const HISTORY_INDEX_MAX_ITEMS = 400; + +/** How long a snapshot is considered fresh before a search triggers a background refresh. */ +export const HISTORY_INDEX_TTL_MS = 60_000; + +interface HistorySessionIndexSnapshot { + items: HistorySessionIndexItem[]; + /** Epoch ms of the last write; 0 when never populated. */ + updatedAt: number; +} + +let snapshot: HistorySessionIndexSnapshot = { items: [], updatedAt: 0 }; +let refresher: (() => Promise) | null = null; +let refreshInFlight = false; + +/** The merged-list shape this module projects from (a subset of `UnifiedSessionItem`). */ +export interface MergedSessionLike { + sessionId: string; + name?: string; + workingDir?: string; + claudeSessionId?: string; + createdAt?: number; + lastActivityAt?: number; +} + +/** + * Project a merged unified list into index items. PURE — the caller supplies the + * owner lookup and the live-id set it already has in hand. + * + * Rows with no working directory AND no name are dropped: they can never match a + * query in a useful way and would only consume the cap. + * + * @param merged unified-list items, newest-first (the order the merge returns) + * @param ownerById owner of a session id, for rows tied to a live/persisted session + * @param liveIds session ids currently in the live map + */ +export function buildHistorySessionIndexItems( + merged: MergedSessionLike[], + ownerById: Map, + liveIds: Set +): HistorySessionIndexItem[] { + const items: HistorySessionIndexItem[] = []; + for (const m of merged) { + if (items.length >= HISTORY_INDEX_MAX_ITEMS) break; + const name = m.name ?? ''; + const workingDir = m.workingDir ?? ''; + if (!name && !workingDir) continue; + items.push({ + sessionId: m.sessionId, + name, + workingDir, + claudeSessionId: m.claudeSessionId, + timestamp: m.lastActivityAt ?? m.createdAt ?? 0, + owner: ownerById.get(m.sessionId), + live: liveIds.has(m.sessionId), + }); + } + return items; +} + +/** Replace the snapshot. Items are capped defensively even if the caller already did. */ +export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = Date.now()): void { + snapshot = { items: items.slice(0, HISTORY_INDEX_MAX_ITEMS), updatedAt: now }; +} + +/** + * Read the snapshot. The returned array is UNSCOPED — callers must apply the + * per-item ownership check before exposing any of it. + */ +export function getHistorySessionIndex(): HistorySessionIndexSnapshot { + return snapshot; +} + +/** True when the snapshot has never been written, or is older than the TTL. */ +export function isHistorySessionIndexStale(now = Date.now(), ttlMs = HISTORY_INDEX_TTL_MS): boolean { + return snapshot.updatedAt === 0 || now - snapshot.updatedAt > ttlMs; +} + +/** + * Register the rebuild function. Called once by the session routes, which own the + * transcript scanner and the stores the unified list is merged from. + */ +export function setHistoryIndexRefresher(fn: (() => Promise) | null): void { + refresher = fn; +} + +/** + * Kick a background rebuild if the snapshot is stale. Returns immediately — + * NEVER await this from a request handler, that is the whole point: the search + * path answers from the current snapshot and stays free of disk I/O. + */ +export function ensureHistorySessionIndexFresh(now = Date.now()): void { + if (refreshInFlight || !refresher || !isHistorySessionIndexStale(now)) return; + refreshInFlight = true; + void refresher() + .catch(() => { + // A failed rebuild leaves the previous snapshot in place; the next search retries. + }) + .finally(() => { + refreshInFlight = false; + }); +} + +/** Test hook: drop the snapshot and any registered refresher. */ +export function resetHistorySessionIndex(): void { + snapshot = { items: [], updatedAt: 0 }; + refresher = null; + refreshInFlight = false; +} diff --git a/test/history-list-controls.test.ts b/test/history-list-controls.test.ts new file mode 100644 index 00000000..515c6d69 --- /dev/null +++ b/test/history-list-controls.test.ts @@ -0,0 +1,291 @@ +/** + * @fileoverview Issue #260 — the home screen's "Resume Conversation" list. + * + * With ~35 past sessions the list showed 4 rows, then a button that dumped every + * remaining row into a fixed 240px box, with no way to sort or filter. The fix + * moved rendering into `_renderHistoryList()` over a cached corpus, so what is + * worth pinning is the model, not the pixels: + * 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4, + * 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is + * class-driven — without the class, expanding just deepens a scroll well), + * 3. filtering matches name / folder / case label / prompt, and implies + * expansion (hiding matches behind "Show more" defeats typing a filter), + * 4. sorting is alphabetical by name or folder, with pinned rows still on top. + * + * Loaded via `vm` against a stub CodemanApp with a fake DOM — same harness as + * resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get + * rendered and in what order, not how one row looks. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +interface FakeEl { + id: string; + value: string; + textContent: string; + scrollTop: number; + className: string; + children: FakeEl[]; + classes: Set; + listeners: Record void)[]>; + classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean }; + replaceChildren: () => void; + appendChild: (child: FakeEl) => FakeEl; + addEventListener: (type: string, fn: (ev: unknown) => void) => void; + style: Record; +} + +function fakeEl(id: string): FakeEl { + const el = { + id, + value: '', + textContent: '', + scrollTop: 0, + className: '', + children: [] as FakeEl[], + classes: new Set(), + listeners: {} as Record void)[]>, + style: {} as Record, + } as FakeEl; + el.classList = { + toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)), + contains: (c: string) => el.classes.has(c), + }; + el.replaceChildren = () => { + el.children = []; + }; + el.appendChild = (child: FakeEl) => { + el.children.push(child); + return child; + }; + el.addEventListener = (type: string, fn: (ev: unknown) => void) => { + (el.listeners[type] ||= []).push(fn); + }; + return el; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * The element map the vm's `document.getElementById` resolves against. Swapped + * per test — the closure is defined in THIS realm, so the shipping code inside + * the vm reads whatever the current test installed. + */ +let currentEls: Record = {}; + +function loadTerminalUiPrototype(): Record { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { + addEventListener: vi.fn(), + getElementById: (id: string) => currentEls[id] ?? null, + createElement: () => fakeEl('created'), + }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as unknown as { __proto: Record }).__proto; +} + +const proto = loadTerminalUiPrototype(); + +type Row = { + sessionId: string; + name?: string; + workingDir?: string; + firstPrompt?: string; + pinned?: boolean; + lastActivityAt?: number; +}; + +/** Host object carrying the real render/filter/sort methods over a fake DOM. */ +function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) { + const els: Record = { + historyList: fakeEl('historyList'), + historyFilter: fakeEl('historyFilter'), + historySort: fakeEl('historySort'), + historyCount: fakeEl('historyCount'), + }; + els.historySort.value = 'recent'; + + const app: any = { + _HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT, + _historyAll: rows, + _historyCases: cases, + _renderHistoryList: proto._renderHistoryList, + _historyRowMatches: proto._historyRowMatches, + _sortHistoryRows: proto._sortHistoryRows, + _historyRowLabel: proto._historyRowLabel, + _resolveCaseLabel: proto._resolveCaseLabel, + _shortenHomePath: proto._shortenHomePath, + // One fake node per row, tagged so assertions can read back the order. + _buildHistoryItem: (s: Row) => { + const el = fakeEl('item'); + el.textContent = s.sessionId; + return el; + }, + els, + /** Rendered row ids, excluding the show-more/less button and empty state. */ + renderedIds(): string[] { + return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent); + }, + button(): FakeEl | undefined { + return els.historyList.children.find((c) => c.id === 'created'); + }, + }; + + // Point the vm's document at this app's elements, then run the shipping method. + app._render = () => { + currentEls = els; + app._renderHistoryList(); + }; + return app; +} + +function rows(n: number, overrides: Partial = {}): Row[] { + return Array.from({ length: n }, (_, i) => ({ + sessionId: `s${i}`, + name: `w${i}-project${i}`, + workingDir: `/home/u/project${i}`, + lastActivityAt: 1000 - i, + ...overrides, + })); +} + +describe('issue #260 — collapsed page size', () => { + it('shows more than the old 4 rows before "Show more"', () => { + expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8); + }); + + it('renders the initial page and a "Show more" button for the rest', () => { + const app = makeApp(rows(35)); + app._render(); + expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT); + expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`); + expect(app.els.historyList.classList.contains('expanded')).toBe(false); + }); + + it('expanding renders every row AND marks the box expanded', () => { + const app = makeApp(rows(35)); + app._historyExpanded = true; + app._render(); + expect(app.renderedIds()).toHaveLength(35); + // Without this class the CSS max-height stays at the collapsed cap and the + // extra rows land in a four-row scroll well — the original bug. + expect(app.els.historyList.classList.contains('expanded')).toBe(true); + expect(app.button()?.textContent).toBe('Show less'); + }); + + it('shows no button at all when everything fits', () => { + const app = makeApp(rows(3)); + app._render(); + expect(app.renderedIds()).toHaveLength(3); + expect(app.button()).toBeUndefined(); + }); +}); + +describe('issue #260 — filter', () => { + it('matches on folder name and shows every match without expanding first', () => { + const app = makeApp([ + ...rows(30), + { sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 }, + { sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 }, + ]); + app.els.historyFilter.value = 'invoices'; + app._render(); + expect(app.renderedIds().sort()).toEqual(['x1', 'x2']); + expect(app.els.historyList.classList.contains('expanded')).toBe(true); + expect(app.els.historyCount.textContent).toBe('2 of 32'); + }); + + it('matches on the case label and on a prompt', () => { + const app = makeApp( + [ + { sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 }, + { + sessionId: 'p1', + name: 'w2-y', + workingDir: '/home/u/other', + firstPrompt: 'fix the CSV export', + lastActivityAt: 2, + }, + ], + [{ name: 'billing', path: '/home/u/cases/billing' }] + ); + app.els.historyFilter.value = '#billing'; + app._render(); + expect(app.renderedIds()).toEqual(['c1']); + + app.els.historyFilter.value = 'csv export'; + app._render(); + expect(app.renderedIds()).toEqual(['p1']); + }); + + it('renders an empty state when nothing matches', () => { + const app = makeApp(rows(5)); + app.els.historyFilter.value = 'zzzz'; + app._render(); + expect(app.renderedIds()).toEqual([]); + expect(app.els.historyList.children[0].textContent).toContain('No conversations match'); + }); +}); + +describe('issue #260 — sort', () => { + const unsorted: Row[] = [ + { sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 }, + { sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 }, + { sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 }, + ]; + + it('recent keeps the backend order', () => { + const app = makeApp(unsorted); + app._render(); + expect(app.renderedIds()).toEqual(['b', 'a', 'c']); + }); + + it('sorts by name', () => { + const app = makeApp(unsorted); + app.els.historySort.value = 'name'; + app._render(); + expect(app.renderedIds()).toEqual(['a', 'b', 'c']); + }); + + it('sorts by folder basename', () => { + const app = makeApp(unsorted); + app.els.historySort.value = 'folder'; + app._render(); + expect(app.renderedIds()).toEqual(['c', 'a', 'b']); + }); + + it('sorts transcript rows (no session name) by the prompt shown as their title', () => { + // Most past rows come from a transcript and have no name at all. Keying the + // A–Z sort off `name` alone made "Name A–Z" a no-op for them. + const app = makeApp([ + { sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' }, + { sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' }, + { sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' }, + ]); + app.els.historySort.value = 'name'; + app._render(); + expect(app.renderedIds()).toEqual(['a', 'm', 'z']); + }); + + it('keeps pinned rows on top in every sort mode', () => { + const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]); + for (const mode of ['recent', 'name', 'folder']) { + app.els.historySort.value = mode; + app._render(); + expect(app.renderedIds()[0]).toBe('p'); + } + }); +}); diff --git a/test/routes/search-routes.test.ts b/test/routes/search-routes.test.ts index 99b59c42..9d63c292 100644 --- a/test/routes/search-routes.test.ts +++ b/test/routes/search-routes.test.ts @@ -9,12 +9,13 @@ * (sessions + events) return results. Source data is injected via the mock * route context (sessions map, runSummaryTrackers map, attachment history). */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import { registerSearchRoutes } from '../../src/web/routes/search-routes.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { createMockRouteContext } from '../mocks/index.js'; import { RunSummaryTracker } from '../../src/run-summary.js'; +import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js'; type Ctx = ReturnType; @@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => { expect(types).toEqual(['event']); }); }); + +// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's +// folder name matched nothing — the corpus was the live session map alone. Past +// sessions now arrive from the out-of-band history index snapshot. +describe('GET /api/search — past sessions (history index)', () => { + beforeEach(() => { + resetHistorySessionIndex(); + }); + + afterEach(() => { + resetHistorySessionIndex(); + delete process.env.CODEMAN_MULTIUSER; + }); + + it('matches a past session by folder name with no live session at all', async () => { + const { app } = await harness(); + setHistorySessionIndex([ + { + sessionId: 'cod-9', + name: 'w4-needlework', + workingDir: '/home/u/projects/needlework', + claudeSessionId: 'claude-uuid', + timestamp: 1000, + live: false, + }, + ]); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.totalResults).toBe(1); + expect(body.data.groups[0].results[0].jumpTo).toMatchObject({ + kind: 'resume-session', + sessionId: 'cod-9', + claudeSessionId: 'claude-uuid', + }); + }); + + it('does not duplicate a session that is both live and in the snapshot', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 'dup', + fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never + ); + }); + setHistorySessionIndex([ + { sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true }, + ]); + const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body); + expect(body.data.totalResults).toBe(1); + // The live harvest wins, so the card still switches to the open tab. + expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session'); + }); + + it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const app = Fastify({ logger: false }); + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' }; + }); + const ctx = createMockRouteContext(); + ctx.sessions.clear(); + ctx.runSummaryTrackers.clear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerSearchRoutes(app, ctx as any); + installRouteErrorHandler(app); + await app.ready(); + + setHistorySessionIndex([ + { + sessionId: 'mine', + name: 'needle-bob', + workingDir: '/home/u/needle-bob', + timestamp: 3, + owner: 'bob', + live: false, + }, + { + sessionId: 'hers', + name: 'needle-alice', + workingDir: '/home/u/needle-alice', + timestamp: 2, + owner: 'alice', + live: false, + }, + // Host-wide transcript row: no owning session, so admin-only — the same + // rule GET /api/sessions/unified applies when it drops history for non-admins. + { sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false }, + ]); + + const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body); + expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']); + await app.close(); + }); +}); diff --git a/test/search-service.test.ts b/test/search-service.test.ts index 2f71c061..f7b89878 100644 --- a/test/search-service.test.ts +++ b/test/search-service.test.ts @@ -233,3 +233,61 @@ describe('searchSources — result card shape & path safety', () => { expect(searchSources('', data).totalResults).toBe(0); }); }); + +// Past sessions (issue #261). The corpus used to be the live session map alone, +// so a folder in the home screen's Resume list matched nothing. History rows now +// arrive marked, and a card for one has to RESUME the conversation — selecting a +// tab that no longer exists is a no-op the user reads as a broken result. +describe('searchSources — past (history) sessions', () => { + it('matches a past session by folder name and returns a resume jump target', () => { + const data = sources({ + sessions: [ + { + sessionId: 'cod-1', + sessionName: 'w3-invoices', + workingDir: '/home/u/projects/invoices', + timestamp: 500, + history: true, + claudeSessionId: 'claude-uuid-1', + }, + ], + }); + const res = searchSources('invoices', data); + expect(res.totalResults).toBe(1); + expect(res.groups[0].results[0].jumpTo).toEqual({ + kind: 'resume-session', + sessionId: 'cod-1', + claudeSessionId: 'claude-uuid-1', + workingDir: '/home/u/projects/invoices', + }); + }); + + it('keeps a live session on the plain session jump target', () => { + const data = sources({ + sessions: [{ sessionId: 'live-1', sessionName: 'w1-invoices', workingDir: '/home/u/invoices', timestamp: 1 }], + }); + expect(searchSources('invoices', data).groups[0].results[0].jumpTo).toEqual({ + kind: 'session', + sessionId: 'live-1', + }); + }); + + it('does not offer a resume for a history row with no working directory', () => { + const data = sources({ + sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }], + }); + // Nothing to resume INTO — a resume card here would always fail. + expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session'); + }); + + it('falls back to the folder basename when a transcript row has no name', () => { + const data = sources({ + sessions: [ + { sessionId: 'cod-3', sessionName: '', workingDir: '/home/u/proj/needle-app', timestamp: 1, history: true }, + ], + }); + const r = searchSources('needle', data).groups[0].results[0]; + expect(r.sessionName).toBe('needle-app'); + expect(r.snippet).toContain('/home/u/proj/needle-app'); + }); +}); diff --git a/test/session-history-index.test.ts b/test/session-history-index.test.ts new file mode 100644 index 00000000..935d6e71 --- /dev/null +++ b/test/session-history-index.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for the past-session search index (issue #261). + * + * The index is the seam that lets `GET /api/search` match sessions that are no + * longer running WITHOUT doing disk I/O per keystroke. Three properties matter + * and are pinned here: the snapshot stays bounded, the refresh never happens on + * the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the + * stored rows carry the owner needed to re-apply multi-user scoping on read — + * the snapshot is written unscoped, so losing that field would leak one user's + * folders into another user's search. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + buildHistorySessionIndexItems, + ensureHistorySessionIndexFresh, + getHistorySessionIndex, + isHistorySessionIndexStale, + resetHistorySessionIndex, + setHistoryIndexRefresher, + setHistorySessionIndex, + HISTORY_INDEX_MAX_ITEMS, + HISTORY_INDEX_TTL_MS, + type MergedSessionLike, +} from '../src/web/session-history-index.js'; + +beforeEach(() => { + resetHistorySessionIndex(); +}); + +describe('buildHistorySessionIndexItems', () => { + const merged: MergedSessionLike[] = [ + { sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 }, + { sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 }, + { sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 }, + ]; + + it('projects name, dir, timestamp, owner and liveness', () => { + const items = buildHistorySessionIndexItems( + merged, + new Map([ + ['a', 'alice'], + ['b', undefined], + ]), + new Set(['a']) + ); + expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']); + expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 }); + // Transcript-only row: no owner (host-wide) and not live. + expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' }); + }); + + it('drops rows with neither a name nor a working directory', () => { + const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set()); + expect(items.some((i) => i.sessionId === 'empty')).toBe(false); + }); + + it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => { + const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({ + sessionId: `s${i}`, + name: `session ${i}`, + workingDir: `/home/u/p${i}`, + lastActivityAt: i, + })); + expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS); + }); +}); + +describe('snapshot storage', () => { + it('starts empty and stale', () => { + expect(getHistorySessionIndex().items).toEqual([]); + expect(isHistorySessionIndexStale()).toBe(true); + }); + + it('caps on write even when the caller did not', () => { + const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({ + sessionId: `s${i}`, + name: 'x', + workingDir: '/x', + timestamp: i, + live: false, + })); + setHistorySessionIndex(items); + expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS); + }); + + it('goes stale again once the TTL elapses', () => { + const t0 = 1_000_000; + setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0); + expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false); + expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true); + }); +}); + +describe('ensureHistorySessionIndexFresh', () => { + it('returns synchronously — the rebuild must never be on the request path', async () => { + let resolveRefresh: () => void = () => {}; + const refresher = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + // Called, but the caller is already past it while the rebuild is pending. + expect(refresher).toHaveBeenCalledTimes(1); + expect(getHistorySessionIndex().items).toEqual([]); + resolveRefresh(); + await Promise.resolve(); + }); + + it('is single-flight: a second call while a rebuild is pending is a no-op', async () => { + let resolveRefresh: () => void = () => {}; + const refresher = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + ensureHistorySessionIndexFresh(); + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(1); + + resolveRefresh(); + await new Promise((r) => setTimeout(r, 0)); + // Snapshot still stale (the fake refresher wrote nothing) → next call runs again. + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(2); + }); + + it('does not rebuild while the snapshot is fresh', () => { + const refresher = vi.fn(async () => {}); + setHistoryIndexRefresher(refresher); + setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]); + ensureHistorySessionIndexFresh(); + expect(refresher).not.toHaveBeenCalled(); + }); + + it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => { + setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1); + const refresher = vi.fn(async () => { + throw new Error('scan failed'); + }); + setHistoryIndexRefresher(refresher); + + ensureHistorySessionIndexFresh(); + await new Promise((r) => setTimeout(r, 0)); + expect(getHistorySessionIndex().items[0].sessionId).toBe('keep'); + + ensureHistorySessionIndexFresh(); + expect(refresher).toHaveBeenCalledTimes(2); + }); + + it('is a no-op when no refresher is registered', () => { + expect(() => ensureHistorySessionIndexFresh()).not.toThrow(); + }); +}); From a80eda8e4c46bc43871167c80cf733a93178c7ea Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:06:23 +0200 Subject: [PATCH 2/7] fix(mobile): make every session tab reachable in the tab strip (#257) With five tabs open on a phone, the right-hand tabs were effectively unreachable. Selecting a tab only toggled the .active class, so the strip never moved, and every full rebuild (a task badge appearing, a session created elsewhere) replaced the strip's innerHTML, which resets scrollLeft to 0 and yanked a mid-swipe strip back to the first tab. Three changes, which only work together: * computeTabScrollLeft() (pure, constants.js) decides the scroll target from measured rects, and _scrollActiveTabIntoView() applies it on selection. Rect math on the strip's own scrollLeft rather than scrollIntoView(), which also scrolls ancestors: on a phone that is the document, under a fixed header and possibly an open keyboard. * _fullRenderSessionTabs() saves and restores scrollLeft across the rebuild, and re-reveals the active tab only when it actually changed (_lastRenderedActiveTabId), so a background render never undoes a manual swipe. * Mobile no longer hoists the active session to the front of the strip. That reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scrolling the active tab into view replaces it. Also sets overscroll-behavior-x: contain on the strip so a swipe that runs past the last tab stays in the strip instead of becoming the browser's back gesture. Tests: scroll-target math in test/tab-overflow.test.ts (runs in CI), plus five browser regressions in test/mobile/tabs.test.ts covering reveal-on- select in both directions, scroll preservation across an ambient rebuild, sessionOrder rendering on phones, and a real touch drag reaching the last tab. Closes #257 Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + src/web/public/app.js | 90 ++++++++++++++++++-- src/web/public/constants.js | 43 ++++++++++ src/web/public/mobile.css | 7 +- test/mobile/tabs.test.ts | 165 ++++++++++++++++++++++++++++++++++++ test/tab-overflow.test.ts | 81 +++++++++++++++++- 6 files changed, 377 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..1ec84f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. +**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it. + **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. diff --git a/src/web/public/app.js b/src/web/public/app.js index f475cb67..9b633c20 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3461,6 +3461,54 @@ class CodemanApp { tab.classList.remove('active'); } } + // #257: selection used to stop at the class toggle. On phones/tablets the + // strip scrolls horizontally, so a tab selected from the palette, a swipe, + // Alt+N or a push notification could stay parked off-screen. + this._scrollActiveTabIntoView(sessionId); + } + + /** + * Scroll the tab strip so the given (default: active) tab is visible. + * + * Only phones/tablets scroll the strip (desktop wraps to a second row), and + * the pure policy no-ops whenever there is nothing to scroll, so this is a + * cheap call on every device. + * + * Deliberately NOT scrollIntoView(): that also scrolls every scrollable + * ANCESTOR, which on a phone is the document itself. With the header fixed + * and the keyboard possibly open, a vertical nudge there shifts the whole + * app. Rect math + scrollLeft touches exactly one scroller. + */ + _scrollActiveTabIntoView(sessionId, behavior = 'smooth') { + const container = this.$('sessionTabs'); + if (!container) return; + const tab = + (sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) || + container.querySelector('.session-tab.active'); + if (!tab) return; + + const policy = window.CodemanTabOverflow?.computeTabScrollLeft; + if (!policy) return; + const containerRect = container.getBoundingClientRect(); + const tabRect = tab.getBoundingClientRect(); + const target = policy({ + scrollLeft: container.scrollLeft, + clientWidth: container.clientWidth, + scrollWidth: container.scrollWidth, + // Offsets are relative to the SCROLL CONTENT, not the offsetParent: the + // tabs' offsetParent is the positioned header, so offsetLeft would carry + // the brand column's width into the math. + tabLeft: tabRect.left - containerRect.left + container.scrollLeft, + tabWidth: tabRect.width, + }); + if (Math.abs(target - container.scrollLeft) < 1) return; + + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + if (typeof container.scrollTo === 'function') { + container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior }); + } else { + container.scrollLeft = target; + } } _setTerminalLoadState(sessionId, selectGen, phase) { @@ -3678,6 +3726,11 @@ class CodemanApp { this._fullRenderSessionTabs(); } + // Keep the reveal-on-change bookkeeping honest when only the incremental + // branch ran: _updateActiveTabImmediate has already scrolled the new active + // tab into view, so the next full rebuild must not treat it as a change. + this._lastRenderedActiveTabId = this.activeSessionId; + this.updateTabOverflowMode(); // After the wrap measurement: the `unroll` style starts tabs at max-width 0, // so measuring mid-animation would decide the wrap on collapsed widths. @@ -3749,15 +3802,25 @@ class CodemanApp { document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove()); this.cancelHideSubagentDropdown(); - // Build tabs HTML using array for better string concatenation performance - // Iterate in sessionOrder to respect user's custom tab arrangement - // On mobile: put active session first (only one tab visible anyway) + // #257: replacing innerHTML below resets scrollLeft to 0. On phones the + // strip scrolls, and ambient rebuilds (a task badge appearing, a session + // created elsewhere) fire often enough that a user swiping toward the + // right-hand tabs kept getting yanked back to the first one. Remember + // where the strip was; the browser clamps the restore to the new content. + const prevScrollLeft = container.scrollLeft; + const prevActiveTabId = this._lastRenderedActiveTabId; + const isFirstRender = !container.querySelector('.session-tab'); + + // Build tabs HTML using array for better string concatenation performance. + // Iterate in sessionOrder to respect the user's custom tab arrangement, on + // EVERY device: mobile used to hoist the active session to the front, from + // when only one tab fit on screen. With five tabs it made the strip jump + // under the user's finger (and renumbered the Alt+N badges) on every full + // rebuild, while the incremental path left the order alone, so the order + // depended on which render path happened to run. Scrolling the active tab + // into view replaces it. const parts = []; - let tabOrder = this.sessionOrder; - if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) { - // Reorder to put active tab first - tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)]; - } + const tabOrder = this.sessionOrder; let _tabIdx = 0; for (const id of tabOrder) { const session = this.sessions.get(id); @@ -3826,6 +3889,17 @@ class CodemanApp { container.innerHTML = parts.join(''); + // Put the strip back where the user left it, then reveal the active tab + // only when it CHANGED (or on the first paint). Restoring unconditionally + // and revealing conditionally is what lets someone browse the far end of + // the strip while a background rebuild fires, without the active tab ever + // being stranded off-screen after a switch. + container.scrollLeft = prevScrollLeft; + this._lastRenderedActiveTabId = this.activeSessionId; + if (isFirstRender || prevActiveTabId !== this.activeSessionId) { + this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth'); + } + // Set up drag-and-drop handlers for tab reordering this.setupTabDragHandlers(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index d411ce9a..2c3f8d64 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -156,6 +156,47 @@ function shouldAutoWrapTabs(input) { return scrollWidth > clientWidth + 1; } +// Sliver of the neighbouring tab left visible when the strip scrolls a tab into +// view. Landing a tab flush against the edge reads as "this is the last one"; +// the gap is what tells the user there is more strip to swipe to. +const TAB_SCROLL_REVEAL_PX = 16; + +// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll +// the strip horizontally (desktop wraps to a second row instead and never +// scrolls), so the active tab can sit entirely outside the visible slice with +// no way back except a swipe the user may not know is possible. +// +// Returns the scrollLeft that puts the tab inside the window, clamped to the +// scrollable range, and returns the CURRENT scrollLeft when the tab is already +// visible: callers compare and skip the write, so an already-correct strip is +// never nudged. Pure: the caller measures, this decides. +function computeTabScrollLeft(input) { + const scrollWidth = Number(input?.scrollWidth) || 0; + const clientWidth = Number(input?.clientWidth) || 0; + const maxScroll = Math.max(0, scrollWidth - clientWidth); + if (maxScroll === 0 || clientWidth <= 0) return 0; + + const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0; + const tabLeft = Number(input?.tabLeft) || 0; + const tabWidth = Number(input?.tabWidth) || 0; + const tabRight = tabLeft + tabWidth; + const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll); + const viewRight = viewLeft + clientWidth; + + let target = viewLeft; + if (tabWidth + pad >= clientWidth) { + // Tab is as wide as the window (long session name on a narrow phone): + // there is no position that shows all of it plus padding, so align its + // start, since the name matters more than the trailing badges. + target = tabLeft; + } else if (tabLeft - pad < viewLeft) { + target = tabLeft - pad; + } else if (tabRight + pad > viewRight) { + target = tabRight + pad - clientWidth; + } + return Math.min(Math.max(Math.round(target), 0), maxScroll); +} + // COD-134 — Terminal WebSocket reconnect policy. // // Decide what to do after a terminal WebSocket closes, given the close `code` @@ -261,6 +302,8 @@ if (typeof window !== 'undefined') { window.shouldSkipWebGL = shouldSkipWebGL; window.CodemanTabOverflow = { shouldAutoWrapTabs, + computeTabScrollLeft, + TAB_SCROLL_REVEAL_PX, }; window.CodemanWsReconnect = { plan: planWsReconnect, diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index c3cf7087..90b8e271 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -115,13 +115,17 @@ html.mobile-init .file-browser-panel { } /* Compact session tabs — .tabs-two-rows override needed to match - specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */ + specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0). + overscroll-behavior-x keeps a swipe that runs past the last tab inside the + strip: chained to the page it becomes the browser's back gesture, which is + exactly the swipe someone makes reaching for the rightmost tabs (#257). */ .session-tabs, .session-tabs.tabs-two-rows { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; scrollbar-width: none; max-height: 52px; gap: 3px; @@ -643,6 +647,7 @@ html.mobile-init .file-browser-panel { overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; scrollbar-width: none; max-height: 36px; gap: 2px; diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 82aba528..33533090 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -183,6 +183,171 @@ describe('Tab Navigation', () => { }); }); + // ─── Tab Strip Scrolling (issue #257) ──────────────────────────────────── + + describe('Tab Strip Scrolling', () => { + /** + * Seed `count` real sessions and render the strip through the production + * code path (_fullRenderSessionTabs), so the tabs carry the real markup, + * widths and CSS rather than hand-built stand-ins. + */ + async function seedTabs(page: Page, count: number, activeIndex = 0): Promise { + await page.evaluate(`(function (n, activeIndex) { + app.sessions.clear(); + app.sessionOrder = []; + for (let i = 1; i <= n; i++) { + const id = 'scroll-sess-' + i; + app.sessions.set(id, { id, name: 'w' + i + '-project', status: 'idle', mode: 'claude', workingDir: '/tmp/p' + i }); + app.sessionOrder.push(id); + } + app.activeSessionId = app.sessionOrder[activeIndex]; + app._lastRenderedActiveTabId = null; + app._fullRenderSessionTabs(); + })(${count}, ${activeIndex})`); + await page.waitForTimeout(200); + } + + async function stripState(page: Page, sessionId: string) { + return page.evaluate(`(function (id) { + const c = document.getElementById('sessionTabs'); + const tab = c.querySelector('.session-tab[data-id="' + id + '"]'); + const cRect = c.getBoundingClientRect(); + const tRect = tab ? tab.getBoundingClientRect() : null; + return { + scrollLeft: Math.round(c.scrollLeft), + maxScroll: Math.round(c.scrollWidth - c.clientWidth), + order: [...c.querySelectorAll('.session-tab[data-id]')].map((t) => t.dataset.id), + visible: tRect ? tRect.left >= cRect.left - 1 && tRect.right <= cRect.right + 1 : false, + }; + })('${sessionId}')`) as Promise<{ scrollLeft: number; maxScroll: number; order: string[]; visible: boolean }>; + } + + it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + const before = await stripState(page, 'scroll-sess-5'); + // Precondition: the strip really does overflow and the last tab is hidden. + expect(before.maxScroll).toBeGreaterThan(0); + expect(before.visible).toBe(false); + + // The selection path selectSession() uses (class toggle, no rebuild). + await page.evaluate(`(function () { + app.activeSessionId = 'scroll-sess-5'; + app._updateActiveTabImmediate('scroll-sess-5'); + })()`); + await page.waitForTimeout(600); // smooth scroll + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.visible).toBe(true); + expect(after.scrollLeft).toBeGreaterThan(before.scrollLeft); + } finally { + await context.close(); + } + }); + + it('scrolls back to reveal a leftmost tab', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`); + + await page.evaluate(`(function () { + app.activeSessionId = 'scroll-sess-1'; + app._updateActiveTabImmediate('scroll-sess-1'); + })()`); + await page.waitForTimeout(600); + + const after = await stripState(page, 'scroll-sess-1'); + expect(after.visible).toBe(true); + expect(after.scrollLeft).toBe(0); + } finally { + await context.close(); + } + }); + + it('keeps the scroll position across an ambient full re-render', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + // User swipes to the end of the strip, then a background rebuild fires + // (a task badge appearing forces the full-render path). + await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`); + const scrolled = await stripState(page, 'scroll-sess-5'); + expect(scrolled.scrollLeft).toBeGreaterThan(0); + + await page.evaluate(`(function () { + app.sessions.get('scroll-sess-2').taskStats = { running: 2, total: 3 }; + app._fullRenderSessionTabs(); + })()`); + await page.waitForTimeout(200); + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.scrollLeft).toBe(scrolled.scrollLeft); + } finally { + await context.close(); + } + }); + + it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5, 3); // 4th tab active + + const state = await stripState(page, 'scroll-sess-4'); + expect(state.order).toEqual([ + 'scroll-sess-1', + 'scroll-sess-2', + 'scroll-sess-3', + 'scroll-sess-4', + 'scroll-sess-5', + ]); + // ...and the active tab is still brought into view by the render. + expect(state.visible).toBe(true); + } finally { + await context.close(); + } + }); + + it('reaches the last tab with a horizontal touch drag', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + const cdp = await context.newCDPSession(page); + const box = await page.locator(SELECTORS.TABS_CONTAINER).boundingBox(); + if (!box) throw new Error('tab strip not found'); + const y = box.y + box.height / 2; + const startX = box.x + box.width * 0.85; + const endX = box.x + box.width * 0.1; + + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }] }); + for (let i = 1; i <= 10; i++) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: startX + ((endX - startX) * i) / 10, y }], + }); + await page.waitForTimeout(16); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(400); + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.scrollLeft).toBeGreaterThan(0); + expect(after.visible).toBe(true); + } finally { + await context.close(); + } + }); + }); + // ─── Swipe Navigation (CDP - Chromium) ─────────────────────────────────── describe('Swipe Navigation (CDP - Chromium)', () => { diff --git a/test/tab-overflow.test.ts b/test/tab-overflow.test.ts index 0ed7895a..65473cfe 100644 --- a/test/tab-overflow.test.ts +++ b/test/tab-overflow.test.ts @@ -3,12 +3,28 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { describe, expect, it } from 'vitest'; +type ScrollInput = { + scrollLeft?: number; + clientWidth?: number; + scrollWidth?: number; + tabLeft?: number; + tabWidth?: number; + padding?: number; +}; + function loadTabOverflowHelper() { const context = vm.createContext({ window: {}, globalThis: {} }); const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); vm.runInContext(source, context, { filename: 'constants.js' }); - return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } }) - .CodemanTabOverflow; + return ( + context.window as { + CodemanTabOverflow: { + shouldAutoWrapTabs: (input: unknown) => boolean; + computeTabScrollLeft: (input: ScrollInput) => number; + TAB_SCROLL_REVEAL_PX: number; + }; + } + ).CodemanTabOverflow; } describe('tab overflow layout policy', () => { @@ -63,3 +79,64 @@ describe('tab overflow layout policy', () => { expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false); }); }); + +// Issue #257: the phone tab strip scrolls horizontally, so the active tab can +// sit entirely outside the visible slice. These pin the scroll target math that +// _scrollActiveTabIntoView() feeds with measured rects. +describe('mobile tab strip scroll-into-view policy', () => { + // A 5-tab phone strip: 335px visible of 558px of tabs. + const strip = { clientWidth: 335, scrollWidth: 558 }; + const pad = 16; + + it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => { + const helper = loadTabOverflowHelper(); + // Last tab: 458..558, strip parked at 0. + const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 }); + // 558 + 16 - 335 = 239, clamped to the 223px maximum. + expect(target).toBe(223); + // The revealed tab is now inside the window. + expect(458).toBeGreaterThanOrEqual(target); + expect(558).toBeLessThanOrEqual(target + strip.clientWidth); + }); + + it('scrolls left to reveal a tab before the left edge', () => { + const helper = loadTabOverflowHelper(); + // First tab: 0..150, strip scrolled to the end. + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0); + // A middle tab partially cut off on the left: reveal it with the sliver. + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad); + }); + + it('leaves an already-visible tab alone (callers skip the write)', () => { + const helper = loadTabOverflowHelper(); + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100); + }); + + it('never scrolls a strip that fits, and never leaves the scrollable range', () => { + const helper = loadTabOverflowHelper(); + // Everything fits: nothing to scroll, whatever the tab geometry says. + expect( + helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 }) + ).toBe(0); + // Clamped at both ends. + const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 }); + expect(low).toBe(0); + const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 }); + expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth); + }); + + it('aligns the start of a tab too wide to fit the window', () => { + const helper = loadTabOverflowHelper(); + // 330px tab in a 335px window: no position shows it plus padding. + expect( + helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 }) + ).toBe(400); + }); + + it('tolerates missing measurements instead of producing NaN', () => { + const helper = loadTabOverflowHelper(); + expect(helper.computeTabScrollLeft({})).toBe(0); + expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0); + expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad); + }); +}); From 053a6d238df36a5fc4472019e9a0099455b6bc04 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:12:02 +0200 Subject: [PATCH 3/7] fix(web): adopt #263's fetch ceiling, persisted sort and numeric collation @jordan8037310 opened #263 against the same two issues while this branch was in flight. Three details there are better than what this had, so they are folded in with credit: - the Resume list pulls 200 unified sessions instead of 60, so the filter can reach a real backlog rather than stopping at an arbitrary ceiling (the endpoint clamps at 500), - the sort choice persists per device in localStorage, like `codeman:skin` and the other display keys that stay out of the synced schema, - alphabetical sorts collate with `{sensitivity:'base', numeric:true}`, so w2- sorts before w10- and case never splits one project's rows apart. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/terminal-ui.js | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d28c0bc7..0b9f4218 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1956,8 +1956,16 @@ Object.assign(CodemanApp.prototype, { /** Number of history items shown before "Show More" */ _HISTORY_INITIAL_COUNT: 10, - /** How many past sessions the home screen loads (also the filter/sort corpus). */ - _HISTORY_FETCH_LIMIT: 60, + /** + * How many past sessions the home screen loads (also the filter/sort corpus). + * 200, not the old 60, so the filter can reach a real backlog — an install with + * 35+ conversations would otherwise hit the ceiling before the filter is useful + * (raised in @jordan8037310's #263; the endpoint clamps at 500). + */ + _HISTORY_FETCH_LIMIT: 200, + + /** localStorage key for the per-device sort choice (#263). */ + _HISTORY_SORT_KEY: 'codeman:historySort', async loadHistorySessions() { const container = document.getElementById('historySessions'); @@ -1995,7 +2003,12 @@ Object.assign(CodemanApp.prototype, { } }, - /** Wire the filter box and sort select once; both re-render from the cached corpus. */ + /** + * Wire the filter box and sort select once; both re-render from the cached + * corpus. The sort choice is restored from (and saved to) localStorage — it is + * a per-device display preference, so it stays out of the synced settings + * schema, same as `codeman:skin`. + */ _wireHistoryControls() { if (this._historyControlsWired) return; const filter = document.getElementById('historyFilter'); @@ -2003,6 +2016,15 @@ Object.assign(CodemanApp.prototype, { if (!filter && !sort) return; this._historyControlsWired = true; + if (sort) { + try { + const saved = localStorage.getItem(this._HISTORY_SORT_KEY); + if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved; + } catch { + /* private mode — the order just won't persist */ + } + } + if (filter) { filter.addEventListener('input', () => this._renderHistoryList()); filter.addEventListener('keydown', (ev) => { @@ -2014,7 +2036,16 @@ Object.assign(CodemanApp.prototype, { } }); } - if (sort) sort.addEventListener('change', () => this._renderHistoryList()); + if (sort) { + sort.addEventListener('change', () => { + try { + localStorage.setItem(this._HISTORY_SORT_KEY, sort.value); + } catch { + /* private mode — the order just won't persist */ + } + this._renderHistoryList(); + }); + } }, /** True when a past-session row matches the filter text (name, folder, case, prompt). */ @@ -2050,7 +2081,14 @@ Object.assign(CodemanApp.prototype, { const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase(); const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase(); const key = mode === 'name' ? label : folder; - const sorted = mode === 'recent' ? rows.slice() : rows.slice().sort((a, b) => key(a).localeCompare(key(b))); + // numeric collation so w2-… sorts before w10-…, and base sensitivity so case + // does not split a project's rows apart (from @jordan8037310's #263). + const sorted = + mode === 'recent' + ? rows.slice() + : rows + .slice() + .sort((a, b) => key(a).localeCompare(key(b), undefined, { sensitivity: 'base', numeric: true })); const pinned = sorted.filter((s) => s.pinned); return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned)); }, From c13b3c55d3c7665b3726f1b77e7320c2bba28a1a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:15:13 +0200 Subject: [PATCH 4/7] 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( () => From aa35c1a0c43df8be7eecbcdfd354d375d0a2a24c Mon Sep 17 00:00:00 2001 From: Jordan Ryan Date: Sun, 9 Aug 2026 21:32:04 -0400 Subject: [PATCH 5/7] feat(sessions): show the git worktree on session rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #266. Sessions from different worktrees of the same repo were indistinguishable in the Resume list, Cmd+K and search — the row showed a session name and a case label, nothing about which worktree it ran in. Claude Code already stamps "cwd" and "gitBranch" on every user/assistant record, and writes a worktree-state record naming the worktree when the session was started through its own worktree feature. scanProjectDir() already buffers the head of every transcript for prompt extraction, so extractTranscriptGitInfo() parses buffers that are already in memory: no extra file reads, no git subprocess. (Measured on this machine: a git rev-parse per directory costs 482ms for 35 rows; parsing the existing buffers costs nothing.) cwd is taken from the first record that carries it, since a session's cwd does not move. gitBranch is taken from the last, since a branch genuinely changes mid-session. The badge requires a worktree NAME. An earlier revision rendered whenever a branch was known, which put a badge on all 35 rows of a real history -- "master" on every ordinary session, burying the ten rows the badge exists to distinguish. A hand-made `git worktree add` therefore gets no badge rather than a guessed name; Claude's own /.claude/worktrees/ layout is recognised from the path when no worktree-state record is present. worktreeName and gitBranch join the filterAndPaginate haystack so the session manager can search by them. panels-ui re-projects the unified item into a 5-field record before rendering, so the new fields are carried there explicitly -- omitting that silently drops them from Cmd+K only. Also prefers the transcript cwd over decodeProjectKey()'s stat-walked guess, which falls back to $HOME when nothing resolves (#265). Note that path is currently LATENT, not active: on the install this was developed against, every project key whose directory is gone has zero transcripts and so produces no row at all. The transcript value is used because it is authoritative and non-lossy, not because a live bug was reproduced. Verified against a real 35-session history on an isolated CODEMAN_INSTANCE: 10 of 36 rows badged, history row count unchanged at 35 (nothing dropped), no page errors. 129 tests pass across the new suite plus the unified service, unified route and session route suites. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016uTqt8ttmsBLXbm5JFHis3 --- src/services/unified-session-service.ts | 14 ++- src/web/public/panels-ui.js | 5 ++ src/web/public/styles.css | 11 +++ src/web/public/terminal-ui.js | 30 +++++++ src/web/routes/session-routes.ts | 115 +++++++++++++++++++++++- test/session-worktree-label.test.ts | 102 +++++++++++++++++++++ 6 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 test/session-worktree-label.test.ts diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 6eaf5545..f83e89c4 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -32,6 +32,12 @@ export type UnifiedSessionItem = { lastPrompt?: string; sizeBytes?: number; projectKey?: string; + /** Git branch recorded in the transcript (#266). */ + gitBranch?: string; + /** Linked-worktree name, when the session ran in one (#266). */ + worktreeName?: string; + /** Main repo root a worktree belongs to (#266). */ + worktreeRepo?: string; remote?: boolean; /** Pinned to the top of the session manager list (COD-139). */ pinned?: boolean; @@ -90,6 +96,9 @@ export type HistoryInput = { /** Most recent user prompt from the transcript (COD-145). */ lastPrompt?: string; projectKey?: string; + gitBranch?: string; + worktreeName?: string; + worktreeRepo?: string; }; /** Mux process-stat view. */ @@ -163,6 +172,9 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'firstPrompt', h.firstPrompt); overwrite(item, 'lastPrompt', h.lastPrompt); overwrite(item, 'projectKey', h.projectKey); + overwrite(item, 'gitBranch', h.gitBranch); + overwrite(item, 'worktreeName', h.worktreeName); + overwrite(item, 'worktreeRepo', h.worktreeRepo); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } @@ -346,7 +358,7 @@ export function filterAndPaginate( const q = (opts.q ?? '').trim().toLowerCase(); const filtered = q ? items.filter((it) => { - const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId] + const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId, it.worktreeName, it.gitBranch] .filter((v): v is string => typeof v === 'string') .join(' ') .toLowerCase(); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 1aedf9df..f5cafc85 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -649,6 +649,11 @@ Object.assign(CodemanApp.prototype, { sizeBytes: s.sizeBytes ?? 0, lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(), firstPrompt: s.firstPrompt || s.name || '', + // Must be carried explicitly: this record is a re-projection, so any + // field omitted here silently vanishes from the Cmd+K list (#266). + gitBranch: s.gitBranch, + worktreeName: s.worktreeName, + worktreeRepo: s.worktreeRepo, }; const isLive = !!this.sessions?.has?.(s.sessionId); const item = this._buildHistoryItem(record, this.cases, { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..a08b575a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3840,6 +3840,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: var(--green); } +/* Worktree pill (#266). Tinted off the accent so it reads as metadata rather + than status — LIVE is the only badge that should look like state. */ +.history-item-badge-worktree { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); + max-width: 22ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .history-item-meta { font-size: 0.7rem; color: var(--text-muted); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1508c32b..a205da31 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1517,6 +1517,24 @@ Object.assign(CodemanApp.prototype, { * - workingDir under a case dir → "#caseName/subdir" * - Otherwise → basename (e.g. "Claudeman") */ + /** + * Badge text for a session's git worktree, or '' when it isn't on one. + * `⑂ · `, either half alone if that's all we know. + * Branch is truncated: the badge row is a single nowrap line. + */ + _worktreeLabel(s) { + // Worktree name is REQUIRED. gitBranch alone is not worktree information — + // every ordinary repo session has one, and badging all of them with `⑂ master` + // is noise that buries the rows this badge exists to distinguish. + const name = s && s.worktreeName; + if (!name) return ''; + let branch = s.gitBranch || ''; + // A worktree's branch often just restates its name; don't print it twice. + if (branch === name || branch === `worktree-${name}`) branch = ''; + if (branch.length > 24) branch = branch.slice(0, 23) + '\u2026'; + return '⑂ ' + [name, branch].filter(Boolean).join(' · '); + }, + _resolveCaseLabel(workingDir, cases) { if (!workingDir) return ''; let best = null; @@ -1639,6 +1657,18 @@ Object.assign(CodemanApp.prototype, { modeBadge.textContent = s.mode; badgeRow.appendChild(modeBadge); } + // Worktree pill (#266): distinguishes sessions from different worktrees of the + // same repo, which are otherwise identical in this list. Name AND branch when + // both are known; a hand-made `git worktree add` yields no recoverable name, + // so it degrades to branch-only rather than guessing one. + const wtLabel = this._worktreeLabel(s); + if (wtLabel) { + const wtBadge = document.createElement('span'); + wtBadge.className = 'history-item-badge history-item-badge-worktree'; + wtBadge.textContent = wtLabel; + wtBadge.title = s.worktreeRepo ? `worktree of ${s.worktreeRepo}` : wtLabel; + badgeRow.appendChild(wtBadge); + } if (isLive) { const liveBadge = document.createElement('span'); liveBadge.className = 'history-item-badge history-item-badge-live'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 49b80c1f..32397510 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3116,6 +3116,91 @@ export function registerSessionRoutes( return sawNonCli; } + /** Git/worktree facts recovered from a transcript. Every field is optional — + * "unknown" must stay distinguishable from "not a worktree" (#265/#266). */ + type TranscriptGitInfo = { + /** The literal `cwd` Claude Code stamped on its own records. */ + cwd?: string; + gitBranch?: string; + worktreeName?: string; + /** Main repo root the worktree belongs to. */ + worktreeRepo?: string; + }; + + /** `/.claude/worktrees/` — the layout Claude Code's own worktree feature creates. */ + const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/; + + /** + * Recover cwd / branch / worktree from a transcript chunk. + * + * Claude Code stamps `"cwd"` and `"gitBranch"` on every user/assistant record, + * and writes a dedicated `worktree-state` record when the session was started + * through its own worktree feature. This reads buffers `scanProjectDir` has + * ALREADY loaded, so it costs no extra file I/O. + * + * Why this matters beyond a label: `decodeProjectKey()` reconstructs a path by + * stat-walking the filesystem and falls back to `$HOME` when nothing resolves. + * A deleted worktree is the normal end of a worktree's life, so every past + * worktree session used to collapse onto `$HOME` (#265). The transcript value + * is the literal cwd — non-lossy, and it survives the directory being removed. + * + * cwd is taken from the FIRST record that carries it (a session's cwd does not + * move); gitBranch from the LAST (a branch genuinely changes mid-session, and + * the newest value in the scanned chunk is the closest to current). + */ + function extractTranscriptGitInfo(text: string): TranscriptGitInfo { + const info: TranscriptGitInfo = {}; + let start = 0; + while (start < text.length) { + const end = text.indexOf('\n', start); + const line = end === -1 ? text.slice(start) : text.slice(start, end); + start = end === -1 ? text.length : end + 1; + + // Highest-confidence source: Claude's own worktree record. Names the + // worktree explicitly, so it beats anything inferred from the path. + if (line.includes('"worktree-state"')) { + try { + const rec = JSON.parse(line) as { + worktreeSession?: { worktreeName?: unknown; worktreePath?: unknown; originalCwd?: unknown }; + }; + const ws = rec.worktreeSession; + if (ws) { + if (typeof ws.worktreeName === 'string') info.worktreeName ||= ws.worktreeName; + if (typeof ws.originalCwd === 'string') info.worktreeRepo ||= ws.originalCwd; + if (typeof ws.worktreePath === 'string') info.cwd ||= ws.worktreePath; + } + } catch { + // Malformed/truncated line — skip + } + continue; + } + + if (!line.includes('"cwd"') && !line.includes('"gitBranch"')) continue; + if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue; + try { + const rec = JSON.parse(line) as { cwd?: unknown; gitBranch?: unknown }; + if (!info.cwd && typeof rec.cwd === 'string' && rec.cwd) info.cwd = rec.cwd; + // Last one wins — closest to the session's current branch. + if (typeof rec.gitBranch === 'string' && rec.gitBranch) info.gitBranch = rec.gitBranch; + } catch { + // Malformed/truncated line — skip + } + } + + // No explicit worktree record: infer from Claude's own worktree path layout. + // A worktree created by hand (`git worktree add` anywhere) has no recoverable + // NAME here — it still gets a branch, and the badge degrades to branch-only + // rather than guessing. + if (!info.worktreeName && info.cwd) { + const m = CLAUDE_WORKTREE_PATH.exec(info.cwd); + if (m) { + info.worktreeName = m[2]; + info.worktreeRepo ||= m[1]; + } + } + return info; + } + /** * Extract the text of the LAST user message from a JSONL transcript chunk * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message @@ -3367,6 +3452,11 @@ export function registerSessionRoutes( lastModified: string; firstPrompt?: string; lastPrompt?: string; + /** True when workingDir came from the transcript rather than decodeProjectKey's guess. */ + workingDirExact?: boolean; + gitBranch?: string; + worktreeName?: string; + worktreeRepo?: string; }; // Scan a single project directory and return all valid history sessions in it. @@ -3473,14 +3563,34 @@ export function registerSessionRoutes( headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint); if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue; + // Git/worktree facts from the buffers already read above — no extra I/O. + // head first (cwd is stamped near the top; median offset ~1KB), tail as the + // fallback for transcripts whose head read failed or came up empty. + const headGit = head ? extractTranscriptGitInfo(head) : {}; + const tailGit = tail ? extractTranscriptGitInfo(tail) : {}; + const git: TranscriptGitInfo = { + cwd: headGit.cwd ?? tailGit.cwd, + // Last-wins within a chunk; across chunks the tail is the newer one. + gitBranch: tailGit.gitBranch ?? headGit.gitBranch, + worktreeName: headGit.worktreeName ?? tailGit.worktreeName, + worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo, + }; + out.push({ sessionId, - workingDir, + // The transcript's literal cwd beats decodeProjectKey's stat-walked guess, + // which silently collapses to $HOME once the directory is gone (#265). + // Absent cwd falls back to the old behaviour rather than inventing a path. + workingDir: git.cwd ?? workingDir, + workingDirExact: git.cwd !== undefined, projectKey: projDir, sizeBytes: fileStat.size, lastModified: fileStat.mtime.toISOString(), firstPrompt, lastPrompt, + gitBranch: git.gitBranch, + worktreeName: git.worktreeName, + worktreeRepo: git.worktreeRepo, }); } return out; @@ -3618,6 +3728,9 @@ export function registerSessionRoutes( firstPrompt: h.firstPrompt, lastPrompt: h.lastPrompt, projectKey: h.projectKey, + gitBranch: h.gitBranch, + worktreeName: h.worktreeName, + worktreeRepo: h.worktreeRepo, }); } } diff --git a/test/session-worktree-label.test.ts b/test/session-worktree-label.test.ts new file mode 100644 index 00000000..53764112 --- /dev/null +++ b/test/session-worktree-label.test.ts @@ -0,0 +1,102 @@ +/** + * @fileoverview Worktree/branch identity on session rows (#265, #266). + * + * Two behaviours are pinned here: + * - the unified merge carries gitBranch/worktreeName/worktreeRepo through from + * the history source, and filterAndPaginate can search them; + * - the client-side badge helper renders `⑂ name · branch`, and stays SILENT + * when only a branch is known (a branch is not a worktree — badging those + * would put `⑂ master` on every ordinary session). + * + * The transcript extractor itself lives inside a closure in session-routes.ts + * and is covered by the route tests; what matters at this level is that the + * fields survive the merge and reach a label. + */ + +import { describe, it, expect } from 'vitest'; +import { + mergeUnifiedSessions, + filterAndPaginate, + type UnifiedSessionItem, +} from '../src/services/unified-session-service.js'; + +const historyRow = (over: Record = {}) => ({ + sessionId: 's1', + workingDir: '/repo/.claude/worktrees/autodev', + sizeBytes: 9000, + lastModified: '2026-01-01T00:00:00.000Z', + ...over, +}); + +describe('worktree fields through the unified merge (#266)', () => { + it('carries gitBranch / worktreeName / worktreeRepo from the history source', () => { + const merged = mergeUnifiedSessions({ + history: [historyRow({ gitBranch: 'feat/CF-195', worktreeName: 'autodev', worktreeRepo: '/repo' })], + }); + expect(merged).toHaveLength(1); + expect(merged[0].worktreeName).toBe('autodev'); + expect(merged[0].gitBranch).toBe('feat/CF-195'); + expect(merged[0].worktreeRepo).toBe('/repo'); + }); + + it('leaves the fields undefined for a non-worktree session rather than inventing them', () => { + const merged = mergeUnifiedSessions({ history: [historyRow({ workingDir: '/plain/repo' })] }); + expect(merged[0].worktreeName).toBeUndefined(); + expect(merged[0].gitBranch).toBeUndefined(); + }); + + it('finds a session by worktree name and by branch', () => { + const items = [ + { sessionId: 'a', worktreeName: 'autodev', sources: ['history'] }, + { sessionId: 'b', gitBranch: 'feat/CF-195', sources: ['history'] }, + { sessionId: 'c', sources: ['history'] }, + ] as unknown as UnifiedSessionItem[]; + + expect(filterAndPaginate(items, { q: 'autodev' }).sessions.map((s) => s.sessionId)).toEqual(['a']); + expect(filterAndPaginate(items, { q: 'cf-195' }).sessions.map((s) => s.sessionId)).toEqual(['b']); + expect(filterAndPaginate(items, { q: 'nothing' }).sessions).toHaveLength(0); + }); +}); + +/** + * Mirrors `_worktreeLabel` in terminal-ui.js. The frontend is plain browser JS + * with no module exports, so the logic is restated here; the rule it encodes — + * never print a branch that merely restates the worktree name — is the part + * worth pinning. + */ +function worktreeLabel(s: { worktreeName?: string; gitBranch?: string }): string { + const name = s.worktreeName; + if (!name) return ''; + let branch = s.gitBranch || ''; + if (branch === name || branch === `worktree-${name}`) branch = ''; + if (branch.length > 24) branch = branch.slice(0, 23) + '…'; + return '⑂ ' + [name, branch].filter(Boolean).join(' · '); +} + +describe('worktree badge label', () => { + it('renders name and branch together', () => { + expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'feat/CF-195' })).toBe('⑂ autodev · feat/CF-195'); + }); + + it('renders NOTHING when only a branch is known — a branch is not a worktree', () => { + // Every ordinary repo session carries gitBranch. Badging those would put + // `⑂ master` on every row and bury the worktree rows this badge is for. + expect(worktreeLabel({ gitBranch: 'master' })).toBe(''); + expect(worktreeLabel({ gitBranch: 'feat/CF-200' })).toBe(''); + }); + + it('does not repeat the name when the branch just restates it', () => { + expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'autodev' })).toBe('⑂ autodev'); + expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'worktree-autodev' })).toBe('⑂ autodev'); + }); + + it('is empty for a session that is not on a worktree', () => { + expect(worktreeLabel({})).toBe(''); + }); + + it('truncates a long branch so the single-line badge row cannot blow out', () => { + const label = worktreeLabel({ worktreeName: 'wt', gitBranch: 'feature/VERY-LONG-BRANCH-NAME-THAT-KEEPS-GOING' }); + expect(label.length).toBeLessThanOrEqual(2 + 2 + 3 + 24); + expect(label.endsWith('…')).toBe(true); + }); +}); From 45ad9de89e65f3cf5a30d754683656d933a2cfe2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:59:43 +0200 Subject: [PATCH 6/7] feat(settings): rebuild App Settings as a rail over one scrolling document The modal had grown to 8 tabs that wrapped onto two rows on desktop and became a horizontal scroller on phones, with a "Display" mega-tab holding 11 sections and ~35 controls. Local Echo sat 60% down it, and the model settings were split across two tabs whose three controls fought each other (the 1M Opus toggle's own hint said it was "ignored when a Claude Model is selected above"). Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling document: every section stays mounted, the rail follows the scroll, and find-in-page works across the whole thing. Nine sections: Terminal & Input (Local Echo is the first row of the first section) Appearance, Header & Panels, Models, Agents & CLIs, Notifications, Voice, Shortcuts, System Models are now one page. The picker is a card grid of BASE models with a single "1M context window" switch; context becomes a property of the chosen model and composes back into `claudeModel` as `base + [1m]`, which retires the precedence trap. Thinking effort is a segmented control on the same page, and the old Models tab (task routing) becomes a collapsed Advanced block under it. The 12 header-button toggles and the 8 panel toggles become chip grids, which is most of the old Display tab reclaimed. Rows now say whether a setting is per-device or synced, stated once per group. Phones drop the rail for a sticky jump pill that names the current section and opens a jump list, move Save into the header (the bottom action bar cost 60px), and render groups as one inset rounded list with hairline dividers instead of a stack of bordered cards. Load and save are untouched: every control keeps its id, so openAppSettings()/saveAppSettings() work as before. Model cards and the effort segment are views over hidden `s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` are still used by `#sessionOptionsModal` and `#createCaseModal`; the settings rail uses its own `set-*` classes and must not restyle them. ⚠️ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure. + **Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron) **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) diff --git a/src/web/public/admin-ui.js b/src/web/public/admin-ui.js index 51929803..90c0901d 100644 --- a/src/web/public/admin-ui.js +++ b/src/web/public/admin-ui.js @@ -110,34 +110,49 @@ } // ── Admin Users panel (injected into the App Settings modal) ────────────── + // The settings modal is a rail (table of contents) over ONE scrolling + // document, so this appends a rail entry plus a real section rather than a + // tab button plus a hidden panel. function injectUsersTab() { const modal = document.getElementById('appSettingsModal'); - if (!modal || modal.querySelector('[data-tab="settings-users"]')) return; - const tabs = modal.querySelector('.modal-tabs'); - const body = modal.querySelector('.modal-body'); - if (!tabs || !body) return; + if (!modal || modal.querySelector('[data-section="settings-users"]')) return; + const rail = modal.querySelector('.set-rail-items'); + const body = modal.querySelector('.set-doc'); + if (!rail || !body) return; const btn = document.createElement('button'); - btn.className = 'modal-tab-btn'; - btn.dataset.tab = 'settings-users'; - btn.textContent = 'Users'; - tabs.appendChild(btn); - const content = document.createElement('div'); - content.className = 'modal-tab-content hidden'; + btn.type = 'button'; + btn.className = 'set-rail-item'; + btn.dataset.section = 'settings-users'; + btn.innerHTML = + 'Users'; + rail.appendChild(btn); + const content = document.createElement('section'); + content.className = 'set-section'; content.id = 'settings-users'; + content.dataset.label = 'Users'; content.innerHTML = ` -
- Users - - - - +
+ +

Users

-

Users share the host account; this separates workspaces, it does not sandbox +

Users share the host account; this separates workspaces, it does not sandbox users from each other. Pair with Docker cases for isolation.

-
-

`; +
+

Accounts

+
+
+
Manage users
+
+ + +
+
+
+

+
+
`; body.appendChild(content); - // Render whenever the tab is shown (the shared switchSettingsTab toggles it). + // Render whenever the entry is used (the shared switchSettingsTab scrolls to it). btn.addEventListener('click', renderUsers); content.querySelector('#adminAddUser').onclick = addUserFlow; content.querySelector('#adminOpenPanel').onclick = openAdminPanel; diff --git a/src/web/public/index.html b/src/web/public/index.html index 6ec66057..07b372ee 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1273,778 +1273,837 @@