Skip to content
Closed
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
16 changes: 15 additions & 1 deletion apps/mobile/src/components/agents/child-session-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { type ReactNode } from 'react';
import { useEffect, type ReactNode } from 'react';
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import {
type ChildSessionHydrationState,
type KiloSessionId,
type OlderMessagesError,
type StoredMessage,
} from '@kilocode/cloud-agent-sdk';
Expand All @@ -12,6 +13,10 @@ import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
import { SheetHeader } from '@/components/sheet-header';
import { Bot } from '@/components/ui/icons';
import {
markChildFirstContent,
takeChildSessionOpenTiming,
} from '@/lib/child-session-open-timing';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import {
Expand Down Expand Up @@ -83,6 +88,15 @@ export function ChildSessionSheet({
const sheetBottomInset = Math.max(insets.bottom, 16);
let content: ReactNode = null;

useEffect(() => {
if (state !== 'content') return;
markChildFirstContent(sessionId as KiloSessionId);
const timing = takeChildSessionOpenTiming();
if (timing) {
console.debug('child_session_open', timing);
}
}, [state, sessionId]);

if (state === 'content') {
content = (
<SessionMessageList
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/agents/mobile-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '@/components/agents/mobile-session-diagnostics';
import { fetchMobileSessionSnapshotPage } from '@/components/agents/mobile-session-page-adapter';
import { type AgentMode } from '@/components/agents/mode-normalize';
import { setChildOpenPhases } from '@/lib/child-session-open-timing';
import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config';
import { SPAWNED_NOT_FOUND_MAX_ATTEMPTS } from '@/lib/spawned-not-found-retry';
import { trpcClient } from '@/lib/trpc';
Expand Down Expand Up @@ -176,6 +177,9 @@ export function createMobileAgentSessionManager({
onFilePart: (partId, file) => {
cacheFilePart(partId, file);
},
onChildSessionOpenTiming: ({ childSessionId, networkMs, storageMs }) => {
setChildOpenPhases(childSessionId, networkMs, storageMs);
},
resolveSession: async (kiloSessionId: KiloSessionId): Promise<ResolvedSession> => {
// Read-only is only ever returned once we have successful evidence the
// session isn't cloud-agent or remote. A failed query here must
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import {
SESSION_VIEWED_EVENT,
} from '@/lib/analytics/posthog';
import { moveA11yFocus } from '@/lib/a11y/announce';
import { markChildOpenStart } from '@/lib/child-session-open-timing';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
Expand Down Expand Up @@ -635,6 +636,7 @@ export function SessionDetailContent({

const handleOpenChildSession = useCallback(
(childSessionId: KiloSessionId, childTitle: string) => {
markChildOpenStart(childSessionId);
clearChildSheetReleaseTimeout();
setChildSessionSheet(current =>
openChildSessionSheet(current, { sessionId: childSessionId, title: childTitle })
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/src/lib/child-session-open-timing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { type KiloSessionId } from '@kilocode/cloud-agent-sdk';

import type * as ChildSessionOpenTimingModule from './child-session-open-timing';

async function freshTiming(): Promise<typeof ChildSessionOpenTimingModule> {
vi.resetModules();
const mod = import('./child-session-open-timing');
// satisfy require-await without return-await
await Promise.resolve();
return mod;
}

describe('child-session-open-timing', () => {
afterEach(() => {
vi.useRealTimers();
});

it('reports the fresh-open phase math: tap, network, storage, and render', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));

const { markChildOpenStart, markChildFirstContent, setChildOpenPhases, takeChildSessionOpenTiming } =
await freshTiming();

markChildOpenStart('child-1' as KiloSessionId);
vi.advanceTimersByTime(50);
setChildOpenPhases('child-1' as KiloSessionId, 50, 30);
vi.advanceTimersByTime(100);
markChildFirstContent('child-1' as KiloSessionId);

const timing = takeChildSessionOpenTiming();
expect(timing).toEqual({
tapToFirstContentMs: 150,
networkMs: 50,
storageMs: 30,
renderMs: 70,
phase: 'fresh',
});
});

it('reports a cached open with no network/storage and phase cached', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));

const { markChildOpenStart, markChildFirstContent, takeChildSessionOpenTiming } =
await freshTiming();

markChildOpenStart('child-2' as KiloSessionId);
vi.advanceTimersByTime(200);
markChildFirstContent('child-2' as KiloSessionId);

expect(takeChildSessionOpenTiming()).toEqual({
tapToFirstContentMs: 200,
networkMs: 0,
storageMs: 0,
renderMs: 200,
phase: 'cached',
});
});

it('returns null before first content and is taken exactly once', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));

const { markChildOpenStart, markChildFirstContent, takeChildSessionOpenTiming } =
await freshTiming();

markChildOpenStart('child-3' as KiloSessionId);
expect(takeChildSessionOpenTiming()).toBeNull();

markChildFirstContent('child-3' as KiloSessionId);
const first = takeChildSessionOpenTiming();
expect(first).not.toBeNull();

expect(takeChildSessionOpenTiming()).toBeNull();
});

it('ignores marks from a superseded open', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));

const { markChildOpenStart, markChildFirstContent, setChildOpenPhases, takeChildSessionOpenTiming } =
await freshTiming();

markChildOpenStart('child-a' as KiloSessionId);
markChildOpenStart('child-b' as KiloSessionId);

// Late marks for the superseded session must not corrupt the current open.
setChildOpenPhases('child-a' as KiloSessionId, 999, 999);
markChildFirstContent('child-a' as KiloSessionId);

expect(takeChildSessionOpenTiming()).toBeNull();
});
});
71 changes: 71 additions & 0 deletions apps/mobile/src/lib/child-session-open-timing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// One-shot child-session open timing, modelled on `startup-timing`. The tap
// (markChildOpenStart) opens the clock; markChildFirstContent closes it when
// the sheet first shows content; setChildOpenPhases records the SDK-reported
// network/storage cost when the hydrate completes. `takeChildSessionOpenTiming`
// returns the payload exactly once per open, or null when no content arrived
// yet or the payload was already taken.
//
// `phase` is `fresh` when the SDK completed a hydrate (setChildOpenPhases ran)
// and `cached` when content was already in the store and no fetch happened.

import { type KiloSessionId } from '@kilocode/cloud-agent-sdk';

export type ChildSessionOpenTiming = {
/** Tap to first rendered content, in milliseconds. */
tapToFirstContentMs: number;
networkMs: number;
storageMs: number;
renderMs: number;
phase: 'fresh' | 'cached';
};

type ChildOpenRecord = {
sessionId: KiloSessionId;
startMs: number;
networkMs?: number;
storageMs?: number;
firstContentMs?: number;
};

// Only the open sheet is measured at a time; the last open wins.
let current: ChildOpenRecord | undefined = undefined;
let taken = false;

export function markChildOpenStart(sessionId: KiloSessionId): void {
current = { sessionId, startMs: Date.now() };
taken = false;
}

export function markChildFirstContent(sessionId: KiloSessionId): void {
if (!current || current.sessionId !== sessionId || current.firstContentMs !== undefined) return;
current.firstContentMs = Date.now();
}

export function setChildOpenPhases(
sessionId: KiloSessionId,
networkMs: number,
storageMs: number
): void {
if (!current || current.sessionId !== sessionId) return;
current.networkMs = networkMs;
current.storageMs = storageMs;
}

// Returns the event payload exactly once per open, and only after first
// content actually rendered. Null means "nothing to send" — never send a
// partial open, and never send twice. Callers may poll this freely.
export function takeChildSessionOpenTiming(): ChildSessionOpenTiming | null {
if (taken || !current || current.firstContentMs === undefined) return null;
taken = true;
const { startMs, firstContentMs, networkMs, storageMs } = current;
const tapToFirstContentMs = firstContentMs - startMs;
const network = networkMs ?? 0;
const storage = storageMs ?? 0;
return {
tapToFirstContentMs,
networkMs: network,
storageMs: storage,
renderMs: Math.max(0, tapToFirstContentMs - network - storage),
phase: networkMs === undefined ? 'cached' : 'fresh',
};
}
15 changes: 15 additions & 0 deletions apps/web/src/components/cloud-agent-next/ChildSessionDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function ChildSessionDrawer({
? getChildSessionHydrationState(selectedSessionId)
: IDLE_HYDRATION_STATE;
const previousStackDepthRef = useRef(stack.length);
const firstContentMarkedSessionRef = useRef<string | null>(null);
const backButtonRef = useRef<HTMLButtonElement | null>(null);
const headingFocusRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -159,9 +160,23 @@ export function ChildSessionDrawer({

useEffect(() => {
if (!selectedSessionId) return;
performance.mark(`child-open-${selectedSessionId}`);
void manager.hydrateChildSession(selectedSessionId);
}, [manager, selectedSessionId]);

useEffect(() => {
if (!selectedSessionId) return;
if (hydrationState.status !== 'ready' || messages.length === 0) return;
if (firstContentMarkedSessionRef.current === selectedSessionId) return;
firstContentMarkedSessionRef.current = selectedSessionId;
performance.mark(`child-first-content-${selectedSessionId}`);
performance.measure(
`child-open-${selectedSessionId}`,
`child-open-${selectedSessionId}`,
`child-first-content-${selectedSessionId}`
);
}, [hydrationState.status, messages.length, selectedSessionId]);

useEffect(() => {
if (!shouldAutoScroll) return;
scheduleScrollToBottom();
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,29 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi
kilo_session_id: kiloSessionId,
});
},
onChildSessionOpenTiming: ({ childSessionId, networkMs, storageMs }) => {
// The first-content mark lands after React commits the ready state, so
// defer one frame and read the open measure the drawer created.
const emit = () => {
const openEntry = performance
.getEntriesByName(`child-open-${childSessionId}`, 'measure')
.at(-1);
const renderMs = openEntry
? Math.max(0, openEntry.duration - networkMs - storageMs)
: 0;
posthogRef.current?.capture('child_session_open', {
child_session_id: childSessionId,
network_ms: networkMs,
storage_ms: storageMs,
render_ms: renderMs,
});
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(emit);
} else {
emit();
}
},
});
}

Expand Down
54 changes: 54 additions & 0 deletions packages/cloud-agent-sdk/src/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2857,6 +2857,60 @@ describe('createSessionManager', () => {
});
});

