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. 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; } /** 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<ShareGateSheetProps>) { 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<CachedActiveSessionsData>, - [trpc] + trpc.activeSessions.list.queryOptions(input) + .queryFn as QueryFunction<CachedActiveSessionsData>, + [trpc, input] ); - const syncRef = useRef<ActiveSessionsLiveSync | null>(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/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> = {}): 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> = {}): 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<EnrichmentField, string | undefined> { +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<T extends { organizationId?: string | null }>( + 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 { 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<string, unknown>; +type MutationOptions = { + onMutate?: (input: { session_id: string; title?: string }) => Promise<unknown> | 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<unknown>) => 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<unknown>) => 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<SessionsListData>({ queryKey: listKey }); queryClient.setQueriesData<SessionsListData>({ 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<CachedActiveSessionsData>(activeListFilter); + queryClient.setQueriesData<CachedActiveSessionsData>(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, 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); 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..bc1c275d7f 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<Record<string, unknown>>): 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,378 @@ 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: <org> }) 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; + + // 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'); + orgSelectSpy.mockImplementation(fields => { + if (orgSelectSpy.mock.calls.length <= 2) { + return Reflect.apply(originalSelect, db, fields === undefined ? [] : [fields]); + } + 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<typeof activeSessionSchema>; +/** + * 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<typeof activeSessionSchema> & { + /** + * 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<typeof connectedInstanceSchema>; +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 }; }),