diff --git a/apps/mobile/src/components/agents/session-detail-rename-state.test.ts b/apps/mobile/src/components/agents/session-detail-rename-state.test.ts index 712e0a4f5f..5c62bab10e 100644 --- a/apps/mobile/src/components/agents/session-detail-rename-state.test.ts +++ b/apps/mobile/src/components/agents/session-detail-rename-state.test.ts @@ -5,6 +5,7 @@ import { initialRenameState, type RenameState, renameStateReducer, + titleFromSessionUpdatedEvent, } from './session-detail-rename-state'; describe('getSessionDetailRenameState', () => { @@ -198,3 +199,36 @@ describe('renameStateReducer', () => { expect(changed.optimisticTitle).toBeNull(); }); }); + +function sessionUpdatedPayload( + over: { sessionId?: string; title?: string | null; source?: string } = {} +) { + return { + source: over.source ?? 'v2', + session: { + sessionId: over.sessionId ?? 'ses-1', + title: over.title === undefined ? 'Auto Title' : over.title, + }, + }; +} + +describe('titleFromSessionUpdatedEvent', () => { + it('returns the title for this session', () => { + expect(titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload())).toBe('Auto Title'); + }); + + it('ignores another session', () => { + expect( + titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ sessionId: 'ses-2' })) + ).toBeUndefined(); + }); + + it('ignores a blank or null title', () => { + expect( + titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ title: null })) + ).toBeUndefined(); + expect( + titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ title: ' ' })) + ).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/agents/session-detail-rename-state.ts b/apps/mobile/src/components/agents/session-detail-rename-state.ts index da7695eced..252265162a 100644 --- a/apps/mobile/src/components/agents/session-detail-rename-state.ts +++ b/apps/mobile/src/components/agents/session-detail-rename-state.ts @@ -72,3 +72,24 @@ export function getSessionDetailRenameState(input: { isModalOpen: input.renameState.isModalOpen, }; } + +/** + * Title from a v2 `session.updated` event for this session, or undefined + * when the event is for another session or carries no usable title. + */ +export function titleFromSessionUpdatedEvent( + sessionId: string, + payload: { + source: string; + session: { sessionId: string; title: string | null }; + } +): string | undefined { + if (payload.source !== 'v2' || payload.session.sessionId !== sessionId) { + return undefined; + } + const title = payload.session.title; + if (title == null || title.trim().length === 0) { + return undefined; + } + return title; +} diff --git a/apps/mobile/src/components/agents/use-session-detail-rename.ts b/apps/mobile/src/components/agents/use-session-detail-rename.ts index 639df57f3b..35f66bb63c 100644 --- a/apps/mobile/src/components/agents/use-session-detail-rename.ts +++ b/apps/mobile/src/components/agents/use-session-detail-rename.ts @@ -1,12 +1,14 @@ import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; -import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; import { useSessionMutations } from '@/lib/hooks/use-session-mutations'; import { getSessionDetailRenameState, initialRenameState, renameStateReducer, + titleFromSessionUpdatedEvent, } from './session-detail-rename-state'; type SessionDetailRenameApi = { @@ -48,8 +50,11 @@ export function useSessionDetailRename({ fallbackTitle, }: Readonly): SessionDetailRenameApi { const { renameSessionAsync } = useSessionMutations(); + const connection = useUserWebConnection(); const [renameState, dispatch] = useReducer(renameStateReducer, initialRenameState()); + const [liveTitle, setLiveTitle] = useState(undefined); const lastSeenServerTitleRef = useRef(serverTitle); + const effectiveServerTitle = liveTitle ?? serverTitle; // Drop the optimistic override when the route's session changes so a // previous screen's pending rename can't leak onto the next one. The @@ -57,23 +62,36 @@ export function useSessionDetailRename({ // remounts this hook with a fresh ref and fresh state — this effect // exists as a defensive reset for callers that reuse the instance. useEffect(() => { + setLiveTitle(undefined); dispatch({ type: 'sessionChanged' }); }, [sessionId]); + useEffect( + () => + connection.onSessionEvent('session.updated', payload => { + const next = titleFromSessionUpdatedEvent(sessionId, payload); + if (next !== undefined) { + setLiveTitle(next); + } + }), + [connection, sessionId] + ); + // Sync the optimistic override only when the authoritative server title - // actually changes (e.g. the parent refetches and pushes a new prop). A - // stable but unrelated prop (failure case: server title stays the same) - // is intentionally ignored — failure is handled explicitly in `submit`. + // actually changes (e.g. the parent refetches and pushes a new prop, or + // ingest emits session.updated). A stable but unrelated prop (failure + // case: server title stays the same) is intentionally ignored — failure + // is handled explicitly in `submit`. useEffect(() => { - if (serverTitle === undefined) { + if (effectiveServerTitle === undefined) { return; } - if (lastSeenServerTitleRef.current === serverTitle) { + if (lastSeenServerTitleRef.current === effectiveServerTitle) { return; } - lastSeenServerTitleRef.current = serverTitle; + lastSeenServerTitleRef.current = effectiveServerTitle; dispatch({ type: 'serverTitleChanged' }); - }, [serverTitle]); + }, [effectiveServerTitle]); const openModal = useCallback(() => { dispatch({ type: 'openModal' }); @@ -94,7 +112,7 @@ export function useSessionDetailRename({ const previousTitle = getSessionDetailRenameState({ fallbackTitle, isLoaded, - serverTitle, + serverTitle: effectiveServerTitle, renameState, }).title; dispatch({ type: 'submit', nextTitle: next }); @@ -113,13 +131,13 @@ export function useSessionDetailRename({ throw error; } }, - [fallbackTitle, isLoaded, renameSessionAsync, renameState, serverTitle, sessionId] + [effectiveServerTitle, fallbackTitle, isLoaded, renameSessionAsync, renameState, sessionId] ); const state = getSessionDetailRenameState({ fallbackTitle, isLoaded, - serverTitle, + serverTitle: effectiveServerTitle, renameState, }); diff --git a/apps/mobile/src/lib/active-sessions-live-sync.title.test.ts b/apps/mobile/src/lib/active-sessions-live-sync.title.test.ts new file mode 100644 index 0000000000..6c51b7147f --- /dev/null +++ b/apps/mobile/src/lib/active-sessions-live-sync.title.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + ActiveSessionsLiveSync, + makeCached, + makeConnection, + makeFakeQueryClient, + makeQueryFn, + QUERY_KEY, + setupTimers, +} from '@/lib/active-sessions-live-sync.test-helpers'; + +setupTimers(); + +describe('ActiveSessionsLiveSync — session.updated', () => { + it('applies the ingest title onto the matching row', async () => { + const conn = makeConnection(); + const qc = makeFakeQueryClient(); + qc.__setCached({ + sessions: [ + makeCached({ + id: 'ses-1', + title: 'New session - 2026-01-01T00:00:00.000Z', + createdOnPlatform: 'cli', + createdAt: 'now', + updatedAt: 'now', + }), + ], + }); + const sync = new ActiveSessionsLiveSync({ + connection: conn, + queryClient: qc, + queryKey: QUERY_KEY, + queryFn: makeQueryFn(), + }); + sync.attach(); + conn.__fireSystem({ + event: 'session.updated', + data: { + source: 'v2', + changedAt: 'now', + session: { + source: 'v2', + sessionId: 'ses-1', + createdAt: 'now', + updatedAt: 'now', + title: 'Fix login', + createdOnPlatform: null, + organizationId: null, + gitUrl: null, + gitBranch: null, + parentSessionId: null, + status: 'busy', + statusUpdatedAt: 'now', + }, + }, + }); + await sync.getWriteQueue(); + expect(qc.__getCached()?.sessions[0]?.title).toBe('Fix login'); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live.title.test.ts b/apps/mobile/src/lib/active-sessions-live.title.test.ts index 150a943f83..b57e6d10e3 100644 --- a/apps/mobile/src/lib/active-sessions-live.title.test.ts +++ b/apps/mobile/src/lib/active-sessions-live.title.test.ts @@ -6,6 +6,7 @@ import { filterActiveSessionsByOrganization, mergeHeartbeatForActiveSessions, mergeSnapshotForActiveSessions, + planLiveSystemEventActions, } from '@/lib/active-sessions-live'; function makeCached(over: Partial = {}): CachedActiveSession { @@ -147,3 +148,90 @@ describe('optimistic rename on an unenriched row', () => { expect(filterActiveSessionsByOrganization(afterHeartbeat, null)).toEqual([]); }); }); + +function v2SessionUpdated(over: { sessionId?: string; title?: string | null } = {}) { + return { + source: 'v2' as const, + changedAt: 'now', + session: { + source: 'v2' as const, + sessionId: over.sessionId ?? 'a', + createdAt: 'now', + updatedAt: 'now', + title: over.title === undefined ? 'Auto Title' : over.title, + createdOnPlatform: null, + organizationId: null, + gitUrl: null, + gitBranch: null, + parentSessionId: null, + status: 'busy' as const, + statusUpdatedAt: 'now', + }, + }; +} + +describe('session.updated title', () => { + it('ignores a null or blank title', () => { + expect( + planLiveSystemEventActions({ + event: 'session.updated', + data: v2SessionUpdated({ title: null }), + }) + ).toEqual([]); + expect( + planLiveSystemEventActions({ + event: 'session.updated', + data: v2SessionUpdated({ title: ' ' }), + }) + ).toEqual([]); + }); + + it('applies the title onto an enriched row', () => { + const current = [ + makeCached({ + id: 'a', + title: 'New session - 2026-01-01T00:00:00.000Z', + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + const actions = planLiveSystemEventActions({ + event: 'session.updated', + data: v2SessionUpdated({ title: 'Fix login' }), + }); + expect(actions).toHaveLength(1); + const action = actions[0]; + expect(action?.type).toBe('write'); + if (action?.type !== 'write') { + return; + } + expect(action.updater(current)[0]?.title).toBe('Fix login'); + }); + + it('keeps the applied title across a later heartbeat', () => { + const current = [ + makeCached({ + id: 'a', + title: 'placeholder', + createdOnPlatform: 'cli', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }), + ]; + const actions = planLiveSystemEventActions({ + event: 'session.updated', + data: v2SessionUpdated({ title: 'Fix login' }), + }); + const action = actions[0]; + if (action?.type !== 'write') { + throw new Error('expected write'); + } + const renamed = action.updater(current); + const afterHeartbeat = mergeHeartbeatForActiveSessions(renamed, { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'busy', title: 'cli-title' }], + }); + expect(afterHeartbeat[0]?.title).toBe('Fix login'); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live.ts b/apps/mobile/src/lib/active-sessions-live.ts index 3f187eba0a..20bc563ee3 100644 --- a/apps/mobile/src/lib/active-sessions-live.ts +++ b/apps/mobile/src/lib/active-sessions-live.ts @@ -7,11 +7,12 @@ * (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. + * never carry a cloud rename. `session.updated` (ingest title write) does + * apply. `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 * only idle/busy while `cli_sessions_v2` holds question/permission. A @@ -24,6 +25,7 @@ import { cliConnectionDataSchema, type HeartbeatData, heartbeatDataSchema, + sessionRowEventPayloadSchema, type SessionsListData, sessionsListDataSchema, type SessionStatusUpdatedPayload, @@ -403,7 +405,9 @@ type LiveSystemEventAction = /** * Pure routing for ActiveSessionsLiveSync system events. session.status.updated - * is included here so the owner can handle it via onSystemEvent only. + * and session.updated are included here so the owner can handle them via + * onSystemEvent only. Blank `session.updated` titles are ignored so a + * heartbeat-sticky row is never blanked. */ export function planLiveSystemEventActions(event: { event: string; @@ -446,6 +450,19 @@ export function planLiveSystemEventActions(event: { }, ]; } + if (event.event === 'session.updated') { + const parsed = sessionRowEventPayloadSchema.safeParse(event.data); + const title = parsed.success ? parsed.data.session.title : null; + if (!parsed.success || title == null || title.trim().length === 0) { + return []; + } + return [ + { + type: 'write', + updater: current => applyActiveSessionTitle(current, parsed.data.session.sessionId, title), + }, + ]; + } if (event.event === 'cli.disconnected') { const payload = parseCliConnectionPayload(event.data); if (!payload) {