From 9aba9a5584f76b7afe240b4b43949c618067d152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:09:45 +0200 Subject: [PATCH 1/7] fix(web): keep DB session titles across active-session WS updates Preserve cached titles on heartbeat and sessions.list so cloud renames do not oscillate back to the CLI title after list enrichment. --- .../hooks/useActiveSessions.test.ts | 40 +++++++++++++++++++ .../hooks/useActiveSessions.ts | 23 ++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts index 3a733620be..e66c74a382 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts @@ -64,6 +64,46 @@ describe('useActiveSessions live payload helpers', () => { ]); }); + it('keeps the cached title for a known id and takes every other field from the heartbeat', () => { + const sessions = applyActiveSessionsHeartbeat( + [ + { id: 'root-1', status: 'idle', title: 'DB title', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + ], + { + connectionId: 'conn-1', + sessions: [ + { + id: 'root-1', + status: 'busy', + title: 'Stale CLI title', + connectionId: 'conn-1', + }, + ], + } + ); + + expect(sessions).toEqual([ + { id: 'root-1', status: 'busy', title: 'DB title', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + ]); + }); + + it('uses the heartbeat title for a brand-new session id', () => { + const sessions = applyActiveSessionsHeartbeat( + [{ id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }], + { + connectionId: 'conn-1', + sessions: [{ id: 'root-3', status: 'busy', title: 'CLI title', connectionId: 'conn-1' }], + } + ); + + expect(sessions).toEqual([ + { id: 'root-3', status: 'busy', title: 'CLI title', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + ]); + }); + it('removes all cached rows for a disconnected connection', () => { const sessions = removeActiveSessionsForConnection( [ diff --git a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts index 5e237835b9..16cea04c40 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts @@ -51,12 +51,29 @@ function getCliConnectionPayload(value: unknown): CliConnectionPayload | null { return parsed.data; } +/** + * The title is DB-authoritative (`activeSessions.list` enriches it from + * cli_sessions_v2); WS rows carry the CLI's own title, which stays stale + * forever after a cloud rename. Keep what the last fetch put in the cache for + * ids we already know; a brand-new id still shows its CLI title immediately. + */ +function preserveCachedTitles( + currentSessions: ActiveSession[], + incoming: ActiveSession[] +): ActiveSession[] { + const titleById = new Map(currentSessions.map(session => [session.id, session.title])); + return incoming.map(session => ({ + ...session, + title: titleById.get(session.id) ?? session.title, + })); +} + export function applyActiveSessionsHeartbeat( currentSessions: ActiveSession[], payload: RootHeartbeatPayload ): ActiveSession[] { return [ - ...payload.sessions, + ...preserveCachedTitles(currentSessions, payload.sessions), ...currentSessions.filter(session => session.connectionId !== payload.connectionId), ]; } @@ -115,7 +132,9 @@ export function useActiveSessions(): { return sharedConnection.onSystemEvent(event => { if (event.event === 'sessions.list') { const sessions = getRootSessionsFromListPayload(event.data); - if (sessions) updateCachedSessions(() => sessions); + if (sessions) { + updateCachedSessions(current => preserveCachedTitles(current, sessions)); + } } if (event.event === 'sessions.heartbeat') { const payload = getRootSessionsFromHeartbeatPayload(event.data); From df57e502cb1c8999f59a0f5bbeba1c3523125d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:13:54 +0200 Subject: [PATCH 2/7] feat(web): filter activeSessions.list by org context and enrich title Emit organizationId on every row (null for unattributable), authorize org access first, and fail filtered callers when the enrichment DB fails. Unfiltered callers keep the unenriched passthrough. --- .../active-sessions-router.list.test.ts | 397 +++++++++++++++++- .../web/src/routers/active-sessions-router.ts | 95 ++++- 2 files changed, 471 insertions(+), 21 deletions(-) diff --git a/apps/web/src/routers/active-sessions-router.list.test.ts b/apps/web/src/routers/active-sessions-router.list.test.ts index f98b8587d9..472ce39324 100644 --- a/apps/web/src/routers/active-sessions-router.list.test.ts +++ b/apps/web/src/routers/active-sessions-router.list.test.ts @@ -1,8 +1,9 @@ import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { db } from '@/lib/drizzle'; import { cli_sessions_v2 } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import type { User } from '@kilocode/db/schema'; jest.mock('@/lib/config.server', () => { @@ -14,13 +15,17 @@ jest.mock('@/lib/config.server', () => { }); let regularUser: User; +let otherUser: User; function mockWorkerSessions(sessions: Array>): jest.SpyInstance { - return jest.spyOn(global, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ sessions }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + // Fresh Response per call — a single Response body can only be read once. + return jest.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ sessions }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) ); } @@ -40,6 +45,11 @@ describe('active-sessions-router.list', () => { google_user_name: 'Active Sessions Router User', is_admin: false, }); + otherUser = await insertTestUser({ + google_user_email: 'active-sessions-router-other@example.com', + google_user_name: 'Active Sessions Router Other', + is_admin: false, + }); }); let fetchSpy: jest.SpyInstance; @@ -86,6 +96,7 @@ describe('active-sessions-router.list', () => { createdOnPlatform: 'cli', createdAt, updatedAt, + organizationId: null, }, ]); // Assert the explicit camelCase keys exist (not snake_case). @@ -211,6 +222,7 @@ describe('active-sessions-router.list', () => { createdOnPlatform: undefined, createdAt: undefined, updatedAt: undefined, + organizationId: null, }, ]); }); @@ -278,4 +290,377 @@ describe('active-sessions-router.list', () => { expect(result).toEqual({ sessions: [] }); }); + + describe('organization context filter and title enrichment', () => { + const personalId = 'ses_active_ctx_personal_1234'; + const orgId = 'ses_active_ctx_org_1234'; + const noRowId = 'ses_active_ctx_norow_1234'; + const titledId = 'ses_active_ctx_titled_1234'; + const nullTitleId = 'ses_active_ctx_nulltitle_1234'; + + afterEach(async () => { + await db + .delete(cli_sessions_v2) + .where( + inArray(cli_sessions_v2.session_id, [personalId, orgId, noRowId, titledId, nullTitleId]) + ); + }); + + it('list({ organizationId: null }) excludes org sessions and includes personal ones', async () => { + const organization = await createTestOrganization( + 'Active Sessions Filter Org', + regularUser.id, + 0 + ); + + await db.insert(cli_sessions_v2).values([ + { + session_id: personalId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: null, + }, + { + session_id: orgId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: organization.id, + }, + ]); + + fetchSpy = mockWorkerSessions([ + { + id: personalId, + status: 'busy', + title: 'personal', + connectionId: 'conn-p', + }, + { + id: orgId, + status: 'busy', + title: 'org', + connectionId: 'conn-o', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list({ organizationId: null }); + + expect(result.sessions.map(s => s.id)).toEqual([personalId]); + expect(result.sessions[0]).toHaveProperty('organizationId', null); + }); + + it('list({ organizationId: null }) includes a live session with no cli_sessions_v2 row', async () => { + fetchSpy = mockWorkerSessions([ + { + id: noRowId, + status: 'busy', + title: 'unattributable', + connectionId: 'conn-nr', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list({ organizationId: null }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.id).toBe(noRowId); + expect(result.sessions[0]).toHaveProperty('organizationId', null); + }); + + it('list({ organizationId: }) includes only that org and excludes personal and no-row', async () => { + const organization = await createTestOrganization( + 'Active Sessions Org-Only Filter Org', + regularUser.id, + 0 + ); + + await db.insert(cli_sessions_v2).values([ + { + session_id: personalId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: null, + }, + { + session_id: orgId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: organization.id, + }, + ]); + + fetchSpy = mockWorkerSessions([ + { + id: personalId, + status: 'busy', + title: 'personal', + connectionId: 'conn-p', + }, + { + id: orgId, + status: 'busy', + title: 'org', + connectionId: 'conn-o', + }, + { + id: noRowId, + status: 'busy', + title: 'unattributable', + connectionId: 'conn-nr', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list({ + organizationId: organization.id, + }); + + expect(result.sessions.map(s => s.id)).toEqual([orgId]); + expect(result.sessions[0]).toHaveProperty('organizationId', organization.id); + }); + + it('list() with no input returns personal + org + no-row sessions', async () => { + const organization = await createTestOrganization( + 'Active Sessions Unfiltered Org', + regularUser.id, + 0 + ); + + await db.insert(cli_sessions_v2).values([ + { + session_id: personalId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: null, + }, + { + session_id: orgId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: organization.id, + }, + ]); + + fetchSpy = mockWorkerSessions([ + { + id: personalId, + status: 'busy', + title: 'personal', + connectionId: 'conn-p', + }, + { + id: orgId, + status: 'busy', + title: 'org', + connectionId: 'conn-o', + }, + { + id: noRowId, + status: 'busy', + title: 'unattributable', + connectionId: 'conn-nr', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions.map(s => s.id).sort()).toEqual([personalId, orgId, noRowId].sort()); + }); + + it('rejects list for an organization the user is not a member of', async () => { + const foreignOrg = await createTestOrganization( + 'Active Sessions Foreign Org', + otherUser.id, + 0 + ); + + fetchSpy = mockWorkerSessions([ + { + id: noRowId, + status: 'busy', + title: 'should not matter', + connectionId: 'conn-x', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + // ensureOrganizationAccess throws UNAUTHORIZED for non-members. + await expect( + caller.activeSessions.list({ organizationId: foreignOrg.id }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('emits organizationId on every returned row including null for no-row sessions', async () => { + const organization = await createTestOrganization( + 'Active Sessions OrgId Emit Org', + regularUser.id, + 0 + ); + + await db.insert(cli_sessions_v2).values([ + { + session_id: personalId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: null, + }, + { + session_id: orgId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + organization_id: organization.id, + }, + ]); + + fetchSpy = mockWorkerSessions([ + { + id: personalId, + status: 'busy', + title: 'personal', + connectionId: 'conn-p', + }, + { + id: orgId, + status: 'busy', + title: 'org', + connectionId: 'conn-o', + }, + { + id: noRowId, + status: 'busy', + title: 'unattributable', + connectionId: 'conn-nr', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + const byId = new Map(result.sessions.map(s => [s.id, s])); + expect(byId.get(personalId)).toHaveProperty('organizationId', null); + expect(byId.get(orgId)).toHaveProperty('organizationId', organization.id); + // toHaveProperty with null fails on undefined — the client filter depends on this. + expect(byId.get(noRowId)).toHaveProperty('organizationId', null); + }); + + it('uses cli_sessions_v2 title when set and falls back to the worker title when NULL', async () => { + await db.insert(cli_sessions_v2).values([ + { + session_id: titledId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + title: 'db renamed title', + }, + { + session_id: nullTitleId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + title: null, + }, + ]); + + fetchSpy = mockWorkerSessions([ + { + id: titledId, + status: 'busy', + title: 'cli stale title', + connectionId: 'conn-t', + }, + { + id: nullTitleId, + status: 'busy', + title: 'cli live title', + connectionId: 'conn-nt', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + const byId = new Map(result.sessions.map(s => [s.id, s])); + expect(byId.get(titledId)?.title).toBe('db renamed title'); + expect(byId.get(nullTitleId)?.title).toBe('cli live title'); + }); + + it('on enrichment DB failure: unfiltered degrades, filtered contexts reject', async () => { + const organization = await createTestOrganization( + 'Active Sessions Enrich Fail Org', + regularUser.id, + 0 + ); + + fetchSpy = mockWorkerSessions([ + { + id: personalId, + status: 'running', + title: 'db fail filtered', + connectionId: 'conn-fail', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + + // Capture the real select before any spy replaces it. Filtered org + // calls run ensureOrganizationAccess first (two selects); only the + // enrichment select must throw. + const originalSelect = db.select.bind(db); + + // Unfiltered: best-effort unenriched passthrough (same contract as the + // existing enrichment-failure test). No auth selects → first select is + // enrichment. + const unfilteredSelectSpy = jest.spyOn(db, 'select').mockImplementationOnce(() => { + throw new Error('synthetic enrichment db failure unfiltered'); + }); + try { + const unfiltered = await caller.activeSessions.list(); + expect(unfiltered.sessions).toEqual([ + { + id: personalId, + status: 'running', + title: 'db fail filtered', + connectionId: 'conn-fail', + createdOnPlatform: undefined, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + } finally { + unfilteredSelectSpy.mockRestore(); + } + + // Personal filter: no auth selects → first select is enrichment → reject. + const personalSelectSpy = jest.spyOn(db, 'select').mockImplementationOnce(() => { + throw new Error('synthetic enrichment db failure personal'); + }); + try { + await expect(caller.activeSessions.list({ organizationId: null })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to resolve the organization context for active sessions', + }); + } finally { + personalSelectSpy.mockRestore(); + } + + // Org filter: ensureOrganizationAccess issues two selects, then + // enrichment is the third. Pass the auth selects through. + const orgSelectSpy = jest.spyOn(db, 'select').mockImplementation((...args: never[]) => { + if (orgSelectSpy.mock.calls.length <= 2) { + return (originalSelect as (...a: never[]) => unknown)(...args); + } + throw new Error('synthetic enrichment db failure org'); + }); + try { + await expect( + caller.activeSessions.list({ organizationId: organization.id }) + ).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to resolve the organization context for active sessions', + }); + } finally { + orgSelectSpy.mockRestore(); + } + }); + }); }); diff --git a/apps/web/src/routers/active-sessions-router.ts b/apps/web/src/routers/active-sessions-router.ts index 6003049cc9..134f35b996 100644 --- a/apps/web/src/routers/active-sessions-router.ts +++ b/apps/web/src/routers/active-sessions-router.ts @@ -7,6 +7,7 @@ import { generateInternalServiceToken } from '@/lib/tokens'; import { db } from '@/lib/drizzle'; import { cli_sessions_v2 } from '@kilocode/db/schema'; import { and, eq, inArray } from 'drizzle-orm'; +import { ensureOrganizationAccess } from '@/routers/organizations/utils'; export const activeSessionSchema = z.object({ id: z.string(), @@ -50,9 +51,39 @@ const connectedInstancesResponseSchema = z.object({ instances: z.array(connectedInstanceSchema), }); -export type ActiveSession = z.infer; +/** + * A live session as this router returns it: the worker's wire row plus the + * fields enriched from `cli_sessions_v2`. + */ +export type ActiveSession = z.infer & { + /** + * Owning organization from `cli_sessions_v2`; `null` = personal, which + * also covers a live session with no `cli_sessions_v2` row (an + * unattributable session — the server attributes it to personal). + * + * This router sets the field on EVERY row it returns, so an absent value + * on a client-cached row means exactly one thing: that row entered the + * cache from a WS payload and has never been server-attributed. The + * client filter relies on that (see the mobile + * `filterActiveSessionsByOrganization`). The field stays optional in the + * type only because those WS-inserted cached rows share it. + */ + organizationId?: string | null; +}; export type ConnectedInstance = z.infer; +const listInputSchema = z + .object({ + /** + * Personal/organization context. `undefined` = no context filter (the + * liveness-resolution callers), `null` = personal only, a uuid = that + * organization. Mirrors `addOrganizationCondition` in + * `cli-sessions-v2-router.ts`. + */ + organizationId: z.uuid().nullable().optional(), + }) + .optional(); + /** * Overlay stored attention (question/permission) onto a live heartbeat * status. Non-attention DB values yield to live so busy/idle remain @@ -78,7 +109,12 @@ export const activeSessionsRouter = createTRPCRouter({ return { token }; }), - list: baseProcedure.query(async ({ ctx }) => { + list: baseProcedure.input(listInputSchema).query(async ({ ctx, input }) => { + const organizationId = input?.organizationId; + if (typeof organizationId === 'string') { + await ensureOrganizationAccess(ctx, organizationId); + } + if (!SESSION_INGEST_WORKER_URL) { return { sessions: [] as ActiveSession[] }; } @@ -125,6 +161,8 @@ export const activeSessionsRouter = createTRPCRouter({ created_at: string; updated_at: string; status: string | null; + title: string | null; + organization_id: string | null; }> = []; try { rows = await db @@ -134,6 +172,8 @@ export const activeSessionsRouter = createTRPCRouter({ created_at: cli_sessions_v2.created_at, updated_at: cli_sessions_v2.updated_at, status: cli_sessions_v2.status, + title: cli_sessions_v2.title, + organization_id: cli_sessions_v2.organization_id, }) .from(cli_sessions_v2) .where( @@ -144,30 +184,55 @@ export const activeSessionsRouter = createTRPCRouter({ ); } catch (error) { console.warn('[active-sessions] enrichment db query failed:', error); - return parsed; + // Attribution is unknowable without the join. An unfiltered caller (web, + // `resolveSession`) keeps the existing best-effort unenriched passthrough — + // a DB blip must not collapse its list. A filtered caller cannot be + // answered at all: calling every row personal would lie (breaking AC 1) + // and returning an empty list would silently blank the tray with no + // explanation. So fail the query and let the client's already-shipped + // retryable state handle it (D11). + if (organizationId === undefined) { + return parsed; + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to resolve the organization context for active sessions', + cause: error, + }); } const byId = new Map(rows.map(r => [r.session_id, r])); - const sessions: ActiveSession[] = parsed.sessions.map(session => { + const sessions: ActiveSession[] = []; + for (const session of parsed.sessions) { const row = byId.get(session.id); + // No `cli_sessions_v2` row → unattributable → personal. An SQL-side filter + // could not tell this case apart from "belongs to another organization". + const rowOrganizationId = row?.organization_id ?? null; + if (organizationId !== undefined && rowOrganizationId !== organizationId) { + continue; + } if (!row) { - return session; + // Always emit the field, `null` included: an absent `organizationId` on + // a client-cached row must mean "never server-attributed" and nothing + // else, or the client filter cannot tell a heartbeat-inserted row apart + // from a server-attributed personal one (D4/D6). + sessions.push({ ...session, organizationId: null }); + continue; } - // Explicit snake_case → camelCase mapping: the mobile client only - // reads createdOnPlatform/createdAt/updatedAt, so we do not spread - // the DB row (which carries snake_case keys it never uses). - // Status: prefer DB attention (question/permission) over the live - // heartbeat so a released CLI that only reports idle/busy still - // surfaces NEEDS INPUT after tRPC seed / enrichment / reconnect. - return { + sessions.push({ ...session, status: resolveActiveSessionStatus(session.status, row.status), + // The tray title must be what a rename wrote, not what the CLI still + // reports: nothing propagates a cloud rename back to the CLI, so the + // heartbeat title stays stale forever. A NULL title (never-ingested + // placeholder row) falls back to the live one. + title: row.title ?? session.title, + organizationId: row.organization_id, createdOnPlatform: row.created_on_platform ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, - }; - }); - + }); + } return { sessions }; }), From 35d301dbc77e527916bfe6295de8221d25248d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:14:08 +0200 Subject: [PATCH 3/7] fix(mobile): stop showing CLI status in Active now timestamp slot Return undefined from remoteMeta when updatedAt is missing so the row shows the live dot alone; align spoken accessibility meta the same way. --- .../components/agents/remote-session-row.tsx | 15 +++-------- .../agents/session-list-helpers.test.ts | 27 +++++++++++++------ .../components/agents/session-list-helpers.ts | 11 ++++---- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/components/agents/remote-session-row.tsx b/apps/mobile/src/components/agents/remote-session-row.tsx index d534c4381d..f14f4bb4a1 100644 --- a/apps/mobile/src/components/agents/remote-session-row.tsx +++ b/apps/mobile/src/components/agents/remote-session-row.tsx @@ -53,17 +53,10 @@ export function RemoteSessionRow({ // Spoken meta mirrors the visible meta the row renders. When `needsInput` // wins, the right eyebrow shows `NEEDS INPUT` and meta is NOT rendered, - // so the label omits it. The remote row's `live` eyebrow (`live-and-meta`) - // renders the timestamp when `updatedAt` is present, otherwise the - // uppercased status. For speech we expand the timestamp via - // `formatSpokenTimeAgo` and lowercase/underscore-strip the status so - // VoiceOver doesn't read it letter-by-letter. - let spokenMeta: string | null = null; - if (!needsInput) { - spokenMeta = session.updatedAt - ? formatSpokenTimeAgo(session.updatedAt) - : session.status.toLowerCase().replaceAll('_', ' '); - } + // so the label omits it. Otherwise announce the relative timestamp only + // when `updatedAt` is present — never the CLI status (same as `remoteMeta`). + const spokenMeta = + !needsInput && session.updatedAt ? formatSpokenTimeAgo(session.updatedAt) : null; const { iconKind: platformIconKind, spokenPlatform } = selectRowPlatformPresentation({ platform: session.createdOnPlatform, 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 b83c50fff2..a77ac3f5d3 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -229,15 +229,26 @@ describe('remoteAgentLabel', () => { }); describe('remoteMeta', () => { - it('returns the uppercased relative time when updatedAt is present', () => { + it('returns the same relative-time string as formatMeta when updatedAt is present', () => { const updatedAt = '2024-01-01T00:00:00.000Z'; - const expected = timeAgo(parseTimestamp(updatedAt)).toUpperCase(); - expect(remoteMeta({ status: 'running', updatedAt })).toBe(expected); - }); - - it('returns the uppercased status when updatedAt is absent', () => { - expect(remoteMeta({ status: 'running' })).toBe('RUNNING'); - expect(remoteMeta({ status: 'needs_input' })).toBe('NEEDS_INPUT'); + expect(remoteMeta({ updatedAt })).toBe(formatMeta(updatedAt)); + expect(remoteMeta({ updatedAt })).toBe(timeAgo(parseTimestamp(updatedAt)).toUpperCase()); + }); + + it('returns undefined when updatedAt is absent (never idle/busy/retry status words)', () => { + // Former fallback uppercased session.status into the timestamp slot + // (BUSY/IDLE/RETRY). Status is no longer a parameter; assert undefined + // for each status that used to leak, plus a row with no status field. + // Extra `status` fields stay on the object so a regression that re-reads + // `.status` would still see them — remoteMeta must ignore them. + const idleRow: { updatedAt?: string; status?: string } = { status: 'idle' }; + const busyRow: { updatedAt?: string; status?: string } = { status: 'busy' }; + const retryRow: { updatedAt?: string; status?: string } = { status: 'retry' }; + const noStatusRow: { updatedAt?: string } = {}; + expect(remoteMeta(idleRow)).toBeUndefined(); + expect(remoteMeta(busyRow)).toBeUndefined(); + expect(remoteMeta(retryRow)).toBeUndefined(); + expect(remoteMeta(noStatusRow)).toBeUndefined(); }); }); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 1103f3e3db..3ea815ea7b 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -150,12 +150,13 @@ export function remoteAgentLabel(createdOnPlatform: string | undefined): string } /** - * Pinned-tray meta line for an active session. Mirrors `formatMeta` when an - * `updatedAt` timestamp is available, otherwise falls back to the uppercased - * status string (matches the legacy RemoteSessionRow behavior). + * Pinned-tray meta line for an active session. Returns the relative timestamp + * when `updatedAt` is present; otherwise `undefined` so `SessionRow` renders + * the live dot alone. Never the CLI status — status words in the timestamp + * slot were a defect (BUSY/IDLE/RETRY). */ -export function remoteMeta(session: { status: string; updatedAt?: string }): string { - return session.updatedAt ? formatMeta(session.updatedAt) : session.status.toUpperCase(); +export function remoteMeta(session: { updatedAt?: string }): string | undefined { + return session.updatedAt ? formatMeta(session.updatedAt) : undefined; } /** From b65561a9f18872bf89a5784272acebec0b0e6806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:17:55 +0200 Subject: [PATCH 4/7] feat(mobile): sticky org id and DB title in active-sessions cache merges Carry organizationId through WS merges, keep enriched titles sticky, and add pure filter/rename helpers for the context-filtered tray. --- .../lib/active-sessions-live.context.test.ts | 194 ++++++++++++++++++ .../lib/active-sessions-live.merge.test.ts | 43 +++- .../lib/active-sessions-live.title.test.ts | 149 ++++++++++++++ apps/mobile/src/lib/active-sessions-live.ts | 86 ++++++-- 4 files changed, 444 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/lib/active-sessions-live.context.test.ts create mode 100644 apps/mobile/src/lib/active-sessions-live.title.test.ts diff --git a/apps/mobile/src/lib/active-sessions-live.context.test.ts b/apps/mobile/src/lib/active-sessions-live.context.test.ts new file mode 100644 index 0000000000..da869b6a66 --- /dev/null +++ b/apps/mobile/src/lib/active-sessions-live.context.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; + +import { + type CachedActiveSession, + filterActiveSessionsByOrganization, + mergeHeartbeatForActiveSessions, + mergeSnapshotForActiveSessions, +} from '@/lib/active-sessions-live'; + +function makeCached(over: Partial = {}): CachedActiveSession { + return { + id: 'a1', + status: 'running', + title: 'test', + connectionId: 'c1', + ...over, + }; +} + +// ── organizationId sticky through WS merges ────────────────────────── + +describe('organizationId preservation across merges', () => { + it('preserves a known uuid organizationId across a heartbeat', () => { + const current = [ + makeCached({ + id: 'a', + organizationId: 'org-1', + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + const payload = { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'busy', title: 'wire' }], + }; + const result = mergeHeartbeatForActiveSessions(current, payload); + expect(result[0]?.organizationId).toBe('org-1'); + }); + + it('preserves a known uuid organizationId across a snapshot', () => { + const current = [ + makeCached({ + id: 'a', + organizationId: 'org-1', + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + const snapshot = [{ id: 'a', status: 'busy', title: 'wire', connectionId: 'c1' }]; + const result = mergeSnapshotForActiveSessions(current, snapshot); + expect(result[0]?.organizationId).toBe('org-1'); + }); + + it('leaves organizationId undefined for an id that first appears in the payload', () => { + const heartbeat = mergeHeartbeatForActiveSessions([], { + connectionId: 'c1', + sessions: [{ id: 'new', status: 'running', title: 'New' }], + }); + expect(heartbeat[0]?.organizationId).toBeUndefined(); + expect('organizationId' in (heartbeat[0] ?? {})).toBe(true); + + const snapshot = mergeSnapshotForActiveSessions( + [], + [{ id: 'new', status: 'running', title: 'New', connectionId: 'c1' }] + ); + expect(snapshot[0]?.organizationId).toBeUndefined(); + }); + + it('preserves organizationId: null as null (not collapsed to undefined)', () => { + const current = [ + makeCached({ + id: 'a', + organizationId: null, + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + const heartbeat = mergeHeartbeatForActiveSessions(current, { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'running', title: 'wire' }], + }); + expect(heartbeat[0]?.organizationId).toBeNull(); + // Explicit backstop: null must not become undefined (that would hide + // every personal row from the personal tray under the D6 filter). + expect(heartbeat[0]?.organizationId).not.toBeUndefined(); + + const snapshot = mergeSnapshotForActiveSessions(current, [ + { id: 'a', status: 'running', title: 'wire', connectionId: 'c1' }, + ]); + expect(snapshot[0]?.organizationId).toBeNull(); + expect(snapshot[0]?.organizationId).not.toBeUndefined(); + }); + + it('still preserves the three enrichment fields and capabilities as before', () => { + const current = [ + makeCached({ + id: 'a', + organizationId: null, + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + capabilities: { attachments: true }, + }), + ]; + const payload = { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'busy', title: 'wire' }], + }; + const result = mergeHeartbeatForActiveSessions(current, payload); + expect(result[0]?.createdOnPlatform).toBe('cli'); + expect(result[0]?.createdAt).toBe('2024-01-01T00:00:00Z'); + expect(result[0]?.updatedAt).toBe('2024-01-02T00:00:00Z'); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + expect(result[0]?.organizationId).toBeNull(); + }); +}); + +// ── filterActiveSessionsByOrganization ─────────────────────────────── + +describe('filterActiveSessionsByOrganization', () => { + type Row = { id: string; organizationId?: string | null }; + const personal: Row = { id: 'p', organizationId: null }; + const org1: Row = { id: 'o1', organizationId: 'org-1' }; + const org2: Row = { id: 'o2', organizationId: 'org-2' }; + // Field omitted — never server-attributed (WS-inserted). + const unattributed: Row = { id: 'u' }; + const all: Row[] = [personal, org1, org2, unattributed]; + + it('undefined context returns a copy of everything', () => { + const result = filterActiveSessionsByOrganization(all, undefined); + expect(result.map(r => r.id)).toEqual(['p', 'o1', 'o2', 'u']); + expect(result).not.toBe(all); + }); + + it('null context keeps personal, drops org rows and unattributed rows', () => { + const result = filterActiveSessionsByOrganization(all, null); + expect(result.map(r => r.id)).toEqual(['p']); + }); + + it('uuid context keeps only that org, drops personal and unattributed', () => { + const result = filterActiveSessionsByOrganization(all, 'org-1'); + expect(result.map(r => r.id)).toEqual(['o1']); + }); + + it('drops an absent-field row in both null and uuid contexts', () => { + const onlyUnattributed: Row[] = [unattributed]; + expect(filterActiveSessionsByOrganization(onlyUnattributed, null)).toEqual([]); + expect(filterActiveSessionsByOrganization(onlyUnattributed, 'org-1')).toEqual([]); + }); +}); + +// ── D6 two-heartbeat re-admission guard ────────────────────────────── + +describe('D6 heartbeat re-admission guard', () => { + it('does not re-admit an out-of-context heartbeat row into a filtered tray', () => { + // Cache starts with one server-attributed personal row. + const cache = [ + makeCached({ + id: 'personal', + organizationId: null, + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + + // Heartbeat also carries an org session (no org id on the wire → enters + // unattributed). Under a naive "missing ⇒ personal" rule this would + // appear in the personal tray forever. + const heartbeat = { + connectionId: 'c1', + sessions: [ + { id: 'personal', status: 'running', title: 'Personal' }, + { id: 'org-session', status: 'running', title: 'Org' }, + ], + }; + + const afterFirst = mergeHeartbeatForActiveSessions(cache, heartbeat); + const filteredFirst = filterActiveSessionsByOrganization(afterFirst, null); + expect(filteredFirst.map(r => r.id)).toEqual(['personal']); + + // Second heartbeat: still only the personal row survives the filter. + const afterSecond = mergeHeartbeatForActiveSessions(afterFirst, heartbeat); + const filteredSecond = filterActiveSessionsByOrganization(afterSecond, null); + expect(filteredSecond.map(r => r.id)).toEqual(['personal']); + + // The unattributed org row is present in the raw cache (enrichment will + // attribute it later) but never shown in the filtered tray. + expect(afterSecond.find(r => r.id === 'org-session')?.organizationId).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live.merge.test.ts b/apps/mobile/src/lib/active-sessions-live.merge.test.ts index 7d6f60bdfe..d6228911d1 100644 --- a/apps/mobile/src/lib/active-sessions-live.merge.test.ts +++ b/apps/mobile/src/lib/active-sessions-live.merge.test.ts @@ -34,8 +34,8 @@ describe('mergeSnapshotForActiveSessions', () => { expect(result[1]?.title).toBe('C'); }); - it('preserves ONLY the three enrichment fields for known ids', () => { - const current: CachedActiveSession[] = [ + it('preserves the enrichment fields and the DB title for known ids', () => { + const enriched: CachedActiveSession[] = [ makeCached({ id: 'a', title: 'cached-title', @@ -45,6 +45,13 @@ describe('mergeSnapshotForActiveSessions', () => { gitUrl: 'git@cached', }), ]; + const unenriched: CachedActiveSession[] = [ + makeCached({ + id: 'a', + title: 'cached-title', + gitUrl: 'git@cached', + }), + ]; const snapshot = [ { id: 'a', @@ -54,14 +61,26 @@ describe('mergeSnapshotForActiveSessions', () => { gitUrl: 'git@ws', }, ]; - const result = mergeSnapshotForActiveSessions(current, snapshot); - expect(result).toHaveLength(1); - expect(result[0]?.title).toBe('ws-title'); - expect(result[0]?.connectionId).toBe('c1'); - expect(result[0]?.gitUrl).toBe('git@ws'); - expect(result[0]?.createdOnPlatform).toBe('cli'); - expect(result[0]?.createdAt).toBe('2024-01-01T00:00:00Z'); - expect(result[0]?.updatedAt).toBe('2024-01-02T00:00:00Z'); + + const enrichedResult = mergeSnapshotForActiveSessions(enriched, snapshot); + expect(enrichedResult).toHaveLength(1); + // Enriched: DB title sticky; enrichment fields sticky; other fields from wire. + expect(enrichedResult[0]?.title).toBe('cached-title'); + expect(enrichedResult[0]?.connectionId).toBe('c1'); + expect(enrichedResult[0]?.gitUrl).toBe('git@ws'); + expect(enrichedResult[0]?.createdOnPlatform).toBe('cli'); + expect(enrichedResult[0]?.createdAt).toBe('2024-01-01T00:00:00Z'); + expect(enrichedResult[0]?.updatedAt).toBe('2024-01-02T00:00:00Z'); + + const unenrichedResult = mergeSnapshotForActiveSessions(unenriched, snapshot); + expect(unenrichedResult).toHaveLength(1); + // Unenriched: wire title wins so a just-spawned session shows something. + expect(unenrichedResult[0]?.title).toBe('ws-title'); + expect(unenrichedResult[0]?.connectionId).toBe('c1'); + expect(unenrichedResult[0]?.gitUrl).toBe('git@ws'); + expect(unenrichedResult[0]?.createdOnPlatform).toBeUndefined(); + expect(unenrichedResult[0]?.createdAt).toBeUndefined(); + expect(unenrichedResult[0]?.updatedAt).toBeUndefined(); }); it('drops rows absent from the snapshot', () => { @@ -133,6 +152,7 @@ describe('mergeHeartbeatForActiveSessions', () => { makeCached({ id: 'a', connectionId: 'c1', + title: 'cached-title', createdOnPlatform: 'cli', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', @@ -144,7 +164,8 @@ describe('mergeHeartbeatForActiveSessions', () => { }; const result = mergeHeartbeatForActiveSessions(current, payload); expect(result[0]).toBeDefined(); - expect(result[0]?.title).toBe('A2'); + // Enriched row: DB title sticky; enrichment fields sticky. + expect(result[0]?.title).toBe('cached-title'); expect(result[0]?.createdOnPlatform).toBe('cli'); expect(result[0]?.createdAt).toBe('2024-01-01T00:00:00Z'); expect(result[0]?.updatedAt).toBe('2024-01-02T00:00:00Z'); diff --git a/apps/mobile/src/lib/active-sessions-live.title.test.ts b/apps/mobile/src/lib/active-sessions-live.title.test.ts new file mode 100644 index 0000000000..150a943f83 --- /dev/null +++ b/apps/mobile/src/lib/active-sessions-live.title.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyActiveSessionTitle, + type CachedActiveSession, + filterActiveSessionsByOrganization, + mergeHeartbeatForActiveSessions, + mergeSnapshotForActiveSessions, +} from '@/lib/active-sessions-live'; + +function makeCached(over: Partial = {}): CachedActiveSession { + return { + id: 'a1', + status: 'running', + title: 'test', + connectionId: 'c1', + ...over, + }; +} + +// ── Sticky DB title (D7) ───────────────────────────────────────────── + +describe('sticky DB title across WS merges', () => { + it('keeps the cached title when the row is enriched (heartbeat)', () => { + const current = [ + makeCached({ + id: 'a', + title: 'db-title', + status: 'question', + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + capabilities: { attachments: true }, + }), + ]; + const result = mergeHeartbeatForActiveSessions(current, { + connectionId: 'c1', + sessions: [ + { + id: 'a', + status: 'busy', + title: 'cli-title', + capabilities: { attachments: false }, + }, + ], + }); + expect(result[0]?.title).toBe('db-title'); + // Status change still applies (sticky attention keeps question). + expect(result[0]?.status).toBe('question'); + // Capabilities upgrade/downgrade unaffected. + expect(result[0]?.capabilities).toEqual({ attachments: false }); + }); + + it('keeps the cached title when the row is enriched (snapshot)', () => { + const current = [ + makeCached({ + id: 'a', + title: 'db-title', + status: 'idle', + createdAt: '2024-01-01T00:00:00Z', + capabilities: { attachments: false }, + }), + ]; + const result = mergeSnapshotForActiveSessions(current, [ + { + id: 'a', + status: 'busy', + title: 'cli-title', + connectionId: 'c1', + capabilities: { attachments: true }, + }, + ]); + expect(result[0]?.title).toBe('db-title'); + expect(result[0]?.status).toBe('busy'); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + }); + + it('takes the wire title when the row is not enriched (heartbeat)', () => { + const current = [makeCached({ id: 'a', title: 'stale', status: 'idle' })]; + const result = mergeHeartbeatForActiveSessions(current, { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'busy', title: 'cli-title' }], + }); + expect(result[0]?.title).toBe('cli-title'); + expect(result[0]?.status).toBe('busy'); + }); + + it('takes the wire title when the row is not enriched (snapshot)', () => { + const current = [makeCached({ id: 'a', title: 'stale', status: 'idle' })]; + const result = mergeSnapshotForActiveSessions(current, [ + { id: 'a', status: 'busy', title: 'cli-title', connectionId: 'c1' }, + ]); + expect(result[0]?.title).toBe('cli-title'); + expect(result[0]?.status).toBe('busy'); + }); +}); + +// ── applyActiveSessionTitle ────────────────────────────────────────── + +describe('applyActiveSessionTitle', () => { + it('renames the matching row', () => { + const current = [ + makeCached({ id: 'a', title: 'old' }), + makeCached({ id: 'b', title: 'other' }), + ]; + const result = applyActiveSessionTitle(current, 'a', 'new'); + expect(result[0]?.title).toBe('new'); + expect(result[1]?.title).toBe('other'); + // Other rows untouched by identity. + expect(result[1]).toBe(current[1]); + }); + + it('returns the same row object identity when the title already matches', () => { + const current = [makeCached({ id: 'a', title: 'same' })]; + const result = applyActiveSessionTitle(current, 'a', 'same'); + expect(result[0]).toBe(current[0]); + }); + + it('ignores unknown session ids', () => { + const current = [makeCached({ id: 'a', title: 'old' })]; + const result = applyActiveSessionTitle(current, 'missing', 'new'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(current[0]); + expect(result[0]?.title).toBe('old'); + }); +}); + +// ── Optimistic rename on an unenriched row (D8) ────────────────────── + +describe('optimistic rename on an unenriched row', () => { + it('heartbeat takes the wire title back, and the filter hides the row', () => { + // Unenriched row (never through a tRPC fetch) — no organizationId. + const unenriched = [makeCached({ id: 'a', title: 'cli-title' })]; + const renamed = applyActiveSessionTitle(unenriched, 'a', 'optimistic'); + expect(renamed[0]?.title).toBe('optimistic'); + + // Next heartbeat carries the old CLI title → wire wins (stickiness + // keys on isEnriched). No second stickiness mechanism. + const afterHeartbeat = mergeHeartbeatForActiveSessions(renamed, { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'running', title: 'cli-title' }], + }); + expect(afterHeartbeat[0]?.title).toBe('cli-title'); + + // Under D6 an unattributed row is not displayed in a filtered tray, so + // the reverted title is invisible and AC 4 still holds. + expect(filterActiveSessionsByOrganization(afterHeartbeat, null)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live.ts b/apps/mobile/src/lib/active-sessions-live.ts index 63ee0210ea..c28b261969 100644 --- a/apps/mobile/src/lib/active-sessions-live.ts +++ b/apps/mobile/src/lib/active-sessions-live.ts @@ -2,13 +2,15 @@ * Pure helpers for the app-level active-sessions live-sync owner. * * WS payloads lack enrichment fields (`createdOnPlatform`/`createdAt`/ - * `updatedAt`); the merge helpers preserve those fields for ids already in - * the cache while letting every other field (including `connectionId`) - * come from the latest WS payload, so session ownership can transfer - * between CLI connections. `capabilities` is the hybrid exception: the WS - * value wins when present (upgrade or downgrade), and the cached value is - * preserved only when the WS row omits the field. The functions here never - * touch React, the network, or a QueryClient — they are pure and + * `updatedAt`) and `organizationId`; the merge helpers preserve those + * fields for ids already in the cache while letting every other field + * (including `connectionId`) come from the latest WS payload, so session + * ownership can transfer between CLI connections. Once a row has been + * through a tRPC fetch the cached DB title is sticky too — heartbeats + * never carry a cloud rename. `capabilities` is the hybrid exception: the + * WS value wins when present (upgrade or downgrade), and the cached value + * is preserved only when the WS row omits the field. The functions here + * never touch React, the network, or a QueryClient — they are pure and * exhaustively unit-tested alongside this file. * * Status resolution for live rows: CLI heartbeats/snapshots often report @@ -49,8 +51,8 @@ export type CachedActiveSessionsData = { sessions: CachedActiveSession[]; }; +/** The three fields that mark a row as having been through a tRPC fetch. */ const ENRICHMENT_FIELDS = ['createdOnPlatform', 'createdAt', 'updatedAt'] as const; -type EnrichmentField = (typeof ENRICHMENT_FIELDS)[number]; /** Structured question/permission — the Active Now "NEEDS INPUT" badge. */ export function isAttentionStatus(status: string | null | undefined): boolean { @@ -139,14 +141,24 @@ export function parseSessionStatusUpdatedPayload( // ── Enrichment-preserving merge helpers ────────────────────────────── -function readEnrichment( - current: CachedActiveSession | undefined -): Record { +type PreservedFields = { + createdOnPlatform: string | undefined; + createdAt: string | undefined; + updatedAt: string | undefined; + /** Sticky like the three above: WS payloads never carry an org id. */ + organizationId: string | null | undefined; +}; + +function readEnrichment(current: CachedActiveSession | undefined): PreservedFields { return { createdOnPlatform: typeof current?.createdOnPlatform === 'string' ? current.createdOnPlatform : undefined, createdAt: typeof current?.createdAt === 'string' ? current.createdAt : undefined, updatedAt: typeof current?.updatedAt === 'string' ? current.updatedAt : undefined, + // Pass through as-is: `null` means "the server said personal". Do not + // collapse with a `typeof === 'string'` guard — that would hide every + // personal row from the personal tray (see filter helper). + organizationId: current?.organizationId, }; } @@ -163,7 +175,13 @@ function withEnrichmentAndConnectionId( // Sticky attention: a non-attention WS status must not clear a held // question/permission. "stored" for WS paths is the cached row status. status: effectiveStatus(row.status, current?.status), - title: row.title, + // The tray title is DB-authoritative (the router enriches it from + // cli_sessions_v2), so once a row has been through a tRPC fetch a heartbeat's + // CLI title must not overwrite it — nothing propagates a cloud rename back to + // the CLI, which is what made a renamed session flash its old name. An + // unenriched row (never joined) still takes the wire title so a just-spawned + // session shows something immediately. + title: current && isEnriched(current) ? current.title : row.title, gitUrl: row.gitUrl, gitBranch: row.gitBranch, connectionId, @@ -257,6 +275,42 @@ export function applySessionStatusUpdated( ); } +/** + * Optimistic tray rename. Unknown session ids are ignored — the live cache only + * holds active rows. + */ +export function applyActiveSessionTitle( + current: readonly CachedActiveSession[], + sessionId: string, + title: string +): CachedActiveSession[] { + return current.map(row => + row.id === sessionId && row.title !== title ? { ...row, title } : row + ); +} + +/** + * Keep only the rows belonging to the selected personal/org context: + * `undefined` = no filter, `null` = personal, a uuid = that organization. + * + * The router attributes every row it returns (`null` for a session with no + * `cli_sessions_v2` row), so an absent `organizationId` here means the row was + * inserted by the WS push path, which cannot carry one. Such a row is hidden in + * ANY filtered context — strict equality against a `string | null` context drops + * it — and reappears once the next tRPC fetch attributes it. Treating unknown as + * personal instead would re-admit an out-of-context session on every heartbeat, + * for the whole life of that session (D6). + */ +export function filterActiveSessionsByOrganization( + sessions: readonly T[], + organizationId: string | null | undefined +): T[] { + if (organizationId === undefined) { + return [...sessions]; + } + return sessions.filter(session => session.organizationId === organizationId); +} + export function removeActiveSessionsForConnection( current: readonly CachedActiveSession[], connectionId: string @@ -269,13 +323,11 @@ export function removeActiveSessionsForConnection( * field is set. Empty `createdOnPlatform` (e.g. `'unknown'`) is still a * real value from the tRPC router and counts as enriched; the trpc * pipeline is the source of truth for "the DB row has been joined in". + * `organizationId` is sticky too but does NOT count — WS-inserted rows + * never carry it, and the enrichment-retry cadence must not shift. */ export function isEnriched(row: CachedActiveSession): boolean { - return ( - typeof row.createdOnPlatform === 'string' || - typeof row.createdAt === 'string' || - typeof row.updatedAt === 'string' - ); + return ENRICHMENT_FIELDS.some(field => typeof row[field] === 'string'); } export function hasUnenrichedLiveId(rows: readonly CachedActiveSession[]): boolean { From b20035c22c2932b4fd9d365beaa4f2d684f5a546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:25:37 +0200 Subject: [PATCH 5/7] feat(mobile): wire org context through active-sessions tray and rename Key activeSessions.list by context, hold a standing WS lease across owner swaps, filter unattributed heartbeat rows client-side, gate Home's session queries on the loaded context, and optimistically retitle tray rows on rename. --- .../src/components/home/home-screen.tsx | 5 +- .../components/share/share-destinations.ts | 4 +- .../src/components/share/share-gate-sheet.tsx | 2 +- .../lib/active-sessions-live-sync-mount.tsx | 53 +++-- apps/mobile/src/lib/agent-session-input.ts | 14 ++ .../src/lib/hooks/use-agent-sessions.test.ts | 21 ++ .../src/lib/hooks/use-agent-sessions.ts | 21 +- .../lib/hooks/use-session-mutations.test.ts | 196 +++++++++++++++--- .../src/lib/hooks/use-session-mutations.ts | 33 ++- 9 files changed, 287 insertions(+), 62 deletions(-) diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index f41d2c019c..b34e50d320 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -42,7 +42,7 @@ export function HomeScreen() { const isFocused = useIsFocused(); const [refreshing, setRefreshing] = useState(false); - const { organizationId } = useOrganization(); + const { organizationId, isLoaded: orgLoaded } = useOrganization(); const invalidateHomeQueries = useCallback(() => { void queryClient.invalidateQueries({ @@ -97,9 +97,10 @@ export function HomeScreen() { refetch: refetchSessions, } = useAgentSessions({ organizationId, + enabled: orgLoaded, }); - const isLoading = instancesPending || sessionsLoading; + const isLoading = instancesPending || sessionsLoading || !orgLoaded; // Match what the Home Agent-sessions section actually renders (cloud-agent // stored + any active), so a CLI-only account shows the first-use promo diff --git a/apps/mobile/src/components/share/share-destinations.ts b/apps/mobile/src/components/share/share-destinations.ts index e7ccb57cfa..9a36b63196 100644 --- a/apps/mobile/src/components/share/share-destinations.ts +++ b/apps/mobile/src/components/share/share-destinations.ts @@ -9,8 +9,8 @@ export type ShareDestinationRow = StoredSession & { /** * Derive the share-gate destination list from the org-scoped stored page. - * `activeSessionIds` is used only to mark and hoist live rows — never as a - * source of rows (activeSessions.list has no organizationId filter). + * The tray query is context-filtered; `activeSessionIds` is still used only + * to mark and hoist live rows — never as a source of rows. */ export function selectShareDestinations( storedSessions: readonly StoredSession[], diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index 77488de55b..3cdff79f5a 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -60,7 +60,7 @@ export function ShareGateSheet({ shareId }: Readonly) { const trpc = useTRPC(); const { organizationId, isLoaded: orgLoaded } = useOrganization(); // Org-scoped stored page only (cloud-agent + cli). Active list is an - // id/capability lookup — never a row source (no organizationId filter). + // id/capability lookup — never a row source. const sessions = useAgentSessions({ createdOnPlatform: expandPlatformFilter(['cloud-agent', 'cli']), organizationId, diff --git a/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx b/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx index d2b4239e24..f2d3c80269 100644 --- a/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx +++ b/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx @@ -1,41 +1,54 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { type QueryFunction, useQueryClient } from '@tanstack/react-query'; import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; import { ActiveSessionsLiveSync } from '@/lib/active-sessions-live-sync'; import { type CachedActiveSessionsData } from '@/lib/active-sessions-live'; +import { buildActiveSessionsInput } from '@/lib/agent-session-input'; +import { useOrganization } from '@/lib/organization-context'; import { useTRPC } from '@/lib/trpc'; /** - * React entry point for the active-sessions live-sync owner. Mounts an - * `ActiveSessionsLiveSync` instance exactly once per provider lifetime. + * React entry point for the active-sessions live-sync owner. Holds a standing + * socket lease for the mount lifetime and recreates the per-context owner when + * the selected personal/org context changes. */ function useActiveSessionsLiveSync(): void { const connection = useUserWebConnection(); const queryClient = useQueryClient(); const trpc = useTRPC(); - const queryKey = useMemo(() => trpc.activeSessions.list.queryKey(), [trpc]); - // `trpc.activeSessions.list.queryOptions()` returns a fresh object on - // every call; we want a stable queryFn per provider lifetime so the - // live-sync owner can call it via fetchQuery. + const { organizationId, isLoaded } = useOrganization(); + + // The per-context owner below is recreated on every context switch, and its + // detach()/attach() pair would drop the retain count to zero in between — + // which stops the shared user-web socket. This lease spans the mount's whole + // lifetime, so a context switch swaps owners without ever closing the socket, + // and the socket still comes up before the organization context has loaded, + // exactly as it does today. `retain()` returns its own release function. + useEffect(() => connection.retain(), [connection]); + + const input = useMemo(() => buildActiveSessionsInput(organizationId), [organizationId]); + const queryKey = useMemo(() => trpc.activeSessions.list.queryKey(input), [trpc, input]); const queryFn = useMemo( () => - trpc.activeSessions.list.queryOptions().queryFn as QueryFunction, - [trpc] + trpc.activeSessions.list.queryOptions(input) + .queryFn as QueryFunction, + [trpc, input] ); - const syncRef = useRef(null); - syncRef.current ??= new ActiveSessionsLiveSync({ - connection, - queryClient, - queryKey, - queryFn, - }); - + // One owner per context: the tray cache key varies with the selected + // personal/org context, so switching context has to retarget the WS writes. A + // fresh instance per key is simpler than mutating a live owner's key, and + // `attach()`'s cleanup releases the previous retain and listeners. Gated on + // `isLoaded` so the pre-load default (personal) never claims the socket for a + // context the user has not actually selected. useEffect(() => { - const sync = syncRef.current; - return sync ? sync.attach() : undefined; - }, []); + if (!isLoaded) { + return undefined; + } + const sync = new ActiveSessionsLiveSync({ connection, queryClient, queryKey, queryFn }); + return sync.attach(); + }, [connection, isLoaded, queryClient, queryFn, queryKey]); } /** diff --git a/apps/mobile/src/lib/agent-session-input.ts b/apps/mobile/src/lib/agent-session-input.ts index 4f5a139be9..dc741bd944 100644 --- a/apps/mobile/src/lib/agent-session-input.ts +++ b/apps/mobile/src/lib/agent-session-input.ts @@ -79,3 +79,17 @@ export function buildAgentSessionSearchInput(options: { organizationId: options.organizationId, }; } + +/** + * Pure input-builder for the `activeSessions.list` tRPC query. + * + * An absent context collapses to `null` (personal): the tray must never show + * organization sessions before the context has loaded, and every caller in the + * same context must produce the *same* query key — the live-sync owner writes WS + * payloads straight into that key, so a mismatch would silently split the cache. + */ +export function buildActiveSessionsInput(organizationId: string | null | undefined): { + organizationId: string | null; +} { + return { organizationId: organizationId ?? null }; +} diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.test.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.test.ts index c1236906bc..965430b0d0 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.test.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildActiveSessionsInput, buildAgentSessionListInput, buildAgentSessionSearchInput, } from '@/lib/agent-session-input'; @@ -64,6 +65,26 @@ describe('buildAgentSessionListInput', () => { }); }); +describe('buildActiveSessionsInput', () => { + it('collapses null to { organizationId: null }', () => { + expect(buildActiveSessionsInput(null)).toEqual({ organizationId: null }); + }); + + it('passes a uuid through unchanged', () => { + expect(buildActiveSessionsInput('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toEqual({ + organizationId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }); + }); + + it('collapses undefined to { organizationId: null }', () => { + expect(buildActiveSessionsInput(undefined)).toEqual({ organizationId: null }); + }); + + it('produces the same key for null and undefined (key-stability rule)', () => { + expect(buildActiveSessionsInput(null)).toEqual(buildActiveSessionsInput(undefined)); + }); +}); + describe('buildAgentSessionSearchInput', () => { it('defaults to updated_at when sortBy is omitted', () => { expect(buildAgentSessionSearchInput({ searchQuery: 'hello' })).toMatchObject({ diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index e520640841..8f3fb8460f 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -3,7 +3,9 @@ import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-qu import { useEffect, useMemo, useRef } from 'react'; import { sortActiveSessionsByCreatedAt } from '@/lib/active-session-order'; +import { filterActiveSessionsByOrganization } from '@/lib/active-sessions-live'; import { + buildActiveSessionsInput, buildAgentSessionListInput, buildAgentSessionSearchInput, } from '@/lib/agent-session-input'; @@ -92,8 +94,12 @@ function useActiveSessions(options?: UseAgentSessionsOptions) { // the source of truth. When the socket is down, fall back to the 10s // interval so a transient outage still updates the tray. const wsConnected = useUserWebConnectionState(); + const input = useMemo( + () => buildActiveSessionsInput(options?.organizationId), + [options?.organizationId] + ); return useQuery( - trpc.activeSessions.list.queryOptions(undefined, { + trpc.activeSessions.list.queryOptions(input, { refetchInterval: wsConnected ? false : 10_000, staleTime: 5000, enabled: options?.enabled, @@ -174,9 +180,18 @@ export function useAgentSessions(options?: UseAgentSessionsOptions) { return sessions; }, [stored.data]); + // The server already filters by context; this covers the window where a WS + // heartbeat has introduced a row the client has not enriched yet (see + // `filterActiveSessionsByOrganization`). const activeSessions = useMemo( - () => sortActiveSessionsByCreatedAt(active.data?.sessions ?? []), - [active.data] + () => + sortActiveSessionsByCreatedAt( + filterActiveSessionsByOrganization( + active.data?.sessions ?? [], + options?.organizationId ?? null + ) + ), + [active.data, options?.organizationId] ); const activeSessionIds = useMemo(() => new Set(activeSessions.map(s => s.id)), [activeSessions]); diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.test.ts b/apps/mobile/src/lib/hooks/use-session-mutations.test.ts index b73fd32b63..b9d4804def 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.test.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSessionMutations } from './use-session-mutations'; -type MutationOptions = Record; +type MutationOptions = { + onMutate?: (input: { session_id: string; title?: string }) => Promise | unknown; + onError?: (error: Error, input: unknown, context: unknown) => void; + onSettled?: () => unknown; + [key: string]: unknown; +}; type TrpcMock = { cliSessionsV2: { list: { infiniteQueryKey: () => readonly unknown[] }; @@ -10,10 +15,16 @@ type TrpcMock = { rename: { mutationOptions: (opts: MutationOptions) => MutationOptions }; delete: { mutationOptions: (opts: MutationOptions) => MutationOptions }; }; + activeSessions: { + list: { pathFilter: () => { queryKey: readonly unknown[] } }; + }; }; const mutationOptionsSpy = vi.fn<(opts: MutationOptions) => MutationOptions>(); -const capturedOptions: { current: MutationOptions | null } = { current: null }; +const capturedOptions: { rename: MutationOptions | null; delete: MutationOptions | null } = { + rename: null, + delete: null, +}; const mutateAsyncMock = vi.fn(); const cancelQueriesMock = vi.fn(); const getQueriesDataMock = vi.fn(); @@ -25,6 +36,9 @@ const toastErrorMock = vi.fn(); // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule const chainSaveMock = vi.fn((_id: string, op: () => Promise) => op()); +const listKey = ['cliSessionsV2', 'list'] as const; +const activeFilter = { queryKey: ['activeSessions', 'list'] as const }; + const makeMutationOptions = (opts: MutationOptions) => { mutationOptionsSpy(opts); return opts; @@ -32,7 +46,12 @@ const makeMutationOptions = (opts: MutationOptions) => { vi.mock('@tanstack/react-query', () => ({ useMutation: (opts: MutationOptions) => { - capturedOptions.current = opts; + // rename is registered second in useSessionMutations; capture both. + if (capturedOptions.delete === null) { + capturedOptions.delete = opts; + } else { + capturedOptions.rename = opts; + } return { mutateAsync: mutateAsyncMock }; }, useQueryClient: () => ({ @@ -48,11 +67,14 @@ vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ cliSessionsV2: { - list: { infiniteQueryKey: () => ['cliSessionsV2', 'list'] }, + list: { infiniteQueryKey: () => listKey }, recentRepositories: {}, rename: { mutationOptions: makeMutationOptions }, delete: { mutationOptions: makeMutationOptions }, }, + activeSessions: { + list: { pathFilter: () => activeFilter }, + }, }) satisfies TrpcMock, })); @@ -77,10 +99,11 @@ vi.mock('@/lib/hooks/save-chain', () => ({ chainSave: (id: string, op: () => Promise) => chainSaveMock(id, op), })); -describe('useSessionMutations.renameSessionAsync', () => { +describe('useSessionMutations', () => { beforeEach(() => { mutationOptionsSpy.mockClear(); - capturedOptions.current = null; + capturedOptions.rename = null; + capturedOptions.delete = null; mutateAsyncMock.mockReset(); cancelQueriesMock.mockReset(); getQueriesDataMock.mockReset(); @@ -98,29 +121,146 @@ describe('useSessionMutations.renameSessionAsync', () => { vi.clearAllMocks(); }); - it('rejects after the mutation onError has toasted and rolled back list cache', async () => { - const error = new Error('rename failed'); - mutateAsyncMock.mockRejectedValueOnce(error); - getQueriesDataMock.mockReturnValue([]); - - const { renameSessionAsync } = useSessionMutations(); - await expect(renameSessionAsync('s1', 'New title')).rejects.toBe(error); - - expect(chainSaveMock).toHaveBeenCalledWith('s1', expect.any(Function)); - // The mutation's onError must run before the rejection propagates so the - // existing list-cache rollback and user-visible toast still fire. - const options = capturedOptions.current as { onError?: (err: unknown) => void } | null; - expect(options?.onError).toBeDefined(); - options?.onError?.(error); - expect(toastErrorMock).toHaveBeenCalledWith('rename failed'); + describe('renameSessionAsync', () => { + it('rejects after the mutation onError has toasted and rolled back list cache', async () => { + const error = new Error('rename failed'); + mutateAsyncMock.mockRejectedValueOnce(error); + getQueriesDataMock.mockReturnValue([]); + + const { renameSessionAsync } = useSessionMutations(); + await expect(renameSessionAsync('s1', 'New title')).rejects.toBe(error); + + expect(chainSaveMock).toHaveBeenCalledWith('s1', expect.any(Function)); + // The mutation's onError must run before the rejection propagates so the + // existing list-cache rollback and user-visible toast still fire. + const options = capturedOptions.rename; + expect(options?.onError).toBeDefined(); + options?.onError?.(error, { session_id: 's1', title: 'New title' }, undefined); + expect(toastErrorMock).toHaveBeenCalledWith('rename failed'); + }); + + it('reuses the same rename mutation options as renameSession', () => { + // The detail hook relies on the async variant being backed by the exact + // same mutation (and therefore the same onError/onSettled wiring) as + // the list's fire-and-forget variant. + const { renameSessionAsync } = useSessionMutations(); + void renameSessionAsync; + expect(mutationOptionsSpy).toHaveBeenCalled(); + }); }); - it('reuses the same rename mutation options as renameSession', () => { - // The detail hook relies on the async variant being backed by the exact - // same mutation (and therefore the same onError/onSettled wiring) as - // the list's fire-and-forget variant. - const { renameSessionAsync } = useSessionMutations(); - void renameSessionAsync; - expect(mutationOptionsSpy).toHaveBeenCalled(); + describe('rename optimistic tray patch', () => { + it('onMutate cancels and patches both the stored-list and active-list caches', async () => { + const storedSnapshot: [unknown, unknown][] = [[listKey, { pages: [], pageParams: [] }]]; + const activeSnapshot: [unknown, unknown][] = [ + [ + activeFilter.queryKey, + { + sessions: [ + { id: 's1', title: 'Old' }, + { id: 's2', title: 'Other' }, + ], + }, + ], + ]; + // First getQueriesData = stored list; second = active list. + getQueriesDataMock.mockReturnValueOnce(storedSnapshot).mockReturnValueOnce(activeSnapshot); + + useSessionMutations(); + const onMutate = capturedOptions.rename?.onMutate; + expect(onMutate).toBeDefined(); + if (!onMutate) { + throw new Error('expected rename onMutate'); + } + + const context = await onMutate({ session_id: 's1', title: 'New title' }); + + expect(cancelQueriesMock).toHaveBeenCalledWith({ queryKey: listKey }); + expect(cancelQueriesMock).toHaveBeenCalledWith(activeFilter); + expect(setQueriesDataMock).toHaveBeenCalledTimes(2); + + // Stored-list updater (first setQueriesData call). + const storedUpdater = setQueriesDataMock.mock.calls[0]?.[1] as + | ((old: unknown) => unknown) + | undefined; + expect(storedUpdater).toBeTypeOf('function'); + + // Active-list updater retitles only the target row. + const activeUpdater = setQueriesDataMock.mock.calls[1]?.[1] as + | ((old: { sessions: { id: string; title: string }[] } | undefined) => unknown) + | undefined; + expect(setQueriesDataMock.mock.calls[1]?.[0]).toBe(activeFilter); + expect(activeUpdater).toBeTypeOf('function'); + expect( + activeUpdater?.({ + sessions: [ + { id: 's1', title: 'Old' }, + { id: 's2', title: 'Other' }, + ], + }) + ).toEqual({ + sessions: [ + { id: 's1', title: 'New title' }, + { id: 's2', title: 'Other' }, + ], + }); + expect(activeUpdater?.(undefined)).toBeUndefined(); + + expect(context).toEqual({ + previous: storedSnapshot, + previousActive: activeSnapshot, + }); + }); + + it('onError restores both snapshots and toasts the error', () => { + useSessionMutations(); + const options = capturedOptions.rename; + const storedKey = ['stored-key'] as const; + const activeKey = ['active-key'] as const; + const previous = [[storedKey, { pages: ['stored'] }]] as [unknown, unknown][]; + const previousActive = [[activeKey, { sessions: [{ id: 's1', title: 'Old' }] }]] as [ + unknown, + unknown, + ][]; + + options?.onError?.( + new Error('rename failed'), + { session_id: 's1', title: 'New' }, + { previous, previousActive } + ); + + expect(setQueryDataMock).toHaveBeenCalledWith(storedKey, { pages: ['stored'] }); + expect(setQueryDataMock).toHaveBeenCalledWith(activeKey, { + sessions: [{ id: 's1', title: 'Old' }], + }); + expect(toastErrorMock).toHaveBeenCalledWith('rename failed'); + }); + + it('onSettled still invalidates agent session queries', async () => { + useSessionMutations(); + const options = capturedOptions.rename; + await options?.onSettled?.(); + expect(invalidateAgentSessionsMock).toHaveBeenCalled(); + }); + }); + + describe('delete does not touch the active cache', () => { + it('onMutate only patches the stored-list cache', async () => { + getQueriesDataMock.mockReturnValue([]); + + useSessionMutations(); + const onMutate = capturedOptions.delete?.onMutate; + expect(onMutate).toBeDefined(); + if (!onMutate) { + throw new Error('expected delete onMutate'); + } + + await onMutate({ session_id: 's1' }); + + expect(cancelQueriesMock).toHaveBeenCalledWith({ queryKey: listKey }); + expect(cancelQueriesMock).not.toHaveBeenCalledWith(activeFilter); + expect(setQueriesDataMock).toHaveBeenCalledTimes(1); + expect(setQueriesDataMock.mock.calls[0]?.[0]).toEqual({ queryKey: listKey }); + }); }); }); diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index c8dcf6d703..f1328e0c94 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -1,6 +1,7 @@ import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; +import { applyActiveSessionTitle, type CachedActiveSessionsData } from '@/lib/active-sessions-live'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { chainSave } from '@/lib/hooks/save-chain'; import { @@ -20,12 +21,15 @@ export function useSessionMutations() { const trpc = useTRPC(); const queryClient = useQueryClient(); const listKey = trpc.cliSessionsV2.list.infiniteQueryKey(); + const activeListFilter = trpc.activeSessions.list.pathFilter(); const invalidateSessions = async () => { await invalidateAgentSessionQueries(queryClient, trpc); }; - const snapshotAndUpdate = async (update: (data: SessionsListData) => SessionsListData) => { + const snapshotAndUpdate = async ( + update: (data: SessionsListData) => SessionsListData + ): Promise<{ previous: SessionsListSnapshot }> => { await queryClient.cancelQueries({ queryKey: listKey }); const previous = queryClient.getQueriesData({ queryKey: listKey }); queryClient.setQueriesData({ queryKey: listKey }, old => @@ -34,7 +38,21 @@ export function useSessionMutations() { return { previous }; }; - const rollback = (previous?: SessionsListSnapshot) => { + /** + * Optimistically retitle the row in every cached "Active now" tray. The tray + * cache is keyed per personal/org context, so patch them all by path filter — + * the mutation hook has no context of its own, and a rename is rare. + */ + const snapshotAndUpdateActive = async (sessionId: string, title: string) => { + await queryClient.cancelQueries(activeListFilter); + const previousActive = queryClient.getQueriesData(activeListFilter); + queryClient.setQueriesData(activeListFilter, old => + old ? { sessions: applyActiveSessionTitle(old.sessions, sessionId, title) } : old + ); + return previousActive; + }; + + const rollback = (previous?: [QueryKey, unknown][]) => { for (const [key, data] of previous ?? []) { queryClient.setQueryData(key, data); } @@ -55,13 +73,16 @@ export function useSessionMutations() { const renameSessionMutation = useMutation( trpc.cliSessionsV2.rename.mutationOptions({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - onMutate: ({ session_id, title }) => - snapshotAndUpdate(data => + onMutate: async ({ session_id, title }) => { + const { previous } = await snapshotAndUpdate(data => mapStoredSessions(data, session_id, session => ({ ...session, title })) - ), + ); + const previousActive = await snapshotAndUpdateActive(session_id, title); + return { previous, previousActive }; + }, onError: (error, _input, context) => { rollback(context?.previous); + rollback(context?.previousActive); onError(error); }, onSettled: invalidateSessions, From e4540ec2778e1f485042e4178d99dd806f8d3e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:34:54 +0200 Subject: [PATCH 6/7] fix(web): type org select spy in active-sessions list test Use Reflect.apply passthrough for ensureOrganizationAccess selects so root typecheck accepts the enrichment-failure mock. --- apps/web/src/routers/active-sessions-router.list.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routers/active-sessions-router.list.test.ts b/apps/web/src/routers/active-sessions-router.list.test.ts index 472ce39324..bc1c275d7f 100644 --- a/apps/web/src/routers/active-sessions-router.list.test.ts +++ b/apps/web/src/routers/active-sessions-router.list.test.ts @@ -605,7 +605,7 @@ describe('active-sessions-router.list', () => { // Capture the real select before any spy replaces it. Filtered org // calls run ensureOrganizationAccess first (two selects); only the // enrichment select must throw. - const originalSelect = db.select.bind(db); + const originalSelect = db.select; // Unfiltered: best-effort unenriched passthrough (same contract as the // existing enrichment-failure test). No auth selects → first select is @@ -645,9 +645,10 @@ describe('active-sessions-router.list', () => { // Org filter: ensureOrganizationAccess issues two selects, then // enrichment is the third. Pass the auth selects through. - const orgSelectSpy = jest.spyOn(db, 'select').mockImplementation((...args: never[]) => { + const orgSelectSpy = jest.spyOn(db, 'select'); + orgSelectSpy.mockImplementation(fields => { if (orgSelectSpy.mock.calls.length <= 2) { - return (originalSelect as (...a: never[]) => unknown)(...args); + return Reflect.apply(originalSelect, db, fields === undefined ? [] : [fields]); } throw new Error('synthetic enrichment db failure org'); }); From a58ca333a6088d63634a347b77dd80827cb06d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 19:14:58 +0200 Subject: [PATCH 7/7] docs(workflow): learnings from sessions-context-d669 E2E run --- ...-rename-entry-points-and-field-clearing.md | 19 +++++++++++++++++++ ...-session-ingest-log-empty-use-pipe-pane.md | 14 ++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .kilo_workflow/learnings/mobile-rename-entry-points-and-field-clearing.md create mode 100644 .kilo_workflow/learnings/mobile-session-ingest-log-empty-use-pipe-pane.md diff --git a/.kilo_workflow/learnings/mobile-rename-entry-points-and-field-clearing.md b/.kilo_workflow/learnings/mobile-rename-entry-points-and-field-clearing.md new file mode 100644 index 0000000000..9fb397bd83 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-rename-entry-points-and-field-clearing.md @@ -0,0 +1,19 @@ +# mobile: rename entry points — tray row has NO rename action; use history row or detail header + +Symptom: an E2E flow that long-presses the "Active now" tray row to rename gets an action sheet with +only `Copy session ID` / `Cancel` — no `Rename` — and stalls waiting for it. + +Cause: by design `RemoteSessionRow` (tray) offers only copy-id; `canManage`/rename exists on the +HISTORY row (`session-row.tsx`, sheet: Copy session ID / Rename / Delete session / Cancel) and on the +session-detail header (pressable title, a11y label `Rename session: `, opens RenameModal). +A live session is hoisted out of history into the tray, so while it is live its only row is the +non-renameable tray row. To rename a live session on device: tray row tap → detail → tap title. + +Fix (flow notes, verified on iOS 26.5 simulator): +- History row: `longPressOn: '<title>(.)*'` → tap `Rename` → native Alert.prompt: `eraseText: 40` + then `inputText: '<new>'`, VERIFY the field value via hierarchy before tapping the alert's + `Rename` button (this machine's inputText appends/doubles on non-empty fields; erase-first worked + on both the native prompt and the RN RenameModal field `Session name`). +- Detail header: tap `Rename session:(.)*` → field a11y `Session name` → same erase+input+verify → + tap `Save`. Tap target `Rename` regex is full-string: `Rename session: <title>` will not match a + bare `Rename` pattern — the sheet option and the alert button both match `Rename` exactly. diff --git a/.kilo_workflow/learnings/mobile-session-ingest-log-empty-use-pipe-pane.md b/.kilo_workflow/learnings/mobile-session-ingest-log-empty-use-pipe-pane.md new file mode 100644 index 0000000000..a7b824f032 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-session-ingest-log-empty-use-pipe-pane.md @@ -0,0 +1,14 @@ +# mobile: session-ingest log file stays empty — use dev:capture or tmux pipe-pane for the socket tail + +Symptom: `dev/logs/cloudflare-session-ingest.log` is 0 bytes for the whole run, so `tail -f` on it +shows nothing, while the service is clearly logging (connection and heartbeat lines exist). + +Cause: this worktree's dev runner writes service output to the tmux pane, not the log file (observed +2026-07-28 on sessions-context-d669; `pnpm dev:capture cloudflare-session-ingest` had all output). + +Fix: for one-shot reads use `pnpm dev:capture cloudflare-session-ingest` (runbook-prescribed). For a +continuous capture across a UI interaction (e.g. the context-switch socket-lease check), pipe the +pane: resolve the window with `tmux list-windows -t kilo-dev-<slug>` (cloudflare-session-ingest was +window 12), then `tmux pipe-pane -t kilo-dev-<slug>:12.0 -o "cat >> '<scratch>/ingest-tail.log'"`. +`#{pane_pipe}` = 1 while active. Toggle it off with the same command at cleanup. Record byte offsets +(`wc -c`) at interaction markers to bracket log segments; the wrangler lines carry no timestamps.