it('reports child-session open timing to the config sink once per hydrate', async () => {
const childMessage = createStoredMessage('msg-child-timing', 'child-timing', 'assistant');
const childPart = stubTextPart({
id: 'part-child-timing',
sessionID: 'child-timing',
messageID: childMessage.info.id,
text: 'Timed child message',
});
const onChildSessionOpenTiming = jest.fn() as jest.MockedFunction<
NonNullable<SessionManagerConfig['onChildSessionOpenTiming']>
>;
const config = createMockConfig({
fetchSnapshot: jest.fn().mockResolvedValue(
makeSnapshot({ id: 'child-timing', parentID: 'ses-root' }, [
{ info: childMessage.info, parts: [childPart] },
])
),
onChildSessionOpenTiming,
});
const mgr = createSessionManager(config);

await mgr.switchSession(kiloId('ses-root'));
await mgr.hydrateChildSession(kiloId('child-timing'));

expect(onChildSessionOpenTiming).toHaveBeenCalledTimes(1);
const timing = onChildSessionOpenTiming.mock.calls[0]?.[0];
expect(timing?.childSessionId).toBe(kiloId('child-timing'));
expect(timing?.networkMs).toBeGreaterThanOrEqual(0);
expect(timing?.storageMs).toBeGreaterThanOrEqual(0);
expect(timing?.phase).toBe('fresh');
});

it('does not fail hydration when the timing sink throws', async () => {
const onChildSessionOpenTiming = jest.fn().mockImplementation(() => {
throw new Error('timing sink failure');
}) as jest.MockedFunction<NonNullable<SessionManagerConfig['onChildSessionOpenTiming']>>;
const config = createMockConfig({
fetchSnapshot: jest
.fn()
.mockResolvedValue(makeSnapshot({ id: 'child-sink-throw', parentID: 'ses-root' })),
onChildSessionOpenTiming,
});
const mgr = createSessionManager(config);

await mgr.switchSession(kiloId('ses-root'));
await mgr.hydrateChildSession(kiloId('child-sink-throw'));

const state = atomValue<(childSessionId: string) => { status: string }>(
config.store,
mgr.atoms.childSessionHydrationState
);
expect(state('child-sink-throw')).toEqual(expect.objectContaining({ status: 'ready' }));
});

it('merges fetched history into live child messages without duplicating them', async () => {
const childMessage = createStoredMessage('msg-child-live', 'child-live', 'assistant');
const livePart = stubTextPart({
Expand Down
Loading