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: '(.)*'` → tap `Rename` → native Alert.prompt: `eraseText: 40`
+ then `inputText: ''`, 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: ` 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-` (cloudflare-session-ingest was
+window 12), then `tmux pipe-pane -t kilo-dev-:12.0 -o "cat >> '/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) {
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/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 {
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,
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>): 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: }) 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;
+/**
+ * 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 };
}),