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..049a7330 --- /dev/null +++ b/docs/plans/2026-03-26-title-search-subdir-tabs-test-plan.md @@ -0,0 +1,160 @@ +# 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-10. +- **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 11-12. +- **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 13. +- **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 14. +- **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 15. + +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. This scenario remains the broad UI regression gate; the store-level commit-authority invariants live in tests 8-9. + **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:** Visible refresh commits against the same visible result set even if requested state drifts again, and stale refreshes cannot overwrite a newer committed window + **Type:** integration + **Disposition:** extend + **Harness:** Store harnesses (`sessionsThunks.test.ts`) + **Preconditions:** A store with committed sidebar search results for query A plus deferred promises for a visible refresh of A, a replacement request whose requested state moves to browse or query B, and a later replacement or refresh that can commit a newer visible window before the older refresh resolves. + **Actions:** Start a visible refresh for query A, then change requested state with a replacement request while leaving A visible; resolve the older visible refresh and inspect state. In a second phase, let a newer commit replace the visible window before the older visible refresh resolves, then resolve the stale refresh. + **Expected outcome:** Source of truth: implementation plan Behavior Contract bullets for visible-refresh authority. Requested-state drift alone does not invalidate the visible refresh: if query A is still the visible applied result set and the captured visible-window version/token is unchanged, the refresh may commit without rewriting requested state or cancelling the pending replacement. If a newer commit has already replaced the visible window, the stale refresh is discarded instead of overwriting newer data that happens to share the same query/tier. + **Interactions:** Visible-refresh commit guard, applied result-set identity token, replacement sequencing, stale response suppression, and requested-vs-applied drift. + +10. **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. + +11. **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. + +12. **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. + +13. **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. + +14. **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. + +15. **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; visible-refresh commit ordering under requested-state drift; 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. 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..17395a30 --- /dev/null +++ b/docs/plans/2026-03-26-title-search-subdir-tabs.md @@ -0,0 +1,269 @@ +# 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:** Finish the already-started sidebar search feature by preserving the shipped leaf-directory title matching and match-aware open-tab visibility, while fixing the remaining refresh drift bug so direct and queued refreshes revalidate only the visible result set without mutating requested search state or aborting a pending browse/search replacement. + +**Architecture:** Keep the existing user-facing search behavior already present on this branch: title-tier search matches the leaf subdirectory, fallback open tabs only appear when they locally prove a title-tier match, and applied search disables tab pinning. The remaining work is architectural: make replacement commits and visible-refresh commits distinct reducer contracts, add an explicit monotonic visible result-set token to sidebar window state, and make both `refreshActiveSessionWindow()` and queued invalidations refresh by visible-result identity instead of routing back through the generic replacement path. + +**Tech Stack:** React 18, Redux Toolkit, TypeScript, shared utilities, Vitest, Testing Library + +--- + +## Behavior Contract + +- Title-tier search must continue to match `title`, then the project-path leaf subtitle, then a distinct `cwd` leaf, then `summary` and `firstUserMessage`. +- Only leaf directory names are searchable for the new metadata behavior. `/home/user/code/trycycle` matches `trycycle`; it does not match `code` unless some other searchable field independently matches `code`. +- During an applied search, open-tab fallback rows appear only when local metadata proves a title-tier match. Deep-search tiers remain server-authoritative and must not inject fallback rows. +- During an applied search, `hasTab` must not pin rows above other matches. Archived-last behavior still applies. +- `query/searchTier` represent the next requested sidebar state. `appliedQuery/appliedSearchTier` represent the result set currently displayed. +- Clearing the search box starts a browse replacement immediately, but the visible list stays on the old applied search result set until browse data commits. +- Visible refreshes are not replacement requests. They revalidate whatever result set is currently on screen and must not: + - rewrite requested `query/searchTier` + - abort or replace the controller for a pending replacement request + - discard a pending replacement request when refresh data commits +- Visible-refresh commit eligibility must be based on visible result-set identity only: `appliedQuery`, `appliedSearchTier`, and a monotonic committed result-set token captured when the refresh starts. +- If a newer commit replaces the visible result set before an older refresh resolves, the stale refresh must be dropped. + +## File Structure + +- Modify: `src/store/sessionsSlice.ts` + Responsibility: model committed result-set identity explicitly and give replacement commits and visible-refresh commits different reducer entry points. +- Modify: `src/store/sessionsThunks.ts` + Responsibility: keep replacement requests abort-driven and make direct/queued refreshes use a separate visible-refresh flow keyed to committed visible identity. +- Modify: `test/unit/client/store/sessionsSlice.test.ts` + Responsibility: lock the reducer contract for requested state, applied state, result-set identity, and loading preservation. +- Modify: `test/unit/client/store/sessionsThunks.test.ts` + Responsibility: lock the refresh-vs-replacement thunk contract, including the direct-refresh drift bug that is still open. +- Modify: `test/e2e/open-tab-session-sidebar-visibility.test.tsx` + Responsibility: prove the real sidebar keeps visible search results stable during drift, keeps refresh silent, and still lets the pending browse replacement commit afterward. + +## Strategy Gate + +- Do not rework the already-landed leaf-directory matcher or selector fallback policy unless a regression test proves a real bug. The branch already contains `shared/session-title-search.ts`, server search wiring, and applied-search fallback gating; the remaining blocker is the refresh pipeline. +- Do not keep using ambiguous reducer flags as the primary abstraction. `preserveRequestedSearch` / `preserveLoading` are acceptable only as compatibility shims during the refactor; the final reducer API must make replacement commits and visible-refresh commits obviously different operations. +- Do not route `refreshActiveSessionWindow()` through `fetchSessionWindow()`. That path owns requested state and the surface abort controller, which is exactly what broke the search-to-browse drift contract. +- Do not key refresh safety to requested `query/searchTier`. Requested state is future intent and is allowed to drift while the old result set remains visible. +- Do not use wall-clock timing as the conceptual identity of a visible result set. Add an explicit monotonic token on the sidebar window state so tests can assert stale-refresh dropping without depending on `Date.now()`. +- Do not touch `Sidebar.tsx`, `sidebarSelectors.ts`, shared matcher code, or server search code unless the focused regression runs in Task 2 show a real failure there. + +### Task 1: Make Sidebar Window Commits Explicit In The Reducer + +**Files:** +- Modify: `src/store/sessionsSlice.ts` +- Modify: `test/unit/client/store/sessionsSlice.test.ts` + +- [ ] **Step 1: Write the failing reducer tests for explicit commit types** + +In `test/unit/client/store/sessionsSlice.test.ts`, replace the flag-oriented reducer coverage with tests that prove these exact contracts: + +- replacement loading updates requested `query/searchTier` immediately, preserves `appliedQuery/appliedSearchTier`, and does not bump the committed result-set token +- replacement commit updates `projects`, requested state, applied state, clears loading, and increments the committed result-set token +- visible-refresh commit updates `projects` and the committed result-set token, preserves requested `query/searchTier`, preserves an in-flight replacement loading state when requested, and keeps `appliedQuery/appliedSearchTier` on the refreshed visible context +- replacement failure preserves the last applied context and the current committed result-set token + +Make the tests name the new state field directly. Use `resultVersion` unless an equivalent explicit monotonic name is already present after refactor. + +- [ ] **Step 2: Run the targeted reducer tests to verify they fail** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 explicit sidebar reducer commits" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.test.ts +``` + +Expected: FAIL because the reducer still relies on one generic data commit shape plus preservation flags, and it does not yet expose an explicit committed result-set token. + +- [ ] **Step 3: Refactor the reducer around explicit replacement and visible-refresh commits** + +In `src/store/sessionsSlice.ts`: + +- add an explicit monotonic committed result-set token on `SessionWindowState` + +```ts +resultVersion?: number +``` + +- keep `setSessionWindowLoading()` as the replacement-start action that writes requested `query/searchTier` +- replace the generic “one payload fits both cases” data commit shape with two explicit reducer actions: + +```ts +commitSessionWindowReplacement(...) +commitSessionWindowVisibleRefresh(...) +``` + +- make `commitSessionWindowReplacement(...)`: + - write `projects`, paging metadata, and error/loading cleanup + - advance both requested and applied `query/searchTier` + - increment `resultVersion` +- make `commitSessionWindowVisibleRefresh(...)`: + - write `projects`, paging metadata, and clear any refresh error + - preserve requested `query/searchTier` + - keep `appliedQuery/appliedSearchTier` on the refreshed visible context + - preserve replacement loading state when instructed by the thunk + - increment `resultVersion` +- keep top-level active-surface syncing behavior unchanged + +- [ ] **Step 4: Re-run the targeted reducer tests to verify they pass** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 explicit sidebar reducer commits" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify the reducer seam** + +After the tests are green: + +- remove leftover flag-only branches that no longer express the primary contract +- keep reducer names and payload shapes self-describing enough that a future thunk bug cannot “accidentally” use the wrong commit path + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task1 reducer seam verification" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsSlice.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 +git commit -m "refactor: split sidebar replacement and refresh commits" +``` + +### Task 2: Rebuild Sidebar Refresh Flow Around Visible Result-Set Identity + +**Files:** +- Modify: `src/store/sessionsThunks.ts` +- Modify: `test/unit/client/store/sessionsThunks.test.ts` +- Modify: `test/e2e/open-tab-session-sidebar-visibility.test.tsx` + +- [ ] **Step 1: Write the failing thunk and end-to-end regressions** + +In `test/unit/client/store/sessionsThunks.test.ts`, add or tighten coverage that proves: + +- `refreshActiveSessionWindow()` during search-to-browse drift does **not** call the replacement path contract: + - the already-started browse `fetchSidebarSessionsSnapshot()` stays the only in-flight browse replacement + - its `AbortSignal` is still not aborted after the direct refresh completes + - requested `query` stays cleared while `appliedQuery` stays on the visible search result set + - the pending browse replacement still resolves and commits after the direct refresh +- `queueActiveSessionWindowRefresh()` obeys the same invariants during the same drift +- a visible refresh captures `{ appliedQuery, appliedSearchTier, resultVersion }` at start and still commits when requested state drifts again but the visible result set has not changed +- a stale visible refresh is dropped when a newer replacement or refresh commit increments `resultVersion` before the old refresh resolves +- direct refresh without drift still uses the visible applied context and remains background/silent rather than “new search” chrome + +In `test/e2e/open-tab-session-sidebar-visibility.test.tsx`, strengthen the existing direct-refresh scenario so it asserts: + +- clearing the search box starts one browse replacement request and leaves the old search results visible +- dispatching `refreshActiveSessionWindow()` during that drift keeps the search result rows visible and keeps the search indicator silent +- after the direct refresh resolves, the browse replacement still commits and the applied search state finally clears + +- [ ] **Step 2: Run the targeted thunk and e2e tests to verify they fail** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 visible refresh identity contract" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsThunks.test.ts \ + test/e2e/open-tab-session-sidebar-visibility.test.tsx +``` + +Expected: FAIL because `refreshActiveSessionWindow()` still routes through `fetchSessionWindow()`, which rewrites requested state and aborts the pending replacement controller. + +- [ ] **Step 3: Refactor the thunks to separate replacement requests from visible refreshes** + +In `src/store/sessionsThunks.ts`: + +- keep `fetchSessionWindow()` as the explicit browse/search replacement path and the only path that owns the surface abort controller in `controllers` +- introduce an explicit visible-result identity helper: + +```ts +type VisibleResultIdentity = { + query: string + searchTier: SearchOptions['tier'] + resultVersion: number +} +``` + +- capture visible refresh identity from `appliedQuery`, `appliedSearchTier`, and the committed `resultVersion` +- make the visible-refresh helper: + - fetch using the visible applied context + - commit through `commitSessionWindowVisibleRefresh(...)` + - decide stale-vs-valid using only the captured visible identity + - never rewrite requested `query/searchTier` + - never abort or replace the controller for a pending replacement request +- update `refreshActiveSessionWindow()` to call the visible-refresh helper directly instead of dispatching `fetchSessionWindow()` +- keep `queueActiveSessionWindowRefresh()` queue-based, but make queued invalidations use the same visible-refresh helper whenever they are revalidating what is already on screen +- preserve current two-phase deep search behavior and browse pagination behavior for replacement requests + +- [ ] **Step 4: Re-run the targeted thunk and e2e tests to verify they pass** + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 visible refresh identity contract" \ + npm run test:vitest -- \ + test/unit/client/store/sessionsThunks.test.ts \ + test/e2e/open-tab-session-sidebar-visibility.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and run the broader regression suite** + +After the targeted tests are green: + +- remove any remaining helper path that infers visible refresh safety from requested `query/searchTier` +- confirm the refactor did not regress the already-landed user-facing feature behavior in shared matcher, server search, selector gating, and sidebar rendering + +Run: + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +FRESHELL_TEST_SUMMARY="task2 title-search subdir regressions" \ + 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 \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/components/Sidebar.test.tsx \ + test/e2e/sidebar-search-flow.test.tsx \ + test/e2e/open-tab-session-sidebar-visibility.test.tsx \ + test/unit/client/store/sessionsSlice.test.ts \ + test/unit/client/store/sessionsThunks.test.ts +npm run lint +FRESHELL_TEST_SUMMARY="final verification for title-search subdir tabs" npm run check +``` + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /home/user/code/freshell/.worktrees/trycycle-title-search-subdir-tabs +git add \ + src/store/sessionsThunks.ts \ + test/unit/client/store/sessionsThunks.test.ts \ + test/e2e/open-tab-session-sidebar-visibility.test.tsx +git commit -m "fix: refresh sidebar results without mutating requested search" +``` diff --git a/docs/plans/2026-03-27-title-search-subdir-tabs-test-plan.md b/docs/plans/2026-03-27-title-search-subdir-tabs-test-plan.md new file mode 100644 index 00000000..da9f8c0b --- /dev/null +++ b/docs/plans/2026-03-27-title-search-subdir-tabs-test-plan.md @@ -0,0 +1,133 @@ +# Title Search Subdirectory And Open-Tab Search Behavior Revised Test Plan + +Minor reconciliation adjustment: the prior strategy still holds on scope, harness cost, and external dependencies, but the revised implementation plan changes test priority. The blocking contract is now the sidebar window commit model and visible-refresh identity, so the first gates move to `sessionsSlice`, `sessionsThunks`, and the full-app drift scenario. The already-landed leaf-directory matcher, selector fallback gating, and server transport checks remain regression coverage after those red checks. + +## Harness requirements + +No new harness families are required. Extend the existing local Vitest harnesses with low-complexity fixtures that expose the explicit visible-result identity and drift timing called for by the revised implementation plan. + +- **Explicit window-commit reducer harness**: `test/unit/client/store/sessionsSlice.test.ts`. Dispatch reducer actions directly and assert `query`, `searchTier`, `appliedQuery`, `appliedSearchTier`, `loading/loadingKind`, top-level active-surface sync, and the committed result-set token (`resultVersion`, or the final equivalent explicit field name if renamed during refactor). Estimated complexity: low. Depends on tests 1 and 4. +- **Refresh-drift thunk harness**: `test/unit/client/store/sessionsThunks.test.ts`. Redux store with deferred promises, captured `AbortSignal`s, and ordered resolution for replacement requests, direct refreshes, queued invalidations, and stale responses. Estimated complexity: low-medium fixture expansion. Depends on tests 2-5. +- **Full app invalidation harness**: `test/e2e/open-tab-session-sidebar-visibility.test.tsx`. Mount real `App` and `Sidebar`, drive the actual search input and clear button, and trigger websocket invalidation plus direct thunk refresh while observing rendered rows and search chrome. Estimated complexity: low fixture tightening. Depends on test 6. +- **Sidebar search-flow harness**: `test/e2e/sidebar-search-flow.test.tsx`. Real `Sidebar` with mocked `searchSessions` and `fetchSidebarSessionsSnapshot`, fake timers for debounce, and real DOM typing/tier-change/clear interactions. Estimated complexity: none beyond reusing the current branch coverage. Depends on test 7. +- **Selector harness**: `test/unit/client/store/selectors/sidebarSelectors.test.ts`. Pure selector fixtures spanning server rows, synthesized fallback rows, sort modes, archived rows, and requested/applied drift. Estimated complexity: low. Depends on tests 8-9. +- **HTTP router harness**: `test/integration/server/session-directory-router.test.ts`. Express round-trip via `supertest` against `/api/session-directory`. Estimated complexity: none beyond fixture reuse. Depends on test 10. +- **Service harness**: `test/unit/server/session-directory/service.test.ts`. Direct `querySessionDirectory()` calls with project, provider, file, and large-corpus fixtures. Estimated complexity: low. Depends on test 11. +- **Shared matcher harness**: `test/unit/shared/session-title-search.test.ts`. Pure metadata/path fixtures for cross-platform leaf extraction and metadata precedence. Estimated complexity: none beyond current coverage. Depends on test 12. + +## Test plan + +1. **Name:** Replacement and visible-refresh commits keep requested state, applied state, and committed result identity distinct + **Type:** unit + **Disposition:** extend + **Harness:** Explicit window-commit reducer harness + **Preconditions:** A sidebar window with committed search results for query `alpha`, `appliedQuery/appliedSearchTier` set to that visible result set, and a known committed result token. A second state also represents an in-flight browse replacement (`query=''`, `loadingKind='search'`) while the `alpha` results are still visible. + **Actions:** Dispatch the explicit replacement-loading action with query `beta`; dispatch the replacement-commit action with `beta` data; dispatch the visible-refresh-commit action against the still-visible `alpha` context while preserving replacement loading; dispatch replacement failure/error after requested state has moved but before a new commit lands. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract and Task 1. Replacement loading updates only requested `query/searchTier`; replacement commit updates both requested and applied search state and increments the committed result token; visible-refresh commit updates projects and increments the committed result token without rewriting requested `query/searchTier` or clearing a preserved replacement load; failure preserves the last applied visible context and current committed result token. + **Interactions:** Reducer API shape, active-surface top-level sync, loading semantics, and the explicit result-set identity field that later thunk tests depend on. + +2. **Name:** Direct refresh during search-to-browse drift revalidates the visible search result set without aborting the pending browse replacement + **Type:** integration + **Disposition:** extend + **Harness:** Refresh-drift thunk harness + **Preconditions:** The store has committed `alpha` title-search results on screen, requested state has already moved to browse (`query=''`, `searchTier='title'`) because `fetchSessionWindow()` for browse was dispatched and left in flight, and the browse request's `AbortSignal` is captured. + **Actions:** Dispatch `refreshActiveSessionWindow()` while the browse replacement is still pending; resolve the refresh first; then resolve the browse replacement. + **Expected outcome:** Source of truth: user transcript plus revised implementation plan Behavior Contract and Task 2. The refresh fetches using the visible applied context (`alpha`, title tier), does not abort the pending browse request, does not rewrite requested browse state, keeps `appliedQuery/appliedSearchTier` on `alpha` after refresh commit, and still allows the original browse replacement to resolve and finally clear the applied search state. + **Interactions:** `refreshActiveSessionWindow()`, visible-context capture, surface abort-controller ownership, reducer commit boundary, and browse replacement sequencing. + +3. **Name:** Queued websocket invalidation during the same drift obeys the same no-abort, no-requested-rewrite contract + **Type:** integration + **Disposition:** extend + **Harness:** Refresh-drift thunk harness + **Preconditions:** Same as test 2, except the refresh is triggered through `queueActiveSessionWindowRefresh()` while a browse replacement is already in flight. + **Actions:** Dispatch `queueActiveSessionWindowRefresh()` during the drift; resolve the queued visible refresh; then resolve the pending browse replacement. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract and Task 2. The queued invalidation revalidates the visible applied result set instead of routing back through the replacement path, does not abort the pending browse replacement, keeps requested state cleared, keeps applied search state on the visible search results until the browse replacement commits, and coalesces through the existing invalidation runner rather than spawning a second browse replacement. + **Interactions:** Websocket invalidation path, queue state, in-flight request coordination, visible-refresh helper reuse, and reducer commit sequencing. + +4. **Name:** Stale visible refresh responses are dropped once a newer visible result set has committed + **Type:** invariant + **Disposition:** extend + **Harness:** Refresh-drift thunk harness plus explicit window-commit reducer harness + **Preconditions:** A committed visible result set with an explicit result token is on screen. A visible refresh for that result set is started and held. Before it resolves, a newer replacement or refresh commits a different visible window and increments the committed result token. + **Actions:** Start the older visible refresh; commit the newer window; then resolve the older refresh response. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract and Strategy Gate. The stale refresh is discarded because the visible identity captured at refresh start no longer matches the currently committed visible identity. The newer committed projects, applied search context, and committed result token remain unchanged. + **Interactions:** Visible-result identity capture, monotonic committed result token, stale-response suppression, and commit-authority rules across reducer and thunk seams. + +5. **Name:** Visible deep-search refreshes stay two-phase and remain keyed to the visible applied context, not requested drift + **Type:** integration + **Disposition:** extend + **Harness:** Refresh-drift thunk harness + **Preconditions:** The store is showing committed deep-search (`userMessages` or `fullText`) results for query `alpha`, and requested state can drift independently while that deep-search result set remains visible. + **Actions:** Dispatch a visible refresh against the deep-search result set; resolve Phase 1 title results, then Phase 2 deep results; repeat with requested state drifting while the original deep-search result set is still visible. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract and unchanged two-phase deep-search behavior in the current branch. The refresh uses the visible applied query and tier, preserves `deepSearchPending` semantics, does not rewrite requested state during drift, and only commits while the captured visible identity is still current. + **Interactions:** Two-phase search merge, deep-search pending indicator state, visible-refresh helper, and requested-vs-applied drift handling. + +6. **Name:** Clearing search leaves stale search rows visible, silent refresh keeps them visible, and the browse replacement commits afterward in the full app + **Type:** scenario + **Disposition:** extend + **Harness:** Full app invalidation harness + **Preconditions:** `App` is mounted with committed sidebar title-search results, search input populated from requested/applied state, and mocked browse plus refresh requests held as deferred promises. + **Actions:** Clear the search input through the real `Clear search` button or equivalent input change path; wait for the browse replacement to start; trigger a websocket `sessions.changed` invalidation or direct `refreshActiveSessionWindow()` while the browse request is still pending; resolve the silent refresh; then resolve the browse replacement. + **Expected outcome:** Source of truth: user transcript plus revised implementation plan Behavior Contract. Clearing search starts exactly one browse replacement request and leaves the old search rows visible; the refresh keeps those rows visible and does not show search-loading chrome; after the refresh resolves, the browse replacement still commits and the applied search state finally clears in the rendered sidebar. + **Interactions:** Real `Sidebar` search input and clear action, `App` websocket listener, `queueActiveSessionWindowRefresh()` and `refreshActiveSessionWindow()`, Redux state propagation, and rendered DOM assertions. + +7. **Name:** Searching by a subdirectory leaf returns indexed sessions and only matching open-tab fallback rows, without pinning tabs above newer server results + **Type:** scenario + **Disposition:** existing + **Harness:** Sidebar search-flow harness + **Preconditions:** A rendered sidebar with one indexed server session whose title does not contain `trycycle` but whose `projectPath` or distinct `cwd` leaf does; a newer non-tab server result; one open fallback tab whose local metadata leaf also matches `trycycle`; and a second query `code` that appears only in ancestor path segments. + **Actions:** Type `trycycle` into the search input and wait for debounce plus the title-tier server response; inspect ordered rows. Replace the query with `code` and wait for the next response. + **Expected outcome:** Source of truth: user transcript and revised implementation plan Behavior Contract. `trycycle` returns the indexed session and the locally provable fallback row; `code` does not match that same session or fallback row on ancestor-only path text; and during the applied search the matching fallback row is not pinned ahead of the newer non-tab server result. + **Interactions:** Search debounce, title-tier request payload, selector fallback synthesis, applied-search pinning rules, and DOM row ordering. + +8. **Name:** Applied title search only injects fallback rows that the client can prove locally, and requested-state drift does not change the visible filtered set early + **Type:** invariant + **Disposition:** existing + **Harness:** Selector harness + **Preconditions:** Selector state contains server rows, matching and non-matching fallback rows, requested state intentionally different from applied state, and an applied title-search query whose local proof succeeds for only a subset of fallbacks. + **Actions:** Run `makeSelectSortedSessionItems()` with applied title-search state while requested `query/searchTier` differ from applied fields. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract. The selector filters fallback rows based on `appliedQuery/appliedSearchTier`, not requested drift; only locally provable title-tier fallback rows remain; unrelated fallbacks and ancestor-only matches stay hidden; and visible ordering still respects no-pinning during applied search. + **Interactions:** Applied-vs-requested selector inputs, fallback proof via shared title matcher, sort comparator behavior, and synthesized fallback row construction. + +9. **Name:** Applied deep-search result sets never inject fallback tabs and search disables tab pinning while preserving archived-last ordering + **Type:** invariant + **Disposition:** existing + **Harness:** Selector harness + **Preconditions:** Selector state contains a deep-search result set, fallback tabs whose local metadata would match the query, a newer non-tab row, an archived row, and both `activity` and `recency-pinned` sort modes. + **Actions:** Run the selector for applied deep-search state, then for applied title-search state, and compare the sorted outputs across sort modes. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract. Deep-search tiers show only server-authoritative rows; title-tier applied search may include locally provable fallback rows; applied search disables `hasTab` pinning in both sort modes; and archived rows still sort last. + **Interactions:** Applied search tier gating, fallback suppression, tab-pinning comparator options, and archived grouping. + +10. **Name:** `/api/session-directory` matches leaf directory names and rejects ancestor-only path text through the real HTTP transport contract + **Type:** integration + **Disposition:** existing + **Harness:** HTTP router harness + **Preconditions:** The Express route is mounted with indexed sessions whose `projectPath` leaf is `trycycle`, whose titles omit that term, and whose ancestor path contains `code`. + **Actions:** Send `GET /api/session-directory?priority=visible&query=trycycle&tier=title`; then send the same request with `query=code`. + **Expected outcome:** Source of truth: user transcript, revised implementation plan Behavior Contract, and the `SessionDirectoryPage` schema. The `trycycle` request returns the matching item with the existing HTTP shape and `matchedIn/snippet` semantics; the `code` request returns no match when the only occurrence is an ancestor-only path segment. + **Interactions:** Router query parsing, service invocation, read-model schema stability, and title-tier metadata search over the real HTTP endpoint the sidebar consumes. + +11. **Name:** Service-level title-tier search stays provider-free, preserves metadata precedence and ordering, and keeps the existing low-risk performance guard + **Type:** integration + **Disposition:** existing + **Harness:** Service harness + **Preconditions:** `querySessionDirectory()` fixtures cover title matches, project-path leaf matches, distinct `cwd` leaf matches, summary matches, first-user-message matches, archived sessions, and a large-enough corpus for the existing generous timing guard. + **Actions:** Query the service with title-tier searches that hit each metadata source, with an ancestor-only query, and with providers omitted; run the existing large-corpus timing check. + **Expected outcome:** Source of truth: revised implementation plan Behavior Contract and current `querySessionDirectory()` transport contract. Metadata precedence remains `title`, then project-path leaf, then distinct `cwd` leaf, then `summary`, then `firstUserMessage`; title-tier search stays provider-free; ancestor-only path text does not match; canonical ordering and archived handling remain unchanged; and the existing generous timing guard still passes, catching only catastrophic regressions. + **Interactions:** Server projection ordering, snippet extraction, provider lookup bypass for title tier, and metadata-search cost. + +12. **Name:** Shared title-tier metadata matching extracts cross-platform leaf directory names and rejects ancestor-only segments + **Type:** unit + **Disposition:** existing + **Harness:** Shared matcher harness + **Preconditions:** Pure metadata fixtures cover POSIX paths, Windows paths, trailing separators, sessions with both `projectPath` and deeper `cwd`, fallback-only metadata with only `cwd`, and an ancestor-only query. + **Actions:** Call `getLeafDirectoryName()` and `matchTitleTierMetadata()` across those fixtures. + **Expected outcome:** Source of truth: user transcript and revised implementation plan Behavior Contract. Leaf extraction returns the final directory name on POSIX and Windows inputs, ignores trailing separators, prefers the indexed `projectPath` leaf over a deeper `cwd` leaf when both exist, still matches fallback-only `cwd` metadata, and returns `null` for ancestor-only segments such as `code`. + **Interactions:** Shared matcher seam used by both client fallback gating and server title-tier search. + +## Coverage summary + +- **Covered action space:** typing into the sidebar search input; changing the requested search tier; clearing the search input to start a browse replacement; dispatching `refreshActiveSessionWindow()` directly; receiving websocket `sessions.changed` invalidations that flow through `queueActiveSessionWindowRefresh()`; resolving replacement requests, visible refreshes, and stale responses in different orders; rendering search rows and silent-refresh chrome in the mounted app; computing selector-visible rows and sort order from applied search state; calling `GET /api/session-directory`; executing `querySessionDirectory()` title-tier search; and running the shared leaf-directory matcher. +- **Covered high-risk boundaries:** reducer commit semantics for requested versus applied search state, abort-controller ownership for direct replacements versus visible refreshes, invalidation queueing, stale response suppression by explicit visible-result identity, client/server agreement on leaf-directory title-tier matching, and selector fallback injection during applied search. +- **Explicitly excluded:** click-to-open session row behavior, context-menu mutation UX itself, terminal-directory busy-state rendering, and deep file-content search correctness beyond the unchanged two-phase refresh and fallback-suppression contract. Those surfaces are not being changed by this task and already have dedicated coverage elsewhere. +- **Risk carried by the exclusions:** a regression isolated to session-opening, context-menu presentation, busy indicators, or unrelated deep-search file scanning could land alongside this change without this plan catching it. This plan is intentionally concentrated on the search/filter/refresh contract the user asked to fix and the revised implementation plan now makes explicit. 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/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 239ad382..577a0756 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 && @@ -197,14 +199,23 @@ 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' + const [filter, setFilter] = useState(requestedQueryValue) + const [searchTier, setSearchTier] = useState(requestedSearchTier) + const localQuery = filter.trim() + const localMatchesRequestedSearch = filter === requestedQueryValue && searchTier === requestedSearchTier + // 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. @@ -215,8 +226,42 @@ export default function Sidebar({ }, []) useEffect(() => { - const query = filter.trim() - if (!query) { + setFilter(requestedQueryValue) + }, [requestedQueryValue]) + + useEffect(() => { + setSearchTier(requestedSearchTier) + }, [requestedSearchTier]) + + useEffect(() => { + let shouldDispatchInitialRequestedSearch = false + + if (!hasInitializedSearchEffectRef.current) { + const currentSidebarWindow = store.getState().sessions.windows?.sidebar + const currentRequestedQuery = (currentSidebarWindow?.query ?? '').trim() + const currentRequestedSearchTier = currentSidebarWindow?.searchTier ?? 'title' + const currentAppliedQuery = (currentSidebarWindow?.appliedQuery ?? '').trim() + const currentAppliedSearchTier = currentSidebarWindow?.appliedSearchTier ?? 'title' + shouldDispatchInitialRequestedSearch = localMatchesRequestedSearch + && currentRequestedQuery.length > 0 + && ( + currentRequestedQuery !== currentAppliedQuery + || currentRequestedSearchTier !== currentAppliedSearchTier + || typeof currentSidebarWindow?.lastLoadedAt !== 'number' + ) + + hasInitializedSearchEffectRef.current = true + wasSearchingRef.current = currentRequestedQuery.length > 0 || currentAppliedQuery.length > 0 + if (!shouldDispatchInitialRequestedSearch) { + return + } + } + + if (localMatchesRequestedSearch && !shouldDispatchInitialRequestedSearch) { + return + } + + if (!localQuery) { if (wasSearchingRef.current) { wasSearchingRef.current = false lastMarkedSearchQueryRef.current = null @@ -233,7 +278,7 @@ export default function Sidebar({ void dispatch(fetchSessionWindow({ surface: 'sidebar', priority: 'visible', - query, + query: localQuery, searchTier, }) as any) }, 300) // Debounce 300ms @@ -241,7 +286,13 @@ export default function Sidebar({ return () => { clearTimeout(timeoutId) } - }, [dispatch, filter, searchTier]) + }, [ + dispatch, + localMatchesRequestedSearch, + localQuery, + searchTier, + store, + ]) const localFilteredItems = useAppSelector((state) => selectSortedItems(state, terminals, '')) const computedItems = useMemo(() => localFilteredItems, [localFilteredItems]) @@ -373,30 +424,31 @@ 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 hasLoadedSidebarWindow = typeof sidebarWindow?.lastLoadedAt === 'number' const sidebarWindowHasItems = (sidebarWindow?.projects ?? []).some((project) => (project.sessions?.length ?? 0) > 0) - const activeQuery = (sidebarWindow?.query ?? filter).trim() + const visibleQuery = appliedQuery || requestedQuery + const visibleSearchTier = appliedQuery ? appliedSearchTier : requestedSearchTier const loadingKind = sidebarWindow?.loadingKind + const hasRequestedQuery = requestedQuery.length > 0 const showBlockingLoad = !!sidebarWindow?.loading && loadingKind === 'initial' && !hasLoadedSidebarWindow && !sidebarWindowHasItems - const showSearchLoading = !!sidebarWindow?.loading && loadingKind === 'search' + const showSearchLoading = !!sidebarWindow?.loading + && loadingKind === 'search' + && hasRequestedQuery const showDeepSearchPending = !!sidebarWindow?.deepSearchPending const sidebarHasMore = sidebarWindow?.hasMore ?? false 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 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({ @@ -410,7 +462,7 @@ export default function Sidebar({ }, 15_000) }, [ dispatch, - hasActiveQuery, + hasAppliedQuery, sidebarHasMore, sidebarOldestLoadedSessionId, sidebarOldestLoadedTimestamp, @@ -491,17 +543,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 (
- {filter.trim() && ( + {localQuery && (