mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13b3c55d3 | ||
|
|
053a6d238d | ||
|
|
5d42f64393 |
@@ -0,0 +1,24 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Home screen: make the past-conversation list usable, and let search find past sessions.
|
||||
|
||||
- **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining
|
||||
one into a fixed 240px box, with no ordering or filtering. The list now opens
|
||||
with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the
|
||||
height cap is class-driven instead of fixed), and the header carries a filter
|
||||
box (matches name, folder, `#case` label and the conversation's prompts), a
|
||||
sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a
|
||||
shown-of-total count. Filtering implies expansion, so every match is visible.
|
||||
- **#261**: the search box could not match a past project by folder name: its
|
||||
session corpus was the live in-memory map, while past sessions come from
|
||||
`/api/sessions/unified`. Search now also harvests a bounded snapshot of that
|
||||
unified list, refreshed OUTSIDE the request path (published by
|
||||
`/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the
|
||||
search path keeps its no-filesystem-reads property. Results for a closed
|
||||
session resume the conversation instead of trying to select a tab that no
|
||||
longer exists, and are badged `RESUME`. In multi-user mode the snapshot is
|
||||
re-scoped per row on read, matching what `/api/sessions/unified` exposes.
|
||||
|
||||
Reported by @jordan8037310.
|
||||
@@ -234,7 +234,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
@@ -254,7 +254,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places**, `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
@@ -270,8 +272,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+23
-4
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -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. */
|
||||
|
||||
@@ -848,11 +848,9 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings. Always set it (not only when the
|
||||
// extended bar is on) so the bar's remembered agent-session layout matches
|
||||
// the setting before the first shell session swaps in the terminal bar.
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
KeyboardAccessoryBar.setMode(_kbSettings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
@@ -4459,10 +4457,6 @@ class CodemanApp {
|
||||
this.loadAttachmentHistory?.(sessionId);
|
||||
}
|
||||
this._updateLocalEchoState();
|
||||
// Shell sessions get the terminal keyboard bar, agent sessions the command
|
||||
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
|
||||
// just left, so it can never fire against the session we just opened.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -397,7 +397,27 @@
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<div class="history-header" id="historyHeader">
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<span class="history-count" id="historyCount" data-i18n-skip></span>
|
||||
<div class="history-controls">
|
||||
<input
|
||||
type="search"
|
||||
id="historyFilter"
|
||||
class="history-filter"
|
||||
placeholder="Filter…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="100"
|
||||
aria-label="Filter past conversations"
|
||||
/>
|
||||
<select id="historySort" class="search-select history-sort" aria-label="Sort past conversations">
|
||||
<option value="recent">Recent</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="folder">Folder A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* SHELL sessions get their own layout automatically (issue #262): Ctrl, Esc, Tab,
|
||||
* four arrows, paste, dismiss. Ctrl is a ONE-SHOT modifier: arm it, type a
|
||||
* character on the system keyboard, and terminal-ui.js's onData hook swaps the
|
||||
* character for its control byte (ctrlByteFor) and disarms. That is what makes
|
||||
* Ctrl+C/D/Z/R/L/A/E/W/U/K reachable without a button per chord. It resets on
|
||||
* use, on a second tap, on any other accessory key, on a session switch
|
||||
* (refreshForActiveSession) and when the keyboard is dismissed (hide).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
@@ -421,58 +414,12 @@ const PathPicker = {
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Control byte a terminal sends for Ctrl+<char> (issue #262).
|
||||
*
|
||||
* Returns null for characters with no control equivalent (digits, most
|
||||
* punctuation): the caller then sends the character unchanged, matching a
|
||||
* hardware keyboard where Ctrl+7 just types "7".
|
||||
*
|
||||
* `code & 0x1f` covers both ranges a terminal maps: @A-Z[\]^_ (64-95 → 0-31)
|
||||
* and a-z (97-122 → 1-26). Space and ? are the two conventional extras
|
||||
* (Ctrl+Space = NUL, Ctrl+? = DEL) and can't come from the mask.
|
||||
*/
|
||||
function ctrlByteFor(char) {
|
||||
if (typeof char !== 'string' || char.length !== 1) return null;
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 32) return '\x00';
|
||||
if (code === 63) return '\x7f';
|
||||
if ((code >= 64 && code <= 95) || (code >= 97 && code <= 122)) {
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an armed one-shot Ctrl to one chunk of terminal input.
|
||||
* Returns `{ data, consumed }`, where `consumed` tells the bar to disarm.
|
||||
*
|
||||
* Multi-character chunks (pastes, escape sequences, IME commits) have no
|
||||
* single key to modify, but they still spend the modifier: leaving it armed
|
||||
* would silently turn the NEXT innocent keystroke into a control byte.
|
||||
*/
|
||||
function applyOneShotCtrl(data) {
|
||||
if (typeof data !== 'string' || data.length === 0) return { data, consumed: false };
|
||||
if (data.length === 1) {
|
||||
const byte = ctrlByteFor(data);
|
||||
return { data: byte === null ? data : byte, consumed: true };
|
||||
}
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
// Layout currently in the DOM: 'simple' | 'extended' | 'shell'.
|
||||
_mode: 'simple',
|
||||
// Layout the user picked for AGENT sessions ('simple' | 'extended', the
|
||||
// extendedKeyboardBar setting). Shell sessions override it with the shell
|
||||
// bar; this is what we come back to when they switch to an agent tab.
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
@@ -501,45 +448,6 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for shell mode (issue #262): terminal controls instead of agent
|
||||
* commands. Ctrl is a one-shot modifier rather than one button per chord,
|
||||
* which is what puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a 9-button bar. */
|
||||
_shellButtons: `
|
||||
<button class="accessory-btn accessory-btn-ctrl" data-action="ctrl" title="Ctrl, then tap a key" aria-pressed="false">Ctrl</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||
_extendedButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
@@ -606,7 +514,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -622,91 +530,14 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Pick the layout the user wants for AGENT sessions ('simple' | 'extended',
|
||||
* the extendedKeyboardBar setting). A shell session keeps the shell bar;
|
||||
* the preference is remembered and applied on the next agent tab. */
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
setMode(mode) {
|
||||
this._baseMode = mode === 'extended' ? 'extended' : 'simple';
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Re-resolve the layout after the active session changed (issue #262):
|
||||
* shell sessions get the terminal bar, everything else the agent bar. Also
|
||||
* disarms Ctrl, because a modifier armed on one session must never fire on
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
_resolveMode() {
|
||||
return this._isShellSession() ? 'shell' : this._baseMode;
|
||||
},
|
||||
|
||||
_isShellSession() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return false;
|
||||
return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
|
||||
},
|
||||
|
||||
/** Swap the button set in the DOM. */
|
||||
_applyLayout(mode) {
|
||||
if (!this.element || mode === this._mode) return;
|
||||
if (mode === this._mode || !this.element) return;
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
// Reset before the rewrite: _setCtrl() styles the button it can find, and
|
||||
// the one holding the armed class is about to be replaced.
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
// Tap Ctrl, then type a character on the system keyboard: the character is
|
||||
// replaced by its control byte and Ctrl disarms. Tapping Ctrl again cancels.
|
||||
// The interception lives in the terminal onData handler (terminal-ui.js),
|
||||
// which is where system-keyboard input arrives on a phone. A keydown hook
|
||||
// would miss it, since virtual keyboards report no usable key events.
|
||||
|
||||
/** Is the one-shot Ctrl waiting for a key? */
|
||||
isCtrlArmed() {
|
||||
return this._ctrlArmed === true;
|
||||
},
|
||||
|
||||
/** Arm/cancel the one-shot Ctrl (the Ctrl button toggles). */
|
||||
toggleCtrl() {
|
||||
this._setCtrl(!this._ctrlArmed);
|
||||
},
|
||||
|
||||
/** Disarm: used by session switch, keyboard dismissal and every other key. */
|
||||
clearCtrl() {
|
||||
if (this._ctrlArmed) this._setCtrl(false);
|
||||
},
|
||||
|
||||
_setCtrl(on) {
|
||||
this._ctrlArmed = !!on;
|
||||
const btn = this.element?.querySelector('[data-action="ctrl"]');
|
||||
if (btn) {
|
||||
btn.classList.toggle('armed', this._ctrlArmed);
|
||||
btn.setAttribute('aria-pressed', this._ctrlArmed ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply an armed Ctrl to a chunk of typed input and disarm.
|
||||
* Returns the data unchanged (and leaves the modifier alone) when Ctrl is
|
||||
* not armed, so the caller can pipe every keystroke through it.
|
||||
*/
|
||||
consumeCtrl(data) {
|
||||
if (!this._ctrlArmed) return data;
|
||||
const result = applyOneShotCtrl(data);
|
||||
if (result.consumed) this.clearCtrl();
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/** Exposed for tests: pure char to control byte mapping. */
|
||||
ctrlByteFor,
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
@@ -714,15 +545,7 @@ const KeyboardAccessoryBar = {
|
||||
handleAction(action, btn) {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
// Any key other than Ctrl itself spends the modifier. It is a one-shot for
|
||||
// the next TYPED character, so an accessory key tapped in between (Esc, an
|
||||
// arrow, paste) must not leave it armed to bite the keystroke after that.
|
||||
if (action !== 'ctrl') this.clearCtrl();
|
||||
|
||||
switch (action) {
|
||||
case 'ctrl':
|
||||
this.toggleCtrl();
|
||||
break;
|
||||
case 'scroll-up':
|
||||
this.sendKey('\x1b[A');
|
||||
break;
|
||||
@@ -961,10 +784,6 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
/** Hide the accessory bar */
|
||||
hide() {
|
||||
// The bar goes away with the keyboard, so an armed Ctrl has nothing left
|
||||
// to modify, and a modifier the user can no longer see must not survive
|
||||
// to the next time they open the keyboard.
|
||||
this.clearCtrl();
|
||||
if (this.element) {
|
||||
this.element.classList.remove('visible');
|
||||
}
|
||||
|
||||
@@ -1162,18 +1162,6 @@ html.mobile-init .file-browser-panel {
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Phone palette is hardcoded in
|
||||
this block, so the state needs its own entry here. Three classes beat the
|
||||
plain .accessory-btn rules; the light-skin rule at the bottom of this file
|
||||
is higher still at (0,3,1) and is excluded by hand there, not outranked. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.9);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
@@ -2919,13 +2907,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* `.accessory-btn:not(.armed)` on purpose: this selector is (0,3,1) — `:is()`
|
||||
takes the specificity of its most specific argument, and `.btn-toolbar
|
||||
.btn-shell` is two classes — so it OUTRANKS the (0,3,0) armed-Ctrl rules in
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
|
||||
+84
-16
@@ -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;
|
||||
@@ -12081,20 +12163,6 @@ body.touch-device.cjk-input-visible .main {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Three classes on purpose, to beat
|
||||
the plain .accessory-btn rules. It still cannot outrank the light-skin
|
||||
compatibility rule in mobile.css, which repaints every .accessory-btn at
|
||||
(0,3,1) — `:is()` inherits its most specific argument — so that rule excludes
|
||||
`.armed` by hand. Without the exclusion the modifier looks unarmed on the four
|
||||
light skins, which is worse than having no armed style at all. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
+196
-101
@@ -15,15 +15,6 @@
|
||||
(function (global) {
|
||||
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
||||
// Pointer and focus reports xterm emits through onData on the terminal's OWN
|
||||
// initiative, with no key pressed: SGR mouse (DECSET 1006, also 1016), legacy
|
||||
// X10 mouse (DECSET 1000 — three raw bytes after CSI M) and focus in/out
|
||||
// (DECSET 1004). They are not query REPLIES, so the query-response filter
|
||||
// above does not match them, and they must keep reaching the PTY. What they
|
||||
// must NOT do is stand in for a keystroke: see isTerminalFocusOrMouseReport.
|
||||
const MOUSE_SGR_REPORT_PATTERN = /^\x1b\[<\d+;\d+;\d+[Mm]$/;
|
||||
const MOUSE_X10_REPORT_PATTERN = /^\x1b\[M[\s\S]{3}$/;
|
||||
const FOCUS_REPORT_PATTERN = /^\x1b\[[IO]$/;
|
||||
// Grace window after a manual scroll-up gesture during which sticky-scroll is
|
||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||
// back to the bottom while the user is inspecting earlier output.
|
||||
@@ -115,30 +106,6 @@
|
||||
return isTerminalQueryResponse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the terminal generate this chunk itself, rather than a human pressing a
|
||||
* key? True for mouse and focus reports (issue #262).
|
||||
*
|
||||
* Consumers that treat one onData chunk as "the next keystroke" must skip
|
||||
* these. The one-shot Ctrl modifier is why this exists, and the MOUSE half is
|
||||
* the live one: a shell session keeps the narrow scrollback strip, so mouse
|
||||
* DECSETs reach the browser and anything the user runs that enables tracking
|
||||
* (vim, htop, less) turns a tap into `\x1b[<0;31;23M`. Measured in a real
|
||||
* shell session: with Ctrl armed, one tap on the terminal spent it silently.
|
||||
*
|
||||
* Focus reports are the same class and cost nothing to cover, but they cannot
|
||||
* reach xterm today: `FOCUS_ESCAPE_FILTER` in session.ts strips `\x1b[?1004h`
|
||||
* (and the reports themselves) from every PTY read, so `sendFocusMode` never
|
||||
* turns on. Were that filter to go, the Ctrl button would spend the modifier
|
||||
* on its OWN refocus — the bar refocuses the terminal after every key so the
|
||||
* keyboard stays open, and that refocus emits `\x1b[I`.
|
||||
*/
|
||||
function isTerminalFocusOrMouseReport(data) {
|
||||
return (
|
||||
FOCUS_REPORT_PATTERN.test(data) || MOUSE_SGR_REPORT_PATTERN.test(data) || MOUSE_X10_REPORT_PATTERN.test(data)
|
||||
);
|
||||
}
|
||||
|
||||
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
|
||||
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||
@@ -167,7 +134,6 @@
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
isTerminalFocusOrMouseReport,
|
||||
isComposerNavKey,
|
||||
classifyPredictInput,
|
||||
isCodexComposerRow,
|
||||
@@ -964,28 +930,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// A virtual keyboard reports no usable key events, so a keydown hook
|
||||
// would never see the character the modifier applies to: it arrives
|
||||
// here as onData text. Sits AFTER the query-response filter so xterm's
|
||||
// own DA/CPR replies can never spend the modifier, and BEFORE every
|
||||
// send path so the control byte follows the normal control-char route
|
||||
// (immediate flush, local-echo state cleared).
|
||||
//
|
||||
// Mouse and focus reports are skipped rather than suppressed: they are
|
||||
// real bytes the PTY still needs, they just were not typed by anyone.
|
||||
// A shell session passes mouse DECSETs through, so with vim or htop
|
||||
// running, one tap on the terminal used to spend the modifier silently
|
||||
// (measured against a real shell). See isTerminalFocusOrMouseReport.
|
||||
if (
|
||||
typeof KeyboardAccessoryBar !== 'undefined' &&
|
||||
KeyboardAccessoryBar.isCtrlArmed?.() &&
|
||||
!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)
|
||||
) {
|
||||
data = KeyboardAccessoryBar.consumeCtrl(data);
|
||||
}
|
||||
|
||||
this._lastTerminalData = { data, time: performance.now() };
|
||||
|
||||
// ── Local Echo Pass-through ──
|
||||
@@ -1684,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');
|
||||
@@ -2010,7 +1954,18 @@ 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).
|
||||
* 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');
|
||||
@@ -2024,7 +1979,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) {
|
||||
@@ -2032,27 +1987,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) {
|
||||
@@ -2061,6 +2003,161 @@ 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
|
||||
* 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');
|
||||
const sort = document.getElementById('historySort');
|
||||
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) => {
|
||||
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', () => {
|
||||
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). */
|
||||
_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;
|
||||
// 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));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -2640,20 +2737,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||
return;
|
||||
}
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// While the CJK field is visible it OWNS the keyboard: onData returns early
|
||||
// for everything it swallows, and the focus router even redirects
|
||||
// terminal.focus() into it — which is where the accessory bar sends focus
|
||||
// after every key. So the onData hook never sees these keystrokes, and an
|
||||
// armed modifier could neither fire NOR be spent: it survived until a
|
||||
// session switch and then turned an innocent keystroke into a control byte.
|
||||
// This is the module's single choke point to the PTY, so applying it here
|
||||
// covers typed characters, IME flushes, Enter, backspace and arrows at once.
|
||||
// Same policy as the onData hook: the next single character is modified,
|
||||
// anything longer merely spends the modifier.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.()) {
|
||||
text = KeyboardAccessoryBar.consumeCtrl(text);
|
||||
}
|
||||
// Bypasses onData (like insertTerminalText): predictions cannot see this
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||
@@ -3902,13 +3985,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 = '';
|
||||
@@ -3977,9 +4063,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';
|
||||
@@ -4011,13 +4099,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();
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string, string | undefined>();
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
for (const p of Object.values(stored)) ownerById.set(p.id, p.owner);
|
||||
// Live wins: a session's owner on disk can lag the running one.
|
||||
for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner);
|
||||
const liveIds = new Set(ctx.sessions.keys());
|
||||
setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds));
|
||||
}
|
||||
|
||||
// Rebuild hook for the search route: it kicks this (fire-and-forget) when the
|
||||
// snapshot goes stale, so a search never pays for the scan itself.
|
||||
setHistoryIndexRefresher(async () => {
|
||||
if (ctx.testMode) return;
|
||||
publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs()));
|
||||
});
|
||||
|
||||
// Unified, read-only session list: merges live + persisted + lifecycle +
|
||||
// transcript history + mux stats into one de-duplicated, searchable list
|
||||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
||||
app.get('/api/sessions/unified', async (req) => {
|
||||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
||||
|
||||
if (ctx.testMode) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
|
||||
const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs();
|
||||
|
||||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||
// 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, {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Bounded in-memory index of PAST sessions, harvested by `GET /api/search`.
|
||||
*
|
||||
* `GET /api/search` used to build its session corpus from the live in-memory
|
||||
* session map alone, so a folder sitting in the home screen's "Resume
|
||||
* Conversation" list matched nothing (issue #261). The corpus that list renders
|
||||
* comes from `GET /api/sessions/unified`, which reads the lifecycle log and every
|
||||
* Claude transcript file: disk I/O the search path deliberately does not do (its
|
||||
* no-fs property is what keeps a per-keystroke query cheap and traversal-free).
|
||||
*
|
||||
* This module is the seam between the two: a capped snapshot of the unified list
|
||||
* that the search route reads synchronously, refreshed OUT of the request path.
|
||||
* Two things fill it:
|
||||
* 1. `/api/sessions/unified` writes it as a side effect (free, it just merged
|
||||
* that list). The home screen calls that endpoint whenever it opens, which
|
||||
* is the same screen the search box lives on, so it is warm in practice.
|
||||
* 2. `ensureHistorySessionIndexFresh()`, fire-and-forget, single-flight,
|
||||
* TTL-guarded, kicks the registered refresher when a search finds the
|
||||
* snapshot stale. The caller never awaits it: the current query answers from
|
||||
* the existing snapshot and the next one sees fresh data.
|
||||
*
|
||||
* OWNERSHIP: each item carries the `owner` of the session it came from, and rows
|
||||
* not tied to any live/persisted session (host-wide transcript history) carry
|
||||
* `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule
|
||||
* exactly, in multi-user mode a non-admin sees neither other users' sessions nor
|
||||
* unowned host-wide history, and in single-user mode every check short-circuits
|
||||
* true. The snapshot is written UNSCOPED, so it must never be returned unfiltered.
|
||||
*
|
||||
* Key exports:
|
||||
* - setHistorySessionIndex / getHistorySessionIndex: the snapshot accessors.
|
||||
* - buildHistorySessionIndexItems: pure merged-list → index-item projection.
|
||||
* - setHistoryIndexRefresher / ensureHistorySessionIndexFresh: the refresh hook.
|
||||
*/
|
||||
|
||||
/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */
|
||||
export interface HistorySessionIndexItem {
|
||||
/** Codeman session id (the search result's session id and dedupe key). */
|
||||
sessionId: string;
|
||||
/** Display name, may be empty for a transcript-only row. */
|
||||
name: string;
|
||||
/** Absolute working directory, the field issue #261 is about matching. */
|
||||
workingDir: string;
|
||||
/** Claude conversation UUID, when known: what a resume actually replays. */
|
||||
claudeSessionId?: string;
|
||||
/** Recency timestamp (lastActivityAt, else createdAt). */
|
||||
timestamp: number;
|
||||
/**
|
||||
* Owning user, when the row is tied to a live or persisted session. `undefined`
|
||||
* means host-wide transcript history, which only admins (or single-user mode)
|
||||
* may see, the same rule `/api/sessions/unified` applies.
|
||||
*/
|
||||
owner?: string;
|
||||
/** True when the session is still in the live map (search harvests those directly). */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/** Hard cap on snapshot size, so a host with thousands of transcripts stays bounded. */
|
||||
export const HISTORY_INDEX_MAX_ITEMS = 400;
|
||||
|
||||
/** How long a snapshot is considered fresh before a search triggers a background refresh. */
|
||||
export const HISTORY_INDEX_TTL_MS = 60_000;
|
||||
|
||||
interface HistorySessionIndexSnapshot {
|
||||
items: HistorySessionIndexItem[];
|
||||
/** Epoch ms of the last write; 0 when never populated. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
let snapshot: HistorySessionIndexSnapshot = { items: [], updatedAt: 0 };
|
||||
let refresher: (() => Promise<void>) | null = null;
|
||||
let refreshInFlight = false;
|
||||
|
||||
/** The merged-list shape this module projects from (a subset of `UnifiedSessionItem`). */
|
||||
export interface MergedSessionLike {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
workingDir?: string;
|
||||
claudeSessionId?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a merged unified list into index items. PURE, the caller supplies the
|
||||
* owner lookup and the live-id set it already has in hand.
|
||||
*
|
||||
* Rows with no working directory AND no name are dropped: they can never match a
|
||||
* query in a useful way and would only consume the cap.
|
||||
*
|
||||
* @param merged unified-list items, newest-first (the order the merge returns)
|
||||
* @param ownerById owner of a session id, for rows tied to a live/persisted session
|
||||
* @param liveIds session ids currently in the live map
|
||||
*/
|
||||
export function buildHistorySessionIndexItems(
|
||||
merged: MergedSessionLike[],
|
||||
ownerById: Map<string, string | undefined>,
|
||||
liveIds: Set<string>
|
||||
): HistorySessionIndexItem[] {
|
||||
const items: HistorySessionIndexItem[] = [];
|
||||
for (const m of merged) {
|
||||
if (items.length >= HISTORY_INDEX_MAX_ITEMS) break;
|
||||
const name = m.name ?? '';
|
||||
const workingDir = m.workingDir ?? '';
|
||||
if (!name && !workingDir) continue;
|
||||
items.push({
|
||||
sessionId: m.sessionId,
|
||||
name,
|
||||
workingDir,
|
||||
claudeSessionId: m.claudeSessionId,
|
||||
timestamp: m.lastActivityAt ?? m.createdAt ?? 0,
|
||||
owner: ownerById.get(m.sessionId),
|
||||
live: liveIds.has(m.sessionId),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Replace the snapshot. Items are capped defensively even if the caller already did. */
|
||||
export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = Date.now()): void {
|
||||
snapshot = { items: items.slice(0, HISTORY_INDEX_MAX_ITEMS), updatedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the snapshot. The returned array is UNSCOPED, callers must apply the
|
||||
* per-item ownership check before exposing any of it.
|
||||
*/
|
||||
export function getHistorySessionIndex(): HistorySessionIndexSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** True when the snapshot has never been written, or is older than the TTL. */
|
||||
export function isHistorySessionIndexStale(now = Date.now(), ttlMs = HISTORY_INDEX_TTL_MS): boolean {
|
||||
return snapshot.updatedAt === 0 || now - snapshot.updatedAt > ttlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the rebuild function. Called once by the session routes, which own the
|
||||
* transcript scanner and the stores the unified list is merged from.
|
||||
*/
|
||||
export function setHistoryIndexRefresher(fn: (() => Promise<void>) | null): void {
|
||||
refresher = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick a background rebuild if the snapshot is stale. Returns immediately,
|
||||
* NEVER await this from a request handler, that is the whole point: the search
|
||||
* path answers from the current snapshot and stays free of disk I/O.
|
||||
*/
|
||||
export function ensureHistorySessionIndexFresh(now = Date.now()): void {
|
||||
if (refreshInFlight || !refresher || !isHistorySessionIndexStale(now)) return;
|
||||
refreshInFlight = true;
|
||||
void refresher()
|
||||
.catch(() => {
|
||||
// A failed rebuild leaves the previous snapshot in place; the next search retries.
|
||||
})
|
||||
.finally(() => {
|
||||
refreshInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Test hook: drop the snapshot and any registered refresher. */
|
||||
export function resetHistorySessionIndex(): void {
|
||||
snapshot = { items: [], updatedAt: 0 };
|
||||
refresher = null;
|
||||
refreshInFlight = false;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @fileoverview Issue #260, the home screen's "Resume Conversation" list.
|
||||
*
|
||||
* With ~35 past sessions the list showed 4 rows, then a button that dumped every
|
||||
* remaining row into a fixed 240px box, with no way to sort or filter. The fix
|
||||
* moved rendering into `_renderHistoryList()` over a cached corpus, so what is
|
||||
* worth pinning is the model, not the pixels:
|
||||
* 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4,
|
||||
* 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is
|
||||
* class-driven, without the class, expanding just deepens a scroll well),
|
||||
* 3. filtering matches name / folder / case label / prompt, and implies
|
||||
* expansion (hiding matches behind "Show more" defeats typing a filter),
|
||||
* 4. sorting is alphabetical by name or folder, with pinned rows still on top.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
|
||||
* resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get
|
||||
* rendered and in what order, not how one row looks.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface FakeEl {
|
||||
id: string;
|
||||
value: string;
|
||||
textContent: string;
|
||||
scrollTop: number;
|
||||
className: string;
|
||||
children: FakeEl[];
|
||||
classes: Set<string>;
|
||||
listeners: Record<string, ((ev: unknown) => void)[]>;
|
||||
classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean };
|
||||
replaceChildren: () => void;
|
||||
appendChild: (child: FakeEl) => FakeEl;
|
||||
addEventListener: (type: string, fn: (ev: unknown) => void) => void;
|
||||
style: Record<string, string>;
|
||||
}
|
||||
|
||||
function fakeEl(id: string): FakeEl {
|
||||
const el = {
|
||||
id,
|
||||
value: '',
|
||||
textContent: '',
|
||||
scrollTop: 0,
|
||||
className: '',
|
||||
children: [] as FakeEl[],
|
||||
classes: new Set<string>(),
|
||||
listeners: {} as Record<string, ((ev: unknown) => void)[]>,
|
||||
style: {} as Record<string, string>,
|
||||
} as FakeEl;
|
||||
el.classList = {
|
||||
toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)),
|
||||
contains: (c: string) => el.classes.has(c),
|
||||
};
|
||||
el.replaceChildren = () => {
|
||||
el.children = [];
|
||||
};
|
||||
el.appendChild = (child: FakeEl) => {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
};
|
||||
el.addEventListener = (type: string, fn: (ev: unknown) => void) => {
|
||||
(el.listeners[type] ||= []).push(fn);
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* The element map the vm's `document.getElementById` resolves against. Swapped
|
||||
* per test, the closure is defined in THIS realm, so the shipping code inside
|
||||
* the vm reads whatever the current test installed.
|
||||
*/
|
||||
let currentEls: Record<string, FakeEl> = {};
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: (id: string) => currentEls[id] ?? null,
|
||||
createElement: () => fakeEl('created'),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
const proto = loadTerminalUiPrototype();
|
||||
|
||||
type Row = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
workingDir?: string;
|
||||
firstPrompt?: string;
|
||||
pinned?: boolean;
|
||||
lastActivityAt?: number;
|
||||
};
|
||||
|
||||
/** Host object carrying the real render/filter/sort methods over a fake DOM. */
|
||||
function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) {
|
||||
const els: Record<string, FakeEl> = {
|
||||
historyList: fakeEl('historyList'),
|
||||
historyFilter: fakeEl('historyFilter'),
|
||||
historySort: fakeEl('historySort'),
|
||||
historyCount: fakeEl('historyCount'),
|
||||
};
|
||||
els.historySort.value = 'recent';
|
||||
|
||||
const app: any = {
|
||||
_HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT,
|
||||
_historyAll: rows,
|
||||
_historyCases: cases,
|
||||
_renderHistoryList: proto._renderHistoryList,
|
||||
_historyRowMatches: proto._historyRowMatches,
|
||||
_sortHistoryRows: proto._sortHistoryRows,
|
||||
_historyRowLabel: proto._historyRowLabel,
|
||||
_resolveCaseLabel: proto._resolveCaseLabel,
|
||||
_shortenHomePath: proto._shortenHomePath,
|
||||
// One fake node per row, tagged so assertions can read back the order.
|
||||
_buildHistoryItem: (s: Row) => {
|
||||
const el = fakeEl('item');
|
||||
el.textContent = s.sessionId;
|
||||
return el;
|
||||
},
|
||||
els,
|
||||
/** Rendered row ids, excluding the show-more/less button and empty state. */
|
||||
renderedIds(): string[] {
|
||||
return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent);
|
||||
},
|
||||
button(): FakeEl | undefined {
|
||||
return els.historyList.children.find((c) => c.id === 'created');
|
||||
},
|
||||
};
|
||||
|
||||
// Point the vm's document at this app's elements, then run the shipping method.
|
||||
app._render = () => {
|
||||
currentEls = els;
|
||||
app._renderHistoryList();
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
function rows(n: number, overrides: Partial<Row> = {}): Row[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `w${i}-project${i}`,
|
||||
workingDir: `/home/u/project${i}`,
|
||||
lastActivityAt: 1000 - i,
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('issue #260: collapsed page size', () => {
|
||||
it('shows more than the old 4 rows before "Show more"', () => {
|
||||
expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('renders the initial page and a "Show more" button for the rest', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT);
|
||||
expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('expanding renders every row AND marks the box expanded', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._historyExpanded = true;
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(35);
|
||||
// Without this class the CSS max-height stays at the collapsed cap and the
|
||||
// extra rows land in a four-row scroll well, the original bug.
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.button()?.textContent).toBe('Show less');
|
||||
});
|
||||
|
||||
it('shows no button at all when everything fits', () => {
|
||||
const app = makeApp(rows(3));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(3);
|
||||
expect(app.button()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260: filter', () => {
|
||||
it('matches on folder name and shows every match without expanding first', () => {
|
||||
const app = makeApp([
|
||||
...rows(30),
|
||||
{ sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 },
|
||||
{ sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 },
|
||||
]);
|
||||
app.els.historyFilter.value = 'invoices';
|
||||
app._render();
|
||||
expect(app.renderedIds().sort()).toEqual(['x1', 'x2']);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.els.historyCount.textContent).toBe('2 of 32');
|
||||
});
|
||||
|
||||
it('matches on the case label and on a prompt', () => {
|
||||
const app = makeApp(
|
||||
[
|
||||
{ sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 },
|
||||
{
|
||||
sessionId: 'p1',
|
||||
name: 'w2-y',
|
||||
workingDir: '/home/u/other',
|
||||
firstPrompt: 'fix the CSV export',
|
||||
lastActivityAt: 2,
|
||||
},
|
||||
],
|
||||
[{ name: 'billing', path: '/home/u/cases/billing' }]
|
||||
);
|
||||
app.els.historyFilter.value = '#billing';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c1']);
|
||||
|
||||
app.els.historyFilter.value = 'csv export';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('renders an empty state when nothing matches', () => {
|
||||
const app = makeApp(rows(5));
|
||||
app.els.historyFilter.value = 'zzzz';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual([]);
|
||||
expect(app.els.historyList.children[0].textContent).toContain('No conversations match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260: sort', () => {
|
||||
const unsorted: Row[] = [
|
||||
{ sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 },
|
||||
{ sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('recent keeps the backend order', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by name', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by folder basename', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'folder';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('sorts transcript rows (no session name) by the prompt shown as their title', () => {
|
||||
// Most past rows come from a transcript and have no name at all. Keying the
|
||||
// A–Z sort off `name` alone made "Name A–Z" a no-op for them.
|
||||
const app = makeApp([
|
||||
{ sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' },
|
||||
{ sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' },
|
||||
{ sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' },
|
||||
]);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'm', 'z']);
|
||||
});
|
||||
|
||||
it('keeps pinned rows on top in every sort mode', () => {
|
||||
const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]);
|
||||
for (const mode of ['recent', 'name', 'folder']) {
|
||||
app.els.historySort.value = mode;
|
||||
app._render();
|
||||
expect(app.renderedIds()[0]).toBe('p');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Shell-specific mobile keyboard bar and its one-shot Ctrl
|
||||
* modifier (issue #262).
|
||||
*
|
||||
* The bar is a `const` singleton in a non-module script, so it is loaded with
|
||||
* `vm` against a small fake DOM (no jsdom in this repo), the same approach as
|
||||
* test/path-picker-ui.test.ts. What matters here is the state machine: which
|
||||
* layout a session gets, when the modifier arms, what byte a keystroke turns
|
||||
* into, and every path that must disarm it. Behavior against a real shell
|
||||
* (Ctrl+C reaching the PTY) is covered in test/mobile/keyboard.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
type TerminalInput = { isTerminalFocusOrMouseReport(data: string): boolean };
|
||||
type TerminalModule = {
|
||||
terminalInput: TerminalInput;
|
||||
CodemanApp: { prototype: Record<string, (...args: never[]) => unknown> };
|
||||
bar: Bar;
|
||||
};
|
||||
let terminalModule: TerminalModule | null = null;
|
||||
|
||||
/**
|
||||
* terminal-ui.js in a vm, with the REAL accessory bar in the same script scope
|
||||
* (it is a `const` singleton, so only a shared scope makes the bare
|
||||
* `KeyboardAccessoryBar` reference in the CJK path resolve). Its IIFE only
|
||||
* needs a window to hang `CodemanTerminalInput` on, but the rest of the file
|
||||
* assigns to CodemanApp.prototype at top level, so constants.js + app.js load
|
||||
* first — the same recipe as test/local-echo-codex-gating.test.ts.
|
||||
*/
|
||||
function loadTerminalModule(): TerminalModule {
|
||||
if (terminalModule) return terminalModule;
|
||||
const read = (file: string) => readFileSync(resolve(`src/web/public/${file}`), 'utf8');
|
||||
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
URLSearchParams,
|
||||
document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: windowStub,
|
||||
MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${keyboardSource}\n${read('app.js')}\n${terminalSource}\n` +
|
||||
`globalThis.__CodemanApp = CodemanApp; globalThis.__bar = KeyboardAccessoryBar;`,
|
||||
context
|
||||
);
|
||||
const exported = context as unknown as { __CodemanApp: TerminalModule['CodemanApp']; __bar: Bar };
|
||||
terminalModule = {
|
||||
terminalInput: (windowStub as { CodemanTerminalInput?: TerminalInput }).CodemanTerminalInput!,
|
||||
CodemanApp: exported.__CodemanApp,
|
||||
bar: exported.__bar,
|
||||
};
|
||||
return terminalModule;
|
||||
}
|
||||
|
||||
function loadTerminalInput(): TerminalInput {
|
||||
return loadTerminalModule().terminalInput;
|
||||
}
|
||||
|
||||
type FakeButton = {
|
||||
dataset: { action: string };
|
||||
classList: { has: Set<string>; toggle(name: string, on: boolean): void; contains(name: string): boolean };
|
||||
attrs: Record<string, string>;
|
||||
setAttribute(name: string, value: string): void;
|
||||
};
|
||||
|
||||
function fakeButton(action: string): FakeButton {
|
||||
const has = new Set<string>();
|
||||
return {
|
||||
dataset: { action },
|
||||
classList: {
|
||||
has,
|
||||
toggle(name: string, on: boolean) {
|
||||
if (on) has.add(name);
|
||||
else has.delete(name);
|
||||
},
|
||||
contains: (name: string) => has.has(name),
|
||||
},
|
||||
attrs: {},
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attrs[name] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fake bar element: tracks the button set parsed out of the assigned HTML. */
|
||||
function fakeBarElement() {
|
||||
let html = '';
|
||||
let buttons = new Map<string, FakeButton>();
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
className: '',
|
||||
classList: {
|
||||
add: (name: string) => classes.add(name),
|
||||
remove: (name: string) => classes.delete(name),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
set innerHTML(next: string) {
|
||||
html = next;
|
||||
buttons = new Map();
|
||||
for (const match of next.matchAll(/data-action="([^"]+)"/g)) {
|
||||
buttons.set(match[1], fakeButton(match[1]));
|
||||
}
|
||||
},
|
||||
get actions() {
|
||||
return [...buttons.keys()];
|
||||
},
|
||||
querySelector(selector: string) {
|
||||
const match = /\[data-action="([^"]+)"\]/.exec(selector);
|
||||
return match ? (buttons.get(match[1]) ?? null) : null;
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
type Bar = {
|
||||
element: ReturnType<typeof fakeBarElement>;
|
||||
_mode: string;
|
||||
init(): void;
|
||||
setMode(mode: string): void;
|
||||
refreshForActiveSession(): void;
|
||||
handleAction(action: string, btn?: unknown): void;
|
||||
isCtrlArmed(): boolean;
|
||||
toggleCtrl(): void;
|
||||
clearCtrl(): void;
|
||||
consumeCtrl(data: string): string;
|
||||
ctrlByteFor(char: string): string | null;
|
||||
hide(): void;
|
||||
show(): void;
|
||||
};
|
||||
|
||||
function loadBar(sessionMode = 'claude') {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map<string, { mode: string }>([['session-1', { mode: sessionMode }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, catch: () => {} }));
|
||||
const barElement = fakeBarElement();
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => true },
|
||||
URLSearchParams,
|
||||
fetch: fetchMock,
|
||||
document: {
|
||||
createElement: () => barElement,
|
||||
querySelector: () => ({ parentNode: { insertBefore: vi.fn() } }),
|
||||
},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar;`, context, {
|
||||
filename: 'keyboard-accessory.js',
|
||||
});
|
||||
const bar = (context as unknown as { __bar: Bar }).__bar;
|
||||
bar.init();
|
||||
return { app, bar, barElement, fetchMock };
|
||||
}
|
||||
|
||||
describe('ctrlByteFor: character to control byte', () => {
|
||||
const { bar } = loadBar();
|
||||
|
||||
it.each([
|
||||
['c', '\x03'], // interrupt
|
||||
['d', '\x04'], // EOF
|
||||
['z', '\x1a'], // suspend
|
||||
['r', '\x12'], // reverse search
|
||||
['l', '\x0c'], // clear
|
||||
['a', '\x01'],
|
||||
['e', '\x05'],
|
||||
['w', '\x17'],
|
||||
['u', '\x15'],
|
||||
['k', '\x0b'],
|
||||
])('maps %s to its control byte', (char, byte) => {
|
||||
expect(bar.ctrlByteFor(char)).toBe(byte);
|
||||
});
|
||||
|
||||
it('maps uppercase the same as lowercase (Ctrl+C == Ctrl+c)', () => {
|
||||
expect(bar.ctrlByteFor('C')).toBe('\x03');
|
||||
expect(bar.ctrlByteFor('D')).toBe('\x04');
|
||||
});
|
||||
|
||||
it('maps the punctuation controls a terminal defines', () => {
|
||||
expect(bar.ctrlByteFor('@')).toBe('\x00');
|
||||
expect(bar.ctrlByteFor('[')).toBe('\x1b'); // Ctrl+[ is Escape
|
||||
expect(bar.ctrlByteFor('\\')).toBe('\x1c');
|
||||
expect(bar.ctrlByteFor(']')).toBe('\x1d');
|
||||
expect(bar.ctrlByteFor('^')).toBe('\x1e');
|
||||
expect(bar.ctrlByteFor('_')).toBe('\x1f');
|
||||
expect(bar.ctrlByteFor(' ')).toBe('\x00'); // Ctrl+Space = NUL
|
||||
expect(bar.ctrlByteFor('?')).toBe('\x7f'); // Ctrl+? = DEL
|
||||
});
|
||||
|
||||
it('returns null for characters with no control equivalent', () => {
|
||||
// A hardware keyboard types these straight through under Ctrl.
|
||||
for (const char of ['1', '9', '.', ',', '/', '-', '=', 'é']) {
|
||||
expect(bar.ctrlByteFor(char)).toBeNull();
|
||||
}
|
||||
expect(bar.ctrlByteFor('ab')).toBeNull();
|
||||
expect(bar.ctrlByteFor('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell keyboard bar selection', () => {
|
||||
it('gives a shell session the terminal bar', () => {
|
||||
const { bar, barElement } = loadBar('shell');
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('shell');
|
||||
expect(barElement.actions).toEqual([
|
||||
'ctrl',
|
||||
'esc',
|
||||
'tab',
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'paste',
|
||||
'dismiss',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity'])('leaves a %s session on the agent bar', (mode) => {
|
||||
const { bar, barElement } = loadBar(mode);
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
expect(barElement.actions).toContain('init');
|
||||
expect(barElement.actions).not.toContain('ctrl');
|
||||
});
|
||||
|
||||
it('remembers the extended-bar preference across a shell session', () => {
|
||||
const { app, bar, barElement } = loadBar('claude');
|
||||
bar.setMode('extended');
|
||||
expect(bar._mode).toBe('extended');
|
||||
|
||||
app.sessions.set('shell-1', { mode: 'shell' });
|
||||
app.activeSessionId = 'shell-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('shell');
|
||||
|
||||
// Settings saved while the shell bar is up must not yank it away...
|
||||
bar.setMode('extended');
|
||||
expect(bar._mode).toBe('shell');
|
||||
|
||||
// ...and switching back to the agent session restores the user's choice.
|
||||
app.activeSessionId = 'session-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('extended');
|
||||
expect(barElement.actions).toContain('compact');
|
||||
});
|
||||
|
||||
it('falls back to the agent bar with no active session', () => {
|
||||
const { app, bar } = loadBar('shell');
|
||||
app.activeSessionId = null as unknown as string;
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl modifier', () => {
|
||||
function shellBar() {
|
||||
const loaded = loadBar('shell');
|
||||
loaded.bar.refreshForActiveSession();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
it('is disarmed until the Ctrl key is tapped', () => {
|
||||
const { bar } = shellBar();
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('arms visibly and rewrites the next character as its control byte', () => {
|
||||
const { bar, barElement } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
const button = barElement.querySelector('[data-action="ctrl"]')!;
|
||||
expect(button.classList.contains('armed')).toBe(true);
|
||||
expect(button.attrs['aria-pressed']).toBe('true');
|
||||
|
||||
expect(bar.consumeCtrl('c')).toBe('\x03');
|
||||
|
||||
// One shot: spent, and the button says so.
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(button.classList.contains('armed')).toBe(false);
|
||||
expect(button.attrs['aria-pressed']).toBe('false');
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('sends Ctrl+D for the next key too', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('d')).toBe('\x04');
|
||||
});
|
||||
|
||||
it('cancels on a second tap of Ctrl', () => {
|
||||
const { bar, barElement } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(barElement.querySelector('[data-action="ctrl"]')!.classList.contains('armed')).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
|
||||
it('passes a character with no control byte through unchanged, spending the modifier', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('7')).toBe('7');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on a paste instead of leaving it armed for the next keystroke', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.consumeCtrl('git status')).toBe('git status');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled by any other accessory key', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.handleAction('esc');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled by a session switch', () => {
|
||||
const { app, bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
|
||||
app.sessions.set('shell-2', { mode: 'shell' });
|
||||
app.activeSessionId = 'shell-2';
|
||||
bar.refreshForActiveSession();
|
||||
|
||||
// Same layout, but the modifier must not survive into the next session.
|
||||
expect(bar._mode).toBe('shell');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('is cancelled when the keyboard is dismissed', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
bar.hide();
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the armed state when the layout is swapped out from under it', () => {
|
||||
const { app, bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
app.sessions.set('agent-1', { mode: 'claude' });
|
||||
app.activeSessionId = 'agent-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(bar._mode).toBe('simple');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
expect(bar.consumeCtrl('c')).toBe('c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal input wiring', () => {
|
||||
it('applies the modifier in onData after the query-response filter and before the send paths', () => {
|
||||
const hook = terminalSource.indexOf('KeyboardAccessoryBar.consumeCtrl(data)');
|
||||
const queryFilter = terminalSource.indexOf('shouldSuppressTerminalQueryResponse(data)', hook - 4000);
|
||||
const firstSend = terminalSource.indexOf('this._lastTerminalData', hook - 4000);
|
||||
|
||||
expect(hook).toBeGreaterThan(0);
|
||||
// xterm answers DA/CPR queries through onData as well; letting one of those
|
||||
// spend the modifier would silently eat the user's Ctrl.
|
||||
expect(queryFilter).toBeGreaterThan(0);
|
||||
expect(queryFilter).toBeLessThan(hook);
|
||||
// Every send path (local echo, predictive echo, plain flush) reads `data`
|
||||
// after this point, so the control byte reaches the PTY unchanged.
|
||||
expect(firstSend).toBeGreaterThan(hook);
|
||||
});
|
||||
|
||||
it('guards the hook so a page without the bar (desktop) still types normally', () => {
|
||||
expect(terminalSource).toContain("typeof KeyboardAccessoryBar !== 'undefined'");
|
||||
});
|
||||
|
||||
it('skips terminal-generated focus and mouse reports', () => {
|
||||
// Pins the gate itself: without it the modifier is spent by the `\x1b[I`
|
||||
// that the Ctrl button's own refocus emits (see the describe below).
|
||||
expect(terminalSource).toContain('!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalInput.isTerminalFocusOrMouseReport', () => {
|
||||
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
||||
|
||||
it.each([
|
||||
['\x1b[I', 'focus in (DECSET 1004)'],
|
||||
['\x1b[O', 'focus out (DECSET 1004)'],
|
||||
['\x1b[<0;10;5M', 'SGR mouse press'],
|
||||
['\x1b[<0;10;5m', 'SGR mouse release'],
|
||||
['\x1b[<64;10;5M', 'SGR wheel up'],
|
||||
['\x1b[M !!', 'legacy X10 mouse'],
|
||||
])('classifies %j as terminal-generated (%s)', (data) => {
|
||||
expect(isReport(data)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['c', 'a typed character'],
|
||||
['\x03', 'a control byte'],
|
||||
['\r', 'Enter'],
|
||||
['\x1b', 'the Escape key'],
|
||||
['\x1b[A', 'an arrow key'],
|
||||
['\x1b[200~hi\x1b[201~', 'a bracketed paste'],
|
||||
['\x1b[?1;2c', 'a DA reply'],
|
||||
['I', 'the letter I'],
|
||||
])('leaves %j alone (%s)', (data) => {
|
||||
expect(isReport(data)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl vs terminal-generated reports', () => {
|
||||
// The onData gate, as terminal-ui.js writes it. The wiring test above pins
|
||||
// the real source; this proves the behavior the gate buys.
|
||||
function feed(bar: Bar, data: string): string {
|
||||
const isReport = loadTerminalInput().isTerminalFocusOrMouseReport;
|
||||
return bar.isCtrlArmed() && !isReport(data) ? bar.consumeCtrl(data) : data;
|
||||
}
|
||||
|
||||
function shellBar() {
|
||||
const loaded = loadBar('shell');
|
||||
loaded.bar.refreshForActiveSession();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
it('survives a tap once an app in the pane turns mouse reporting on', () => {
|
||||
const { bar } = shellBar();
|
||||
// The live case: a shell session keeps the narrow scrollback strip, so mouse
|
||||
// DECSETs reach the browser. Measured against a real shell with vim-style
|
||||
// tracking on, one tap on the terminal spent the armed modifier silently.
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, '\x1b[<0;10;5M')).toBe('\x1b[<0;10;5M');
|
||||
expect(feed(bar, '\x1b[<0;10;5m')).toBe('\x1b[<0;10;5m');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
|
||||
// ...so the character the user actually types is still the one modified.
|
||||
expect(feed(bar, 'd')).toBe('\x04');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a focus report, should one ever reach xterm', () => {
|
||||
// Defense in depth: FOCUS_ESCAPE_FILTER (session.ts) strips `\x1b[?1004h`
|
||||
// from every PTY read, so sendFocusMode never turns on today. If it did,
|
||||
// the bar's own post-key refocus would emit `\x1b[I` and eat the modifier
|
||||
// before the user typed a single character.
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, '\x1b[I')).toBe('\x1b[I');
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
expect(feed(bar, 'c')).toBe('\x03');
|
||||
});
|
||||
|
||||
it('still spends the modifier on a paste, which is real input', () => {
|
||||
const { bar } = shellBar();
|
||||
bar.handleAction('ctrl');
|
||||
expect(feed(bar, 'git status')).toBe('git status');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-shot Ctrl through the CJK input field', () => {
|
||||
// The CJK textarea swallows keystrokes before onData sees them, so the CJK
|
||||
// send path needs the modifier applied too. These drive the REAL
|
||||
// _handleCjkInput against the REAL bar, both loaded into one vm scope.
|
||||
function cjkApp() {
|
||||
const { CodemanApp, bar } = loadTerminalModule();
|
||||
bar.clearCtrl();
|
||||
const app = Object.create(CodemanApp.prototype) as {
|
||||
activeSessionId: string;
|
||||
_sendInputAsync: ReturnType<typeof vi.fn>;
|
||||
_handleCjkInput(text: string): void;
|
||||
};
|
||||
app.activeSessionId = 'cjk-session';
|
||||
app._sendInputAsync = vi.fn();
|
||||
return { app, bar };
|
||||
}
|
||||
|
||||
it('sends the control byte for a character typed into the CJK field', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('c');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\x03');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves ordinary CJK input untouched when nothing is armed', () => {
|
||||
const { app } = cjkApp();
|
||||
app._handleCjkInput('你好');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
||||
});
|
||||
|
||||
it('spends the modifier on a committed IME word instead of stranding it', () => {
|
||||
// The gap this closes: with the field focused the modifier could neither
|
||||
// fire nor be spent, so it survived to bite a later innocent keystroke.
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('你好');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '你好');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on Enter, like every other non-character key', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('\r');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('cjk-session', '\r');
|
||||
expect(bar.isCtrlArmed()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the input, and does not spend the modifier, with no active session', () => {
|
||||
const { app, bar } = cjkApp();
|
||||
(app as unknown as { activeSessionId: string | null }).activeSessionId = null;
|
||||
bar.toggleCtrl();
|
||||
app._handleCjkInput('c');
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(bar.isCtrlArmed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('armed styling survives the light-skin overrides', () => {
|
||||
const mobileCss = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
|
||||
|
||||
it('excludes .armed from the light-skin .accessory-btn repaint', () => {
|
||||
// That selector is (0,3,1): `:is()` takes the specificity of its most
|
||||
// specific argument and the list holds `.btn-toolbar.btn-shell`. It
|
||||
// therefore OUTRANKS the (0,3,0) armed rules in both stylesheets, and a
|
||||
// bare `.accessory-btn` there paints the armed modifier back to a resting
|
||||
// button on all four light skins (measured across every skin at 390px).
|
||||
const lightSkinRule = mobileCss
|
||||
.split('\n')
|
||||
.find((line) => line.includes('[data-skin="paper-gray"]') && line.includes('.btn-voice-mobile,'));
|
||||
|
||||
expect(lightSkinRule).toBeDefined();
|
||||
expect(lightSkinRule).toContain('.accessory-btn:not(.armed)');
|
||||
});
|
||||
|
||||
it('keeps an armed rule in both stylesheets', () => {
|
||||
// mobile.css hardcodes the phone palette, styles.css carries the
|
||||
// skin-aware one for everything wider.
|
||||
expect(mobileCss).toContain('.accessory-btn.accessory-btn-ctrl.armed');
|
||||
expect(readFileSync(resolve('src/web/public/styles.css'), 'utf8')).toContain(
|
||||
'.accessory-btn.accessory-btn-ctrl.armed'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1048,183 +1048,6 @@ describe('Virtual Keyboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shell keyboard bar + one-shot Ctrl (issue #262) ───────────────────
|
||||
//
|
||||
// The bar swaps layouts per session mode, and Ctrl is a one-shot modifier
|
||||
// applied to the next character typed on the SYSTEM keyboard, which on a
|
||||
// phone reaches the app as xterm onData text, not a key event. These drive
|
||||
// the real xterm instance with page.keyboard.type() and assert on what would
|
||||
// go out on the wire (_sendInputAsync), not on DOM state alone.
|
||||
|
||||
describe('Shell keyboard bar', () => {
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/** Point the app at a fake session of `mode` and re-resolve the bar. */
|
||||
async function activateSession(mode: string, id = 'kb-shell-1'): Promise<void> {
|
||||
await page.evaluate(`(function (id, mode) {
|
||||
app.sessions.set(id, { id, name: id, status: 'idle', mode, workingDir: '/tmp' });
|
||||
app.activeSessionId = id;
|
||||
KeyboardAccessoryBar.show();
|
||||
KeyboardAccessoryBar.refreshForActiveSession();
|
||||
})('${id}', '${mode}')`);
|
||||
}
|
||||
|
||||
/** Capture what the terminal would send, while typing on the real keyboard. */
|
||||
async function typeAndCapture(text: string): Promise<string[]> {
|
||||
await page.evaluate(`(function () {
|
||||
window.__sent = [];
|
||||
if (!app.__origSend) app.__origSend = app._sendInputAsync;
|
||||
app._sendInputAsync = function (sessionId, input) { window.__sent.push(input); };
|
||||
app.terminal.focus();
|
||||
})()`);
|
||||
await page.keyboard.type(text);
|
||||
await page.waitForTimeout(200);
|
||||
const sent = (await page.evaluate(`window.__sent`)) as string[];
|
||||
await page.evaluate(`(function () { app._sendInputAsync = app.__origSend; })()`);
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function tapCtrl(): Promise<void> {
|
||||
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]').click()`);
|
||||
}
|
||||
|
||||
it('shows the terminal bar for shell sessions', async () => {
|
||||
await activateSession('shell');
|
||||
const actions = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
)
|
||||
);
|
||||
expect(actions).toEqual([
|
||||
'ctrl',
|
||||
'esc',
|
||||
'tab',
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'paste',
|
||||
'dismiss',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the command bar for agent sessions', async () => {
|
||||
await activateSession('claude', 'kb-agent-1');
|
||||
const actions = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
)
|
||||
);
|
||||
expect(actions).toContain('init');
|
||||
expect(actions).not.toContain('ctrl');
|
||||
});
|
||||
|
||||
it('sends Ctrl+C for the next typed character and disarms', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
expect(await typeAndCapture('c')).toEqual(['\x03']);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
// The very next keystroke is a literal c again.
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('sends Ctrl+D for the next typed character', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await typeAndCapture('d')).toEqual(['\x04']);
|
||||
});
|
||||
|
||||
it('survives a terminal tap while the pane has mouse reporting on', async () => {
|
||||
// A shell session keeps the narrow scrollback strip, so mouse DECSETs
|
||||
// reach the browser: run vim or htop and xterm starts reporting taps
|
||||
// through onData as \x1b[<0;31;23M. Those arrive on the same channel as
|
||||
// typed characters, so a hook that treats every chunk as "the next
|
||||
// keystroke" spends Ctrl on a tap and the button looks dead. Verified
|
||||
// against a real shell session before this guard existed.
|
||||
await activateSession('shell');
|
||||
await page.evaluate(`app.terminal.write('\\x1b[?1000h\\x1b[?1006h')`);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
const box = await page.locator('.xterm-screen').first().boundingBox();
|
||||
await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
expect(await typeAndCapture('c')).toEqual(['\x03']);
|
||||
|
||||
await page.evaluate(`app.terminal.write('\\x1b[?1000l\\x1b[?1006l')`);
|
||||
});
|
||||
|
||||
it('cancels on a second tap of Ctrl', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('shows the armed state and keeps the terminal focused', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
const state = await page.evaluate(() => {
|
||||
const button = document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]') as HTMLElement;
|
||||
const style = getComputedStyle(button);
|
||||
return {
|
||||
armed: button.classList.contains('armed'),
|
||||
pressed: button.getAttribute('aria-pressed'),
|
||||
background: style.backgroundColor,
|
||||
focusedTerminal: document.activeElement === (app.terminal as { textarea: Element }).textarea,
|
||||
};
|
||||
});
|
||||
expect(state.armed).toBe(true);
|
||||
expect(state.pressed).toBe('true');
|
||||
// Armed styling must actually land (three-class rule beating the skin
|
||||
// overrides): an invisible modifier is worse than none.
|
||||
expect(state.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(state.focusedTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the armed modifier when switching sessions', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
|
||||
|
||||
await activateSession('shell', 'kb-shell-2');
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('drops the armed modifier when the keyboard is dismissed', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await page.evaluate(`KeyboardAccessoryBar.hide()`);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
});
|
||||
|
||||
it('spends the modifier on another accessory key instead of the next keystroke', async () => {
|
||||
await activateSession('shell');
|
||||
await tapCtrl();
|
||||
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="esc"]').click()`);
|
||||
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
|
||||
expect(await typeAndCapture('c')).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||
|
||||
describe('Cross-device keyboard behavior', () => {
|
||||
|
||||
@@ -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<typeof createMockRouteContext>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Unit tests for the past-session search index (issue #261).
|
||||
*
|
||||
* The index is the seam that lets `GET /api/search` match sessions that are no
|
||||
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter
|
||||
* and are pinned here: the snapshot stays bounded, the refresh never happens on
|
||||
* the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
|
||||
* stored rows carry the owner needed to re-apply multi-user scoping on read,
|
||||
* the snapshot is written unscoped, so losing that field would leak one user's
|
||||
* folders into another user's search.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
buildHistorySessionIndexItems,
|
||||
ensureHistorySessionIndexFresh,
|
||||
getHistorySessionIndex,
|
||||
isHistorySessionIndexStale,
|
||||
resetHistorySessionIndex,
|
||||
setHistoryIndexRefresher,
|
||||
setHistorySessionIndex,
|
||||
HISTORY_INDEX_MAX_ITEMS,
|
||||
HISTORY_INDEX_TTL_MS,
|
||||
type MergedSessionLike,
|
||||
} from '../src/web/session-history-index.js';
|
||||
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
describe('buildHistorySessionIndexItems', () => {
|
||||
const merged: MergedSessionLike[] = [
|
||||
{ sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 },
|
||||
{ sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('projects name, dir, timestamp, owner and liveness', () => {
|
||||
const items = buildHistorySessionIndexItems(
|
||||
merged,
|
||||
new Map([
|
||||
['a', 'alice'],
|
||||
['b', undefined],
|
||||
]),
|
||||
new Set(['a'])
|
||||
);
|
||||
expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']);
|
||||
expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 });
|
||||
// Transcript-only row: no owner (host-wide) and not live.
|
||||
expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' });
|
||||
});
|
||||
|
||||
it('drops rows with neither a name nor a working directory', () => {
|
||||
const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set());
|
||||
expect(items.some((i) => i.sessionId === 'empty')).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => {
|
||||
const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `session ${i}`,
|
||||
workingDir: `/home/u/p${i}`,
|
||||
lastActivityAt: i,
|
||||
}));
|
||||
expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot storage', () => {
|
||||
it('starts empty and stale', () => {
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
expect(isHistorySessionIndexStale()).toBe(true);
|
||||
});
|
||||
|
||||
it('caps on write even when the caller did not', () => {
|
||||
const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: 'x',
|
||||
workingDir: '/x',
|
||||
timestamp: i,
|
||||
live: false,
|
||||
}));
|
||||
setHistorySessionIndex(items);
|
||||
expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
|
||||
it('goes stale again once the TTL elapses', () => {
|
||||
const t0 = 1_000_000;
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureHistorySessionIndexFresh', () => {
|
||||
it('returns synchronously, the rebuild must never be on the request path', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
// Called, but the caller is already past it while the rebuild is pending.
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
resolveRefresh();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it('is single-flight: a second call while a rebuild is pending is a no-op', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRefresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Snapshot still stale (the fake refresher wrote nothing) → next call runs again.
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rebuild while the snapshot is fresh', () => {
|
||||
const refresher = vi.fn(async () => {});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]);
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => {
|
||||
setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1);
|
||||
const refresher = vi.fn(async () => {
|
||||
throw new Error('scan failed');
|
||||
});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(getHistorySessionIndex().items[0].sessionId).toBe('keep');
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is a no-op when no refresher is registered', () => {
|
||||
expect(() => ensureHistorySessionIndexFresh()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user