From a1a31c1057a7a7ce7742dae443fe9091ead252a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 15:08:21 +0200 Subject: [PATCH 1/2] feat(mobile): search input also filters the Active now tray --- .../agents/session-list-body-model.test.ts | 42 ++++- .../agents/session-list-body-model.ts | 23 ++- .../agents/session-list-helpers.test.ts | 146 ++++++++++++++++-- .../components/agents/session-list-helpers.ts | 22 ++- .../components/agents/session-list-screen.tsx | 19 ++- 5 files changed, 221 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-body-model.test.ts b/apps/mobile/src/components/agents/session-list-body-model.test.ts index 58a2b7a519e..830e05c6e57 100644 --- a/apps/mobile/src/components/agents/session-list-body-model.test.ts +++ b/apps/mobile/src/components/agents/session-list-body-model.test.ts @@ -94,15 +94,51 @@ describe('selectSessionListBodyModel', () => { }); }); - it('a populated tray does not change the active-query body decision', () => { + it('a populated tray suppresses the filtered-empty body while searching', () => { expect( model({ hasHistoryContent: false, hasPinnedActive: true, hasActiveQuery: true, isSearching: true, - }).kind - ).toBe('filtered-empty'); + }) + ).toEqual({ + kind: 'render-list', + primaryAction: 'none', + showInlineError: false, + }); + }); + + it('a populated tray does not change the filter-only body decision', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: true, + hasActiveQuery: true, + isSearching: false, + }) + ).toEqual({ + kind: 'filtered-empty', + primaryAction: 'clear-filters', + showInlineError: false, + }); + }); + + it('a populated tray does not change the query-error body decision while searching', () => { + expect( + model({ + hasHistoryContent: false, + hasPinnedActive: true, + hasActiveQuery: true, + isSearching: true, + isError: true, + }) + ).toEqual({ + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'clear-search', + showInlineError: true, + }); }); }); diff --git a/apps/mobile/src/components/agents/session-list-body-model.ts b/apps/mobile/src/components/agents/session-list-body-model.ts index fef9ddd9dc5..5a9ee2e49ff 100644 --- a/apps/mobile/src/components/agents/session-list-body-model.ts +++ b/apps/mobile/src/components/agents/session-list-body-model.ts @@ -16,10 +16,12 @@ * inside the SectionList body). * - When the list body is empty (no history sections rendered): * 1. Active search/filter query → filtered-empty OR query-error - * (when in error). The query-error variant always gets a Retry - * CTA; a secondary Clear CTA is shown for any active query - * ("Clear search" or "Clear filters"), while a no-query error - * shows only Retry. + * (when in error). EXCEPTION: a text search with a populated + * tray returns render-list — tray matches are matches, so the + * body never claims "No sessions match" beneath them. The + * query-error variant always gets a Retry CTA; a secondary + * Clear CTA is shown for any active query ("Clear search" or + * "Clear filters"), while a no-query error shows only Retry. * 2. No query, error → retry-capable error empty state. * 3. No query, no error → compact "No past sessions" + New coding * task CTA. @@ -100,8 +102,10 @@ export function selectSessionListBodyModel( return { kind: 'render-list', primaryAction: 'none', showInlineError }; } - // History empty: priority is the active query branch (even when the - // tray is populated — a query should still narrow the body). + // History empty: priority is the active query branch. A text search + // with a populated tray renders the list instead (tray matches are + // matches); filter-only narrowing keeps the filtered-empty body even + // when the tray is populated (pre-existing decision). if (hasActiveQuery) { if (isError) { // Query-error empty: Retry is always available. A Clear CTA is also @@ -114,6 +118,13 @@ export function selectSessionListBodyModel( showInlineError, }; } + // Text search: matches in the tray are matches. Never claim "No sessions + // match" beneath a populated tray — the tray is part of the searched + // corpus. (Filter-only narrowing keeps the pre-existing behavior by + // design — the tray was always part of that corpus's decision.) + if (isSearching && hasPinnedActive) { + return { kind: 'render-list', primaryAction: 'none', showInlineError }; + } return { kind: 'filtered-empty', primaryAction: isSearching ? 'clear-search' : 'clear-filters', diff --git a/apps/mobile/src/components/agents/session-list-helpers.test.ts b/apps/mobile/src/components/agents/session-list-helpers.test.ts index b83c50fff2d..4167df81c99 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -32,26 +32,138 @@ describe('selectPinnedActiveSessions', () => { it('does not dedup against stored pages (returns actives regardless)', () => { // The helper intentionally has no stored-sessions parameter. The // exclusivity contract lives on the history side (Task 3); the pinned - // set is always driven solely by filters and the active input. + // set is driven by the search query, the platform/project filters, and the active input. const actives: ActiveSession[] = [makeActive({ id: 'a1' }), makeActive({ id: 'a2' })]; const result = selectPinnedActiveSessions({ activeSessions: actives, projectFilter: [], platformFilter: [], + searchQuery: '', }); expect(result).toEqual(actives); }); - it('has no search parameter (search is ignored by construction)', () => { - // Compile-time assertion: selectPinnedActiveSessions must not accept - // a `search` key. If one is added later, @ts-expect-error stops firing - // and typecheck fails. - selectPinnedActiveSessions({ - activeSessions: [], - projectFilter: [], - platformFilter: [], - // @ts-expect-error — search must not be a param of selectPinnedActiveSessions - search: 'anything', + describe('search rules', () => { + it('empty query performs no narrowing', () => { + const a = makeActive({ id: 'a', title: 'Fix login' }); + const b = makeActive({ id: 'b', title: 'Other work' }); + const result = selectPinnedActiveSessions({ + activeSessions: [a, b], + projectFilter: [], + platformFilter: [], + searchQuery: '', + }); + expect(result).toEqual([a, b]); + }); + + it('whitespace-only query performs no narrowing', () => { + const a = makeActive({ id: 'a', title: 'Fix login' }); + const b = makeActive({ id: 'b', title: 'Other work' }); + const result = selectPinnedActiveSessions({ + activeSessions: [a, b], + projectFilter: [], + platformFilter: [], + searchQuery: ' ', + }); + expect(result).toEqual([a, b]); + }); + + it('matches a title substring mid-string', () => { + const match = makeActive({ id: 'm', title: 'Fix login bug' }); + const other = makeActive({ id: 'o', title: 'Deploy release' }); + const result = selectPinnedActiveSessions({ + activeSessions: [match, other], + projectFilter: [], + platformFilter: [], + searchQuery: 'login', + }); + expect(result.map(s => s.id)).toEqual(['m']); + }); + + it('title match is case-insensitive both ways', () => { + const lower = makeActive({ id: 'lower', title: 'Fix login bug' }); + const upper = makeActive({ id: 'upper', title: 'FIX LOGIN BUG' }); + expect( + selectPinnedActiveSessions({ + activeSessions: [lower], + projectFilter: [], + platformFilter: [], + searchQuery: 'LOGIN', + }).map(s => s.id) + ).toEqual(['lower']); + expect( + selectPinnedActiveSessions({ + activeSessions: [upper], + projectFilter: [], + platformFilter: [], + searchQuery: 'fix', + }).map(s => s.id) + ).toEqual(['upper']); + }); + + it('matches a session id substring, case-insensitively', () => { + const match = makeActive({ id: 'ses-ABC123', title: 'Unrelated title' }); + const other = makeActive({ id: 'ses-xyz', title: 'Also unrelated' }); + const result = selectPinnedActiveSessions({ + activeSessions: [match, other], + projectFilter: [], + platformFilter: [], + searchQuery: 'abc123', + }); + expect(result.map(s => s.id)).toEqual(['ses-ABC123']); + }); + + it('excludes sessions matching neither title nor id', () => { + const a = makeActive({ id: 'ses-a', title: 'Fix login' }); + const b = makeActive({ id: 'ses-b', title: 'Deploy' }); + const result = selectPinnedActiveSessions({ + activeSessions: [a, b], + projectFilter: [], + platformFilter: [], + searchQuery: 'zzz', + }); + expect(result).toEqual([]); + }); + + it('applies the text query in conjunction with platform and project filters', () => { + const pass = makeActive({ + id: 'pass', + title: 'Fix login bug', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'cli', + }); + const wrongPlatform = makeActive({ + id: 'wrongPlatform', + title: 'Fix login bug', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'slack', + }); + const wrongProject = makeActive({ + id: 'wrongProject', + title: 'Fix login bug', + gitUrl: 'git@github.com:org/other.git', + createdOnPlatform: 'cli', + }); + const result = selectPinnedActiveSessions({ + activeSessions: [pass, wrongPlatform, wrongProject], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: ['cli'], + searchQuery: 'login', + }); + expect(result.map(s => s.id)).toEqual(['pass']); + }); + + it('treats LIKE metacharacters literally (parity with server position() search)', () => { + const percent = makeActive({ id: 'pct', title: '100% done' }); + const noPercent = makeActive({ id: 'nop', title: '1000 done' }); + expect( + selectPinnedActiveSessions({ + activeSessions: [percent, noPercent], + projectFilter: [], + platformFilter: [], + searchQuery: '100%', + }).map(s => s.id) + ).toEqual(['pct']); }); }); @@ -63,6 +175,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [withUrl, withoutUrl], projectFilter: ['git@github.com:org/repo.git'], platformFilter: [], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['with']); }); @@ -74,6 +187,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [match, other], projectFilter: ['git@github.com:org/repo.git'], platformFilter: [], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['match']); }); @@ -86,6 +200,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [a, b, c], projectFilter: [], platformFilter: [], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['a', 'b', 'c']); }); @@ -99,6 +214,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [cli, slack], projectFilter: [], platformFilter: ['cli'], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['cli']); }); @@ -110,6 +226,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [web, ca], projectFilter: [], platformFilter: ['cloud-agent'], + searchQuery: '', }); expect(result.map(s => s.id).toSorted()).toEqual(['ca', 'web']); }); @@ -122,6 +239,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [vscode, am, slack], projectFilter: [], platformFilter: ['extension'], + searchQuery: '', }); expect(result.map(s => s.id).toSorted()).toEqual(['am', 'vscode']); }); @@ -132,6 +250,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [weird], projectFilter: [], platformFilter: ['other'], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['weird']); }); @@ -142,6 +261,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [cli], projectFilter: [], platformFilter: ['other'], + searchQuery: '', }); expect(result).toEqual([]); }); @@ -154,6 +274,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [cli, weird, vscode], projectFilter: [], platformFilter: ['cli', 'other'], + searchQuery: '', }); expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'weird']); }); @@ -165,6 +286,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [unknown, cli], projectFilter: [], platformFilter: ['cli'], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['cli']); }); @@ -176,6 +298,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [unknown, cli], projectFilter: [], platformFilter: [], + searchQuery: '', }); expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'unknown']); }); @@ -201,6 +324,7 @@ describe('selectPinnedActiveSessions', () => { activeSessions: [pass, wrongProject, wrongPlatform], projectFilter: ['git@github.com:org/repo.git'], platformFilter: ['cli'], + searchQuery: '', }); expect(result.map(s => s.id)).toEqual(['pass']); }); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 1103f3e3db7..1c470a1260c 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -205,10 +205,12 @@ const KNOWN_PLATFORM_VALUES: readonly string[] = KNOWN_PLATFORMS; /** * Select which active sessions appear in the pinned "Active now" tray. * - * Free-text search is not a parameter: the pinned set ignores search by - * construction. Filters mirror the server-side platform/project narrowing - * used by the stored-session list so the tray never shows a session that - * the user has explicitly filtered out. + * `searchQuery` narrows the tray with the same semantics as the + * server-side history search: a case-insensitive substring match on the + * session's `title` OR `id` (`position()`/`includes` — LIKE wildcards + * match literally). Empty or whitespace-only queries perform no narrowing. + * The text query applies in conjunction with the platform/project filters, + * which mirror the server-side narrowing used by the stored-session list. * * No dedup against stored pages is performed here — exclusivity is enforced * on the history side by Task 3, so this helper stays pure and symmetric. @@ -217,15 +219,25 @@ export function selectPinnedActiveSessions(params: { activeSessions: ActiveSession[]; projectFilter: string[]; platformFilter: string[]; + searchQuery: string; }): ActiveSession[] { - const { activeSessions, projectFilter, platformFilter } = params; + const { activeSessions, projectFilter, platformFilter, searchQuery } = params; const projectActive = projectFilter.length > 0; const platformActive = platformFilter.length > 0; + const needle = searchQuery.trim().toLowerCase(); const concretePlatforms = platformFilter.filter(p => p !== 'other'); const includeOther = platformFilter.includes('other'); const expanded = expandPlatformFilter(concretePlatforms); return activeSessions.filter(session => { + if (needle.length > 0) { + const hayTitle = session.title.toLowerCase(); + const hayId = session.id.toLowerCase(); + if (!hayTitle.includes(needle) && !hayId.includes(needle)) { + return false; + } + } + if (projectActive && (!session.gitUrl || !projectFilter.includes(session.gitUrl))) { return false; } diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 08dd97f8374..51226882dfa 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -176,13 +176,20 @@ export function AgentSessionListScreen() { // `isSearchPending` drives a lightweight inline indicator instead. const effectiveSearchQuery = isSearchPending ? '' : searchQuery; - // Pinned "Active now" tray. Free-text search is intentionally NOT a - // narrowing input here — the tray persists while the user types. The - // helper applies only the platform/project filters so the tray never - // shows a session the user has explicitly filtered out. + // Pinned "Active now" tray. The committed effective search query narrows + // the tray together with the history data source (blank while the first + // fetch for the text is pending), in conjunction with the platform/project + // filters, so the tray never shows a session the user has searched or + // filtered away. const pinnedActive = useMemo( - () => selectPinnedActiveSessions({ activeSessions, projectFilter, platformFilter }), - [activeSessions, platformFilter, projectFilter] + () => + selectPinnedActiveSessions({ + activeSessions, + projectFilter, + platformFilter, + searchQuery: effectiveSearchQuery, + }), + [activeSessions, effectiveSearchQuery, platformFilter, projectFilter] ); const hasPinnedActive = pinnedActive.length > 0; From ba8330b848c2cc590612f17345796b36cee89282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 18:20:59 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(workflow):=20learning=20=E2=80=94=20si?= =?UTF-8?q?mulator=20claim=20killed=20mid-boot=20leaves=20device=20Shutdow?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile-simulator-claim-killed-mid-boot.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .kilo_workflow/learnings/mobile-simulator-claim-killed-mid-boot.md diff --git a/.kilo_workflow/learnings/mobile-simulator-claim-killed-mid-boot.md b/.kilo_workflow/learnings/mobile-simulator-claim-killed-mid-boot.md new file mode 100644 index 00000000000..c0b685616c3 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-simulator-claim-killed-mid-boot.md @@ -0,0 +1,29 @@ +# mobile: a simulator claim killed mid-boot reclaims as alreadyOwned but leaves the device Shutdown (build then fails code=405) + +Symptom: `pnpm dev:mobile:simulator claim` is interrupted (e.g. the invoking shell's own +command timeout kills it) after it wrote the claim lock but before the boot finished. +(A fresh claim boots before it renames, so the `Kilo E2E - ` label may or may +not have been applied yet at the moment of the kill.) A later +`pnpm dev:mobile:simulator claim` returns instantly with `alreadyOwned: true` and does +**not** boot the device. `pnpm dev:mobile:ios build ` then fails: + +``` +An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405): +Unable to lookup in current state: Shutdown +``` + +Cause: the reclaim path (`alreadyOwned`) returns before the boot step entirely; only a +fresh claim boots. The device is left Shutdown with the claim lock in place (the reclaim +reapplies the label if the killed attempt never got to it). + +Fix (no re-claim needed, claim is idempotent): + +```bash +xcrun simctl boot +xcrun simctl bootstatus -b # wait for readiness +pnpm dev:mobile:ios build # now installs +``` + +Prevention: run `pnpm dev:mobile:simulator claim` with a generous timeout (>= 10 min) — +booting an iPhone under parallel-workflow load exceeds 3 minutes on this machine, and the +default 2–3 minute shell timeouts kill the claim at exactly the wrong moment.