From c58f7a1662a87283a2ce2adddabb46546ddd9a4b Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 22:30:26 -0700 Subject: [PATCH 01/30] docs: add trycycle title search plan --- .../2026-03-26-title-search-subdir-tabs.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/plans/2026-03-26-title-search-subdir-tabs.md diff --git a/docs/plans/2026-03-26-title-search-subdir-tabs.md b/docs/plans/2026-03-26-title-search-subdir-tabs.md new file mode 100644 index 00000000..3f407165 --- /dev/null +++ b/docs/plans/2026-03-26-title-search-subdir-tabs.md @@ -0,0 +1,306 @@ +# Sidebar Title Search Subdirectory And Open-Tab Search Behavior Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make sidebar title-tier search match a session's leaf subdirectory name and make active search show open-tab fallback sessions only when they truly match, without pinning them above other search results. + +**Architecture:** Treat the `"title"` tier as metadata search, not literal title-only search. Add one shared pure matcher for title-tier metadata and use it in both the server title-tier query path and the client's fallback-row gating. Keep server search authoritative for indexed results, but explicitly distinguish server-backed rows from synthesized fallback rows so the client can retain only matching fallbacks during committed search and disable `hasTab` pinning without regressing the existing debounce, loading, and silent-refresh behavior. + +**Tech Stack:** React 18, Redux Toolkit, Express, shared TypeScript utilities, Vitest, Testing Library + +--- + +## Behavior Contract + +- Title-tier queries match `title`, then the leaf directory name derived from `cwd ?? projectPath`, then the existing metadata fields `summary` and `firstUserMessage`. +- Only the leaf directory name is searchable. `/home/user/code/trycycle` matches `trycycle`; it does not match `code` unless some other field independently matches `code`. +- During a committed search, server-window rows stay authoritative. The client may inject synthesized fallback rows only when it can locally prove they match the active search tier. +- For `userMessages` and `fullText`, do not inject fallback rows at all. The client cannot safely prove deep-file matches, so the server must stay authoritative. +- A committed search disables `hasTab` pinning regardless of sidebar sort mode. Matching open tabs may appear, but they sort with the normal unpinned comparator for that mode, while archived-last behavior remains intact. +- Uncommitted typing and in-flight query replacement must not locally re-filter the last committed result set. Selector search inputs must come from `sidebarWindow.query` and `sidebarWindow.searchTier`, not the raw input box text. +- Blocking first-load behavior stays unchanged: if there is no committed result set yet and search is loading, fallback rows remain hidden. + +## File Structure + +- Create: `shared/session-title-search.ts` + Responsibility: cross-platform leaf-directory extraction plus shared title-tier metadata matching. This becomes the single contract for what `"title"` search means. +- Modify: `server/session-directory/service.ts` + Responsibility: replace inline metadata matching with the shared helper while preserving current paging, cursor, and schema behavior. +- Modify: `src/store/selectors/sidebarSelectors.ts` + Responsibility: mark fallback rows explicitly, precompute searchable leaf-directory data for local fallback matching, gate fallback rows during committed search, and disable `hasTab` pinning while committed search is active. +- Modify: `src/components/Sidebar.tsx` + Responsibility: pass committed search context into the selector while preserving the current debounce, loading, and silent-refresh rules. +- Create: `test/unit/shared/session-title-search.test.ts` + Responsibility: direct coverage for cross-platform leaf-directory extraction and shared metadata-match precedence. +- Modify: `test/unit/server/session-directory/service.test.ts` + Responsibility: prove server title-tier search matches leaf subdirectories, rejects ancestor-only matches, and keeps current result ordering. +- Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` + Responsibility: prove fallback-row matching and search-time sort behavior, including "no pinning while searching." +- Modify: `test/unit/client/components/Sidebar.test.tsx` + Responsibility: prove committed search hides unrelated open-tab fallbacks, shows matching title-tier fallbacks, preserves blocking-load behavior, and uses committed search context instead of raw input text. +- Modify: `test/e2e/sidebar-search-flow.test.tsx` + Responsibility: user-visible regression coverage for subdirectory matching plus open-tab search behavior through the real sidebar flow. + +## Strategy Gate + +- Do not solve this by passing the raw search box text into the existing selector filter. That would incorrectly drop legitimate server results that matched `summary` or `firstUserMessage`, because the current client filter only sees title/subtitle/path/provider strings. +- Do not widen the read-model schema with a new `matchedIn` enum for directory matches. The `"title"` tier is already shorthand for metadata-only search, no current client flow distinguishes directory matches, and the clean steady state is to keep the existing transport contract stable. +- Do not keep pinning "mostly on" during search. The user explicitly asked for search to stop pinning open tabs. The clean rule is: pinning is a browse-mode concern, not a search-mode concern. +- Do not use raw full-path substring matching for the new behavior. Restrict matching to the leaf directory name so common ancestors like `code`, `src`, and home-directory segments do not produce noisy false positives. + +### Task 1: Add Shared Title-Tier Metadata Matching And Wire The Server To It + +**Files:** +- Create: `shared/session-title-search.ts` +- Create: `test/unit/shared/session-title-search.test.ts` +- Modify: `server/session-directory/service.ts` +- Modify: `test/unit/server/session-directory/service.test.ts` + +- [ ] **Step 1: Write the failing shared and server tests** + +In `test/unit/shared/session-title-search.test.ts`, add direct coverage for: + +- POSIX path leaf extraction: `"/home/user/code/trycycle"` -> `"trycycle"` +- Windows path leaf extraction: `"C:\\Users\\me\\code\\trycycle"` -> `"trycycle"` +- trailing slash trimming on both path styles +- title-tier precedence: title match wins before directory, directory wins before summary / first-user-message +- directory-only match returns a non-null metadata match +- ancestor-only query like `"code"` does not match `"/home/user/code/trycycle"` when no other field contains `"code"` + +In `test/unit/server/session-directory/service.test.ts`, extend `querySessionDirectory()` coverage with cases that prove: + +- a title-tier query matches a session whose `cwd` or `projectPath` leaf is the query text even when the title does not match +- the same query does **not** match solely because an ancestor path segment contains the text +- result ordering still follows the existing recency/archived contract after directory matches are added +- the server still works without file providers for title-tier search + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 shared+server title-tier subdirectory search" \ + npm run test:vitest -- \ + test/unit/shared/session-title-search.test.ts \ + test/unit/server/session-directory/service.test.ts +``` + +Expected: FAIL because the shared helper does not exist yet and the server title-tier search still ignores leaf-directory metadata. + +- [ ] **Step 3: Implement the shared matcher and switch the server title tier to use it** + +In `shared/session-title-search.ts`, add a small pure utility with signatures in this shape: + +```ts +export type TitleTierMetadata = { + title?: string + summary?: string + firstUserMessage?: string + cwd?: string + projectPath?: string +} + +export function getLeafDirectoryName(pathLike?: string): string | undefined + +export function matchTitleTierMetadata( + metadata: TitleTierMetadata, + query: string, +): { matchedIn: 'title' | 'summary' | 'firstUserMessage'; snippet: string } | null +``` + +Implementation requirements: + +- normalize both `/` and `\\` +- trim trailing separators before taking the last non-empty segment +- use `cwd` when present, otherwise `projectPath` +- match precedence is `title` -> leaf directory name -> `summary` -> `firstUserMessage` +- when the leaf directory name is the winning match, return `matchedIn: 'title'` and `snippet: leafDirectoryName` + Rationale: this keeps the existing transport schema stable while still making the new metadata searchable + +In `server/session-directory/service.ts`: + +- replace the inline `applySearch()` field scan with the shared helper +- keep the current page/cursor flow unchanged +- keep existing result ordering and archived handling unchanged +- keep title-tier search provider-free; this remains metadata-only work + +- [ ] **Step 4: Re-run the targeted tests to verify they pass** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 shared+server title-tier subdirectory search" \ + npm run test:vitest -- \ + test/unit/shared/session-title-search.test.ts \ + test/unit/server/session-directory/service.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +git add \ + shared/session-title-search.ts \ + server/session-directory/service.ts \ + test/unit/shared/session-title-search.test.ts \ + test/unit/server/session-directory/service.test.ts +git commit -m "feat: extend title search with subdirectory matches" +``` + +### Task 2: Make Sidebar Search Fallback Rows Match-Aware And Unpinned + +**Files:** +- Modify: `src/store/selectors/sidebarSelectors.ts` +- Modify: `src/components/Sidebar.tsx` +- Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` +- Modify: `test/unit/client/components/Sidebar.test.tsx` +- Modify: `test/e2e/sidebar-search-flow.test.tsx` + +- [ ] **Step 1: Write the failing client and user-visible regressions** + +In `test/unit/client/store/selectors/sidebarSelectors.test.ts`, add coverage for: + +- `buildSessionItems()` marking synthesized local rows with `isFallback: true` and server-window rows with `isFallback: false` +- committed title search keeping a fallback row whose leaf directory name matches the query +- committed title search rejecting a fallback row when only an ancestor path segment matches +- committed deep search (`userMessages` / `fullText`) dropping fallback rows entirely +- committed search disabling tab pinning in both `activity` and `recency-pinned` modes while still preserving archived-last grouping + +Use fixtures where: + +- a server-backed non-tab row is newer than a matching fallback row +- the fallback row has `hasTab: true` +- sort mode is `activity` or `recency-pinned` + +Expected ordering after the fix: + +- the matching fallback row is present +- it is **not** forced ahead of the newer non-tab row solely because `hasTab === true` + +In `test/unit/client/components/Sidebar.test.tsx`, add component regressions for: + +- a committed title search result plus an unrelated open fallback tab: only the server result remains visible +- a committed title search plus a fallback open tab whose `cwd` leaf matches the query: both rows are visible, but the fallback row is not pinned above the newer server result +- a committed deep search: fallback tab rows stay hidden even if their title or directory would have matched locally +- typing a new query while an older committed query is still displayed does not locally re-filter the old committed result set before the new server response arrives + This specifically guards against accidentally wiring the selector to raw `filter` instead of committed `sidebarWindow.query` +- existing blocking-load tests still hold: if there is no committed result set yet, fallback rows do not appear underneath the search spinner + +In `test/e2e/sidebar-search-flow.test.tsx`, add a user-visible flow that proves both halves of the requested behavior: + +- searching `trycycle` returns a title-tier hit whose title does not contain `trycycle` but whose `cwd` or `projectPath` leaf is `trycycle` +- searching `code` does not return that same hit unless another metadata field actually contains `code` +- when search is active, an open fallback tab is shown only when it matches the active committed title-tier query, and it is not pinned above a newer non-tab server match + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 sidebar search fallback gating" \ + npm run test:vitest -- \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/components/Sidebar.test.tsx \ + test/e2e/sidebar-search-flow.test.tsx +``` + +Expected: FAIL because the selector currently ignores committed search context, keeps fallback rows during search regardless of match status, and still pins `hasTab` rows in search mode. + +- [ ] **Step 3: Implement search-aware fallback gating and search-time unpinned sorting** + +In `src/store/selectors/sidebarSelectors.ts`: + +- extend `SidebarSessionItem` with the minimum extra metadata needed for search behavior, for example: + +```ts +isFallback: boolean +searchDirectoryName?: string +``` + +- set `isFallback: false` for sessions coming from the committed server window +- set `isFallback: true` for synthesized open-tab fallback rows +- compute `searchDirectoryName` from the same shared `getLeafDirectoryName()` helper used by the server +- replace the current "one local filter for every row" approach with explicit search-mode behavior: + - no committed query: keep current browse-mode behavior + - committed title query: keep all server-window rows, keep fallback rows only when `matchTitleTierMetadata()` proves the fallback matches via locally available metadata + - committed `userMessages` / `fullText`: keep all server-window rows, drop fallback rows + +Add a small sort option rather than a second search-only sorter, for example: + +```ts +sortSessionItems(items, sortMode, { disableTabPinning: searchQueryActive }) +``` + +Behavior requirements: + +- `recency` stays unchanged +- `recency-pinned` and `activity` skip the `hasTab` split when `disableTabPinning` is true +- archived sessions still stay after active sessions +- project-mode ordering stays unchanged + +If any new non-render fields affect filtering or ordering, update the relevant equality helpers in this file and in `src/components/Sidebar.tsx` so `useStableArray()` and memoized rows stay correct. + +In `src/components/Sidebar.tsx`: + +- stop hard-coding the selector input to `''` +- derive selector search context from the committed window state: + +```ts +const committedQuery = (sidebarWindow?.query ?? '').trim() +const committedTier = sidebarWindow?.searchTier ?? 'title' +``` + +- pass committed search context into `makeSelectSortedSessionItems(...)` +- keep the existing debounce and loading behavior intact +- do **not** switch the selector to raw `filter`; that would mutate visible results before the server response lands and would incorrectly hide legitimate metadata matches from the server + +- [ ] **Step 4: Re-run the targeted tests to verify they pass** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 sidebar search fallback gating" \ + npm run test:vitest -- \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/components/Sidebar.test.tsx \ + test/e2e/sidebar-search-flow.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify with the broader required suite** + +Refactor only after the targeted tests are green: + +- remove any duplicated leaf-directory extraction logic introduced during the task +- keep helper boundaries clear: shared metadata matching in `shared/`, selector policy in `sidebarSelectors`, UI state timing in `Sidebar` +- verify there is no regression in silent refresh, blocking-load, or deep-search pending behavior + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +npm run lint +FRESHELL_TEST_SUMMARY="final verification for title-search subdir tabs" npm run check +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +git add \ + src/store/selectors/sidebarSelectors.ts \ + src/components/Sidebar.tsx \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/components/Sidebar.test.tsx \ + test/e2e/sidebar-search-flow.test.tsx +git commit -m "fix: make sidebar search authoritative over open tabs" +``` From cb3be34ecf5785dfa3dffa14308f244d648075f6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 22:40:00 -0700 Subject: [PATCH 02/30] docs: tighten title-search implementation plan --- .../2026-03-26-title-search-subdir-tabs.md | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/docs/plans/2026-03-26-title-search-subdir-tabs.md b/docs/plans/2026-03-26-title-search-subdir-tabs.md index 3f407165..5e46b65c 100644 --- a/docs/plans/2026-03-26-title-search-subdir-tabs.md +++ b/docs/plans/2026-03-26-title-search-subdir-tabs.md @@ -4,7 +4,7 @@ **Goal:** Make sidebar title-tier search match a session's leaf subdirectory name and make active search show open-tab fallback sessions only when they truly match, without pinning them above other search results. -**Architecture:** Treat the `"title"` tier as metadata search, not literal title-only search. Add one shared pure matcher for title-tier metadata and use it in both the server title-tier query path and the client's fallback-row gating. Keep server search authoritative for indexed results, but explicitly distinguish server-backed rows from synthesized fallback rows so the client can retain only matching fallbacks during committed search and disable `hasTab` pinning without regressing the existing debounce, loading, and silent-refresh behavior. +**Architecture:** Treat the `"title"` tier as metadata search, not literal title-only search. Add one shared pure matcher for title-tier metadata and use it in both the server title-tier query path and the client's fallback-row gating, but keep snippet extraction in the server service so existing search-result formatting stays intact. Keep server search authoritative for indexed results, explicitly distinguish synthesized fallback rows inside the selector, and drive search-mode fallback gating plus no-pinning behavior from `sessions.windows.sidebar` state instead of changing the selector's public call signature. **Tech Stack:** React 18, Redux Toolkit, Express, shared TypeScript utilities, Vitest, Testing Library @@ -12,8 +12,9 @@ ## Behavior Contract -- Title-tier queries match `title`, then the leaf directory name derived from `cwd ?? projectPath`, then the existing metadata fields `summary` and `firstUserMessage`. -- Only the leaf directory name is searchable. `/home/user/code/trycycle` matches `trycycle`; it does not match `code` unless some other field independently matches `code`. +- Title-tier queries match `title`, then the leaf directory name derived from `projectPath`, then a distinct leaf directory name from `cwd` when it adds information the `projectPath` leaf does not, then the existing metadata fields `summary` and `firstUserMessage`. +- Only leaf directory names are searchable. `/home/user/code/trycycle` matches `trycycle`; it does not match `code` unless some other field independently matches `code`. +- For indexed sessions, the canonical "subdirectory" match is the same project-path leaf the sidebar already shows as the subtitle. For synthesized fallback rows that only know `cwd`, the `cwd` leaf remains searchable. - During a committed search, server-window rows stay authoritative. The client may inject synthesized fallback rows only when it can locally prove they match the active search tier. - For `userMessages` and `fullText`, do not inject fallback rows at all. The client cannot safely prove deep-file matches, so the server must stay authoritative. - A committed search disables `hasTab` pinning regardless of sidebar sort mode. Matching open tabs may appear, but they sort with the normal unpinned comparator for that mode, while archived-last behavior remains intact. @@ -23,17 +24,15 @@ ## File Structure - Create: `shared/session-title-search.ts` - Responsibility: cross-platform leaf-directory extraction plus shared title-tier metadata matching. This becomes the single contract for what `"title"` search means. + Responsibility: cross-platform leaf-directory extraction plus shared title-tier metadata matching. This becomes the single contract for what `"title"` search means, while leaving snippet formatting to the server. - Modify: `server/session-directory/service.ts` - Responsibility: replace inline metadata matching with the shared helper while preserving current paging, cursor, and schema behavior. + Responsibility: replace inline metadata matching with the shared helper while preserving current paging, cursor, snippet formatting, and schema behavior. - Modify: `src/store/selectors/sidebarSelectors.ts` - Responsibility: mark fallback rows explicitly, precompute searchable leaf-directory data for local fallback matching, gate fallback rows during committed search, and disable `hasTab` pinning while committed search is active. -- Modify: `src/components/Sidebar.tsx` - Responsibility: pass committed search context into the selector while preserving the current debounce, loading, and silent-refresh rules. + Responsibility: mark fallback rows explicitly, gate fallback rows during committed search using the shared matcher against existing item metadata, and disable `hasTab` pinning while committed search is active without changing the selector's public signature. - Create: `test/unit/shared/session-title-search.test.ts` - Responsibility: direct coverage for cross-platform leaf-directory extraction and shared metadata-match precedence. + Responsibility: direct coverage for cross-platform leaf-directory extraction plus project-path-vs-cwd match precedence. - Modify: `test/unit/server/session-directory/service.test.ts` - Responsibility: prove server title-tier search matches leaf subdirectories, rejects ancestor-only matches, and keeps current result ordering. + Responsibility: prove server title-tier search matches the indexed subdirectory leaf, rejects ancestor-only matches, and keeps current result ordering and snippet behavior. - Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` Responsibility: prove fallback-row matching and search-time sort behavior, including "no pinning while searching." - Modify: `test/unit/client/components/Sidebar.test.tsx` @@ -44,9 +43,13 @@ ## Strategy Gate - Do not solve this by passing the raw search box text into the existing selector filter. That would incorrectly drop legitimate server results that matched `summary` or `firstUserMessage`, because the current client filter only sees title/subtitle/path/provider strings. +- Do not prefer `cwd` over `projectPath` for indexed sessions. The sidebar's indexed "subdirectory" comes from `projectPath`; `cwd` is only a secondary signal and the fallback-only path source. +- Do not move snippet extraction into the shared helper. The shared matcher should answer "what matched?" while `server/session-directory/service.ts` keeps the existing `extractSnippet(...).slice(0, 140)` behavior. +- Do not change the public call shape of `makeSelectSortedSessionItems()`. Read committed search context from `sessions.windows.sidebar` inside the selector so existing callers and tests do not need a new argument contract. - Do not widen the read-model schema with a new `matchedIn` enum for directory matches. The `"title"` tier is already shorthand for metadata-only search, no current client flow distinguishes directory matches, and the clean steady state is to keep the existing transport contract stable. - Do not keep pinning "mostly on" during search. The user explicitly asked for search to stop pinning open tabs. The clean rule is: pinning is a browse-mode concern, not a search-mode concern. - Do not use raw full-path substring matching for the new behavior. Restrict matching to the leaf directory name so common ancestors like `code`, `src`, and home-directory segments do not produce noisy false positives. +- Do not add duplicate cached directory-name fields to sidebar items when the existing `projectPath`/`cwd` plus the shared matcher already provide the needed match inputs. ### Task 1: Add Shared Title-Tier Metadata Matching And Wire The Server To It @@ -63,16 +66,20 @@ In `test/unit/shared/session-title-search.test.ts`, add direct coverage for: - POSIX path leaf extraction: `"/home/user/code/trycycle"` -> `"trycycle"` - Windows path leaf extraction: `"C:\\Users\\me\\code\\trycycle"` -> `"trycycle"` - trailing slash trimming on both path styles -- title-tier precedence: title match wins before directory, directory wins before summary / first-user-message +- title-tier precedence: title match wins before project-path leaf, project-path leaf wins before distinct cwd leaf, and both leaf sources win before summary / first-user-message +- indexed-session precedence: `projectPath="/repo/trycycle"` and `cwd="/repo/trycycle/server"` still match `trycycle` +- fallback/local-only coverage: `cwd="/repo/trycycle"` with no `projectPath` still matches `trycycle` - directory-only match returns a non-null metadata match - ancestor-only query like `"code"` does not match `"/home/user/code/trycycle"` when no other field contains `"code"` In `test/unit/server/session-directory/service.test.ts`, extend `querySessionDirectory()` coverage with cases that prove: -- a title-tier query matches a session whose `cwd` or `projectPath` leaf is the query text even when the title does not match +- a title-tier query matches a session whose `projectPath` leaf is the query text even when the title does not match +- the same indexed session still matches by `projectPath` leaf when its `cwd` points deeper into that repo - the same query does **not** match solely because an ancestor path segment contains the text - result ordering still follows the existing recency/archived contract after directory matches are added - the server still works without file providers for title-tier search +- existing snippet behavior remains bounded and query-focused for title / summary / first-user-message matches while leaf-directory matches produce the expected short snippet - [ ] **Step 2: Run the targeted tests to verify they fail** @@ -101,26 +108,31 @@ export type TitleTierMetadata = { projectPath?: string } +export type TitleTierMatch = { + matchedIn: 'title' | 'summary' | 'firstUserMessage' + matchedValue: string +} + export function getLeafDirectoryName(pathLike?: string): string | undefined export function matchTitleTierMetadata( metadata: TitleTierMetadata, query: string, -): { matchedIn: 'title' | 'summary' | 'firstUserMessage'; snippet: string } | null +): TitleTierMatch | null ``` Implementation requirements: - normalize both `/` and `\\` - trim trailing separators before taking the last non-empty segment -- use `cwd` when present, otherwise `projectPath` -- match precedence is `title` -> leaf directory name -> `summary` -> `firstUserMessage` -- when the leaf directory name is the winning match, return `matchedIn: 'title'` and `snippet: leafDirectoryName` +- match precedence is `title` -> `projectPath` leaf -> distinct `cwd` leaf -> `summary` -> `firstUserMessage` +- when a leaf directory name is the winning match, return `matchedIn: 'title'` and `matchedValue: leafDirectoryName` Rationale: this keeps the existing transport schema stable while still making the new metadata searchable In `server/session-directory/service.ts`: - replace the inline `applySearch()` field scan with the shared helper +- keep `extractSnippet(match.matchedValue, queryText, 40).slice(0, 140)` in the server service so title / summary / first-user-message snippets stay consistent with current behavior - keep the current page/cursor flow unchanged - keep existing result ordering and archived handling unchanged - keep title-tier search provider-free; this remains metadata-only work @@ -155,7 +167,6 @@ git commit -m "feat: extend title search with subdirectory matches" **Files:** - Modify: `src/store/selectors/sidebarSelectors.ts` -- Modify: `src/components/Sidebar.tsx` - Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` - Modify: `test/unit/client/components/Sidebar.test.tsx` - Modify: `test/e2e/sidebar-search-flow.test.tsx` @@ -164,11 +175,13 @@ git commit -m "feat: extend title search with subdirectory matches" In `test/unit/client/store/selectors/sidebarSelectors.test.ts`, add coverage for: -- `buildSessionItems()` marking synthesized local rows with `isFallback: true` and server-window rows with `isFallback: false` +- `buildSessionItems()` marking synthesized local rows with a fallback-origin marker while leaving project-backed rows non-fallback - committed title search keeping a fallback row whose leaf directory name matches the query +- committed title search preferring the project-path leaf for indexed rows while still allowing cwd-only fallback rows to match - committed title search rejecting a fallback row when only an ancestor path segment matches - committed deep search (`userMessages` / `fullText`) dropping fallback rows entirely - committed search disabling tab pinning in both `activity` and `recency-pinned` modes while still preserving archived-last grouping +- selector search behavior coming from `sessions.windows.sidebar.query/searchTier`, not from a new selector argument Use fixtures where: @@ -215,21 +228,21 @@ Expected: FAIL because the selector currently ignores committed search context, In `src/store/selectors/sidebarSelectors.ts`: -- extend `SidebarSessionItem` with the minimum extra metadata needed for search behavior, for example: +- extend `SidebarSessionItem` with the minimum extra metadata needed to distinguish synthesized rows without forcing unrelated typed fixtures to change, for example: ```ts -isFallback: boolean -searchDirectoryName?: string +isFallback?: true ``` -- set `isFallback: false` for sessions coming from the committed server window +- leave project-backed rows without the marker - set `isFallback: true` for synthesized open-tab fallback rows -- compute `searchDirectoryName` from the same shared `getLeafDirectoryName()` helper used by the server - replace the current "one local filter for every row" approach with explicit search-mode behavior: - no committed query: keep current browse-mode behavior - - committed title query: keep all server-window rows, keep fallback rows only when `matchTitleTierMetadata()` proves the fallback matches via locally available metadata + - committed title query: keep all project-backed server-window rows, keep fallback rows only when `matchTitleTierMetadata()` proves the fallback matches via locally available metadata already on the item (`title`, `projectPath`, `cwd`, `firstUserMessage`) - committed `userMessages` / `fullText`: keep all server-window rows, drop fallback rows +Drive that search-mode decision from `state.sessions.windows?.sidebar?.query` and `state.sessions.windows?.sidebar?.searchTier` inside the selector. Keep `makeSelectSortedSessionItems()` callable as `(state, terminals, filter)` so existing callers and tests do not take a new search-context parameter. + Add a small sort option rather than a second search-only sorter, for example: ```ts @@ -242,22 +255,7 @@ Behavior requirements: - `recency-pinned` and `activity` skip the `hasTab` split when `disableTabPinning` is true - archived sessions still stay after active sessions - project-mode ordering stays unchanged - -If any new non-render fields affect filtering or ordering, update the relevant equality helpers in this file and in `src/components/Sidebar.tsx` so `useStableArray()` and memoized rows stay correct. - -In `src/components/Sidebar.tsx`: - -- stop hard-coding the selector input to `''` -- derive selector search context from the committed window state: - -```ts -const committedQuery = (sidebarWindow?.query ?? '').trim() -const committedTier = sidebarWindow?.searchTier ?? 'title' -``` - -- pass committed search context into `makeSelectSortedSessionItems(...)` -- keep the existing debounce and loading behavior intact -- do **not** switch the selector to raw `filter`; that would mutate visible results before the server response lands and would incorrectly hide legitimate metadata matches from the server +- keep the existing debounce and loading behavior intact by leaving that UI timing in `Sidebar.tsx` untouched unless a test proves a selector-only change is insufficient - [ ] **Step 4: Re-run the targeted tests to verify they pass** @@ -298,7 +296,6 @@ Expected: PASS. cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs git add \ src/store/selectors/sidebarSelectors.ts \ - src/components/Sidebar.tsx \ test/unit/client/store/selectors/sidebarSelectors.test.ts \ test/unit/client/components/Sidebar.test.tsx \ test/e2e/sidebar-search-flow.test.tsx From e4c68860ab39ef0a5826d02395facf30b8b51c9a Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 22:50:21 -0700 Subject: [PATCH 03/30] docs: fix title search subdir implementation plan --- .../2026-03-26-title-search-subdir-tabs.md | 204 ++++++++++++++---- 1 file changed, 166 insertions(+), 38 deletions(-) diff --git a/docs/plans/2026-03-26-title-search-subdir-tabs.md b/docs/plans/2026-03-26-title-search-subdir-tabs.md index 5e46b65c..dfe866ea 100644 --- a/docs/plans/2026-03-26-title-search-subdir-tabs.md +++ b/docs/plans/2026-03-26-title-search-subdir-tabs.md @@ -4,7 +4,7 @@ **Goal:** Make sidebar title-tier search match a session's leaf subdirectory name and make active search show open-tab fallback sessions only when they truly match, without pinning them above other search results. -**Architecture:** Treat the `"title"` tier as metadata search, not literal title-only search. Add one shared pure matcher for title-tier metadata and use it in both the server title-tier query path and the client's fallback-row gating, but keep snippet extraction in the server service so existing search-result formatting stays intact. Keep server search authoritative for indexed results, explicitly distinguish synthesized fallback rows inside the selector, and drive search-mode fallback gating plus no-pinning behavior from `sessions.windows.sidebar` state instead of changing the selector's public call signature. +**Architecture:** Treat the `"title"` tier as metadata search, not literal title-only search. Add one shared pure matcher for title-tier metadata and use it in both the server title-tier query path and the client's fallback-row gating, while keeping snippet extraction in the server service so existing search-result formatting stays intact. Separate requested search state from applied search state in `sessions.windows.sidebar` so selector behavior follows the result set currently on screen instead of the next in-flight query, then disable tab pinning whenever an applied search is active. **Tech Stack:** React 18, Redux Toolkit, Express, shared TypeScript utilities, Vitest, Testing Library @@ -15,11 +15,14 @@ - Title-tier queries match `title`, then the leaf directory name derived from `projectPath`, then a distinct leaf directory name from `cwd` when it adds information the `projectPath` leaf does not, then the existing metadata fields `summary` and `firstUserMessage`. - Only leaf directory names are searchable. `/home/user/code/trycycle` matches `trycycle`; it does not match `code` unless some other field independently matches `code`. - For indexed sessions, the canonical "subdirectory" match is the same project-path leaf the sidebar already shows as the subtitle. For synthesized fallback rows that only know `cwd`, the `cwd` leaf remains searchable. -- During a committed search, server-window rows stay authoritative. The client may inject synthesized fallback rows only when it can locally prove they match the active search tier. +- During an applied search, server-window rows stay authoritative. The client may inject synthesized fallback rows only when it can locally prove they match the applied search tier. - For `userMessages` and `fullText`, do not inject fallback rows at all. The client cannot safely prove deep-file matches, so the server must stay authoritative. -- A committed search disables `hasTab` pinning regardless of sidebar sort mode. Matching open tabs may appear, but they sort with the normal unpinned comparator for that mode, while archived-last behavior remains intact. -- Uncommitted typing and in-flight query replacement must not locally re-filter the last committed result set. Selector search inputs must come from `sidebarWindow.query` and `sidebarWindow.searchTier`, not the raw input box text. -- Blocking first-load behavior stays unchanged: if there is no committed result set yet and search is loading, fallback rows remain hidden. +- An applied search disables `hasTab` pinning regardless of sidebar sort mode. Matching open tabs may appear, but they sort with the normal unpinned comparator for that mode, while archived-last behavior remains intact. +- Requested search state and applied search state are different contracts: + `query/searchTier` track the current request and can change as soon as loading starts. + `appliedQuery/appliedSearchTier` describe the result set currently stored in `projects` and must remain stable until `setSessionWindowData()` commits replacement data. +- Typing and in-flight query replacement must not locally re-filter the last committed result set. Selector search inputs must come from `appliedQuery/appliedSearchTier`, not the raw input box text or the just-requested query. +- Blocking first-load behavior stays unchanged: if there is no applied result set yet and search is loading, fallback rows remain hidden. ## File Structure @@ -27,27 +30,35 @@ Responsibility: cross-platform leaf-directory extraction plus shared title-tier metadata matching. This becomes the single contract for what `"title"` search means, while leaving snippet formatting to the server. - Modify: `server/session-directory/service.ts` Responsibility: replace inline metadata matching with the shared helper while preserving current paging, cursor, snippet formatting, and schema behavior. +- Modify: `src/store/sessionsSlice.ts` + Responsibility: track both requested search state and applied search state per surface so selectors can reason about the visible result set without guessing from loading flags. - Modify: `src/store/selectors/sidebarSelectors.ts` - Responsibility: mark fallback rows explicitly, gate fallback rows during committed search using the shared matcher against existing item metadata, and disable `hasTab` pinning while committed search is active without changing the selector's public signature. + Responsibility: mark fallback rows explicitly, gate fallback rows during applied search using the shared matcher against existing item metadata, and disable `hasTab` pinning while applied search is active without changing the selector's public signature. - Create: `test/unit/shared/session-title-search.test.ts` Responsibility: direct coverage for cross-platform leaf-directory extraction plus project-path-vs-cwd match precedence. - Modify: `test/unit/server/session-directory/service.test.ts` Responsibility: prove server title-tier search matches the indexed subdirectory leaf, rejects ancestor-only matches, and keeps current result ordering and snippet behavior. +- Modify: `test/unit/client/store/sessionsSlice.test.ts` + Responsibility: prove requested search state and applied search state stay intentionally separated across loading and data commits. +- Modify: `test/unit/client/store/sessionsThunks.test.ts` + Responsibility: prove the actual thunk flow preserves applied search context while replacement searches are in flight, then advances it when data lands. - Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` - Responsibility: prove fallback-row matching and search-time sort behavior, including "no pinning while searching." + Responsibility: prove fallback-row matching and applied-search sort behavior, including "no pinning while searching." - Modify: `test/unit/client/components/Sidebar.test.tsx` - Responsibility: prove committed search hides unrelated open-tab fallbacks, shows matching title-tier fallbacks, preserves blocking-load behavior, and uses committed search context instead of raw input text. + Responsibility: prove committed search hides unrelated open-tab fallbacks, shows matching title-tier fallbacks, preserves blocking-load behavior, and keeps old visible results stable while a replacement search is loading. - Modify: `test/e2e/sidebar-search-flow.test.tsx` Responsibility: user-visible regression coverage for subdirectory matching plus open-tab search behavior through the real sidebar flow. ## Strategy Gate -- Do not solve this by passing the raw search box text into the existing selector filter. That would incorrectly drop legitimate server results that matched `summary` or `firstUserMessage`, because the current client filter only sees title/subtitle/path/provider strings. +- Do not treat `sessions.windows.sidebar.query/searchTier` as the committed search context. `setSessionWindowLoading()` updates those fields before new results arrive, so using them for selector policy would incorrectly re-filter old visible results against the next in-flight query. +- Do add explicit `appliedQuery` and `appliedSearchTier` fields to `SessionWindowState`, and drive search-mode selector behavior from those fields instead of inferring from `loadingKind`. +- Do not solve this by passing the raw search box text into the selector. That would incorrectly drop legitimate server results that matched `summary` or `firstUserMessage`, because the client cannot prove those matches locally. - Do not prefer `cwd` over `projectPath` for indexed sessions. The sidebar's indexed "subdirectory" comes from `projectPath`; `cwd` is only a secondary signal and the fallback-only path source. - Do not move snippet extraction into the shared helper. The shared matcher should answer "what matched?" while `server/session-directory/service.ts` keeps the existing `extractSnippet(...).slice(0, 140)` behavior. -- Do not change the public call shape of `makeSelectSortedSessionItems()`. Read committed search context from `sessions.windows.sidebar` inside the selector so existing callers and tests do not need a new argument contract. +- Do not change the public call shape of `makeSelectSortedSessionItems()`. Read applied search context from `sessions.windows.sidebar` inside the selector so existing callers and tests do not need a new argument contract. - Do not widen the read-model schema with a new `matchedIn` enum for directory matches. The `"title"` tier is already shorthand for metadata-only search, no current client flow distinguishes directory matches, and the clean steady state is to keep the existing transport contract stable. -- Do not keep pinning "mostly on" during search. The user explicitly asked for search to stop pinning open tabs. The clean rule is: pinning is a browse-mode concern, not a search-mode concern. +- Do not keep pinning "mostly on" during applied search. The user explicitly asked for search to stop pinning open tabs. The clean rule is: pinning is a browse-mode concern, not a search-mode concern. - Do not use raw full-path substring matching for the new behavior. Restrict matching to the leaf directory name so common ancestors like `code`, `src`, and home-directory segments do not produce noisy false positives. - Do not add duplicate cached directory-name fields to sidebar items when the existing `projectPath`/`cwd` plus the shared matcher already provide the needed match inputs. @@ -151,7 +162,28 @@ FRESHELL_TEST_SUMMARY="task1 shared+server title-tier subdirectory search" \ Expected: PASS. -- [ ] **Step 5: Commit** +- [ ] **Step 5: Refactor and verify the server-side seam** + +Refactor only after the targeted tests are green: + +- remove any duplicated leaf-directory extraction logic introduced during the task +- keep helper boundaries clear: shared metadata matching in `shared/`, snippet formatting in the server service +- verify the HTTP layer still honors the unchanged read-model contract + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 server seam verification" \ + npm run test:vitest -- \ + test/unit/shared/session-title-search.test.ts \ + test/unit/server/session-directory/service.test.ts \ + test/integration/server/session-directory-router.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** ```bash cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs @@ -163,7 +195,103 @@ git add \ git commit -m "feat: extend title search with subdirectory matches" ``` -### Task 2: Make Sidebar Search Fallback Rows Match-Aware And Unpinned +### Task 2: Separate Requested Search State From Applied Search State + +**Files:** +- Modify: `src/store/sessionsSlice.ts` +- Modify: `test/unit/client/store/sessionsSlice.test.ts` +- Modify: `test/unit/client/store/sessionsThunks.test.ts` + +- [ ] **Step 1: Write the failing reducer and thunk tests** + +In `test/unit/client/store/sessionsSlice.test.ts`, add coverage that proves: + +- `setSessionWindowLoading()` updates `query/searchTier` for the next request but preserves existing `appliedQuery/appliedSearchTier` +- `setSessionWindowData()` updates `projects`, `query/searchTier`, and `appliedQuery/appliedSearchTier` together so the applied context always describes the visible result set +- starting a browse reload from previously searched results keeps the old applied search context until new browse data lands + +In `test/unit/client/store/sessionsThunks.test.ts`, add an async flow that proves: + +- with visible search results already loaded, dispatching a replacement search immediately changes `query` to the new request +- while that replacement request is still in flight, `appliedQuery/appliedSearchTier` still describe the older visible results +- once the replacement response resolves, `appliedQuery/appliedSearchTier` advance to the new result set + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 applied search state separation" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.test.ts \ + test/unit/client/store/sessionsThunks.test.ts +``` + +Expected: FAIL because `SessionWindowState` does not yet distinguish requested search state from applied search state. + +- [ ] **Step 3: Implement applied search state in the session window** + +In `src/store/sessionsSlice.ts`: + +- extend `SessionWindowState` with: + +```ts +appliedQuery?: string +appliedSearchTier?: 'title' | 'userMessages' | 'fullText' +``` + +- keep `query/searchTier` as the requested control state written by `setSessionWindowLoading()` +- update `setSessionWindowData()` so the payload's `query/searchTier` also become `appliedQuery/appliedSearchTier`, because that action is the commit point for replacing visible results +- preserve the previous applied fields when loading begins, errors occur, or a request is aborted before new data lands + +Do not rewrite thunk control flow unless a failing test proves it is necessary; the existing thunk dispatch sequence should become correct once the reducer records applied state at the right boundary. + +- [ ] **Step 4: Re-run the targeted tests to verify they pass** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 applied search state separation" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.test.ts \ + test/unit/client/store/sessionsThunks.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify the state contract** + +Refactor only after the targeted tests are green: + +- keep the reducer contract obvious: requested fields can move early, applied fields move only with data commits +- remove any duplicated "current vs applied" reasoning from tests once the intent is clear in helper fixtures + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 state contract verification" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.test.ts \ + test/unit/client/store/sessionsThunks.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +git add \ + src/store/sessionsSlice.ts \ + test/unit/client/store/sessionsSlice.test.ts \ + test/unit/client/store/sessionsThunks.test.ts +git commit -m "refactor: track applied sidebar search state" +``` + +### Task 3: Make Sidebar Search Fallback Rows Match-Aware And Unpinned **Files:** - Modify: `src/store/selectors/sidebarSelectors.ts` @@ -176,18 +304,19 @@ git commit -m "feat: extend title search with subdirectory matches" In `test/unit/client/store/selectors/sidebarSelectors.test.ts`, add coverage for: - `buildSessionItems()` marking synthesized local rows with a fallback-origin marker while leaving project-backed rows non-fallback -- committed title search keeping a fallback row whose leaf directory name matches the query -- committed title search preferring the project-path leaf for indexed rows while still allowing cwd-only fallback rows to match -- committed title search rejecting a fallback row when only an ancestor path segment matches -- committed deep search (`userMessages` / `fullText`) dropping fallback rows entirely -- committed search disabling tab pinning in both `activity` and `recency-pinned` modes while still preserving archived-last grouping -- selector search behavior coming from `sessions.windows.sidebar.query/searchTier`, not from a new selector argument +- applied title search keeping a fallback row whose leaf directory name matches the query +- applied title search preferring the project-path leaf for indexed rows while still allowing cwd-only fallback rows to match +- applied title search rejecting a fallback row when only an ancestor path segment matches +- applied deep search (`userMessages` / `fullText`) dropping fallback rows entirely +- applied search disabling tab pinning in both `activity` and `recency-pinned` modes while still preserving archived-last grouping +- selector search behavior coming from `appliedQuery/appliedSearchTier`, not from the requested `query/searchTier` Use fixtures where: - a server-backed non-tab row is newer than a matching fallback row - the fallback row has `hasTab: true` - sort mode is `activity` or `recency-pinned` +- requested search state differs from applied search state to prove in-flight replacement does not locally re-filter old results Expected ordering after the fix: @@ -196,18 +325,17 @@ Expected ordering after the fix: In `test/unit/client/components/Sidebar.test.tsx`, add component regressions for: -- a committed title search result plus an unrelated open fallback tab: only the server result remains visible -- a committed title search plus a fallback open tab whose `cwd` leaf matches the query: both rows are visible, but the fallback row is not pinned above the newer server result -- a committed deep search: fallback tab rows stay hidden even if their title or directory would have matched locally -- typing a new query while an older committed query is still displayed does not locally re-filter the old committed result set before the new server response arrives - This specifically guards against accidentally wiring the selector to raw `filter` instead of committed `sidebarWindow.query` -- existing blocking-load tests still hold: if there is no committed result set yet, fallback rows do not appear underneath the search spinner +- a loaded title search result plus an unrelated open fallback tab: only the server result remains visible +- a loaded title search plus a fallback open tab whose `cwd` leaf matches the query: both rows are visible, but the fallback row is not pinned above the newer server result +- a loaded deep search: fallback tab rows stay hidden even if their title or directory would have matched locally +- starting a replacement search while an older applied query is still displayed does not locally re-filter the old committed result set before the new server response arrives +- existing blocking-load tests still hold: if there is no applied result set yet, fallback rows do not appear underneath the search spinner In `test/e2e/sidebar-search-flow.test.tsx`, add a user-visible flow that proves both halves of the requested behavior: - searching `trycycle` returns a title-tier hit whose title does not contain `trycycle` but whose `cwd` or `projectPath` leaf is `trycycle` - searching `code` does not return that same hit unless another metadata field actually contains `code` -- when search is active, an open fallback tab is shown only when it matches the active committed title-tier query, and it is not pinned above a newer non-tab server match +- when an applied search is active, an open fallback tab is shown only when it matches the applied title-tier query, and it is not pinned above a newer non-tab server match - [ ] **Step 2: Run the targeted tests to verify they fail** @@ -215,16 +343,16 @@ Run: ```bash cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs -FRESHELL_TEST_SUMMARY="task2 sidebar search fallback gating" \ +FRESHELL_TEST_SUMMARY="task3 sidebar search fallback gating" \ npm run test:vitest -- \ test/unit/client/store/selectors/sidebarSelectors.test.ts \ test/unit/client/components/Sidebar.test.tsx \ test/e2e/sidebar-search-flow.test.tsx ``` -Expected: FAIL because the selector currently ignores committed search context, keeps fallback rows during search regardless of match status, and still pins `hasTab` rows in search mode. +Expected: FAIL because the selector currently ignores applied search context, keeps fallback rows during search regardless of match status, and still pins `hasTab` rows in search mode. -- [ ] **Step 3: Implement search-aware fallback gating and search-time unpinned sorting** +- [ ] **Step 3: Implement applied-search fallback gating and search-time unpinned sorting** In `src/store/selectors/sidebarSelectors.ts`: @@ -236,17 +364,17 @@ isFallback?: true - leave project-backed rows without the marker - set `isFallback: true` for synthesized open-tab fallback rows -- replace the current "one local filter for every row" approach with explicit search-mode behavior: - - no committed query: keep current browse-mode behavior - - committed title query: keep all project-backed server-window rows, keep fallback rows only when `matchTitleTierMetadata()` proves the fallback matches via locally available metadata already on the item (`title`, `projectPath`, `cwd`, `firstUserMessage`) - - committed `userMessages` / `fullText`: keep all server-window rows, drop fallback rows +- replace the current "one local filter for every row" approach with explicit applied-search behavior: + - no applied query: keep current browse-mode behavior + - applied title query: keep all project-backed server-window rows, keep fallback rows only when `matchTitleTierMetadata()` proves the fallback matches via locally available metadata already on the item (`title`, `projectPath`, `cwd`, `firstUserMessage`) + - applied `userMessages` / `fullText`: keep all server-window rows, drop fallback rows -Drive that search-mode decision from `state.sessions.windows?.sidebar?.query` and `state.sessions.windows?.sidebar?.searchTier` inside the selector. Keep `makeSelectSortedSessionItems()` callable as `(state, terminals, filter)` so existing callers and tests do not take a new search-context parameter. +Drive that search-mode decision from `state.sessions.windows?.sidebar?.appliedQuery` and `state.sessions.windows?.sidebar?.appliedSearchTier` inside the selector. Keep `makeSelectSortedSessionItems()` callable as `(state, terminals, filter)` so existing callers and tests do not take a new search-context parameter. Add a small sort option rather than a second search-only sorter, for example: ```ts -sortSessionItems(items, sortMode, { disableTabPinning: searchQueryActive }) +sortSessionItems(items, sortMode, { disableTabPinning: appliedQueryActive }) ``` Behavior requirements: @@ -255,7 +383,7 @@ Behavior requirements: - `recency-pinned` and `activity` skip the `hasTab` split when `disableTabPinning` is true - archived sessions still stay after active sessions - project-mode ordering stays unchanged -- keep the existing debounce and loading behavior intact by leaving that UI timing in `Sidebar.tsx` untouched unless a test proves a selector-only change is insufficient +- update any loaded-search test fixtures to seed both requested and applied search fields when they represent already-visible results - [ ] **Step 4: Re-run the targeted tests to verify they pass** @@ -263,7 +391,7 @@ Run: ```bash cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs -FRESHELL_TEST_SUMMARY="task2 sidebar search fallback gating" \ +FRESHELL_TEST_SUMMARY="task3 sidebar search fallback gating" \ npm run test:vitest -- \ test/unit/client/store/selectors/sidebarSelectors.test.ts \ test/unit/client/components/Sidebar.test.tsx \ @@ -277,7 +405,7 @@ Expected: PASS. Refactor only after the targeted tests are green: - remove any duplicated leaf-directory extraction logic introduced during the task -- keep helper boundaries clear: shared metadata matching in `shared/`, selector policy in `sidebarSelectors`, UI state timing in `Sidebar` +- keep helper boundaries clear: shared metadata matching in `shared/`, applied-search state in `sessionsSlice`, selector policy in `sidebarSelectors` - verify there is no regression in silent refresh, blocking-load, or deep-search pending behavior Run: From f1af19566598531709e374035d20c2b112cc4c71 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 22:59:37 -0700 Subject: [PATCH 04/30] docs: tighten title search implementation plan --- .../2026-03-26-title-search-subdir-tabs.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-03-26-title-search-subdir-tabs.md b/docs/plans/2026-03-26-title-search-subdir-tabs.md index dfe866ea..6b585842 100644 --- a/docs/plans/2026-03-26-title-search-subdir-tabs.md +++ b/docs/plans/2026-03-26-title-search-subdir-tabs.md @@ -22,6 +22,7 @@ `query/searchTier` track the current request and can change as soon as loading starts. `appliedQuery/appliedSearchTier` describe the result set currently stored in `projects` and must remain stable until `setSessionWindowData()` commits replacement data. - Typing and in-flight query replacement must not locally re-filter the last committed result set. Selector search inputs must come from `appliedQuery/appliedSearchTier`, not the raw input box text or the just-requested query. +- Component logic that needs to know what result set is currently on screen must also use the applied fields. In particular, browse pagination must stay disabled while stale search results remain visible during a search-to-browse transition. - Blocking first-load behavior stays unchanged: if there is no applied result set yet and search is loading, fallback rows remain hidden. ## File Structure @@ -34,6 +35,8 @@ Responsibility: track both requested search state and applied search state per surface so selectors can reason about the visible result set without guessing from loading flags. - Modify: `src/store/selectors/sidebarSelectors.ts` Responsibility: mark fallback rows explicitly, gate fallback rows during applied search using the shared matcher against existing item metadata, and disable `hasTab` pinning while applied search is active without changing the selector's public signature. +- Modify: `src/components/Sidebar.tsx` + Responsibility: keep search UI chrome driven by requested state, but use applied search state for visible-result-set decisions such as suppressing browse pagination while stale search results are still on screen. - Create: `test/unit/shared/session-title-search.test.ts` Responsibility: direct coverage for cross-platform leaf-directory extraction plus project-path-vs-cwd match precedence. - Modify: `test/unit/server/session-directory/service.test.ts` @@ -57,6 +60,7 @@ - Do not prefer `cwd` over `projectPath` for indexed sessions. The sidebar's indexed "subdirectory" comes from `projectPath`; `cwd` is only a secondary signal and the fallback-only path source. - Do not move snippet extraction into the shared helper. The shared matcher should answer "what matched?" while `server/session-directory/service.ts` keeps the existing `extractSnippet(...).slice(0, 140)` behavior. - Do not change the public call shape of `makeSelectSortedSessionItems()`. Read applied search context from `sessions.windows.sidebar` inside the selector so existing callers and tests do not need a new argument contract. +- Do not leave `Sidebar.tsx`'s "committed search" checks on requested `query/searchTier`. Clearing the search box starts a browse request immediately, but the visible list is still the old applied search result set until replacement browse data lands. - Do not widen the read-model schema with a new `matchedIn` enum for directory matches. The `"title"` tier is already shorthand for metadata-only search, no current client flow distinguishes directory matches, and the clean steady state is to keep the existing transport contract stable. - Do not keep pinning "mostly on" during applied search. The user explicitly asked for search to stop pinning open tabs. The clean rule is: pinning is a browse-mode concern, not a search-mode concern. - Do not use raw full-path substring matching for the new behavior. Restrict matching to the leaf directory name so common ancestors like `code`, `src`, and home-directory segments do not produce noisy false positives. @@ -295,6 +299,7 @@ git commit -m "refactor: track applied sidebar search state" **Files:** - Modify: `src/store/selectors/sidebarSelectors.ts` +- Modify: `src/components/Sidebar.tsx` - Modify: `test/unit/client/store/selectors/sidebarSelectors.test.ts` - Modify: `test/unit/client/components/Sidebar.test.tsx` - Modify: `test/e2e/sidebar-search-flow.test.tsx` @@ -329,6 +334,7 @@ In `test/unit/client/components/Sidebar.test.tsx`, add component regressions for - a loaded title search plus a fallback open tab whose `cwd` leaf matches the query: both rows are visible, but the fallback row is not pinned above the newer server result - a loaded deep search: fallback tab rows stay hidden even if their title or directory would have matched locally - starting a replacement search while an older applied query is still displayed does not locally re-filter the old committed result set before the new server response arrives +- clearing the search box while older applied search results are still visible does not release browse append pagination until browse data replaces that visible result set - existing blocking-load tests still hold: if there is no applied result set yet, fallback rows do not appear underneath the search spinner In `test/e2e/sidebar-search-flow.test.tsx`, add a user-visible flow that proves both halves of the requested behavior: @@ -350,7 +356,7 @@ FRESHELL_TEST_SUMMARY="task3 sidebar search fallback gating" \ test/e2e/sidebar-search-flow.test.tsx ``` -Expected: FAIL because the selector currently ignores applied search context, keeps fallback rows during search regardless of match status, and still pins `hasTab` rows in search mode. +Expected: FAIL because the selector currently ignores applied search context, keeps fallback rows during search regardless of match status, still pins `hasTab` rows in search mode, and the sidebar component still treats requested `query` as the visible-result-set contract for append suppression. - [ ] **Step 3: Implement applied-search fallback gating and search-time unpinned sorting** @@ -385,6 +391,12 @@ Behavior requirements: - project-mode ordering stays unchanged - update any loaded-search test fixtures to seed both requested and applied search fields when they represent already-visible results +In `src/components/Sidebar.tsx`: + +- keep the input control, loading chrome, and tier dropdown driven by requested `query/searchTier` +- switch "the list currently on screen is a search result set" decisions to `appliedQuery/appliedSearchTier` +- specifically, keep browse append pagination disabled while `appliedQuery` is non-empty, even if the local input has already been cleared and a browse request is in flight + - [ ] **Step 4: Re-run the targeted tests to verify they pass** Run: @@ -405,7 +417,7 @@ Expected: PASS. Refactor only after the targeted tests are green: - remove any duplicated leaf-directory extraction logic introduced during the task -- keep helper boundaries clear: shared metadata matching in `shared/`, applied-search state in `sessionsSlice`, selector policy in `sidebarSelectors` +- keep helper boundaries clear: shared metadata matching in `shared/`, applied-search state in `sessionsSlice`, selector policy in `sidebarSelectors`, and visible-result-set policy in `Sidebar.tsx` - verify there is no regression in silent refresh, blocking-load, or deep-search pending behavior Run: @@ -424,6 +436,7 @@ Expected: PASS. cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs git add \ src/store/selectors/sidebarSelectors.ts \ + src/components/Sidebar.tsx \ test/unit/client/store/selectors/sidebarSelectors.test.ts \ test/unit/client/components/Sidebar.test.tsx \ test/e2e/sidebar-search-flow.test.tsx From 312143254cb7f4e1703002dc030cd935373be016 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 23:20:19 -0700 Subject: [PATCH 05/30] docs: add title search subdir test plan --- ...3-26-title-search-subdir-tabs-test-plan.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/2026-03-26-title-search-subdir-tabs-test-plan.md diff --git a/docs/plans/2026-03-26-title-search-subdir-tabs-test-plan.md b/docs/plans/2026-03-26-title-search-subdir-tabs-test-plan.md new file mode 100644 index 00000000..14a62d10 --- /dev/null +++ b/docs/plans/2026-03-26-title-search-subdir-tabs-test-plan.md @@ -0,0 +1,151 @@ +# Title Search Subdirectory And Open-Tab Search Behavior Test Plan + +## Harness requirements + +No new harnesses are required. The implementation plan stays within existing local test infrastructure and does not add paid APIs, external services, or new browser automation dependencies. Extend the existing harnesses with low-complexity fixtures instead of building new ones. + +- **Sidebar search flow harness**: `test/e2e/sidebar-search-flow.test.tsx`. Real `Sidebar` + Redux store + mocked `searchSessions` and `fetchSidebarSessionsSnapshot`, with fake timers for debounce and direct DOM actions for typing, tier changes, and clearing. Estimated complexity: low fixture expansion. Depends on test 1. +- **Sidebar component harness**: `test/unit/client/components/Sidebar.test.tsx`. Rendered `Sidebar` with preloaded store state, tabs/panes fixtures, and scroll geometry helpers for append behavior. Estimated complexity: low fixture expansion. Depends on tests 2-6. +- **Open-tab App harness**: `test/e2e/open-tab-session-sidebar-visibility.test.tsx`. Full `App` with mocked WebSocket invalidation and API calls. Estimated complexity: none beyond reusing an existing regression gate. Depends on test 7. +- **Store harnesses**: `test/unit/client/store/sessionsThunks.test.ts` and `test/unit/client/store/sessionsSlice.test.ts`. Redux store with deferred promises for in-flight request timing plus direct reducer action coverage. Estimated complexity: none. Depends on tests 8-9. +- **Selector harness**: `test/unit/client/store/selectors/sidebarSelectors.test.ts`. Pure selector state fixtures spanning server rows, synthesized fallback rows, tabs, panes, sort modes, and requested/applied search drift. Estimated complexity: low fixture expansion. Depends on tests 10-11. +- **HTTP router harness**: `test/integration/server/session-directory-router.test.ts`. Express router round-trip via `supertest`. Estimated complexity: low fixture expansion. Depends on test 12. +- **Service harness**: `test/unit/server/session-directory/service.test.ts`. Direct `querySessionDirectory()` calls with provider and file fixtures. Estimated complexity: low fixture expansion. Depends on test 13. +- **Shared matcher harness**: `test/unit/shared/session-title-search.test.ts`. New pure unit harness for cross-platform path leaf extraction and metadata precedence. Estimated complexity: low. Depends on test 14. + +Minor reconciliation adjustment: in addition to the implementation plan's unit/service coverage, keep the existing `/api/session-directory` router round-trip as an explicit acceptance gate because that is the transport contract the sidebar actually consumes. + +## Test plan + +1. **Name:** Searching by a subdirectory leaf returns the session, ancestor-only terms do not, and open tabs only appear when they also match + **Type:** scenario + **Disposition:** extend + **Harness:** Sidebar search flow harness + **Preconditions:** A rendered sidebar with one indexed session whose `title` does not contain `trycycle`, whose `projectPath` or `cwd` leaf is `trycycle`, plus an open fallback tab whose leaf also matches `trycycle`; a newer non-tab server result is also present so ordering is observable; sidebar sort mode is `activity`. + **Actions:** Type `trycycle` into the search input; wait for debounce and mocked title-tier server response; observe the ordered rows. Then replace the query with `code`; wait for the new response. + **Expected outcome:** Source of truth: the user transcript, plus the implementation plan Behavior Contract bullets for leaf-directory matching, ancestor rejection, authoritative server rows during applied search, and "no pinning while searching." The `trycycle` query renders the indexed session even though the title lacks `trycycle`; the `code` query does not return that same session unless another metadata field independently contains `code`; the matching fallback open tab is shown only for the matching query; the matching fallback row is not forced above the newer non-tab server row. + **Interactions:** Sidebar debounce, `searchSessions()` request payload, Redux search state, selector fallback synthesis, sort policy, and DOM row ordering. + +2. **Name:** Applied title search hides unrelated fallback tabs and keeps only locally provable fallback matches + **Type:** scenario + **Disposition:** extend + **Harness:** Sidebar component harness + **Preconditions:** Sidebar window already contains committed title-search results from the server. Tabs/panes include one open fallback tab whose `cwd` leaf matches the applied query and one open fallback tab whose metadata does not match. A newer server-backed non-tab row is present. + **Actions:** Render the sidebar with the committed search window and inspect the visible rows without issuing a new request. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullets for authoritative server rows during applied search and local fallback injection only when the client can prove a title-tier match. The unrelated fallback tab is absent, the matching fallback tab is present, and the newer server row remains above the fallback row even when the fallback row has `hasTab: true`. + **Interactions:** Selector merge of server rows with synthesized fallback rows, applied search gating, activity sort behavior, and row rendering. + +3. **Name:** Applied deep search never injects fallback tabs, even when local metadata would have matched + **Type:** regression + **Disposition:** extend + **Harness:** Sidebar component harness + **Preconditions:** Sidebar window contains committed `userMessages` or `fullText` results. Tabs/panes include an open fallback tab whose title or `cwd` leaf matches the query text locally. + **Actions:** Render the sidebar while the applied deep-search result set is on screen. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullet stating that `userMessages` and `fullText` searches must not inject fallback rows because the client cannot prove deep-file matches. Only server-window rows are visible; the locally matching fallback row is hidden. + **Interactions:** Applied search tier handling, selector fallback suppression, and deep-search UI state. + +4. **Name:** Starting a replacement search does not locally re-filter the previous committed result set + **Type:** regression + **Disposition:** extend + **Harness:** Sidebar component harness + **Preconditions:** Sidebar shows a committed title-search result set for query A. A replacement title-search request for query B is configured to stay in flight after the search input changes. + **Actions:** Type query B into the search input and advance past debounce without resolving the new server response. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullets separating requested `query/searchTier` from applied `appliedQuery/appliedSearchTier`, and forbidding local re-filtering of the last committed result set while a replacement query is in flight. The old rows for query A remain visible until query B data commits, even though the input now shows query B and the search-loading chrome is active. + **Interactions:** Search input, debounce timer, requested vs applied search state, search-loading indicator, and selector inputs. + +5. **Name:** Clearing search keeps browse append pagination disabled until browse data replaces the stale search results + **Type:** regression + **Disposition:** extend + **Harness:** Sidebar component harness + **Preconditions:** Sidebar is displaying committed search results with `hasMore: true`. The user clears the search box, triggering a browse reload that has not resolved yet. The list can be scrolled near the bottom. + **Actions:** Click the clear-search button; before the browse response resolves, trigger near-bottom scroll or underfilled-viewport backfill logic; then resolve the browse response and repeat the append trigger. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullet that browse pagination must stay disabled while stale search results remain on screen during a search-to-browse transition. No append fetch is issued before browse data lands; once browse results replace the visible search result set, append requests are allowed again. + **Interactions:** Clear-search button, fetch-session browse path, requested vs applied search state, append guard, scroll handler, and resize/backfill logic. + +6. **Name:** First-load search remains blocking and does not reveal fallback tabs under the spinner + **Type:** regression + **Disposition:** existing + **Harness:** Sidebar component harness + **Preconditions:** Sidebar has no committed result set yet, `loadingKind` is `initial`, the applied search is empty because nothing has committed, and tabs/panes contain fallback open sessions. + **Actions:** Render the sidebar during the first blocking search load. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullet that blocking first-load behavior stays unchanged. The search-loading UI remains the only visible state; fallback rows do not appear underneath it. + **Interactions:** Loading-state hierarchy, fallback synthesis suppression, and empty-result rendering. + +7. **Name:** Active-query refresh stays silent while already-committed search results remain on screen + **Type:** regression + **Disposition:** existing + **Harness:** Open-tab App harness + **Preconditions:** The full app is mounted with committed search results in the sidebar and a WebSocket-driven refresh is triggered for the active query. + **Actions:** Broadcast the refresh/invalidation event and keep the refresh request in flight long enough to observe the UI before it resolves. + **Expected outcome:** Source of truth: current user-visible refresh behavior already covered by the existing suite, plus the implementation plan requirement that component logic reason from the result set currently on screen. The existing search result rows remain visible and no extra search chrome appears during the silent refresh. + **Interactions:** App-level WebSocket invalidation, `refreshActiveSessionWindow`, active-query reuse, and sidebar rendering under background work. + +8. **Name:** In-flight replacement requests move requested search state immediately but keep applied search state on the visible results until commit + **Type:** integration + **Disposition:** extend + **Harness:** Store harnesses (`sessionsThunks.test.ts`) + **Preconditions:** A store with committed sidebar search results for query A. Deferred promises are used for a replacement search for query B and a subsequent search-to-browse transition. + **Actions:** Dispatch `fetchSessionWindow()` for query B and inspect state before resolution; resolve query B and inspect state again. Then dispatch `fetchSessionWindow()` for an empty query to return to browse mode and inspect state before and after the browse response resolves. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullets for requested vs applied search state. `query/searchTier` change as soon as loading starts; `appliedQuery/appliedSearchTier` keep describing query A until query B data commits; clearing search starts a browse request but leaves the applied search context intact until browse data commits. + **Interactions:** Thunk control flow, reducer commit boundary, abort handling, loading-kind classification, and browse/search request routing. + +9. **Name:** The reducer only advances applied search fields when new window data commits + **Type:** unit + **Disposition:** extend + **Harness:** Store harnesses (`sessionsSlice.test.ts`) + **Preconditions:** A `SessionWindowState` with committed search results and populated applied search fields. + **Actions:** Dispatch `setSessionWindowLoading()` with a new requested query and tier; inspect state. Then dispatch `setSessionWindowData()` for the replacement result set; inspect state again. + **Expected outcome:** Source of truth: implementation plan Strategy Gate and Behavior Contract sections describing `setSessionWindowLoading()` as a requested-state update and `setSessionWindowData()` as the commit point for the visible result set. Loading updates only `query/searchTier`; data commit updates both requested and applied fields to the newly committed values. + **Interactions:** Pure reducer boundary for the visible-result-set contract. + +10. **Name:** Applied title search uses the shared metadata rules for fallback gating and rejects ancestor-only matches + **Type:** invariant + **Disposition:** extend + **Harness:** Selector harness + **Preconditions:** Selector state contains server-backed items, synthesized fallback rows, and requested search state that intentionally differs from applied search state. Fixtures cover an indexed row whose `projectPath` leaf is `trycycle`, a fallback row whose `cwd` leaf is `trycycle`, and rows whose ancestor path segment is `code`. + **Actions:** Run `makeSelectSortedSessionItems()` with an applied title query of `trycycle`, then with an applied title query of `code`, then with an applied deep-search tier while keeping the same fallback fixtures. + **Expected outcome:** Source of truth: user transcript plus implementation plan Behavior Contract bullets on leaf-only directory matching, project-path precedence for indexed rows, fallback `cwd` matching, and no fallback injection for deep tiers. Indexed rows match on their leaf subtitle metadata, cwd-only fallback rows match on their leaf, ancestor-only `code` does not match, and deep tiers drop fallback rows entirely. + **Interactions:** Shared metadata matcher contract, selector state inputs, fallback-row synthesis, and applied tier handling. + +11. **Name:** Applied search disables tab pinning in `activity` and `recency-pinned` modes while preserving archived-last ordering and ignoring requested-state drift + **Type:** invariant + **Disposition:** extend + **Harness:** Selector harness + **Preconditions:** Selector state includes a newer non-tab server row, an older matching fallback row with `hasTab: true`, archived and non-archived rows, and requested search state that differs from applied search state. + **Actions:** Run the selector under `activity` sort, then under `recency-pinned`, first with an applied search active and then with no applied search. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullets that search disables `hasTab` pinning regardless of sort mode, archived-last remains intact, and selector search behavior must come from applied fields rather than requested ones. During applied search, the older fallback row is not promoted above the newer non-tab row in either sort mode, archived rows remain last, and requested-state drift does not re-enable pinning or re-filter the visible set early. Without applied search, the existing pinning behavior stays unchanged. + **Interactions:** Sort comparator behavior, archived grouping, requested vs applied state, and synthesized fallback rows. + +12. **Name:** `/api/session-directory` title-tier search matches the subdirectory leaf through the real HTTP contract and keeps the existing schema + **Type:** integration + **Disposition:** extend + **Harness:** HTTP router harness + **Preconditions:** The Express router is mounted with indexed sessions whose `projectPath` or deeper `cwd` leaf is `trycycle`, while titles omit that term. A sibling path containing ancestor segment `code` is also present to prove rejection. Providers are omitted for title-tier requests where possible. + **Actions:** Issue `GET /api/session-directory?priority=visible&query=trycycle`, then `GET /api/session-directory?priority=visible&query=code`, and inspect the returned JSON. + **Expected outcome:** Source of truth: user transcript, implementation plan Behavior Contract, and the unchanged `SessionDirectoryPage` schema in `shared/read-models.ts`. The `trycycle` query returns the matching session through the real endpoint; the `code` query does not return it on ancestor-only path text; the response shape stays in the current read-model contract, including existing `matchedIn` semantics and no new transport fields. + **Interactions:** Router query parsing, service invocation, read-model schema validation, and title-tier provider-free search path. + +13. **Name:** Service-level title-tier search keeps ordering, snippet behavior, provider-free execution, and the existing low-risk performance guard after directory matching is added + **Type:** integration + **Disposition:** extend + **Harness:** Service harness + **Preconditions:** Direct `querySessionDirectory()` fixtures cover title matches, summary or first-user-message matches, project-path leaf matches, deeper `cwd` values for indexed sessions, archived sessions, and a large corpus for the performance guard. + **Actions:** Query the service with title-tier searches that hit each metadata source; query with an ancestor-only term; query without providers; run the existing many-session timing guard. + **Expected outcome:** Source of truth: implementation plan Behavior Contract and Strategy Gate, especially the rules to keep title-tier metadata search provider-free and keep snippet extraction in the service. Directory matches preserve the canonical ordering and archived handling, metadata snippets stay bounded and query-focused, title-tier search still works without file providers, ancestor-only queries do not match, and the generous timing guard still catches catastrophic regressions without turning this task into performance work. + **Interactions:** Projection ordering, server-side snippet extraction, provider lookup bypass for title tier, and metadata-only search cost. + +14. **Name:** Shared title-tier metadata matching extracts leaf directory names cross-platform and honors the required precedence + **Type:** unit + **Disposition:** new + **Harness:** Shared matcher harness + **Preconditions:** Pure metadata fixtures cover POSIX and Windows paths, trailing separators, indexed sessions with both `projectPath` and deeper `cwd`, fallback rows with only `cwd`, summary and first-user-message metadata, and an ancestor-only query. + **Actions:** Call `getLeafDirectoryName()` and `matchTitleTierMetadata()` across those fixtures. + **Expected outcome:** Source of truth: implementation plan Behavior Contract and Task 1 test requirements. Leaf extraction returns `trycycle` from both POSIX and Windows paths, trailing separators are ignored, precedence is `title` then `projectPath` leaf then distinct `cwd` leaf then `summary` then `firstUserMessage`, indexed sessions prefer the `projectPath` leaf, cwd-only fallback metadata still matches, and ancestor-only `code` does not match. + **Interactions:** Pure shared metadata-matching seam used by both the server search path and client fallback gating. + +## Coverage summary + +- **Covered action space:** typing into the sidebar search input; changing the search tier dropdown; clicking the clear-search button; triggering near-bottom scroll and underfilled-viewport append logic; rendering committed search results while replacement work is in flight; active-query refresh via app-level invalidation; selector merging of server rows with synthesized fallback rows from tabs/panes; HTTP `GET /api/session-directory` title-tier queries; service-level metadata search; shared path-leaf extraction. +- **Covered unchanged behaviors kept as regression gates:** first-load blocking search hides fallback tabs; active-query background refresh remains silent; title-tier search remains provider-free; archived-last ordering remains intact; existing read-model transport shape does not change. +- **Explicitly excluded:** deep file-content matching correctness beyond fallback suppression, click-to-open session behavior, and terminal-directory/busy-indicator behavior. Those surfaces are unchanged by this task and already have dedicated coverage elsewhere. +- **Risk carried by the exclusions:** if unrelated deep-search file scanning, session-open behavior, or terminal-state rendering regress at the same time, this plan will detect only the parts that overlap with applied search state and fallback gating, not every independent failure in those adjacent features. From b9fbca6f561a87f58205c2746a3bed3c1778a406 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 23:27:50 -0700 Subject: [PATCH 06/30] feat: extend title search with subdirectory matches --- server/session-directory/service.ts | 16 +-- shared/session-title-search.ts | 57 ++++++++ .../server/session-directory-router.test.ts | 36 +++++ .../server/session-directory/service.test.ts | 133 ++++++++++++++++++ test/unit/shared/session-title-search.test.ts | 96 +++++++++++++ 5 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 shared/session-title-search.ts create mode 100644 test/unit/shared/session-title-search.test.ts diff --git a/server/session-directory/service.ts b/server/session-directory/service.ts index 24442801..ad004d1f 100644 --- a/server/session-directory/service.ts +++ b/server/session-directory/service.ts @@ -3,6 +3,7 @@ import type { ProjectGroup } from '../coding-cli/types.js' import type { TerminalMeta } from '../terminal-metadata-service.js' import { extractSnippet, searchSessionFile } from '../session-search.js' import { MAX_DIRECTORY_PAGE_ITEMS } from '../../shared/read-models.js' +import { matchTitleTierMetadata } from '../../shared/session-title-search.js' import { buildSessionDirectoryComparableSnapshot, compareSessionDirectoryComparableItems, @@ -63,20 +64,13 @@ function compareItems(a: SessionDirectoryItem, b: SessionDirectoryItem): number } function applySearch(item: SessionDirectoryItem, queryText: string): SessionDirectoryItem | null { - const normalizedQuery = queryText.toLowerCase() - const searchable: Array<[SessionDirectoryItem['matchedIn'], string | undefined]> = [ - ['title', item.title], - ['summary', item.summary], - ['firstUserMessage', item.firstUserMessage], - ] - - const match = searchable.find(([, value]) => typeof value === 'string' && value.toLowerCase().includes(normalizedQuery)) - if (!match || !match[1]) return null + const match = matchTitleTierMetadata(item, queryText) + if (!match) return null return { ...item, - matchedIn: match[0], - snippet: extractSnippet(match[1], queryText, 40).slice(0, 140), + matchedIn: match.matchedIn, + snippet: extractSnippet(match.matchedValue, queryText, 40).slice(0, 140), } } diff --git a/shared/session-title-search.ts b/shared/session-title-search.ts new file mode 100644 index 00000000..55c1d645 --- /dev/null +++ b/shared/session-title-search.ts @@ -0,0 +1,57 @@ +export type TitleTierMetadata = { + title?: string + summary?: string + firstUserMessage?: string + cwd?: string + projectPath?: string +} + +export type TitleTierMatch = { + matchedIn: 'title' | 'summary' | 'firstUserMessage' + matchedValue: string +} + +function includesQuery(value: string | undefined, normalizedQuery: string): value is string { + return typeof value === 'string' && value.toLowerCase().includes(normalizedQuery) +} + +export function getLeafDirectoryName(pathLike?: string): string | undefined { + if (typeof pathLike !== 'string') return undefined + + const trimmed = pathLike.trim() + if (!trimmed) return undefined + + const normalized = trimmed.replace(/[\\/]+/g, '/').replace(/\/+$/, '') + if (!normalized || /^[A-Za-z]:$/.test(normalized)) return undefined + + const segments = normalized.split('/').filter(Boolean) + return segments.at(-1) +} + +export function matchTitleTierMetadata( + metadata: TitleTierMetadata, + query: string, +): TitleTierMatch | null { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return null + + const projectLeaf = getLeafDirectoryName(metadata.projectPath) + const cwdLeaf = getLeafDirectoryName(metadata.cwd) + const distinctCwdLeaf = cwdLeaf && cwdLeaf !== projectLeaf ? cwdLeaf : undefined + + const searchable: Array<[TitleTierMatch['matchedIn'], string | undefined]> = [ + ['title', metadata.title], + ['title', projectLeaf], + ['title', distinctCwdLeaf], + ['summary', metadata.summary], + ['firstUserMessage', metadata.firstUserMessage], + ] + + const match = searchable.find(([, value]) => includesQuery(value, normalizedQuery)) + if (!match || !match[1]) return null + + return { + matchedIn: match[0], + matchedValue: match[1], + } +} diff --git a/test/integration/server/session-directory-router.test.ts b/test/integration/server/session-directory-router.test.ts index 67cfbb7e..4a5c327f 100644 --- a/test/integration/server/session-directory-router.test.ts +++ b/test/integration/server/session-directory-router.test.ts @@ -292,6 +292,42 @@ describe('search tiers through the HTTP route (full round-trip)', () => { expect(res.body.items[0].matchedIn).toBe('title') }) + it('matches a project-path leaf through the HTTP contract and rejects ancestor-only path text', async () => { + createAppWithProjects([{ + projectPath: '/home/user/code/trycycle', + sessions: [{ + provider: 'claude', + sessionId: 'session-1', + projectPath: '/home/user/code/trycycle', + cwd: '/home/user/code/trycycle/server', + lastActivityAt: 100, + title: 'Routine work', + }], + }]) + + const leafResponse = await request(app) + .get('/api/session-directory?priority=visible&query=trycycle&tier=title') + .set('x-auth-token', TEST_AUTH_TOKEN) + + expect(leafResponse.status).toBe(200) + expect(leafResponse.body.items).toHaveLength(1) + expect(leafResponse.body.items[0]).toMatchObject({ + sessionId: 'session-1', + matchedIn: 'title', + snippet: 'trycycle', + projectPath: '/home/user/code/trycycle', + cwd: '/home/user/code/trycycle/server', + }) + expect(leafResponse.body.items[0]).not.toHaveProperty('matchedPath') + + const ancestorResponse = await request(app) + .get('/api/session-directory?priority=visible&query=code&tier=title') + .set('x-auth-token', TEST_AUTH_TOKEN) + + expect(ancestorResponse.status).toBe(200) + expect(ancestorResponse.body.items).toHaveLength(0) + }) + it('userMessages tier searches JSONL user messages', async () => { const sessionFile = path.join(tempDir, 'session-1.jsonl') await fsp.writeFile(sessionFile, [ diff --git a/test/unit/server/session-directory/service.test.ts b/test/unit/server/session-directory/service.test.ts index 47ba2b0d..1e88c39c 100644 --- a/test/unit/server/session-directory/service.test.ts +++ b/test/unit/server/session-directory/service.test.ts @@ -133,6 +133,139 @@ describe('querySessionDirectory', () => { expect(page.items[2]?.snippet?.toLowerCase()).toContain('deploy') }) + it('matches a title-tier query against the indexed project-path leaf and rejects ancestor-only path text', async () => { + const page = await querySessionDirectory({ + projects: [ + makeProject('/home/user/code/trycycle', [ + makeSession({ + sessionId: 'session-leaf', + projectPath: '/home/user/code/trycycle', + cwd: '/home/user/code/trycycle/server', + lastActivityAt: 900, + title: 'Routine work', + summary: 'Metadata without the query', + firstUserMessage: 'Still no query here', + }), + ]), + ], + terminalMeta: [], + query: { + priority: 'visible', + query: 'trycycle', + tier: 'title', + }, + }) + + expect(page.items).toHaveLength(1) + expect(page.items[0]).toMatchObject({ + sessionId: 'session-leaf', + matchedIn: 'title', + snippet: 'trycycle', + }) + + const ancestorOnlyPage = await querySessionDirectory({ + projects: [ + makeProject('/home/user/code/trycycle', [ + makeSession({ + sessionId: 'session-leaf', + projectPath: '/home/user/code/trycycle', + cwd: '/home/user/code/trycycle/server', + lastActivityAt: 900, + title: 'Routine work', + summary: 'Metadata without the query', + firstUserMessage: 'Still no query here', + }), + ]), + ], + terminalMeta: [], + query: { + priority: 'visible', + query: 'code', + tier: 'title', + }, + }) + + expect(ancestorOnlyPage.items).toHaveLength(0) + }) + + it('keeps ordering and focused snippets for title-tier metadata and directory matches without providers', async () => { + const page = await querySessionDirectory({ + projects: [ + makeProject('/repo/title', [ + makeSession({ + sessionId: 'session-title', + projectPath: '/repo/title', + lastActivityAt: 1_300, + title: 'Trycycle rollout notes', + }), + ]), + makeProject('/repo/team/trycycle', [ + makeSession({ + sessionId: 'session-leaf', + projectPath: '/repo/team/trycycle', + cwd: '/repo/team/trycycle/server', + lastActivityAt: 1_250, + title: 'Routine work', + }), + ]), + makeProject('/repo/summary', [ + makeSession({ + sessionId: 'session-summary', + projectPath: '/repo/summary', + lastActivityAt: 1_100, + title: 'Routine work', + summary: 'This summary explains how the trycycle migration should roll out in production without surprises.', + }), + ]), + makeProject('/repo/first-user', [ + makeSession({ + sessionId: 'session-first-user', + projectPath: '/repo/first-user', + lastActivityAt: 1_000, + title: 'Routine work', + firstUserMessage: 'Please double-check the trycycle migration before shipping.', + }), + ]), + makeProject('/repo/archive/trycycle', [ + makeSession({ + sessionId: 'session-archived', + projectPath: '/repo/archive/trycycle', + lastActivityAt: 1_400, + archived: true, + title: 'Archived notes', + }), + ]), + ], + terminalMeta: [], + query: { + priority: 'visible', + query: 'trycycle', + tier: 'title', + }, + }) + + expect(page.items.map((item) => item.sessionId)).toEqual([ + 'session-title', + 'session-leaf', + 'session-summary', + 'session-first-user', + 'session-archived', + ]) + expect(page.items.map((item) => item.matchedIn)).toEqual([ + 'title', + 'title', + 'summary', + 'firstUserMessage', + 'title', + ]) + expect(page.items.every((item) => (item.snippet?.length ?? 0) <= 140)).toBe(true) + expect(page.items[0]?.snippet?.toLowerCase()).toContain('trycycle') + expect(page.items[1]?.snippet).toBe('trycycle') + expect(page.items[2]?.snippet?.toLowerCase()).toContain('trycycle') + expect(page.items[3]?.snippet?.toLowerCase()).toContain('trycycle') + expect(page.items[4]?.snippet).toBe('trycycle') + }) + it('bounds page size and provides a deterministic cursor window', async () => { const firstPage = await querySessionDirectory({ projects, diff --git a/test/unit/shared/session-title-search.test.ts b/test/unit/shared/session-title-search.test.ts new file mode 100644 index 00000000..ca45e8e7 --- /dev/null +++ b/test/unit/shared/session-title-search.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { getLeafDirectoryName, matchTitleTierMetadata } from '../../../shared/session-title-search.js' + +describe('getLeafDirectoryName', () => { + it('extracts a POSIX leaf directory name and trims trailing separators', () => { + expect(getLeafDirectoryName('/home/user/code/trycycle')).toBe('trycycle') + expect(getLeafDirectoryName('/home/user/code/trycycle/')).toBe('trycycle') + }) + + it('extracts a Windows leaf directory name and trims trailing separators', () => { + expect(getLeafDirectoryName('C:\\Users\\me\\code\\trycycle')).toBe('trycycle') + expect(getLeafDirectoryName('C:\\Users\\me\\code\\trycycle\\')).toBe('trycycle') + }) +}) + +describe('matchTitleTierMetadata', () => { + it('matches title metadata before directory, summary, and first-user-message metadata', () => { + expect(matchTitleTierMetadata({ + title: 'Trycycle planning notes', + projectPath: '/repo/trycycle', + cwd: '/repo/work/trycycle', + summary: 'Summary mentions trycycle too', + firstUserMessage: 'Need help with trycycle rollout', + }, 'trycycle')).toEqual({ + matchedIn: 'title', + matchedValue: 'Trycycle planning notes', + }) + }) + + it('matches the indexed project-path leaf before a deeper cwd leaf and later metadata fields', () => { + expect(matchTitleTierMetadata({ + title: 'Routine work', + projectPath: '/repo/trycycle', + cwd: '/repo/trycycle/server', + summary: 'Summary mentions trycycle too', + firstUserMessage: 'Need help with trycycle rollout', + }, 'trycycle')).toEqual({ + matchedIn: 'title', + matchedValue: 'trycycle', + }) + }) + + it('matches a distinct cwd leaf before summary and first-user-message metadata', () => { + expect(matchTitleTierMetadata({ + title: 'Routine work', + projectPath: '/repo/alpha', + cwd: '/repo/alpha/trycycle', + summary: 'Summary mentions trycycle too', + firstUserMessage: 'Need help with trycycle rollout', + }, 'trycycle')).toEqual({ + matchedIn: 'title', + matchedValue: 'trycycle', + }) + }) + + it('matches summary metadata before first-user-message metadata', () => { + expect(matchTitleTierMetadata({ + title: 'Routine work', + summary: 'Summary mentions trycycle first', + firstUserMessage: 'Trycycle also appears here', + }, 'trycycle')).toEqual({ + matchedIn: 'summary', + matchedValue: 'Summary mentions trycycle first', + }) + }) + + it('matches a cwd leaf for fallback-only metadata when no project path is available', () => { + expect(matchTitleTierMetadata({ + cwd: '/repo/trycycle', + firstUserMessage: 'No other metadata matches', + }, 'trycycle')).toEqual({ + matchedIn: 'title', + matchedValue: 'trycycle', + }) + }) + + it('returns a non-null metadata match for directory-only metadata', () => { + expect(matchTitleTierMetadata({ + title: 'Routine work', + projectPath: '/repo/trycycle', + }, 'trycycle')).toEqual({ + matchedIn: 'title', + matchedValue: 'trycycle', + }) + }) + + it('does not match ancestor-only path segments when no other metadata contains the query', () => { + expect(matchTitleTierMetadata({ + title: 'Routine work', + projectPath: '/home/user/code/trycycle', + cwd: '/home/user/code/trycycle/server', + summary: 'Summary without the search term', + firstUserMessage: 'No match here either', + }, 'code')).toBeNull() + }) +}) From 3429cd89f2b35507f2611bdd7a728eabb630a973 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 26 Mar 2026 23:33:05 -0700 Subject: [PATCH 07/30] refactor: track applied sidebar search state --- src/store/sessionsSlice.ts | 12 +- test/unit/client/store/sessionsSlice.test.ts | 104 +++++++++++++++++ test/unit/client/store/sessionsThunks.test.ts | 109 ++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index e1117206..953ccbf2 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -15,6 +15,8 @@ export interface SessionWindowState { error?: string query?: string searchTier?: 'title' | 'userMessages' | 'fullText' + appliedQuery?: string + appliedSearchTier?: 'title' | 'userMessages' | 'fullText' deepSearchPending?: boolean partial?: boolean partialReason?: 'budget' | 'io_error' @@ -219,8 +221,14 @@ export const sessionsSlice = createSlice({ window.deepSearchPending = action.payload.deepSearchPending ?? false window.partial = action.payload.partial window.partialReason = action.payload.partialReason - if (action.payload.query !== undefined) window.query = action.payload.query - if (action.payload.searchTier !== undefined) window.searchTier = action.payload.searchTier + if (action.payload.query !== undefined) { + window.query = action.payload.query + window.appliedQuery = action.payload.query + } + if (action.payload.searchTier !== undefined) { + window.searchTier = action.payload.searchTier + window.appliedSearchTier = action.payload.searchTier + } if (!state.activeSurface || state.activeSurface === action.payload.surface) { syncTopLevelFromWindow(state, action.payload.surface) } diff --git a/test/unit/client/store/sessionsSlice.test.ts b/test/unit/client/store/sessionsSlice.test.ts index 143fa408..35def74c 100644 --- a/test/unit/client/store/sessionsSlice.test.ts +++ b/test/unit/client/store/sessionsSlice.test.ts @@ -663,4 +663,108 @@ describe('sessionsSlice', () => { expect(state.windows.sidebar.deepSearchPending).toBe(false) }) }) + + describe('requested vs applied search state', () => { + it('setSessionWindowLoading updates the requested query and tier without changing the applied search context', () => { + const stateWithAppliedSearch: SessionsState = { + ...initialState, + activeSurface: 'sidebar', + windows: { + sidebar: { + projects: [mockProjects[0]], + query: 'alpha', + searchTier: 'title', + appliedQuery: 'alpha', + appliedSearchTier: 'title', + } as any, + }, + } + + const state = sessionsReducer(stateWithAppliedSearch, setSessionWindowLoading({ + surface: 'sidebar', + loading: true, + loadingKind: 'search', + query: 'beta', + searchTier: 'fullText', + })) + + expect(state.windows.sidebar.query).toBe('beta') + expect(state.windows.sidebar.searchTier).toBe('fullText') + expect((state.windows.sidebar as any).appliedQuery).toBe('alpha') + expect((state.windows.sidebar as any).appliedSearchTier).toBe('title') + }) + + it('setSessionWindowData commits requested and applied search fields together with the visible result set', () => { + const stateWithAppliedSearch: SessionsState = { + ...initialState, + activeSurface: 'sidebar', + windows: { + sidebar: { + projects: [mockProjects[0]], + query: 'alpha', + searchTier: 'title', + appliedQuery: 'alpha', + appliedSearchTier: 'title', + } as any, + }, + } + + const state = sessionsReducer(stateWithAppliedSearch, setSessionWindowData({ + surface: 'sidebar', + projects: [mockProjects[1]], + totalSessions: 1, + hasMore: false, + query: 'beta', + searchTier: 'fullText', + })) + + expect(state.windows.sidebar.projects).toEqual([mockProjects[1]]) + expect(state.windows.sidebar.query).toBe('beta') + expect(state.windows.sidebar.searchTier).toBe('fullText') + expect((state.windows.sidebar as any).appliedQuery).toBe('beta') + expect((state.windows.sidebar as any).appliedSearchTier).toBe('fullText') + expect(state.projects).toEqual([mockProjects[1]]) + }) + + it('keeps the previous applied search context during a search-to-browse transition until browse data commits', () => { + const stateWithAppliedSearch: SessionsState = { + ...initialState, + activeSurface: 'sidebar', + windows: { + sidebar: { + projects: [mockProjects[0]], + query: 'alpha', + searchTier: 'title', + appliedQuery: 'alpha', + appliedSearchTier: 'title', + } as any, + }, + } + + const loadingState = sessionsReducer(stateWithAppliedSearch, setSessionWindowLoading({ + surface: 'sidebar', + loading: true, + loadingKind: 'search', + query: '', + searchTier: 'title', + })) + + expect(loadingState.windows.sidebar.query).toBe('') + expect(loadingState.windows.sidebar.searchTier).toBe('title') + expect((loadingState.windows.sidebar as any).appliedQuery).toBe('alpha') + expect((loadingState.windows.sidebar as any).appliedSearchTier).toBe('title') + + const committedState = sessionsReducer(loadingState, setSessionWindowData({ + surface: 'sidebar', + projects: mockProjects, + totalSessions: mockProjects.length, + hasMore: true, + query: '', + searchTier: 'title', + })) + + expect((committedState.windows.sidebar as any).appliedQuery).toBe('') + expect((committedState.windows.sidebar as any).appliedSearchTier).toBe('title') + }) + }) }) diff --git a/test/unit/client/store/sessionsThunks.test.ts b/test/unit/client/store/sessionsThunks.test.ts index b87c4d28..5e055ef4 100644 --- a/test/unit/client/store/sessionsThunks.test.ts +++ b/test/unit/client/store/sessionsThunks.test.ts @@ -270,6 +270,115 @@ describe('sessionsThunks', () => { expect((store.getState().sessions.windows.sidebar as any).loadingKind).toBeUndefined() }) + it('moves requested search state immediately but keeps applied search state on the visible results until each replacement commits', async () => { + const replacementSearch = createDeferred() + searchSessions.mockReturnValueOnce(replacementSearch.promise) + + const appliedProjects = [{ + projectPath: '/tmp/project-alpha', + sessions: [{ + provider: 'claude', + sessionId: 'session-alpha', + projectPath: '/tmp/project-alpha', + lastActivityAt: 1_000, + title: 'Alpha result', + }], + }] + + const store = createStoreWithSessions({ + activeSurface: 'sidebar', + projects: appliedProjects, + lastLoadedAt: 1_000, + windows: { + sidebar: { + projects: appliedProjects, + lastLoadedAt: 1_000, + query: 'alpha', + searchTier: 'title', + appliedQuery: 'alpha', + appliedSearchTier: 'title', + }, + }, + }) + + const replacementRequest = store.dispatch(fetchSessionWindow({ + surface: 'sidebar', + priority: 'visible', + query: 'beta', + searchTier: 'title', + }) as any) + + let replacementResolved = false + + try { + expect((store.getState().sessions.windows.sidebar as any).query).toBe('beta') + expect((store.getState().sessions.windows.sidebar as any).searchTier).toBe('title') + expect((store.getState().sessions.windows.sidebar as any).appliedQuery).toBe('alpha') + expect((store.getState().sessions.windows.sidebar as any).appliedSearchTier).toBe('title') + + replacementSearch.resolve({ + results: [{ + provider: 'claude', + sessionId: 'session-beta', + projectPath: '/tmp/project-beta', + title: 'Beta result', + matchedIn: 'title', + lastActivityAt: 2_000, + archived: false, + }], + tier: 'title', + query: 'beta', + totalScanned: 1, + }) + replacementResolved = true + + await replacementRequest + + expect((store.getState().sessions.windows.sidebar as any).appliedQuery).toBe('beta') + expect((store.getState().sessions.windows.sidebar as any).appliedSearchTier).toBe('title') + + const browseReload = createDeferred() + fetchSidebarSessionsSnapshot.mockReturnValueOnce(browseReload.promise) + + const browseRequest = store.dispatch(fetchSessionWindow({ + surface: 'sidebar', + priority: 'visible', + query: '', + searchTier: 'title', + }) as any) + + try { + expect((store.getState().sessions.windows.sidebar as any).query).toBe('') + expect((store.getState().sessions.windows.sidebar as any).searchTier).toBe('title') + expect((store.getState().sessions.windows.sidebar as any).appliedQuery).toBe('beta') + expect((store.getState().sessions.windows.sidebar as any).appliedSearchTier).toBe('title') + } finally { + browseReload.resolve({ + projects: [], + totalSessions: 0, + oldestIncludedTimestamp: 0, + oldestIncludedSessionId: '', + hasMore: false, + }) + + await browseRequest + } + + expect((store.getState().sessions.windows.sidebar as any).appliedQuery).toBe('') + expect((store.getState().sessions.windows.sidebar as any).appliedSearchTier).toBe('title') + } finally { + if (!replacementResolved) { + replacementSearch.resolve({ + results: [], + tier: 'title', + query: 'beta', + totalScanned: 0, + }) + await replacementRequest + } + } + }) + it('appends a later page into the same surface window', async () => { fetchSidebarSessionsSnapshot .mockResolvedValueOnce({ From ae9fa4b193a73a417f175853bb5a470529547dab Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Fri, 27 Mar 2026 00:31:07 -0700 Subject: [PATCH 08/30] feat: finalize applied sidebar search behavior --- src/components/Sidebar.tsx | 24 +- src/store/selectors/sidebarSelectors.ts | 73 ++- test/e2e/network-setup.test.tsx | 35 +- test/e2e/settings-devices-flow.test.tsx | 2 +- test/e2e/sidebar-click-opens-pane.test.tsx | 3 + test/e2e/sidebar-search-flow.test.tsx | 127 ++++- test/e2e/update-flow.test.ts | 286 ++++------ test/integration/client/editor-pane.test.tsx | 33 +- .../server/codex-session-flow.test.ts | 327 ++++++++--- .../components/SettingsView.behavior.test.tsx | 33 +- test/unit/client/components/Sidebar.test.tsx | 537 +++++++++++++++++- .../store/selectors/sidebarSelectors.test.ts | 307 +++++++++- .../client/store/turnCompletionSlice.test.ts | 7 +- 13 files changed, 1469 insertions(+), 325 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 239ad382..1e171518 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -72,6 +72,7 @@ export function areSessionItemsEqual(a: SessionItem[], b: SessionItem[]): boolea ai.projectColor !== bi.projectColor || ai.cwd !== bi.cwd || ai.projectPath !== bi.projectPath || + ai.isFallback !== bi.isFallback || ai.timestamp !== bi.timestamp ) return false } @@ -123,6 +124,7 @@ function isSessionItemEqual(a: SessionItem, b: SessionItem): boolean { a.projectColor === b.projectColor && a.cwd === b.cwd && a.projectPath === b.projectPath && + a.isFallback === b.isFallback && a.ratchetedActivity === b.ratchetedActivity && a.hasTitle === b.hasTitle && a.isSubagent === b.isSubagent && @@ -373,10 +375,14 @@ export default function Sidebar({ const activeTab = tabs.find((t) => t.id === activeTabId) const activeSessionKey = activeSessionKeyFromPanes const activeTerminalId = activeTab?.terminalId - const activeSearchTier = sidebarWindow?.searchTier ?? searchTier + const requestedSearchTier = sidebarWindow?.searchTier ?? searchTier + const appliedQuery = (sidebarWindow?.appliedQuery ?? '').trim() + const appliedSearchTier = sidebarWindow?.appliedSearchTier ?? 'title' const hasLoadedSidebarWindow = typeof sidebarWindow?.lastLoadedAt === 'number' const sidebarWindowHasItems = (sidebarWindow?.projects ?? []).some((project) => (project.sessions?.length ?? 0) > 0) - const activeQuery = (sidebarWindow?.query ?? filter).trim() + const requestedQuery = (sidebarWindow?.query ?? filter).trim() + const visibleQuery = appliedQuery || requestedQuery + const visibleSearchTier = appliedQuery ? appliedSearchTier : requestedSearchTier const loadingKind = sidebarWindow?.loadingKind const showBlockingLoad = !!sidebarWindow?.loading && loadingKind === 'initial' @@ -388,8 +394,7 @@ export default function Sidebar({ const sidebarOldestLoadedTimestamp = sidebarWindow?.oldestLoadedTimestamp const sidebarOldestLoadedSessionId = sidebarWindow?.oldestLoadedSessionId const localQuery = filter.trim() - const committedQuery = (sidebarWindow?.query ?? '').trim() - const hasActiveQuery = localQuery.length > 0 || committedQuery.length > 0 + const hasActiveQuery = localQuery.length > 0 || appliedQuery.length > 0 const loadMoreInFlightRef = useRef(false) const loadMoreTimeoutRef = useRef | null>(null) @@ -652,11 +657,11 @@ export default function Sidebar({ {showBlockingLoad ? (
- {activeQuery ? 'Searching...' : 'Loading sessions...'} + {requestedQuery ? 'Searching...' : 'Loading sessions...'}
) : sortedItems.length === 0 ? ( @@ -667,9 +672,9 @@ export default function Sidebar({ ) : (
- {activeQuery && activeSearchTier !== 'title' + {visibleQuery && visibleSearchTier !== 'title' ? 'No results found' - : activeQuery + : visibleQuery ? 'No matching sessions' : 'No sessions yet'}
@@ -750,7 +755,8 @@ function areSidebarItemPropsEqual(prev: SidebarItemProps, next: SidebarItemProps a.archived === b.archived && a.projectColor === b.projectColor && a.cwd === b.cwd && - a.projectPath === b.projectPath + a.projectPath === b.projectPath && + a.isFallback === b.isFallback ) } diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index cc92dd5d..612b356c 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -6,6 +6,7 @@ import { collectSessionRefsFromNode, collectSessionRefsFromTabs } from '@/lib/se import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' import { getSessionMetadata } from '@/lib/session-metadata' import type { SessionListMetadata } from '../types' +import { getLeafDirectoryName, matchTitleTierMetadata } from '../../../shared/session-title-search.js' export interface SidebarSessionItem { id: string @@ -28,6 +29,7 @@ export interface SidebarSessionItem { isNonInteractive?: boolean firstUserMessage?: string hasTitle: boolean + isFallback?: true } const EMPTY_ACTIVITY: Record = {} @@ -48,12 +50,13 @@ const selectShowNoninteractiveSessions = (state: RootState) => state.settings.se const selectHideEmptySessions = (state: RootState) => state.settings.settings.sidebar?.hideEmptySessions ?? true const selectExcludeFirstChatSubstrings = (state: RootState) => state.settings.settings.sidebar?.excludeFirstChatSubstrings ?? EMPTY_STRINGS const selectExcludeFirstChatMustStart = (state: RootState) => state.settings.settings.sidebar?.excludeFirstChatMustStart ?? false +const selectAppliedQuery = (state: RootState) => state.sessions.windows?.sidebar?.appliedQuery ?? '' +const selectAppliedSearchTier = (state: RootState) => state.sessions.windows?.sidebar?.appliedSearchTier const selectTerminals = (_state: RootState, terminals: BackgroundTerminal[]) => terminals const selectFilter = (_state: RootState, _terminals: BackgroundTerminal[], filter: string) => filter function getProjectName(projectPath: string): string { - const parts = projectPath.replace(/\\/g, '/').split('/') - return parts[parts.length - 1] || projectPath + return getLeafDirectoryName(projectPath) ?? projectPath } export function buildSessionItems( @@ -164,6 +167,7 @@ export function buildSessionItems( isSubagent: input.metadata?.isSubagent, isNonInteractive: input.metadata?.isNonInteractive, firstUserMessage: input.metadata?.firstUserMessage, + isFallback: true, }) } @@ -252,6 +256,29 @@ function filterSessionItems(items: SidebarSessionItem[], filter: string): Sideba ) } +function filterSessionItemsForAppliedSearch( + items: SidebarSessionItem[], + appliedQuery: string, + appliedSearchTier?: 'title' | 'userMessages' | 'fullText', +): SidebarSessionItem[] { + const query = appliedQuery.trim() + if (!query) return items + + const tier = appliedSearchTier ?? 'title' + if (tier !== 'title') { + return items.filter((item) => !item.isFallback) + } + + return items.filter((item) => ( + !item.isFallback || matchTitleTierMetadata({ + title: item.title, + projectPath: item.projectPath, + cwd: item.cwd, + firstUserMessage: item.firstUserMessage, + }, query) !== null + )) +} + export interface VisibilitySettings { showSubagents: boolean ignoreCodexSubagents: boolean @@ -297,31 +324,52 @@ export function filterSessionItemsByVisibility( }) } -export function sortSessionItems(items: SidebarSessionItem[], sortMode: string): SidebarSessionItem[] { +export function sortSessionItems( + items: SidebarSessionItem[], + sortMode: string, + options?: { disableTabPinning?: boolean }, +): SidebarSessionItem[] { const sorted = [...items] const active = sorted.filter((i) => !i.archived) const archived = sorted.filter((i) => i.archived) + const compareByRecency = (a: SidebarSessionItem, b: SidebarSessionItem) => b.timestamp - a.timestamp + const compareByActivity = (a: SidebarSessionItem, b: SidebarSessionItem) => { + const aHasRatcheted = typeof a.ratchetedActivity === 'number' + const bHasRatcheted = typeof b.ratchetedActivity === 'number' + if (aHasRatcheted !== bHasRatcheted) return aHasRatcheted ? -1 : 1 + const aTime = a.ratchetedActivity ?? a.timestamp + const bTime = b.ratchetedActivity ?? b.timestamp + return bTime - aTime + } + const sortByMode = (list: SidebarSessionItem[]) => { const copy = [...list] if (sortMode === 'recency') { - return copy.sort((a, b) => b.timestamp - a.timestamp) + return copy.sort(compareByRecency) } if (sortMode === 'recency-pinned') { + if (options?.disableTabPinning) { + return copy.sort(compareByRecency) + } + const withTabs = copy.filter((i) => i.hasTab) const withoutTabs = copy.filter((i) => !i.hasTab) - // Sort both groups by recency (timestamp) - withTabs.sort((a, b) => b.timestamp - a.timestamp) - withoutTabs.sort((a, b) => b.timestamp - a.timestamp) + withTabs.sort(compareByRecency) + withoutTabs.sort(compareByRecency) return [...withTabs, ...withoutTabs] } if (sortMode === 'activity') { + if (options?.disableTabPinning) { + return copy.sort(compareByActivity) + } + const withTabs = copy.filter((i) => i.hasTab) const withoutTabs = copy.filter((i) => !i.hasTab) @@ -372,6 +420,8 @@ export const makeSelectSortedSessionItems = () => selectHideEmptySessions, selectExcludeFirstChatSubstrings, selectExcludeFirstChatMustStart, + selectAppliedQuery, + selectAppliedSearchTier, selectTerminals, selectFilter, ], @@ -387,6 +437,8 @@ export const makeSelectSortedSessionItems = () => hideEmptySessions, excludeFirstChatSubstrings, excludeFirstChatMustStart, + appliedQuery, + appliedSearchTier, terminals, filter ) => { @@ -399,8 +451,11 @@ export const makeSelectSortedSessionItems = () => excludeFirstChatSubstrings, excludeFirstChatMustStart, }) - const filtered = filterSessionItems(visible, filter) - return sortSessionItems(filtered, sortMode) + const searchAware = filterSessionItemsForAppliedSearch(visible, appliedQuery, appliedSearchTier) + const filtered = filterSessionItems(searchAware, filter) + return sortSessionItems(filtered, sortMode, { + disableTabPinning: appliedQuery.trim().length > 0, + }) } ) diff --git a/test/e2e/network-setup.test.tsx b/test/e2e/network-setup.test.tsx index 5508d456..515c2312 100644 --- a/test/e2e/network-setup.test.tsx +++ b/test/e2e/network-setup.test.tsx @@ -47,6 +47,15 @@ const configuredRemoteStatus: NetworkStatusResponse = { firewall: { platform: 'linux-none', active: false, portOpen: true, commands: [], configuring: false }, } +function resetNetworkMocks(defaultPostResult: unknown = configuredRemoteStatus) { + mockPost.mockReset() + mockGet.mockReset() + mockFetchFirewallConfig.mockReset() + mockCancelFirewallConfirmation.mockReset() + mockCancelFirewallConfirmation.mockResolvedValue(undefined) + mockPost.mockResolvedValue(defaultPostResult) +} + function createStore(networkStatus: NetworkStatusResponse | null = unconfiguredStatus) { return configureStore({ reducer: { @@ -73,22 +82,14 @@ function createStore(networkStatus: NetworkStatusResponse | null = unconfiguredS }) } -function resetNetworkMocks() { - mockPost.mockReset() - mockGet.mockReset() - mockFetchFirewallConfig.mockReset() - mockCancelFirewallConfirmation.mockReset() - mockCancelFirewallConfirmation.mockResolvedValue(undefined) -} - -function openSafetyTab() { - fireEvent.click(screen.getByRole('tab', { name: /safety/i })) +async function openSafetySettings() { + fireEvent.click(screen.getByRole('tab', { name: /^safety$/i })) + return screen.findByRole('switch', { name: /remote access/i }) } describe('Network Setup Wizard (e2e)', () => { beforeEach(() => { resetNetworkMocks() - mockPost.mockResolvedValue(configuredRemoteStatus) }) afterEach(() => { @@ -297,7 +298,7 @@ describe('Settings network section (e2e)', () => { cleanup() }) - it('renders remote access toggle in settings', () => { + it('renders remote access toggle in settings', async () => { const store = createStore(unconfiguredStatus) render( @@ -305,8 +306,7 @@ describe('Settings network section (e2e)', () => { , ) - openSafetyTab() - expect(screen.getByRole('switch', { name: /remote access/i })).toBeInTheDocument() + expect(await openSafetySettings()).toBeInTheDocument() }) it('toggles remote access on and dispatches configure', async () => { @@ -319,8 +319,7 @@ describe('Settings network section (e2e)', () => { , ) - openSafetyTab() - const toggle = screen.getByRole('switch', { name: /remote access/i }) + const toggle = await openSafetySettings() fireEvent.click(toggle) await waitFor(() => { @@ -359,8 +358,8 @@ describe('Settings network section (e2e)', () => { , ) - openSafetyTab() - fireEvent.click(screen.getByRole('button', { name: /fix firewall/i })) + await openSafetySettings() + fireEvent.click(screen.getByRole('button', { name: /fix firewall configuration/i })) const confirmationDialog = await screen.findByRole('dialog', { name: /administrator approval required/i }) expect(confirmationDialog).toBeInTheDocument() diff --git a/test/e2e/settings-devices-flow.test.tsx b/test/e2e/settings-devices-flow.test.tsx index 588af9cb..c6cfca53 100644 --- a/test/e2e/settings-devices-flow.test.tsx +++ b/test/e2e/settings-devices-flow.test.tsx @@ -130,7 +130,7 @@ describe('settings devices management flow (e2e)', () => { , ) - fireEvent.click(screen.getByRole('tab', { name: /safety/i })) + fireEvent.click(screen.getByRole('tab', { name: /^safety$/i })) expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) const devicesHeading = screen.getByText('Devices') diff --git a/test/e2e/sidebar-click-opens-pane.test.tsx b/test/e2e/sidebar-click-opens-pane.test.tsx index 1e64547f..0e12058a 100644 --- a/test/e2e/sidebar-click-opens-pane.test.tsx +++ b/test/e2e/sidebar-click-opens-pane.test.tsx @@ -114,6 +114,9 @@ function createStore(options: { showNoninteractiveSessions: options.showNoninteractiveSessions ?? defaultSettings.sidebar.showNoninteractiveSessions, hideEmptySessions: false, }, + panes: { + sessionOpenMode: options.sessionOpenMode ?? defaultSettings.panes.sessionOpenMode, + }, }) const projects = options.projects.map((project) => ({ diff --git a/test/e2e/sidebar-search-flow.test.tsx b/test/e2e/sidebar-search-flow.test.tsx index 3002271b..1e48899a 100644 --- a/test/e2e/sidebar-search-flow.test.tsx +++ b/test/e2e/sidebar-search-flow.test.tsx @@ -49,6 +49,8 @@ function createDeferred() { function createStore(options?: { projects?: ProjectGroup[] sessions?: Record + tabs?: any[] + panes?: any }) { const projects = (options?.projects ?? []).map((project) => ({ ...project, @@ -90,10 +92,10 @@ function createStore(options?: { lastSavedAt: undefined, }, tabs: { - tabs: [], + tabs: options?.tabs ?? [], activeTabId: null, }, - panes: { + panes: options?.panes ?? { layouts: {}, activePane: {}, paneTitles: {}, @@ -141,6 +143,13 @@ function renderSidebar(store: ReturnType) { return { ...result, onNavigate } } +function getSidebarSessionOrder(labels: string[]): string[] { + const list = screen.getByTestId('sidebar-session-list') + return Array.from(list.querySelectorAll('button')) + .map((button) => labels.find((label) => button.textContent?.includes(label))) + .filter((label): label is string => Boolean(label)) +} + describe('sidebar search flow (e2e)', () => { beforeEach(() => { vi.clearAllMocks() @@ -215,6 +224,120 @@ describe('sidebar search flow (e2e)', () => { expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument() }) + it('matches subdirectory leaves and only shows matching open-tab fallbacks without pinning them above newer server results', async () => { + const matchingFallbackSessionId = 'fallback-trycycle' + vi.mocked(mockSearchSessions) + .mockResolvedValueOnce({ + results: [ + { + sessionId: 'server-newer', + provider: 'codex', + projectPath: '/proj/server', + title: 'Newer Server Result', + matchedIn: 'title', + lastActivityAt: 3_000, + archived: false, + }, + { + sessionId: 'server-leaf', + provider: 'codex', + projectPath: '/proj/code/trycycle', + cwd: '/proj/code/trycycle/server', + title: 'Routine work', + matchedIn: 'title', + lastActivityAt: 2_500, + archived: false, + }, + ], + tier: 'title', + query: 'trycycle', + totalScanned: 8, + } as any) + .mockResolvedValueOnce({ + results: [], + tier: 'title', + query: 'code', + totalScanned: 8, + } as any) + + const store = createStore({ + tabs: [{ + id: 'tab-fallback', + title: 'Open Trycycle Tab', + mode: 'codex', + resumeSessionId: matchingFallbackSessionId, + createdAt: 1_000, + }], + panes: { + layouts: { + 'tab-fallback': { + type: 'leaf', + id: 'pane-fallback', + content: { + kind: 'terminal', + mode: 'codex', + status: 'running', + createRequestId: 'req-fallback', + resumeSessionId: matchingFallbackSessionId, + initialCwd: '/tmp/code/trycycle', + }, + }, + }, + activePane: { + 'tab-fallback': 'pane-fallback', + }, + paneTitles: { + 'tab-fallback': { + 'pane-fallback': 'Open Trycycle Tab', + }, + }, + }, + }) + + renderSidebar(store) + await act(() => vi.advanceTimersByTime(100)) + + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'trycycle' } }) + + await act(async () => { + vi.advanceTimersByTime(500) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockSearchSessions).toHaveBeenCalledWith(expect.objectContaining({ + query: 'trycycle', + tier: 'title', + })) + expect(screen.getByText('Routine work')).toBeInTheDocument() + expect(screen.getByText('Newer Server Result')).toBeInTheDocument() + expect(screen.getByText('Open Trycycle Tab')).toBeInTheDocument() + expect(getSidebarSessionOrder([ + 'Newer Server Result', + 'Routine work', + 'Open Trycycle Tab', + ])).toEqual([ + 'Newer Server Result', + 'Routine work', + 'Open Trycycle Tab', + ]) + + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'code' } }) + + await act(async () => { + vi.advanceTimersByTime(500) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockSearchSessions).toHaveBeenLastCalledWith(expect.objectContaining({ + query: 'code', + tier: 'title', + })) + expect(screen.queryByText('Routine work')).not.toBeInTheDocument() + expect(screen.queryByText('Open Trycycle Tab')).not.toBeInTheDocument() + }) + it('deep-tier search shows title results first, then merged results after Phase 2', async () => { const phase1Deferred = createDeferred() const phase2Deferred = createDeferred() diff --git a/test/e2e/update-flow.test.ts b/test/e2e/update-flow.test.ts index 7fe8ae59..c00d6d27 100644 --- a/test/e2e/update-flow.test.ts +++ b/test/e2e/update-flow.test.ts @@ -1,187 +1,127 @@ -// test/e2e/update-flow.test.ts -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { spawn, type ChildProcess } from 'child_process' +// @vitest-environment node +import { describe, it, expect } from 'vitest' +import { spawn } from 'child_process' +import { createRequire } from 'module' +import net from 'net' import path from 'path' - -/** - * E2E Test Skeleton for Update Flow - * - * These tests are placeholders documenting what should be tested when - * proper E2E infrastructure is set up. They are skipped because they require: - * - * - msw or similar for GitHub API mocking - * - Process spawning and stdin/stdout control - * - Mocking child_process for git/npm commands - * - Potentially a test harness for interactive prompts - * - * The update flow works as follows: - * 1. Server starts and checks GitHub API for latest release tag - * 2. Compares remote version to local package.json version - * 3. If update available, prompts user with readline interface - * 4. If user accepts: runs git pull, npm ci, npm run build, then exits - * 5. If user declines: server continues normal startup - * 6. --skip-update-check flag or SKIP_UPDATE_CHECK env skips the check entirely - */ - -describe('update flow e2e', () => { - // Helper to spawn server process - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const spawnServer = (args: string[] = [], env: Record = {}): ChildProcess => { - const serverPath = path.resolve(__dirname, '../../dist/server/index.js') - return spawn('node', [serverPath, ...args], { - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const REPO_ROOT = path.resolve(__dirname, '../..') +const PRECHECK_SCRIPT = path.resolve(REPO_ROOT, 'scripts/precheck.ts') +const require = createRequire(import.meta.url) +const TSX_CLI = require.resolve('tsx/cli') +const PROCESS_TIMEOUT_MS = 30_000 + +type PrecheckResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (typeof address !== 'object' || !address) { + server.close(() => reject(new Error('Failed to allocate a free port'))) + return + } + + const { port } = address + server.close((err) => { + if (err) { + reject(err) + return + } + resolve(port) + }) }) - } - - it.skip('shows update prompt when new version available (mocked)', async () => { - // This is a placeholder test demonstrating the flow - // Real e2e would need GitHub API mocking via msw or similar - - // TODO: Implementation steps: - // 1. Set up msw to mock GitHub releases API: - // - Mock GET https://api.github.com/repos/OWNER/REPO/releases/latest - // - Return { tag_name: 'v99.0.0' } to simulate newer version - // - // 2. Start server with test environment: - // - Set AUTH_TOKEN env var - // - Capture stdout/stderr streams - // - // 3. Assert update banner appears in stdout: - // - Look for "Update available" message - // - Look for version comparison (e.g., "v0.1.0 -> v99.0.0") - // - Look for prompt asking to update - // - // 4. Send 'n' to decline via stdin: - // - Write 'n\n' to child process stdin - // - // 5. Assert server continues to start: - // - Look for "Server listening" or similar startup message - // - Verify process is still running - // - Clean up by terminating process - - expect(true).toBe(true) // Placeholder assertion }) +} + +async function runPrecheck( + args: string[] = [], + env: NodeJS.ProcessEnv = {}, +): Promise { + const [serverPort, vitePort] = await Promise.all([getFreePort(), getFreePort()]) + + return await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [TSX_CLI, PRECHECK_SCRIPT, ...args], + { + cwd: REPO_ROOT, + env: { + ...process.env, + PORT: String(serverPort), + VITE_PORT: String(vitePort), + npm_lifecycle_event: 'preserve', + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + let stdout = '' + let stderr = '' + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString() + }) - it.skip('applies update when user accepts (mocked)', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return newer version: - // - Set up msw handler for releases/latest - // - Return { tag_name: 'v99.0.0' } - // - // 2. Mock git pull, npm ci, npm run build: - // - Could use a wrapper script that records calls - // - Or mock at the module level before spawning - // - Consider using PATH manipulation to inject mock binaries - // - // 3. Start server: - // - Spawn with test environment - // - Capture all output - // - // 4. Send 'y' (or empty/Enter) to accept: - // - Write 'y\n' or '\n' to stdin - // - Default behavior accepts update - // - // 5. Assert update commands were run: - // - Check for "Running git pull" message - // - Check for "Running npm ci" message - // - Check for "Running npm run build" message - // - // 6. Assert process exits with code 0: - // - Wait for process to exit - // - Verify exit code is 0 (success) - // - Verify "Update complete" message appeared - - expect(true).toBe(true) // Placeholder assertion - }) + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`precheck timed out after ${PROCESS_TIMEOUT_MS}ms`)) + }, PROCESS_TIMEOUT_MS) - it.skip('skips update check with --skip-update-check flag', async () => { - // TODO: Implementation steps: - // 1. Start server with --skip-update-check: - // - const proc = spawnServer(['--skip-update-check']) - // - // 2. Assert no GitHub API call was made: - // - Set up msw handler that records if called - // - Verify handler was never invoked - // - Or check that no network activity occurred - // - // 3. Assert server starts normally: - // - Look for "Server listening" message - // - Verify no "Update available" prompt appeared - // - Clean up by terminating process - - expect(true).toBe(true) // Placeholder assertion - }) + child.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) - it.skip('skips update check with SKIP_UPDATE_CHECK env var', async () => { - // TODO: Implementation steps: - // 1. Start server with SKIP_UPDATE_CHECK=true: - // - const proc = spawnServer([], { SKIP_UPDATE_CHECK: 'true' }) - // - Also test with SKIP_UPDATE_CHECK: '1' - // - // 2. Assert no GitHub API call was made: - // - Same verification as flag test - // - msw handler should not be invoked - // - // 3. Assert server starts normally: - // - Normal startup messages should appear - // - No update prompt should be shown - // - Server should be listening and healthy - - expect(true).toBe(true) // Placeholder assertion + child.once('close', (code, signal) => { + clearTimeout(timeout) + resolve({ code, signal, stdout, stderr }) + }) }) +} + +describe('update flow precheck', () => { + it('skips update checking when --skip-update-check is provided', async () => { + const result = await runPrecheck(['--skip-update-check']) - it.skip('handles GitHub API timeout gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to delay beyond timeout: - // - Set up msw handler that delays response by 10+ seconds - // - Version checker has 5 second timeout - // - // 2. Start server and wait: - // - Server should not hang indefinitely - // - Should see timeout error in output - // - // 3. Assert server continues to start despite timeout: - // - Update check failure should not block startup - // - Server should proceed with normal operation - // - May log warning about failed update check - - expect(true).toBe(true) // Placeholder assertion + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) - it.skip('handles GitHub API error gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return 500 error: - // - Set up msw handler returning server error - // - Or return 403 rate limit error - // - // 2. Start server: - // - Capture output for error messages - // - // 3. Assert server continues despite API error: - // - Should not crash or hang - // - Should log the error - // - Should proceed with normal startup - - expect(true).toBe(true) // Placeholder assertion + it('skips update checking when SKIP_UPDATE_CHECK=true', async () => { + const result = await runPrecheck([], { SKIP_UPDATE_CHECK: 'true' }) + + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) - it.skip('handles update command failure gracefully', async () => { - // TODO: Implementation steps: - // 1. Mock GitHub API to return newer version - // - // 2. Mock git pull to fail: - // - Inject failing git binary via PATH - // - Or use a test repository with conflicts - // - // 3. Start server and accept update: - // - Send 'y' to stdin - // - // 4. Assert appropriate error handling: - // - Error message should be displayed - // - Process should exit with non-zero code - // - User should be informed of failure - - expect(true).toBe(true) // Placeholder assertion + it('skips update checking during the predev lifecycle while still succeeding the preflight', async () => { + const result = await runPrecheck([], { npm_lifecycle_event: 'predev' }) + + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).not.toContain('new Freshell') + expect(result.stdout).not.toContain('Update complete!') + expect(result.stderr).toBe('') }) }) diff --git a/test/integration/client/editor-pane.test.tsx b/test/integration/client/editor-pane.test.tsx index c34bd73d..bb6d7913 100644 --- a/test/integration/client/editor-pane.test.tsx +++ b/test/integration/client/editor-pane.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' @@ -176,6 +176,13 @@ const createTestStore = () => }, }) +async function selectEditorFromPicker(user: ReturnType) { + await user.click(screen.getByRole('button', { name: /add pane/i })) + const picker = await screen.findByRole('toolbar', { name: /pane type picker/i }) + await user.click(screen.getByRole('button', { name: 'Editor' })) + fireEvent.transitionEnd(picker) +} + describe('Editor Pane Integration', () => { let store: ReturnType let fetchRouter: ReturnType @@ -214,8 +221,7 @@ describe('Editor Pane Integration', () => { vi.restoreAllMocks() }) - // Skip: JSDOM doesn't fire CSS transitionend events needed for PanePicker selection - it.skip('can add editor pane via FAB', async () => { + it('can add editor pane via FAB', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) // Initialize with terminal @@ -235,11 +241,7 @@ describe('Editor Pane Integration', () => { ) - // Click FAB to add picker pane - await user.click(screen.getByRole('button', { name: /add pane/i })) - - // Select Editor from picker (using keyboard shortcut for reliability) - await user.keyboard('e') + await selectEditorFromPicker(user) // Should see empty state with Open File button await waitFor(() => { @@ -251,8 +253,7 @@ describe('Editor Pane Integration', () => { expect(state.layouts['tab-1'].type).toBe('split') }) - // Skip: JSDOM doesn't fire CSS transitionend events needed for PanePicker selection - it.skip('displays editor toolbar with path input', async () => { + it('displays editor toolbar with path input', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) store.dispatch( @@ -271,9 +272,7 @@ describe('Editor Pane Integration', () => { ) - // Add editor pane via picker - await user.click(screen.getByRole('button', { name: /add pane/i })) - await user.keyboard('e') + await selectEditorFromPicker(user) // Should see the path input await waitFor(() => { @@ -600,8 +599,7 @@ describe('Editor Pane Integration', () => { consoleSpy.mockRestore() }) - // Skip: JSDOM doesn't fire CSS transitionend events needed for PanePicker selection - it.skip('integrates with terminal and editor panes in split view', async () => { + it('integrates with terminal and editor panes in split view', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) // Start with a terminal @@ -630,10 +628,7 @@ describe('Editor Pane Integration', () => { } }) - // Add an editor pane - await user.click(screen.getByRole('button', { name: /add pane/i })) - // Click the Editor option directly (keyboard shortcuts require transition animation) - await user.click(screen.getByText('Editor')) + await selectEditorFromPicker(user) // Both terminal and editor should be visible await waitFor(() => { diff --git a/test/integration/server/codex-session-flow.test.ts b/test/integration/server/codex-session-flow.test.ts index f56ac2d3..4fedb663 100644 --- a/test/integration/server/codex-session-flow.test.ts +++ b/test/integration/server/codex-session-flow.test.ts @@ -1,27 +1,181 @@ -// test/integration/server/codex-session-flow.test.ts -// -// NOTE: This is a true end-to-end integration test that requires: -// 1. The `codex` CLI to be installed and in PATH -// 2. A valid OpenAI API key configured for Codex CLI -// 3. Network access to OpenAI's API -// -// Set RUN_CODEX_INTEGRATION=true to run this test: -// RUN_CODEX_INTEGRATION=true npm run test:server -// -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import fsp from 'fs/promises' import http from 'http' +import os from 'os' +import path from 'path' import express from 'express' import WebSocket from 'ws' import { WsHandler } from '../../../server/ws-handler' import { TerminalRegistry } from '../../../server/terminal-registry' import { CodingCliSessionManager } from '../../../server/coding-cli/session-manager' import { codexProvider } from '../../../server/coding-cli/providers/codex' +import { configStore } from '../../../server/config-store' +import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol' + +vi.mock('../../../server/config-store', () => ({ + configStore: { + snapshot: vi.fn(), + }, +})) + +vi.mock('../../../server/logger', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + logger.child.mockReturnValue(logger) + return { logger } +}) process.env.AUTH_TOKEN = 'test-token' -const runCodexIntegration = process.env.RUN_CODEX_INTEGRATION === 'true' +const MESSAGE_TIMEOUT_MS = 5_000 + +async function writeFakeCodexExecutable(binaryPath: string) { + const script = `#!/usr/bin/env node +const fs = require('fs') + +const sessionId = 'fake-codex-session-1' +const argLogPath = process.env.FAKE_CODEX_ARG_LOG +if (argLogPath) { + fs.writeFileSync(argLogPath, JSON.stringify(process.argv.slice(2)), 'utf8') +} + +const events = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: process.cwd(), + model: 'gpt-5-codex', + }, + }, + { + type: 'event_msg', + session_id: sessionId, + payload: { + type: 'agent_message', + message: 'hello world', + }, + }, +] + +let index = 0 +const emitNext = () => { + if (index >= events.length) { + setTimeout(() => process.exit(0), 10) + return + } + process.stdout.write(JSON.stringify(events[index]) + '\\n') + index += 1 + setTimeout(emitNext, 10) +} + +emitNext() +` + + await fsp.writeFile(binaryPath, script, 'utf8') + await fsp.chmod(binaryPath, 0o755) +} + +function waitForMessage( + ws: WebSocket, + predicate: (msg: any) => boolean, + timeoutMs = MESSAGE_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Timed out waiting for WebSocket message')) + }, timeoutMs) + + const onMessage = (data: WebSocket.Data) => { + const message = JSON.parse(data.toString()) + if (!predicate(message)) return + cleanup() + resolve(message) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + + const onClose = () => { + cleanup() + reject(new Error('WebSocket closed before expected message')) + } + + const cleanup = () => { + clearTimeout(timeout) + ws.off('message', onMessage) + ws.off('error', onError) + ws.off('close', onClose) + } + + ws.on('message', onMessage) + ws.on('error', onError) + ws.on('close', onClose) + }) +} + +async function createAuthenticatedWs(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()) + ws.once('error', reject) + }) + + ws.send(JSON.stringify({ + type: 'hello', + token: process.env.AUTH_TOKEN || 'test-token', + protocolVersion: WS_PROTOCOL_VERSION, + })) + + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws +} + +async function closeWebSocket(ws: WebSocket): Promise { + await new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve() + return + } + + const timeout = setTimeout(() => { + cleanup() + resolve() + }, 1_000) -describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { + const cleanup = () => { + clearTimeout(timeout) + ws.off('close', onClose) + ws.off('error', onClose) + } + + const onClose = () => { + cleanup() + resolve() + } + + ws.on('close', onClose) + ws.on('error', onClose) + ws.close() + }) +} + +describe('Codex Session Flow Integration', () => { + let tempDir: string + let fakeCodexPath: string + let argLogPath: string + let previousCodexCmd: string | undefined + let previousFakeCodexArgLog: string | undefined let server: http.Server let port: number let wsHandler: WsHandler @@ -29,6 +183,16 @@ describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { let cliManager: CodingCliSessionManager beforeAll(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-flow-')) + fakeCodexPath = path.join(tempDir, 'fake-codex') + argLogPath = path.join(tempDir, 'args.json') + await writeFakeCodexExecutable(fakeCodexPath) + + previousCodexCmd = process.env.CODEX_CMD + previousFakeCodexArgLog = process.env.FAKE_CODEX_ARG_LOG + process.env.CODEX_CMD = fakeCodexPath + process.env.FAKE_CODEX_ARG_LOG = argLogPath + const app = express() server = http.createServer(app) registry = new TerminalRegistry() @@ -37,73 +201,106 @@ describe.skipIf(!runCodexIntegration)('Codex Session Flow Integration', () => { await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { - port = (server.address() as any).port + port = (server.address() as { port: number }).port resolve() }) }) }) + beforeEach(async () => { + vi.mocked(configStore.snapshot).mockResolvedValue({ + settings: { + codingCli: { + enabledProviders: ['codex'], + providers: {}, + }, + }, + }) + await fsp.rm(argLogPath, { force: true }) + }) + afterAll(async () => { + if (previousCodexCmd === undefined) { + delete process.env.CODEX_CMD + } else { + process.env.CODEX_CMD = previousCodexCmd + } + if (previousFakeCodexArgLog === undefined) { + delete process.env.FAKE_CODEX_ARG_LOG + } else { + process.env.FAKE_CODEX_ARG_LOG = previousFakeCodexArgLog + } + cliManager.shutdown() registry.shutdown() wsHandler.close() await new Promise((resolve) => server.close(() => resolve())) + await fsp.rm(tempDir, { recursive: true, force: true }) }) - function createAuthenticatedWs(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) - ws.on('open', () => { - ws.send(JSON.stringify({ type: 'hello', token: process.env.AUTH_TOKEN || 'test-token' })) - }) - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - if (msg.type === 'ready') resolve(ws) - }) - ws.on('error', reject) - setTimeout(() => reject(new Error('Timeout')), 5000) - }) - } - - it('creates session and streams events', async () => { - const ws = await createAuthenticatedWs() - const events: any[] = [] - let sessionId: string | null = null - - const done = new Promise((resolve) => { - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - - if (msg.type === 'codingcli.created') { - sessionId = msg.sessionId - } + it('creates a codex session and streams parsed provider events from a local codex executable', async () => { + const ws = await createAuthenticatedWs(port) + const observedMessages: any[] = [] + const onMessage = (data: WebSocket.Data) => { + observedMessages.push(JSON.parse(data.toString())) + } + ws.on('message', onMessage) - if (msg.type === 'codingcli.event') { - events.push(msg.event) - } + try { + ws.send(JSON.stringify({ + type: 'codingcli.create', + requestId: 'test-req-codex', + provider: 'codex', + prompt: 'say "hello world" and nothing else', + })) - if (msg.type === 'codingcli.exit') { - resolve() - } - }) - }) - - ws.send(JSON.stringify({ - type: 'codingcli.create', - requestId: 'test-req-codex', - provider: 'codex', - prompt: 'say "hello world" and nothing else', - })) + const created = await waitForMessage( + ws, + (msg) => msg.type === 'codingcli.created' && msg.requestId === 'test-req-codex', + ) + const exited = await waitForMessage( + ws, + (msg) => msg.type === 'codingcli.exit' && msg.sessionId === created.sessionId, + ) - await done + const eventMessages = observedMessages + .filter((msg) => msg.type === 'codingcli.event' && msg.sessionId === created.sessionId) + .map((msg) => msg.event) - expect(sessionId).toBeDefined() - expect(events.length).toBeGreaterThan(0) + expect(created.provider).toBe('codex') + expect(exited.exitCode).toBe(0) + expect(eventMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'session.start', + sessionId: 'fake-codex-session-1', + provider: 'codex', + session: expect.objectContaining({ + cwd: process.cwd(), + model: 'gpt-5-codex', + }), + }), + expect.objectContaining({ + type: 'message.assistant', + sessionId: 'fake-codex-session-1', + provider: 'codex', + message: { + role: 'assistant', + content: 'hello world', + }, + }), + ]), + ) - const hasInit = events.some((e) => e.type === 'session.init') - const hasMessage = events.some((e) => e.type === 'message.assistant') - expect(hasInit || hasMessage).toBe(true) - - ws.close() - }, 30000) + const recordedArgs = JSON.parse(await fsp.readFile(argLogPath, 'utf8')) + expect(recordedArgs).toEqual([ + 'exec', + '--json', + 'say "hello world" and nothing else', + ]) + } finally { + ws.off('message', onMessage) + await closeWebSocket(ws) + } + }) }) diff --git a/test/unit/client/components/SettingsView.behavior.test.tsx b/test/unit/client/components/SettingsView.behavior.test.tsx index 1a722827..aa8e34e2 100644 --- a/test/unit/client/components/SettingsView.behavior.test.tsx +++ b/test/unit/client/components/SettingsView.behavior.test.tsx @@ -32,6 +32,15 @@ function getSlider(predicate: (slider: HTMLElement) => boolean) { return screen.getAllByRole('slider').find((slider) => predicate(slider))! } +function getSettingsSection(title: string) { + const heading = screen.getByRole('heading', { name: title }) + const section = heading.parentElement?.parentElement + if (!section) { + throw new Error(`Could not find settings section for "${title}"`) + } + return section +} + describe('SettingsView behavior sections', () => { describe('additional settings interactions', () => { it('updates terminal theme locally without calling /api/settings', async () => { @@ -414,11 +423,13 @@ describe('SettingsView behavior sections', () => { renderSettingsView(store) switchSettingsTab('Workspace') - expect(screen.getAllByText('New tab').length).toBeGreaterThan(0) - expect(screen.getByText('Close tab')).toBeInTheDocument() - expect(screen.getByText('Previous tab')).toBeInTheDocument() - expect(screen.getByText('Next tab')).toBeInTheDocument() - expect(screen.getByText('Newline')).toBeInTheDocument() + const keyboardShortcuts = within(getSettingsSection('Keyboard shortcuts')) + + expect(keyboardShortcuts.getByText('New tab')).toBeInTheDocument() + expect(keyboardShortcuts.getByText('Close tab')).toBeInTheDocument() + expect(keyboardShortcuts.getByText('Previous tab')).toBeInTheDocument() + expect(keyboardShortcuts.getByText('Next tab')).toBeInTheDocument() + expect(keyboardShortcuts.getByText('Newline')).toBeInTheDocument() }) it('displays keyboard shortcut keys', () => { @@ -426,11 +437,13 @@ describe('SettingsView behavior sections', () => { renderSettingsView(store) switchSettingsTab('Workspace') - expect(screen.getAllByText('Alt').length).toBeGreaterThan(0) - expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0) - expect(screen.getAllByText('Shift').length).toBeGreaterThan(0) - expect(screen.getAllByText('[').length).toBeGreaterThan(0) - expect(screen.getAllByText(']').length).toBeGreaterThan(0) + const keyboardShortcuts = within(getSettingsSection('Keyboard shortcuts')) + + expect(keyboardShortcuts.getAllByText('Alt').length).toBeGreaterThan(0) + expect(keyboardShortcuts.getAllByText('Ctrl').length).toBeGreaterThan(0) + expect(keyboardShortcuts.getAllByText('Shift').length).toBeGreaterThan(0) + expect(keyboardShortcuts.getAllByText('[').length).toBeGreaterThan(0) + expect(keyboardShortcuts.getAllByText(']').length).toBeGreaterThan(0) }) }) diff --git a/test/unit/client/components/Sidebar.test.tsx b/test/unit/client/components/Sidebar.test.tsx index 58e31af9..35e18e00 100644 --- a/test/unit/client/components/Sidebar.test.tsx +++ b/test/unit/client/components/Sidebar.test.tsx @@ -8,7 +8,7 @@ import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import tabsReducer from '@/store/tabsSlice' import panesReducer from '@/store/panesSlice' import connectionReducer from '@/store/connectionSlice' -import sessionsReducer from '@/store/sessionsSlice' +import sessionsReducer, { setSessionWindowData } from '@/store/sessionsSlice' import sessionActivityReducer from '@/store/sessionActivitySlice' import extensionsReducer from '@/store/extensionsSlice' import codexActivityReducer, { type CodexActivityState } from '@/store/codexActivitySlice' @@ -63,6 +63,16 @@ const sessionId = (label: string) => { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + function createTestStore(options?: { projects?: ProjectGroup[] sessions?: Record @@ -85,6 +95,7 @@ function createTestStore(options?: { serverInstanceId?: string sortMode?: 'recency' | 'activity' | 'project' showProjectBadges?: boolean + sessionOpenMode?: 'tab' | 'split' sessionActivity?: Record codexActivity?: Partial sessionOpenMode?: 'tab' | 'split' @@ -151,6 +162,10 @@ function createTestStore(options?: { showProjectBadges: options?.showProjectBadges ?? true, hideEmptySessions: false, }, + panes: { + ...defaultSettings.panes, + sessionOpenMode: options?.sessionOpenMode ?? defaultSettings.panes.sessionOpenMode, + }, }, loaded: true, lastSavedAt: undefined, @@ -253,10 +268,18 @@ function triggerNearBottomScroll( fireEvent.scroll(node) } +function getSidebarSessionOrder(labels: string[]): string[] { + const list = screen.getByTestId('sidebar-session-list') + return Array.from(list.querySelectorAll('button')) + .map((button) => labels.find((label) => button.textContent?.includes(label))) + .filter((label): label is string => Boolean(label)) +} + describe('Sidebar Component - Session-Centric Display', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() + vi.mocked(mockSearchSessions).mockReset() mockFetchSidebarSessionsSnapshot.mockReset() mockFetchSidebarSessionsSnapshot.mockResolvedValue({ projects: [] }) mockGetTerminalDirectoryPage.mockReset() @@ -1364,14 +1387,7 @@ describe('Sidebar Component - Session-Centric Display', () => { expect(state.tabs.activeTabId).toBe('existing-tab-id') }) - // Note: Tests for running sessions require complex WebSocket mocking that is currently - // broken in the test setup. The implementation is verified to be correct through: - // 1. Code review - handleItemClick checks for existing tab before creating new one - // 2. The non-running session test passes, which uses the same pattern - // 3. Manual testing - // - // TODO: Fix WebSocket mock to properly simulate terminal.list responses with fake timers - it.skip('switches to existing tab when clicking running session that has a tab', async () => { + it('switches to existing tab when clicking running session that has a tab', async () => { const projects: ProjectGroup[] = [ { projectPath: '/home/user/project', @@ -1407,10 +1423,38 @@ describe('Sidebar Component - Session-Centric Display', () => { id: 'existing-tab-for-terminal', terminalId: 'running-terminal-id', mode: 'claude' as const, + resumeSessionId: sessionId('session-running'), }, ] - const store = createTestStore({ projects, tabs: existingTabs, activeTabId: null, sortMode: 'activity' }) + const panes = { + layouts: { + 'existing-tab-for-terminal': { + type: 'leaf', + id: 'pane-running', + content: { + kind: 'terminal', + mode: 'claude', + createRequestId: 'req-running', + status: 'running', + terminalId: 'running-terminal-id', + resumeSessionId: sessionId('session-running'), + }, + }, + }, + activePane: { + 'existing-tab-for-terminal': 'pane-running', + }, + paneTitles: {}, + } + + const store = createTestStore({ + projects, + tabs: existingTabs, + panes, + activeTabId: null, + sortMode: 'activity', + }) const { onNavigate } = renderSidebar(store, terminals) // Advance timers to process the mock response and wait for state update @@ -1418,10 +1462,6 @@ describe('Sidebar Component - Session-Centric Display', () => { vi.advanceTimersByTime(100) }) - // Verify the "Running" section appears (confirms terminals are loaded) - const runningSection = screen.queryByText('Running') - expect(runningSection).not.toBeNull() - const sessionButton = screen.getByText('Running session').closest('button') fireEvent.click(sessionButton!) @@ -2302,6 +2342,8 @@ describe('Sidebar Component - Session-Centric Display', () => { loading: false, query: 'search', searchTier: 'title', + appliedQuery: 'search', + appliedSearchTier: 'title', }, }, }, @@ -2612,7 +2654,26 @@ describe('Sidebar Component - Session-Centric Display', () => { }, ] - const store = createTestStore({ projects, tabs, activeTabId: 'tab-1', sessionOpenMode: 'split' }) + const panes = { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'shell', + createRequestId: 'req-1', + status: 'running', + }, + }, + }, + activePane: { + 'tab-1': 'pane-1', + }, + paneTitles: {}, + } + + const store = createTestStore({ projects, tabs, panes, activeTabId: 'tab-1', sessionOpenMode: 'split' }) const { onNavigate } = renderSidebar(store, []) await act(async () => { @@ -3013,4 +3074,450 @@ describe('Sidebar Component - Session-Centric Display', () => { expect(statusElement.textContent).toContain('Scanning files...') }) }) + + describe('applied search fallback behavior', () => { + it('shows only matching title-search fallback tabs and keeps them unpinned below newer server results', async () => { + const matchingFallbackSessionId = sessionId('matching-fallback') + const unrelatedFallbackSessionId = sessionId('unrelated-fallback') + const searchProjects: ProjectGroup[] = [ + { + projectPath: '/work/server', + sessions: [ + { + provider: 'codex', + sessionId: 'server-newer', + projectPath: '/work/server', + lastActivityAt: 3_000, + title: 'Newer Server Result', + }, + ], + }, + { + projectPath: '/work/repos/trycycle', + sessions: [ + { + provider: 'codex', + sessionId: 'server-leaf', + projectPath: '/work/repos/trycycle', + cwd: '/work/repos/trycycle/server', + lastActivityAt: 2_500, + title: 'Routine work', + }, + ], + }, + ] + + const store = createTestStore({ + projects: searchProjects, + tabs: [ + { + id: 'tab-match', + title: 'Matching Fallback', + mode: 'codex', + resumeSessionId: matchingFallbackSessionId, + createdAt: 1_000, + }, + { + id: 'tab-unrelated', + title: 'Unrelated Fallback', + mode: 'codex', + resumeSessionId: unrelatedFallbackSessionId, + createdAt: 900, + }, + ], + panes: { + layouts: { + 'tab-match': { + type: 'leaf', + id: 'pane-match', + content: { + kind: 'terminal', + mode: 'codex', + createRequestId: 'req-match', + status: 'running', + resumeSessionId: matchingFallbackSessionId, + initialCwd: '/tmp/local/trycycle', + }, + }, + 'tab-unrelated': { + type: 'leaf', + id: 'pane-unrelated', + content: { + kind: 'terminal', + mode: 'codex', + createRequestId: 'req-unrelated', + status: 'running', + resumeSessionId: unrelatedFallbackSessionId, + initialCwd: '/tmp/local/elsewhere', + }, + }, + }, + activePane: { + 'tab-match': 'pane-match', + 'tab-unrelated': 'pane-unrelated', + }, + paneTitles: { + 'tab-match': { + 'pane-match': 'Matching Fallback', + }, + 'tab-unrelated': { + 'pane-unrelated': 'Unrelated Fallback', + }, + }, + }, + sessions: { + activeSurface: 'sidebar', + projects: searchProjects, + lastLoadedAt: 1_700_000_000_000, + windows: { + sidebar: { + projects: searchProjects, + lastLoadedAt: 1_700_000_000_000, + query: 'trycycle', + searchTier: 'title', + appliedQuery: 'trycycle', + appliedSearchTier: 'title', + loading: false, + }, + }, + }, + sortMode: 'activity', + }) + + renderSidebar(store, []) + + expect(screen.getByText('Newer Server Result')).toBeInTheDocument() + expect(screen.getByText('Routine work')).toBeInTheDocument() + expect(screen.getByText('Matching Fallback')).toBeInTheDocument() + expect(screen.queryByText('Unrelated Fallback')).not.toBeInTheDocument() + expect(getSidebarSessionOrder([ + 'Newer Server Result', + 'Routine work', + 'Matching Fallback', + ])).toEqual([ + 'Newer Server Result', + 'Routine work', + 'Matching Fallback', + ]) + }) + + it('hides fallback tabs entirely while a deep-search result set is on screen', async () => { + const deepFallbackSessionId = sessionId('deep-fallback') + const deepProjects: ProjectGroup[] = [ + { + projectPath: '/work/deep', + sessions: [ + { + provider: 'claude', + sessionId: 'deep-server', + projectPath: '/work/deep', + lastActivityAt: 3_000, + title: 'Deep Search Result', + }, + ], + }, + ] + + const store = createTestStore({ + projects: deepProjects, + tabs: [{ + id: 'tab-deep', + title: 'Deep Matching Fallback', + mode: 'codex', + resumeSessionId: deepFallbackSessionId, + createdAt: 1_000, + }], + panes: { + layouts: { + 'tab-deep': { + type: 'leaf', + id: 'pane-deep', + content: { + kind: 'terminal', + mode: 'codex', + createRequestId: 'req-deep', + status: 'running', + resumeSessionId: deepFallbackSessionId, + initialCwd: '/tmp/local/trycycle', + }, + }, + }, + activePane: { + 'tab-deep': 'pane-deep', + }, + paneTitles: { + 'tab-deep': { + 'pane-deep': 'Deep Matching Fallback', + }, + }, + }, + sessions: { + activeSurface: 'sidebar', + projects: deepProjects, + lastLoadedAt: 1_700_000_000_000, + windows: { + sidebar: { + projects: deepProjects, + lastLoadedAt: 1_700_000_000_000, + query: 'trycycle', + searchTier: 'fullText', + appliedQuery: 'trycycle', + appliedSearchTier: 'fullText', + loading: false, + }, + }, + }, + }) + + renderSidebar(store, []) + + expect(screen.getByText('Deep Search Result')).toBeInTheDocument() + expect(screen.queryByText('Deep Matching Fallback')).not.toBeInTheDocument() + }) + + it('keeps the previous applied title-search result set visible while a replacement search is loading', async () => { + const replacementSearch = createDeferred() + const alphaFallbackSessionId = sessionId('alpha-fallback') + const betaFallbackSessionId = sessionId('beta-fallback') + vi.mocked(mockSearchSessions).mockReturnValueOnce(replacementSearch.promise) + + const alphaProjects: ProjectGroup[] = [ + { + projectPath: '/work/alpha', + sessions: [ + { + provider: 'codex', + sessionId: 'alpha-server', + projectPath: '/work/alpha', + lastActivityAt: 3_000, + title: 'Alpha Server Result', + }, + ], + }, + ] + + const store = createTestStore({ + projects: alphaProjects, + tabs: [ + { + id: 'tab-alpha-fallback', + title: 'Alpha Fallback', + mode: 'codex', + resumeSessionId: alphaFallbackSessionId, + createdAt: 1_000, + }, + { + id: 'tab-beta-fallback', + title: 'Beta Fallback', + mode: 'codex', + resumeSessionId: betaFallbackSessionId, + createdAt: 900, + }, + ], + panes: { + layouts: { + 'tab-alpha-fallback': { + type: 'leaf', + id: 'pane-alpha-fallback', + content: { + kind: 'terminal', + mode: 'codex', + createRequestId: 'req-alpha-fallback', + status: 'running', + resumeSessionId: alphaFallbackSessionId, + initialCwd: '/tmp/local/alpha', + }, + }, + 'tab-beta-fallback': { + type: 'leaf', + id: 'pane-beta-fallback', + content: { + kind: 'terminal', + mode: 'codex', + createRequestId: 'req-beta-fallback', + status: 'running', + resumeSessionId: betaFallbackSessionId, + initialCwd: '/tmp/local/beta', + }, + }, + }, + activePane: { + 'tab-alpha-fallback': 'pane-alpha-fallback', + 'tab-beta-fallback': 'pane-beta-fallback', + }, + paneTitles: { + 'tab-alpha-fallback': { + 'pane-alpha-fallback': 'Alpha Fallback', + }, + 'tab-beta-fallback': { + 'pane-beta-fallback': 'Beta Fallback', + }, + }, + }, + sessions: { + activeSurface: 'sidebar', + projects: alphaProjects, + lastLoadedAt: 1_700_000_000_000, + windows: { + sidebar: { + projects: alphaProjects, + lastLoadedAt: 1_700_000_000_000, + query: 'alpha', + searchTier: 'title', + appliedQuery: 'alpha', + appliedSearchTier: 'title', + loading: false, + }, + }, + }, + }) + + renderSidebar(store, []) + + try { + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'beta' } }) + + await act(async () => { + vi.advanceTimersByTime(350) + await Promise.resolve() + }) + + expect(screen.getByTestId('search-loading')).toBeInTheDocument() + expect(screen.getByText('Alpha Server Result')).toBeInTheDocument() + expect(screen.getByText('Alpha Fallback')).toBeInTheDocument() + expect(screen.queryByText('Beta Fallback')).not.toBeInTheDocument() + } finally { + replacementSearch.resolve({ + results: [], + tier: 'title', + query: 'beta', + totalScanned: 0, + }) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + } + }) + + it('keeps browse append disabled until browse data replaces stale applied search results', async () => { + vi.useRealTimers() + + const browseProjects: ProjectGroup[] = [{ + projectPath: '/browse', + sessions: [{ + provider: 'codex', + sessionId: 'browse-session', + projectPath: '/browse', + lastActivityAt: 20, + title: 'Browse Session', + }], + }] + + mockFetchSidebarSessionsSnapshot.mockResolvedValueOnce({ + projects: [{ + projectPath: '/older', + sessions: [{ + provider: 'codex', + sessionId: 'older-session', + projectPath: '/older', + lastActivityAt: 10, + title: 'Older Session', + }], + }], + totalSessions: 2, + oldestIncludedTimestamp: 10, + oldestIncludedSessionId: 'codex:older-session', + hasMore: false, + }) + + const store = createTestStore({ + projects: [{ + projectPath: '/search', + sessions: [{ + provider: 'codex', + sessionId: 'search-session', + projectPath: '/search', + lastActivityAt: 30, + title: 'Search Result', + }], + }], + sessions: { + activeSurface: 'sidebar', + projects: [{ + projectPath: '/search', + sessions: [{ + provider: 'codex', + sessionId: 'search-session', + projectPath: '/search', + lastActivityAt: 30, + title: 'Search Result', + }], + }], + lastLoadedAt: 1_700_000_000_000, + hasMore: true, + oldestLoadedTimestamp: 30, + oldestLoadedSessionId: 'codex:search-session', + windows: { + sidebar: { + projects: [{ + projectPath: '/search', + sessions: [{ + provider: 'codex', + sessionId: 'search-session', + projectPath: '/search', + lastActivityAt: 30, + title: 'Search Result', + }], + }], + lastLoadedAt: 1_700_000_000_000, + hasMore: true, + oldestLoadedTimestamp: 30, + oldestLoadedSessionId: 'codex:search-session', + loading: true, + loadingKind: 'search', + query: '', + searchTier: 'title', + appliedQuery: 'search', + appliedSearchTier: 'title', + }, + }, + }, + }) + + renderSidebar(store) + const list = screen.getByTestId('sidebar-session-list') + + triggerNearBottomScroll(list, { clientHeight: 560, scrollHeight: 1120 }) + expect(mockFetchSidebarSessionsSnapshot).not.toHaveBeenCalled() + + await act(async () => { + store.dispatch(setSessionWindowData({ + surface: 'sidebar', + projects: browseProjects, + totalSessions: 1, + hasMore: true, + oldestLoadedTimestamp: 20, + oldestLoadedSessionId: 'codex:browse-session', + query: '', + searchTier: 'title', + })) + }) + + triggerNearBottomScroll(list, { clientHeight: 560, scrollHeight: 1120 }) + + await waitFor(() => { + expect(mockFetchSidebarSessionsSnapshot).toHaveBeenCalledWith(expect.objectContaining({ + limit: 50, + before: 20, + beforeId: 'codex:browse-session', + signal: expect.any(AbortSignal), + })) + }) + await waitFor(() => { + expect(screen.getByText('Older Session')).toBeInTheDocument() + }) + }) + }) }) diff --git a/test/unit/client/store/selectors/sidebarSelectors.test.ts b/test/unit/client/store/selectors/sidebarSelectors.test.ts index cf2a7da7..ce89860b 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest' import type { SidebarSessionItem } from '@/store/selectors/sidebarSelectors' +import type { ProjectGroup, CodingCliSession } from '@/store/types' + +import { + buildSessionItems, + filterSessionItemsByVisibility, + makeSelectSortedSessionItems, + sortSessionItems, +} from '@/store/selectors/sidebarSelectors' // Helper to create test session items function createSessionItem(overrides: Partial): SidebarSessionItem { @@ -17,9 +25,77 @@ function createSessionItem(overrides: Partial): SidebarSessi } } -// Import the sort function and buildSessionItems for testing -import { sortSessionItems, buildSessionItems, filterSessionItemsByVisibility } from '@/store/selectors/sidebarSelectors' -import type { CodingCliSession, ProjectGroup } from '@/store/types' +function createFallbackTab(tabId: string, sessionId: string, title: string, cwd: string, mode: 'claude' | 'codex' = 'codex') { + const paneId = `pane-${tabId}` + return { + tab: { id: tabId, title, mode, resumeSessionId: sessionId, createdAt: 1_000 }, + paneId, + layout: { + type: 'leaf', + id: paneId, + content: { + kind: 'terminal', + mode, + status: 'running', + createRequestId: `req-${tabId}`, + resumeSessionId: sessionId, + initialCwd: cwd, + }, + }, + } +} + +function createSelectorState(options: { + projects?: ProjectGroup[] + tabs?: any[] + panes?: any + sortMode?: 'recency' | 'activity' | 'recency-pinned' | 'project' + query?: string + searchTier?: 'title' | 'userMessages' | 'fullText' + appliedQuery?: string + appliedSearchTier?: 'title' | 'userMessages' | 'fullText' + sessionActivity?: Record +} = {}) { + const projects = options.projects ?? [] + return { + sessions: { + projects, + windows: { + sidebar: { + projects, + query: options.query ?? '', + searchTier: options.searchTier ?? 'title', + appliedQuery: options.appliedQuery, + appliedSearchTier: options.appliedSearchTier, + }, + }, + }, + tabs: { + tabs: options.tabs ?? [], + }, + panes: options.panes ?? { + layouts: {}, + activePane: {}, + paneTitles: {}, + }, + settings: { + settings: { + sidebar: { + sortMode: options.sortMode ?? 'activity', + showSubagents: true, + ignoreCodexSubagents: false, + showNoninteractiveSessions: true, + hideEmptySessions: false, + excludeFirstChatSubstrings: [], + excludeFirstChatMustStart: false, + }, + }, + }, + sessionActivity: { + sessions: options.sessionActivity ?? {}, + }, + } as any +} describe('sidebarSelectors', () => { describe('buildSessionItems', () => { @@ -286,10 +362,41 @@ describe('sidebarSelectors', () => { hasTab: true, hasTitle: true, cwd: '/tmp/restored-project', + isFallback: true, }), ]) }) + it('marks synthesized rows as fallback-only while leaving server-backed rows unmarked', () => { + const fallback = createFallbackTab('tab-restored', 'codex-restored', 'Restored Session', '/tmp/restored-project') + const items = buildSessionItems( + [ + makeProject([{ sessionId: 'server-session', provider: 'claude', title: 'Server Session' }]), + ], + [fallback.tab] as any, + { + layouts: { + [fallback.tab.id]: fallback.layout, + }, + activePane: { + [fallback.tab.id]: fallback.paneId, + }, + paneTitles: { + [fallback.tab.id]: { + [fallback.paneId]: fallback.tab.title, + }, + }, + } as any, + emptyTerminals, + emptyActivity, + ) + + expect(items.find((item) => item.sessionId === 'server-session')?.isFallback).toBeUndefined() + expect(items.find((item) => item.sessionId === 'codex-restored')).toMatchObject({ + isFallback: true, + }) + }) + it('preserves fallback visibility metadata from tab session metadata so hidden sessions stay filtered', () => { const hiddenSessionId = 'codex-hidden' const tabs = [ @@ -358,6 +465,176 @@ describe('sidebarSelectors', () => { }) }) + describe('makeSelectSortedSessionItems', () => { + it('uses the applied title query to keep only matching fallback rows and rejects ancestor-only matches', () => { + const matchingFallback = createFallbackTab('tab-match', 'fallback-match', 'Matching Fallback', '/tmp/local/trycycle') + const ancestorFallback = createFallbackTab('tab-ancestor', 'fallback-ancestor', 'Ancestor Fallback', '/tmp/code/local/project') + const unrelatedFallback = createFallbackTab('tab-unrelated', 'fallback-unrelated', 'Unrelated Fallback', '/tmp/local/elsewhere') + const selectSortedItems = makeSelectSortedSessionItems() + + const items = selectSortedItems(createSelectorState({ + projects: [ + { + projectPath: '/repo/server', + sessions: [{ + provider: 'claude', + sessionId: 'server-newer', + projectPath: '/repo/server', + lastActivityAt: 3_000, + title: 'Newer Server Result', + }], + }, + { + projectPath: '/repo/code/trycycle', + sessions: [{ + provider: 'claude', + sessionId: 'server-leaf', + projectPath: '/repo/code/trycycle', + cwd: '/repo/code/trycycle/server', + lastActivityAt: 2_500, + title: 'Routine work', + }], + }, + ], + tabs: [matchingFallback.tab, ancestorFallback.tab, unrelatedFallback.tab], + panes: { + layouts: { + [matchingFallback.tab.id]: matchingFallback.layout, + [ancestorFallback.tab.id]: ancestorFallback.layout, + [unrelatedFallback.tab.id]: unrelatedFallback.layout, + }, + activePane: { + [matchingFallback.tab.id]: matchingFallback.paneId, + [ancestorFallback.tab.id]: ancestorFallback.paneId, + [unrelatedFallback.tab.id]: unrelatedFallback.paneId, + }, + paneTitles: { + [matchingFallback.tab.id]: { [matchingFallback.paneId]: matchingFallback.tab.title }, + [ancestorFallback.tab.id]: { [ancestorFallback.paneId]: ancestorFallback.tab.title }, + [unrelatedFallback.tab.id]: { [unrelatedFallback.paneId]: unrelatedFallback.tab.title }, + }, + }, + sortMode: 'activity', + query: 'code', + searchTier: 'title', + appliedQuery: 'trycycle', + appliedSearchTier: 'title', + }), [], '') + + expect(items.map((item) => item.sessionId)).toEqual([ + 'server-newer', + 'server-leaf', + 'fallback-match', + ]) + expect(items.find((item) => item.sessionId === 'fallback-match')).toMatchObject({ + isFallback: true, + }) + expect(items.some((item) => item.sessionId === 'fallback-ancestor')).toBe(false) + expect(items.some((item) => item.sessionId === 'fallback-unrelated')).toBe(false) + }) + + it('drops fallback rows entirely for applied deep-search tiers', () => { + const matchingFallback = createFallbackTab('tab-match', 'fallback-match', 'Matching Fallback', '/tmp/local/trycycle') + const selectSortedItems = makeSelectSortedSessionItems() + + const items = selectSortedItems(createSelectorState({ + projects: [{ + projectPath: '/repo/server', + sessions: [{ + provider: 'claude', + sessionId: 'server-deep', + projectPath: '/repo/server', + lastActivityAt: 3_000, + title: 'Deep Search Result', + }], + }], + tabs: [matchingFallback.tab], + panes: { + layouts: { + [matchingFallback.tab.id]: matchingFallback.layout, + }, + activePane: { + [matchingFallback.tab.id]: matchingFallback.paneId, + }, + paneTitles: { + [matchingFallback.tab.id]: { [matchingFallback.paneId]: matchingFallback.tab.title }, + }, + }, + appliedQuery: 'trycycle', + appliedSearchTier: 'fullText', + }), [], '') + + expect(items.map((item) => item.sessionId)).toEqual(['server-deep']) + }) + + it('disables tab pinning during applied search in recency-pinned mode while preserving archived-last ordering', () => { + const matchingFallback = createFallbackTab('tab-match', 'fallback-match', 'Matching Fallback', '/tmp/local/trycycle') + const selectSortedItems = makeSelectSortedSessionItems() + const baseOptions = { + projects: [ + { + projectPath: '/repo/server', + sessions: [{ + provider: 'claude', + sessionId: 'server-newer', + projectPath: '/repo/server', + lastActivityAt: 3_000, + title: 'Newer Server Result', + }], + }, + { + projectPath: '/repo/archive', + sessions: [{ + provider: 'claude', + sessionId: 'server-archived', + projectPath: '/repo/archive', + lastActivityAt: 4_000, + title: 'Archived Result', + archived: true, + }], + }, + ], + tabs: [matchingFallback.tab], + panes: { + layouts: { + [matchingFallback.tab.id]: matchingFallback.layout, + }, + activePane: { + [matchingFallback.tab.id]: matchingFallback.paneId, + }, + paneTitles: { + [matchingFallback.tab.id]: { [matchingFallback.paneId]: matchingFallback.tab.title }, + }, + }, + sortMode: 'recency-pinned' as const, + } + + const searchItems = selectSortedItems(createSelectorState({ + ...baseOptions, + appliedQuery: 'trycycle', + appliedSearchTier: 'title', + }), [], '') + + expect(searchItems.map((item) => item.sessionId)).toEqual([ + 'server-newer', + 'fallback-match', + 'server-archived', + ]) + + const browseItems = selectSortedItems(createSelectorState({ + ...baseOptions, + query: 'trycycle', + searchTier: 'title', + }), [], '') + + expect(browseItems.map((item) => item.sessionId)).toEqual([ + 'fallback-match', + 'server-newer', + 'server-archived', + ]) + }) + }) + describe('sortSessionItems', () => { describe('recency mode', () => { it('sorts by timestamp descending', () => { @@ -478,6 +755,18 @@ describe('sidebarSelectors', () => { // Active first (unpinned), then archived (pinned first within archived) expect(sorted.map((i) => i.id)).toEqual(['3', '2', '1']) }) + + it('can disable tab pinning while keeping archived items last', () => { + const items = [ + createSessionItem({ id: '1', timestamp: 3000, hasTab: false }), + createSessionItem({ id: '2', timestamp: 1000, hasTab: true }), + createSessionItem({ id: '3', timestamp: 4000, hasTab: true, archived: true }), + ] + + const sorted = sortSessionItems(items, 'recency-pinned', { disableTabPinning: true }) + + expect(sorted.map((i) => i.id)).toEqual(['1', '2', '3']) + }) }) describe('activity mode', () => { @@ -502,6 +791,18 @@ describe('sidebarSelectors', () => { expect(sorted.map((i) => i.id)).toEqual(['1', '2']) }) + + it('can disable tab pinning and use the normal activity comparator for every item', () => { + const items = [ + createSessionItem({ id: '1', timestamp: 3000, hasTab: false }), + createSessionItem({ id: '2', timestamp: 1000, hasTab: true }), + createSessionItem({ id: '3', timestamp: 4000, hasTab: true, archived: true }), + ] + + const sorted = sortSessionItems(items, 'activity', { disableTabPinning: true }) + + expect(sorted.map((i) => i.id)).toEqual(['1', '2', '3']) + }) }) describe('project mode', () => { diff --git a/test/unit/client/store/turnCompletionSlice.test.ts b/test/unit/client/store/turnCompletionSlice.test.ts index dcc5c6fe..d12f2c76 100644 --- a/test/unit/client/store/turnCompletionSlice.test.ts +++ b/test/unit/client/store/turnCompletionSlice.test.ts @@ -150,6 +150,10 @@ describe('turnCompletionSlice', () => { activePane: { 'tab-1': 'pane-1' }, paneTitles: {}, paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, }, settings: { settings: defaultSettings, loaded: true }, turnCompletion: { @@ -186,7 +190,8 @@ describe('turnCompletionSlice', () => { it('closeTab clears tab and all pane attention entries', async () => { const store = createFullStore() - await store.dispatch(closeTab('tab-1')) + const result = await store.dispatch(closeTab('tab-1')) + expect(result.type).toBe(closeTab.fulfilled.type) expect(store.getState().turnCompletion.attentionByTab['tab-1']).toBeUndefined() expect(store.getState().turnCompletion.attentionByPane['pane-1']).toBeUndefined() expect(store.getState().turnCompletion.attentionByPane['pane-2']).toBeUndefined() From ad97268484a18c9c00f7985476c93c228ae64f5e Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Fri, 27 Mar 2026 00:55:30 -0700 Subject: [PATCH 09/30] fix: honor applied sidebar search state --- src/components/Sidebar.tsx | 7 ++-- ...en-tab-session-sidebar-visibility.test.tsx | 4 ++ test/e2e/sidebar-search-flow.test.tsx | 12 +++--- test/unit/client/components/Sidebar.test.tsx | 41 ++++++++++++++++++- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1e171518..6aeae37b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -393,15 +393,14 @@ export default function Sidebar({ const sidebarHasMore = sidebarWindow?.hasMore ?? false const sidebarOldestLoadedTimestamp = sidebarWindow?.oldestLoadedTimestamp const sidebarOldestLoadedSessionId = sidebarWindow?.oldestLoadedSessionId - const localQuery = filter.trim() - const hasActiveQuery = localQuery.length > 0 || appliedQuery.length > 0 + const hasAppliedQuery = appliedQuery.length > 0 const loadMoreInFlightRef = useRef(false) const loadMoreTimeoutRef = useRef | null>(null) const requestSidebarAppend = useCallback(() => { if (!sidebarHasMore || sidebarWindow?.loading || loadMoreInFlightRef.current) return if (sidebarOldestLoadedTimestamp == null || sidebarOldestLoadedSessionId == null) return - if (hasActiveQuery) return + if (hasAppliedQuery) return loadMoreInFlightRef.current = true void dispatch(fetchSessionWindow({ @@ -415,7 +414,7 @@ export default function Sidebar({ }, 15_000) }, [ dispatch, - hasActiveQuery, + hasAppliedQuery, sidebarHasMore, sidebarOldestLoadedSessionId, sidebarOldestLoadedTimestamp, diff --git a/test/e2e/open-tab-session-sidebar-visibility.test.tsx b/test/e2e/open-tab-session-sidebar-visibility.test.tsx index dd41b9d3..31eaaca2 100644 --- a/test/e2e/open-tab-session-sidebar-visibility.test.tsx +++ b/test/e2e/open-tab-session-sidebar-visibility.test.tsx @@ -822,6 +822,8 @@ describe('open tab session sidebar visibility (e2e)', () => { loading: false, query: 'search', searchTier: 'title', + appliedQuery: 'search', + appliedSearchTier: 'title', }, }, }, @@ -965,6 +967,8 @@ describe('open tab session sidebar visibility (e2e)', () => { loading: false, query: 'search', searchTier: 'title', + appliedQuery: 'search', + appliedSearchTier: 'title', }, }, }, diff --git a/test/e2e/sidebar-search-flow.test.tsx b/test/e2e/sidebar-search-flow.test.tsx index 1e48899a..e30a44a2 100644 --- a/test/e2e/sidebar-search-flow.test.tsx +++ b/test/e2e/sidebar-search-flow.test.tsx @@ -263,7 +263,7 @@ describe('sidebar search flow (e2e)', () => { const store = createStore({ tabs: [{ id: 'tab-fallback', - title: 'Open Trycycle Tab', + title: 'Open Matching Tab', mode: 'codex', resumeSessionId: matchingFallbackSessionId, createdAt: 1_000, @@ -288,7 +288,7 @@ describe('sidebar search flow (e2e)', () => { }, paneTitles: { 'tab-fallback': { - 'pane-fallback': 'Open Trycycle Tab', + 'pane-fallback': 'Open Matching Tab', }, }, }, @@ -311,15 +311,15 @@ describe('sidebar search flow (e2e)', () => { })) expect(screen.getByText('Routine work')).toBeInTheDocument() expect(screen.getByText('Newer Server Result')).toBeInTheDocument() - expect(screen.getByText('Open Trycycle Tab')).toBeInTheDocument() + expect(screen.getByText('Open Matching Tab')).toBeInTheDocument() expect(getSidebarSessionOrder([ 'Newer Server Result', 'Routine work', - 'Open Trycycle Tab', + 'Open Matching Tab', ])).toEqual([ 'Newer Server Result', 'Routine work', - 'Open Trycycle Tab', + 'Open Matching Tab', ]) fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'code' } }) @@ -335,7 +335,7 @@ describe('sidebar search flow (e2e)', () => { tier: 'title', })) expect(screen.queryByText('Routine work')).not.toBeInTheDocument() - expect(screen.queryByText('Open Trycycle Tab')).not.toBeInTheDocument() + expect(screen.queryByText('Open Matching Tab')).not.toBeInTheDocument() }) it('deep-tier search shows title results first, then merged results after Phase 2', async () => { diff --git a/test/unit/client/components/Sidebar.test.tsx b/test/unit/client/components/Sidebar.test.tsx index 35e18e00..74b61e89 100644 --- a/test/unit/client/components/Sidebar.test.tsx +++ b/test/unit/client/components/Sidebar.test.tsx @@ -2356,7 +2356,33 @@ describe('Sidebar Component - Session-Centric Display', () => { expect(mockFetchSidebarSessionsSnapshot).not.toHaveBeenCalled() }) - it('does not append while the user has typed an uncommitted sidebar search query', () => { + it('continues append pagination while the user has only typed an uncommitted sidebar search query', async () => { + vi.useRealTimers() + + mockSearchSessions.mockResolvedValue({ + results: [], + tier: 'title', + query: 'search', + totalScanned: 0, + } as any) + + mockFetchSidebarSessionsSnapshot.mockResolvedValueOnce({ + projects: [{ + projectPath: '/older', + sessions: [{ + provider: 'codex', + sessionId: 'older-session', + projectPath: '/older', + lastActivityAt: 10, + title: 'Older Session', + }], + }], + totalSessions: 2, + oldestIncludedTimestamp: 10, + oldestIncludedSessionId: 'codex:older-session', + hasMore: false, + }) + const store = createTestStore({ sessions: { activeSurface: 'sidebar', @@ -2390,7 +2416,18 @@ describe('Sidebar Component - Session-Centric Display', () => { const list = screen.getByTestId('sidebar-session-list') triggerNearBottomScroll(list, { clientHeight: 560, scrollHeight: 1120 }) - expect(mockFetchSidebarSessionsSnapshot).not.toHaveBeenCalled() + await waitFor(() => { + expect(mockFetchSidebarSessionsSnapshot).toHaveBeenCalledWith(expect.objectContaining({ + limit: 50, + before: 20, + beforeId: 'codex:recent-session', + signal: expect.any(AbortSignal), + })) + }) + await waitFor(() => { + expect(screen.getByText('Older Session')).toBeInTheDocument() + }) + expect(screen.getByText('Recent Session')).toBeInTheDocument() }) it('releases the sidebar append guard even when another session surface is active', async () => { From 78bffd95e271d625eb6beb38ff28f7e061682fb1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Fri, 27 Mar 2026 01:14:45 -0700 Subject: [PATCH 10/30] test: remove remaining skipped coverage --- test/e2e-browser/specs/agent-chat.spec.ts | 128 ++++++++++++++---- test/e2e-browser/specs/tab-management.spec.ts | 6 +- test/integration/server/files-api.test.ts | 4 +- .../server/logger.separation.test.ts | 16 +-- 4 files changed, 110 insertions(+), 44 deletions(-) diff --git a/test/e2e-browser/specs/agent-chat.spec.ts b/test/e2e-browser/specs/agent-chat.spec.ts index 6e90e01f..fa1cc158 100644 --- a/test/e2e-browser/specs/agent-chat.spec.ts +++ b/test/e2e-browser/specs/agent-chat.spec.ts @@ -17,6 +17,14 @@ test.describe('Agent Chat', () => { .toBeVisible({ timeout: 10_000 }) } + async function getActiveLeaf(harness: any) { + const tabId = await harness.getActiveTabId() + expect(tabId).toBeTruthy() + const layout = await harness.getPaneLayout(tabId!) + expect(layout?.type).toBe('leaf') + return { tabId: tabId!, paneId: layout.id as string } + } + test('pane picker shows base pane types', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() await openPanePicker(page) @@ -37,37 +45,105 @@ test.describe('Agent Chat', () => { expect(shellVisible || wslVisible || cmdVisible || psVisible).toBe(true) }) - test('agent chat provider appears when CLI is available', async ({ freshellPage, page, harness, terminal }) => { + test('agent chat provider appears when the Claude CLI is available and enabled', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() - - // Check if any agent chat provider is available via Redux state - const state = await harness.getState() - const availableClis = state.connection?.availableClis ?? {} - const enabledProviders = state.settings?.settings?.codingCli?.enabledProviders ?? [] - - // Find a provider that is both available and enabled - const hasProvider = Object.keys(availableClis).some( - (cli) => availableClis[cli] && enabledProviders.includes(cli) - ) - - if (!hasProvider) { - // No CLI providers available in the isolated test env -- skip - test.skip() - return - } + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'connection/setAvailableClis', + payload: { claude: true }, + }) + harness?.dispatch({ + type: 'settings/updateSettingsLocal', + payload: { + codingCli: { + enabledProviders: ['claude'], + }, + }, + }) + }) await openPanePicker(page) - - // The picker should show more than just Shell/Editor/Browser - const pickerOptions = page.locator('[data-testid="pane-picker-options"] button') - const count = await pickerOptions.count() - expect(count).toBeGreaterThan(3) + await expect(page.getByRole('button', { name: /^Freshclaude$/i })).toBeVisible() }) - test.skip('agent chat permission banners appear', async ({ freshellPage, page }) => { - // This test requires a live SDK session to trigger permission requests. - // In the isolated test environment, no SDK session is available. - // Skipping until a mock SDK bridge is implemented. + test('agent chat permission banners appear and allow sends a response', async ({ freshellPage, page, harness, terminal }) => { + await terminal.waitForTerminal() + const { tabId, paneId } = await getActiveLeaf(harness) + const sessionId = 'sdk-e2e-permission' + const cliSessionId = '33333333-3333-4333-8333-333333333333' + + await page.evaluate((currentPaneId: string) => { + window.__FRESHELL_TEST_HARNESS__?.setAgentChatNetworkEffectsSuppressed(currentPaneId, true) + }, paneId) + + await page.evaluate(({ currentTabId, currentPaneId, currentSessionId, currentCliSessionId }) => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'agentChat/sessionCreated', + payload: { + requestId: 'req-e2e-permission', + sessionId: currentSessionId, + }, + }) + harness?.dispatch({ + type: 'agentChat/sessionInit', + payload: { + sessionId: currentSessionId, + cliSessionId: currentCliSessionId, + }, + }) + harness?.dispatch({ + type: 'agentChat/addPermissionRequest', + payload: { + sessionId: currentSessionId, + requestId: 'perm-e2e', + subtype: 'can_use_tool', + tool: { + name: 'Bash', + input: { command: 'echo hello-from-permission-banner' }, + }, + }, + }) + harness?.dispatch({ + type: 'panes/updatePaneContent', + payload: { + tabId: currentTabId, + paneId: currentPaneId, + content: { + kind: 'agent-chat', + provider: 'freshclaude', + createRequestId: 'req-e2e-permission', + sessionId: currentSessionId, + resumeSessionId: currentCliSessionId, + status: 'running', + }, + }, + }) + }, { + currentTabId: tabId, + currentPaneId: paneId, + currentSessionId: sessionId, + currentCliSessionId: cliSessionId, + }) + + const banner = page.getByRole('alert', { name: /permission request for bash/i }) + await expect(banner).toBeVisible() + await expect(banner).toContainText('Permission requested: Bash') + await expect(banner).toContainText('$ echo hello-from-permission-banner') + + await harness.clearSentWsMessages() + await banner.getByRole('button', { name: /allow tool use/i }).click() + + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() + return sent.find((msg: any) => msg?.type === 'sdk.permission.respond') ?? null + }).toMatchObject({ + type: 'sdk.permission.respond', + sessionId, + requestId: 'perm-e2e', + behavior: 'allow', + }) }) test('picker creates shell pane when shell is selected', async ({ freshellPage, page, harness, terminal }) => { diff --git a/test/e2e-browser/specs/tab-management.spec.ts b/test/e2e-browser/specs/tab-management.spec.ts index 27e114d0..9277f742 100644 --- a/test/e2e-browser/specs/tab-management.spec.ts +++ b/test/e2e-browser/specs/tab-management.spec.ts @@ -164,10 +164,8 @@ test.describe('Tab Management', () => { expect(sent.filter((msg: any) => msg?.type === 'terminal.resize')).toHaveLength(0) }) - test.skip('keyboard shortcut creates new tab', async ({ freshellPage, page, harness }) => { - // Ctrl+T is intercepted by Chromium in headed mode and cannot be tested. - // The app's keyboard shortcut handling is covered by unit tests. - await page.keyboard.press('Control+t') + test('keyboard shortcut creates new tab', async ({ freshellPage, page, harness }) => { + await page.keyboard.press('Alt+T') await harness.waitForTabCount(2) }) diff --git a/test/integration/server/files-api.test.ts b/test/integration/server/files-api.test.ts index d401659b..10dee7bc 100644 --- a/test/integration/server/files-api.test.ts +++ b/test/integration/server/files-api.test.ts @@ -296,7 +296,7 @@ describe('Files API Integration', () => { // from WSL_WINDOWS_SYS32 via a regex that matches the first single-letter path // component. This only works reliably on Linux where temp paths lack single-letter // components (macOS /var/folders/.../T/ confuses the regex). - it.skipIf(process.platform !== 'linux')('supports Windows drive prefixes when running in WSL', async () => { + it('supports Windows drive prefixes when running in WSL', async () => { const originalWslDistro = process.env.WSL_DISTRO_NAME const originalWslSys32 = process.env.WSL_WINDOWS_SYS32 const originalPlatform = process.platform @@ -386,7 +386,7 @@ describe('Files API Integration', () => { }) // WSL path simulation only works on Linux; macOS temp paths confuse the mount prefix regex - it.skipIf(process.platform !== 'linux')('validates Windows drive paths when running in WSL', async () => { + it('validates Windows drive paths when running in WSL', async () => { const originalWslDistro = process.env.WSL_DISTRO_NAME const originalWslSys32 = process.env.WSL_WINDOWS_SYS32 const originalPlatform = process.platform diff --git a/test/integration/server/logger.separation.test.ts b/test/integration/server/logger.separation.test.ts index 9f40444c..6dc8c0ea 100644 --- a/test/integration/server/logger.separation.test.ts +++ b/test/integration/server/logger.separation.test.ts @@ -18,14 +18,6 @@ const __dirname = path.dirname(__filename) const REPO_ROOT = path.resolve(__dirname, '../../..') const require = createRequire(import.meta.url) let TSX_CLI: string | undefined -const HAS_TSX_CLI = (() => { - try { - require.resolve('tsx/cli') - return true - } catch { - return false - } -})() const DEFAULT_TEST_TIMEOUT_MS = 120_000 const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g const SOURCE_LOGGER_PROBE = [ @@ -129,7 +121,7 @@ async function startDistLoggerProcess(env: NodeJS.ProcessEnv) { } describe('debug log separation', () => { - it.skipIf(!HAS_TSX_CLI)( + it( 'dist and source launches choose different mode-specific filenames', { timeout: DEFAULT_TEST_TIMEOUT_MS }, async () => { @@ -160,7 +152,7 @@ describe('debug log separation', () => { }, ) - it.skipIf(!HAS_TSX_CLI)( + it( 'concurrent launches with the same mode keep separate files', { timeout: DEFAULT_TEST_TIMEOUT_MS }, async () => { @@ -191,7 +183,7 @@ describe('debug log separation', () => { }, ) - it.skipIf(!HAS_TSX_CLI)( + it( 'explicit instance settings are respected across launch modes', { timeout: DEFAULT_TEST_TIMEOUT_MS }, async () => { @@ -220,7 +212,7 @@ describe('debug log separation', () => { }, ) - it.skipIf(!HAS_TSX_CLI)( + it( 'startup logs include resolved debug destination details', { timeout: DEFAULT_TEST_TIMEOUT_MS }, async () => { From 9a9f145ccd00adf7a1f48f944c3230d2f3b771a8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Fri, 27 Mar 2026 01:36:21 -0700 Subject: [PATCH 11/30] fix: sync sidebar search controls with requested state --- src/components/Sidebar.tsx | 69 +++++++++++++------- src/store/sessionsSlice.ts | 32 +++++++++ src/store/sessionsThunks.ts | 8 ++- test/e2e/sidebar-search-flow.test.tsx | 37 +++++++++++ test/unit/client/components/Sidebar.test.tsx | 49 +++++++++++++- 5 files changed, 168 insertions(+), 27 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6aeae37b..a663e059 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { getActiveSessionRefForTab } from '@/lib/session-utils' import { useStableArray } from '@/hooks/useStableArray' import { getInstalledPerfAuditBridge } from '@/lib/perf-audit-bridge' import { fetchSessionWindow } from '@/store/sessionsThunks' +import { setSessionWindowRequestedSearch } from '@/store/sessionsSlice' import { mergeSessionMetadataByKey } from '@/lib/session-metadata' import { collectBusySessionKeys } from '@/lib/pane-activity' import type { ChatSessionState } from '@/store/agentChatTypes' @@ -199,14 +200,19 @@ export default function Sidebar({ const terminals = useAppSelector((state) => ( (state as any).terminalDirectory?.windows?.sidebar?.items ?? EMPTY_TERMINALS )) as BackgroundTerminal[] - const [filter, setFilter] = useState('') - const [searchTier, setSearchTier] = useState<'title' | 'userMessages' | 'fullText'>('title') const lastMarkedSearchQueryRef = useRef(null) const wasSearchingRef = useRef(false) + const hasInitializedSearchEffectRef = useRef(false) const listRef = useRef(null) const listContentRef = useRef(null) const listMetricsRef = useRef({ clientHeight: 0, scrollHeight: 0 }) + const requestedQueryValue = sidebarWindow?.query ?? '' + const requestedQuery = requestedQueryValue.trim() + const requestedSearchTier = sidebarWindow?.searchTier ?? 'title' + const appliedQuery = (sidebarWindow?.appliedQuery ?? '').trim() + const appliedSearchTier = sidebarWindow?.appliedSearchTier ?? 'title' + // Tick counter that increments every 15s to keep relative timestamps fresh. // The custom comparator on SidebarItem ensures only the timestamp text node // updates — no DOM flicker despite the frequent ticks. @@ -217,8 +223,13 @@ export default function Sidebar({ }, []) useEffect(() => { - const query = filter.trim() - if (!query) { + if (!hasInitializedSearchEffectRef.current) { + hasInitializedSearchEffectRef.current = true + wasSearchingRef.current = requestedQuery.length > 0 || appliedQuery.length > 0 + return + } + + if (!requestedQuery) { if (wasSearchingRef.current) { wasSearchingRef.current = false lastMarkedSearchQueryRef.current = null @@ -235,15 +246,15 @@ export default function Sidebar({ void dispatch(fetchSessionWindow({ surface: 'sidebar', priority: 'visible', - query, - searchTier, + query: requestedQuery, + searchTier: requestedSearchTier, }) as any) }, 300) // Debounce 300ms return () => { clearTimeout(timeoutId) } - }, [dispatch, filter, searchTier]) + }, [dispatch, requestedQuery, requestedSearchTier]) const localFilteredItems = useAppSelector((state) => selectSortedItems(state, terminals, '')) const computedItems = useMemo(() => localFilteredItems, [localFilteredItems]) @@ -375,12 +386,8 @@ export default function Sidebar({ const activeTab = tabs.find((t) => t.id === activeTabId) const activeSessionKey = activeSessionKeyFromPanes const activeTerminalId = activeTab?.terminalId - const requestedSearchTier = sidebarWindow?.searchTier ?? searchTier - const appliedQuery = (sidebarWindow?.appliedQuery ?? '').trim() - const appliedSearchTier = sidebarWindow?.appliedSearchTier ?? 'title' const hasLoadedSidebarWindow = typeof sidebarWindow?.lastLoadedAt === 'number' const sidebarWindowHasItems = (sidebarWindow?.projects ?? []).some((project) => (project.sessions?.length ?? 0) > 0) - const requestedQuery = (sidebarWindow?.query ?? filter).trim() const visibleQuery = appliedQuery || requestedQuery const visibleSearchTier = appliedQuery ? appliedSearchTier : requestedSearchTier const loadingKind = sidebarWindow?.loadingKind @@ -495,17 +502,16 @@ export default function Sidebar({ }, []) useEffect(() => { - const query = filter.trim() - if (!query) return + if (!requestedQuery) return if (sidebarWindow?.loading) return if (sortedItems.length === 0) return - if (lastMarkedSearchQueryRef.current === query) return + if (lastMarkedSearchQueryRef.current === requestedQuery) return getInstalledPerfAuditBridge()?.mark('sidebar.search_results_visible', { - query, + query: requestedQuery, resultCount: sortedItems.length, }) - lastMarkedSearchQueryRef.current = query - }, [filter, sidebarWindow?.loading, sortedItems.length]) + lastMarkedSearchQueryRef.current = requestedQuery + }, [requestedQuery, sidebarWindow?.loading, sortedItems.length]) return (
setFilter(e.target.value)} + value={requestedQueryValue} + onChange={(e) => { + dispatch(setSessionWindowRequestedSearch({ + surface: 'sidebar', + query: e.target.value, + })) + }} aria-busy={showSearchLoading} className="w-full h-8 pl-8 pr-36 text-sm bg-muted/50 border-0 rounded-md placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-border" /> @@ -592,10 +603,15 @@ export default function Sidebar({ Searching... ) : null} - {filter ? ( + {requestedQueryValue ? (
- {filter.trim() && ( + {requestedQuery && (
{ - dispatch(setSessionWindowRequestedSearch({ - surface: 'sidebar', - query: e.target.value, - })) - }} + value={filter} + onChange={(e) => setFilter(e.target.value)} aria-busy={showSearchLoading} className="w-full h-8 pl-8 pr-36 text-sm bg-muted/50 border-0 rounded-md placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-border" /> @@ -603,15 +634,10 @@ export default function Sidebar({ Searching... ) : null} - {requestedQueryValue ? ( + {filter ? (
- {requestedQuery && ( + {localQuery && (