Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
initialRenameState,
type RenameState,
renameStateReducer,
titleFromSessionUpdatedEvent,
} from './session-detail-rename-state';

describe('getSessionDetailRenameState', () => {
Expand Down Expand Up @@ -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();
});
});
21 changes: 21 additions & 0 deletions apps/mobile/src/components/agents/session-detail-rename-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
40 changes: 29 additions & 11 deletions apps/mobile/src/components/agents/use-session-detail-rename.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -48,32 +50,48 @@ export function useSessionDetailRename({
fallbackTitle,
}: Readonly<SessionDetailRenameInput>): SessionDetailRenameApi {
const { renameSessionAsync } = useSessionMutations();
const connection = useUserWebConnection();
const [renameState, dispatch] = useReducer(renameStateReducer, initialRenameState());
const [liveTitle, setLiveTitle] = useState<string | undefined>(undefined);
const lastSeenServerTitleRef = useRef<string | undefined>(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
// component is keyed on the session in the parent, so a route change
// 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' });
Expand All @@ -94,7 +112,7 @@ export function useSessionDetailRename({
const previousTitle = getSessionDetailRenameState({
fallbackTitle,
isLoaded,
serverTitle,
serverTitle: effectiveServerTitle,
renameState,
}).title;
dispatch({ type: 'submit', nextTitle: next });
Expand All @@ -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,
});

Expand Down
61 changes: 61 additions & 0 deletions apps/mobile/src/lib/active-sessions-live-sync.title.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
88 changes: 88 additions & 0 deletions apps/mobile/src/lib/active-sessions-live.title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
filterActiveSessionsByOrganization,
mergeHeartbeatForActiveSessions,
mergeSnapshotForActiveSessions,
planLiveSystemEventActions,
} from '@/lib/active-sessions-live';

function makeCached(over: Partial<CachedActiveSession> = {}): CachedActiveSession {
Expand Down Expand Up @@ -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');
});
});
29 changes: 23 additions & 6 deletions apps/mobile/src/lib/active-sessions-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@ import {
cliConnectionDataSchema,
type HeartbeatData,
heartbeatDataSchema,
sessionRowEventPayloadSchema,
type SessionsListData,
sessionsListDataSchema,
type SessionStatusUpdatedPayload,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down