Skip to content

Commit 52f1682

Browse files
authored
feat(cloud-agent): add live session updates to sidebar (#3530)
* feat(cloud-agent): monitor sessions over ingest websocket * feat(cloud-agent): harden shared viewer session updates * perf(cloud-agent): coalesce sidebar status reconciliation * perf(session-ingest): avoid session event follow-up reads
1 parent 9b0bba3 commit 52f1682

40 files changed

Lines changed: 4876 additions & 1939 deletions
Lines changed: 83 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Stack } from 'expo-router';
22

3+
import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider';
34
import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount';
45
import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider';
56
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -9,87 +10,89 @@ export default function AppLayout() {
910
const colors = useThemeColors();
1011

1112
return (
12-
<KiloChatProvider>
13-
<KiloChatPresenceMount>
14-
<StoreKiloPassPurchaseProvider>
15-
<Stack
16-
screenOptions={{
17-
contentStyle: { backgroundColor: colors.background },
18-
headerShown: false,
19-
headerStyle: { backgroundColor: colors.background },
20-
headerTintColor: colors.foreground,
21-
}}
22-
>
23-
<Stack.Screen name="(tabs)" />
24-
<Stack.Screen name="agent-chat/new" options={{ headerShown: false }} />
25-
<Stack.Screen name="agent-chat/[session-id]" />
26-
<Stack.Screen
27-
name="agent-chat/model-picker"
28-
options={{
29-
presentation: 'formSheet',
30-
sheetAllowedDetents: [0.5, 1],
31-
sheetGrabberVisible: true,
13+
<UserWebConnectionProvider>
14+
<KiloChatProvider>
15+
<KiloChatPresenceMount>
16+
<StoreKiloPassPurchaseProvider>
17+
<Stack
18+
screenOptions={{
19+
contentStyle: { backgroundColor: colors.background },
3220
headerShown: false,
21+
headerStyle: { backgroundColor: colors.background },
22+
headerTintColor: colors.foreground,
3323
}}
34-
/>
35-
<Stack.Screen
36-
name="agent-chat/repo-picker"
37-
options={{
38-
presentation: 'formSheet',
39-
sheetAllowedDetents: [0.5, 1],
40-
sheetGrabberVisible: true,
41-
headerShown: false,
42-
}}
43-
/>
44-
<Stack.Screen
45-
name="agent-chat/mode-picker"
46-
options={{
47-
presentation: 'formSheet',
48-
sheetAllowedDetents: [0.5],
49-
sheetGrabberVisible: true,
50-
headerShown: true,
51-
title: 'Select Mode',
52-
}}
53-
/>
54-
<Stack.Screen
55-
name="profile"
56-
options={{
57-
presentation: 'modal',
58-
headerShown: false,
59-
}}
60-
/>
61-
<Stack.Screen
62-
name="kilo-pass"
63-
options={{
64-
presentation: 'modal',
65-
headerShown: false,
66-
}}
67-
/>
68-
<Stack.Screen
69-
name="onboarding"
70-
options={{
71-
presentation: 'modal',
72-
headerShown: false,
73-
gestureEnabled: false,
74-
}}
75-
/>
76-
<Stack.Screen
77-
name="consent"
78-
options={{
79-
presentation: 'modal',
80-
headerShown: false,
81-
gestureEnabled: false,
82-
}}
83-
/>
84-
<Stack.Screen
85-
name="consent-details"
86-
options={{
87-
headerShown: false,
88-
}}
89-
/>
90-
</Stack>
91-
</StoreKiloPassPurchaseProvider>
92-
</KiloChatPresenceMount>
93-
</KiloChatProvider>
24+
>
25+
<Stack.Screen name="(tabs)" />
26+
<Stack.Screen name="agent-chat/new" options={{ headerShown: false }} />
27+
<Stack.Screen name="agent-chat/[session-id]" />
28+
<Stack.Screen
29+
name="agent-chat/model-picker"
30+
options={{
31+
presentation: 'formSheet',
32+
sheetAllowedDetents: [0.5, 1],
33+
sheetGrabberVisible: true,
34+
headerShown: false,
35+
}}
36+
/>
37+
<Stack.Screen
38+
name="agent-chat/repo-picker"
39+
options={{
40+
presentation: 'formSheet',
41+
sheetAllowedDetents: [0.5, 1],
42+
sheetGrabberVisible: true,
43+
headerShown: false,
44+
}}
45+
/>
46+
<Stack.Screen
47+
name="agent-chat/mode-picker"
48+
options={{
49+
presentation: 'formSheet',
50+
sheetAllowedDetents: [0.5],
51+
sheetGrabberVisible: true,
52+
headerShown: true,
53+
title: 'Select Mode',
54+
}}
55+
/>
56+
<Stack.Screen
57+
name="profile"
58+
options={{
59+
presentation: 'modal',
60+
headerShown: false,
61+
}}
62+
/>
63+
<Stack.Screen
64+
name="kilo-pass"
65+
options={{
66+
presentation: 'modal',
67+
headerShown: false,
68+
}}
69+
/>
70+
<Stack.Screen
71+
name="onboarding"
72+
options={{
73+
presentation: 'modal',
74+
headerShown: false,
75+
gestureEnabled: false,
76+
}}
77+
/>
78+
<Stack.Screen
79+
name="consent"
80+
options={{
81+
presentation: 'modal',
82+
headerShown: false,
83+
gestureEnabled: false,
84+
}}
85+
/>
86+
<Stack.Screen
87+
name="consent-details"
88+
options={{
89+
headerShown: false,
90+
}}
91+
/>
92+
</Stack>
93+
</StoreKiloPassPurchaseProvider>
94+
</KiloChatPresenceMount>
95+
</KiloChatProvider>
96+
</UserWebConnectionProvider>
9497
);
9598
}

apps/mobile/src/components/agents/mobile-session-manager.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { createStore } from 'jotai';
3+
import { type UserWebConnection } from 'cloud-agent-sdk';
34

45
const mocks = vi.hoisted(() => ({
56
createSessionManager: vi.fn(config => ({ config })),
67
getWithRuntimeStateQuery: vi.fn(),
78
}));
89

10+
function noCleanup(): void {
11+
return undefined;
12+
}
13+
14+
const userWebConnection: UserWebConnection = {
15+
retain: vi.fn(() => noCleanup),
16+
connect: vi.fn(() => undefined),
17+
disconnect: vi.fn(() => undefined),
18+
destroy: vi.fn(() => undefined),
19+
subscribeToCliSession: vi.fn(() => noCleanup),
20+
sendCommand: vi.fn(),
21+
onCliEvent: vi.fn(() => noCleanup),
22+
onSystemEvent: vi.fn(() => noCleanup),
23+
onReconnect: vi.fn(() => noCleanup),
24+
onSessionEvent: vi.fn(() => noCleanup),
25+
};
26+
927
vi.mock('cloud-agent-sdk', () => ({
1028
createSessionManager: mocks.createSessionManager,
1129
}));
@@ -35,7 +53,6 @@ vi.mock('@/components/agents/mobile-session-diagnostics', () => ({
3553
vi.mock('@/lib/config', () => ({
3654
API_BASE_URL: 'https://api.example.com',
3755
CLOUD_AGENT_WS_URL: 'wss://agent.example.com',
38-
SESSION_INGEST_WS_URL: 'wss://ingest.example.com',
3956
WEB_BASE_URL: 'https://web.example.com',
4057
}));
4158

@@ -48,6 +65,9 @@ vi.mock('@/lib/trpc', () => ({
4865
}));
4966

5067
type CapturedSessionManagerConfig = {
68+
userWebConnection: unknown;
69+
cliWebsocketUrl?: string;
70+
getAuthToken?: () => Promise<string>;
5171
fetchSession: (kiloSessionId: string) => Promise<{ associatedPr: unknown }>;
5272
};
5373

@@ -56,6 +76,21 @@ describe('createMobileAgentSessionManager', () => {
5676
vi.clearAllMocks();
5777
});
5878

79+
it('injects the app-scoped user web connection without raw viewer transport options', async () => {
80+
const { createMobileAgentSessionManager } =
81+
await import('@/components/agents/mobile-session-manager');
82+
83+
createMobileAgentSessionManager({
84+
store: createStore(),
85+
userWebConnection,
86+
});
87+
88+
const config = mocks.createSessionManager.mock.calls[0]?.[0] as CapturedSessionManagerConfig;
89+
expect(config.userWebConnection).toBe(userWebConnection);
90+
expect(config.cliWebsocketUrl).toBeUndefined();
91+
expect(config.getAuthToken).toBeUndefined();
92+
});
93+
5994
it('propagates associatedPr from fetched session data', async () => {
6095
const { createMobileAgentSessionManager } =
6196
await import('@/components/agents/mobile-session-manager');
@@ -78,7 +113,10 @@ describe('createMobileAgentSessionManager', () => {
78113
runtimeState: null,
79114
});
80115

81-
createMobileAgentSessionManager({ store: createStore() });
116+
createMobileAgentSessionManager({
117+
store: createStore(),
118+
userWebConnection,
119+
});
82120

83121
const config = mocks.createSessionManager.mock.calls[0]?.[0] as CapturedSessionManagerConfig;
84122
const session = await config.fetchSession('ses_123');

apps/mobile/src/components/agents/mobile-session-manager.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,21 @@ import {
1010
type SessionManager,
1111
type SessionSnapshot,
1212
type TransportSendPayload,
13+
type UserWebConnection,
1314
} from 'cloud-agent-sdk';
1415
import { normalizeAgentMode } from '@/components/agents/mode-options';
1516
import {
1617
formatSafeCloudAgentFailureDiagnostic,
1718
withCloudAgentDiagnostics,
1819
} from '@/components/agents/mobile-session-diagnostics';
1920
import { trpcClient } from '@/lib/trpc';
20-
import {
21-
API_BASE_URL,
22-
CLOUD_AGENT_WS_URL,
23-
SESSION_INGEST_WS_URL,
24-
WEB_BASE_URL,
25-
} from '@/lib/config';
21+
import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config';
2622
import { AUTH_TOKEN_KEY } from '@/lib/storage-keys';
2723
import { type SendMessagePayload } from '@/lib/cloud-agent-next/types';
2824

2925
type CreateMobileAgentSessionManagerOptions = {
3026
store: JotaiStore;
27+
userWebConnection: UserWebConnection;
3128
organizationId?: string;
3229
};
3330

@@ -59,13 +56,14 @@ function normalizeTransportPayload(payload: TransportSendPayload): SendMessagePa
5956

6057
export function createMobileAgentSessionManager({
6158
store,
59+
userWebConnection,
6260
organizationId,
6361
}: Readonly<CreateMobileAgentSessionManagerOptions>): SessionManager {
6462
return createSessionManager({
6563
store,
6664
websocketBaseUrl: CLOUD_AGENT_WS_URL,
6765
websocketHeaders: { Origin: WEB_BASE_URL },
68-
cliWebsocketUrl: `${SESSION_INGEST_WS_URL}/api/user/web`,
66+
userWebConnection,
6967
resolveSession: async (kiloSessionId: KiloSessionId): Promise<ResolvedSession> => {
7068
try {
7169
const session = await trpcClient.cliSessionsV2.get.query({ session_id: kiloSessionId });
@@ -133,10 +131,6 @@ export function createMobileAgentSessionManager({
133131
messages: messagesResult.messages as SessionSnapshot['messages'],
134132
};
135133
},
136-
getAuthToken: async () => {
137-
const result = await trpcClient.activeSessions.getToken.query();
138-
return result.token;
139-
},
140134
api: {
141135
send: async input => {
142136
await withCloudAgentDiagnostics('send', organizationId, async () => {

apps/mobile/src/components/agents/session-provider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createContext, type ReactNode, useContext, useEffect, useRef } from 're
22
import { createStore, Provider as JotaiProvider } from 'jotai';
33
import { type SessionManager } from 'cloud-agent-sdk';
44
import { createMobileAgentSessionManager } from '@/components/agents/mobile-session-manager';
5+
import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
56

67
const ManagerContext = createContext<SessionManager | null>(null);
78

@@ -14,10 +15,12 @@ export function AgentSessionProvider({
1415
children,
1516
organizationId,
1617
}: Readonly<AgentSessionProviderProps>) {
18+
const userWebConnection = useUserWebConnection();
1719
const storeRef = useRef(createStore());
1820
const managerRef = useRef<SessionManager | null>(null);
1921
managerRef.current ??= createMobileAgentSessionManager({
2022
store: storeRef.current,
23+
userWebConnection,
2124
organizationId,
2225
});
2326

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react';
2+
import { createUserWebConnection, type UserWebConnection } from 'cloud-agent-sdk';
3+
4+
import { SESSION_INGEST_WS_URL } from '@/lib/config';
5+
import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-connection-lifecycle';
6+
import { trpcClient } from '@/lib/trpc';
7+
8+
const UserWebConnectionContext = createContext<UserWebConnection | null>(null);
9+
10+
type UserWebConnectionProviderProps = {
11+
children: ReactNode;
12+
};
13+
14+
export function UserWebConnectionProvider({ children }: Readonly<UserWebConnectionProviderProps>) {
15+
const connectionRef = useRef<UserWebConnection | null>(null);
16+
connectionRef.current ??= createUserWebConnection({
17+
websocketUrl: `${SESSION_INGEST_WS_URL}/api/user/web`,
18+
getAuthToken: async () => {
19+
const result = await trpcClient.activeSessions.getToken.query();
20+
return result.token;
21+
},
22+
lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(),
23+
});
24+
25+
useEffect(() => {
26+
const connection = connectionRef.current;
27+
return () => {
28+
connection?.destroy();
29+
};
30+
}, []);
31+
32+
return (
33+
<UserWebConnectionContext.Provider value={connectionRef.current}>
34+
{children}
35+
</UserWebConnectionContext.Provider>
36+
);
37+
}
38+
39+
export function useUserWebConnection(): UserWebConnection {
40+
const connection = useContext(UserWebConnectionContext);
41+
if (!connection) {
42+
throw new Error('useUserWebConnection must be used within UserWebConnectionProvider');
43+
}
44+
return connection;
45+
}

0 commit comments

Comments
 (0)