Skip to content

Commit 86bc4ef

Browse files
feat(mobile): wire session lifecycle + agent streaming across bridge
- bridge: implement session.open/hop, agent.send/interrupt/stop, agent.subscribe/unsubscribe - workspaceApi: module-level ref so bridge drives WorkspaceView without React context - rpc: 30s request timeout so lost replies reject instead of wedging callers - core: fix isResponse guard for void-result frames (JSON drops undefined) - mobile: SessionScreen tap-through from Connected list Co-authored-by: Tempest <tempestai.dev@gmail.com>
1 parent 66c463b commit 86bc4ef

10 files changed

Lines changed: 607 additions & 22 deletions

File tree

apps/mobile/lib/rpc.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,18 @@ export function startRpcClient({ relayUrl, sessionId, sessionKeyB64, onState })
9595
reconnectTimer = setTimeout(dial, delay);
9696
};
9797

98+
// 30 s covers a slow write_to_pty on a laggy machine plus a heavy burst of
99+
// agent.output crossing on the same socket; anything longer means the reply
100+
// is genuinely lost, so reject instead of wedging the caller.
101+
const REQUEST_TIMEOUT_MS = 30_000;
98102
const request = (method, params) => {
99103
if (!peer) return Promise.reject(new Error('rpc_not_connected'));
100-
return peer.request(method, params);
104+
let timer;
105+
const timeout = new Promise((_, reject) => {
106+
timer = setTimeout(() => reject(new Error(`rpc_timeout:${method}`)), REQUEST_TIMEOUT_MS);
107+
});
108+
return Promise.race([peer.request(method, params), timeout])
109+
.finally(() => clearTimeout(timer));
101110
};
102111

103112
const on = (event, handler) => {

apps/mobile/lib/tempest/core/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export interface RpcParams {
100100
"agent.send": { sessionId: string; text: string };
101101
"agent.interrupt": { sessionId: string };
102102
"agent.stop": { sessionId: string };
103+
"agent.subscribe": { sessionId: string };
104+
"agent.unsubscribe": { sessionId: string };
103105

104106
"permission.list": Record<string, never>;
105107
"permission.decide": { sessionId: string; decision: "approve" | "deny" };
@@ -126,6 +128,8 @@ export interface RpcResult {
126128
"agent.send": void;
127129
"agent.interrupt": void;
128130
"agent.stop": void;
131+
"agent.subscribe": { replay: string[] };
132+
"agent.unsubscribe": void;
129133

130134
"permission.list": PermissionRequest[];
131135
"permission.decide": void;
@@ -149,7 +153,9 @@ export function isRequest(f: WireFrame): f is RpcRequest {
149153
return "method" in f && "id" in f;
150154
}
151155
export function isResponse(f: WireFrame): f is RpcResponse {
152-
return "id" in f && ("result" in f || "error" in f);
156+
// Void-result replies serialize as { id } — JSON.stringify drops `result: undefined`.
157+
// So the guard has to accept "id present, method absent" (event has no id).
158+
return "id" in f && !("method" in f);
153159
}
154160
export function isEvent(f: WireFrame): f is RpcEvent {
155161
return "event" in f && !("id" in (f as object));

apps/mobile/package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"react": "19.1.0",
1717
"react-native": "0.81.5",
1818
"react-native-safe-area-context": "~5.6.0",
19+
"react-native-webview": "13.15.0",
1920
"three": "^0.166.1",
2021
"tweetnacl": "^1.0.3",
2122
"tweetnacl-util": "^0.15.1"

apps/mobile/screens/Connected.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { View, Text, Pressable, ScrollView, ActivityIndicator, StyleSheet } from
33
import { SafeAreaView } from 'react-native-safe-area-context';
44
import { StatusBar } from 'expo-status-bar';
55
import { startRpcClient } from '../lib/rpc';
6+
import SessionScreen from './SessionScreen';
67

78
const geist = { regular: 'Geist_400Regular', medium: 'Geist_500Medium', semibold: 'Geist_600SemiBold' };
89

@@ -51,6 +52,7 @@ export default function Connected({ pairing, onUnpair, onBack }) {
5152
// branches. Everything else defaults to expanded — sidebar parity.
5253
const [collapsedProjects, setCollapsedProjects] = useState(() => new Set());
5354
const [collapsedBranches, setCollapsedBranches] = useState(() => new Set());
55+
const [selectedSessionId, setSelectedSessionId] = useState(null);
5456
const clientRef = useRef(null);
5557

5658
useEffect(() => {
@@ -87,6 +89,7 @@ export default function Connected({ pairing, onUnpair, onBack }) {
8789
.catch((e) => { if (!cancelled) setError(e.message); });
8890

8991
const offUpdated = client.on('session.updated', (s) => {
92+
console.log(`[Connected] session.updated id=${s.id.slice(0, 8)} status=${s.status} closed=${s.closed}`);
9093
setSnapshot((prev) => {
9194
if (!prev) return prev;
9295
const i = prev.sessions.findIndex((x) => x.id === s.id);
@@ -228,6 +231,22 @@ export default function Connected({ pairing, onUnpair, onBack }) {
228231
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
229232
});
230233

234+
// A row was tapped — hand control to SessionScreen, sharing the live client.
235+
// If the session vanishes (removed / snapshot missing), fall back to the list.
236+
const selectedSession = selectedSessionId
237+
? (snapshot?.sessions || []).find((s) => s.id === selectedSessionId) || null
238+
: null;
239+
if (selectedSession) {
240+
return (
241+
<SessionScreen
242+
client={clientRef.current}
243+
session={selectedSession}
244+
connState={connState}
245+
onBack={() => setSelectedSessionId(null)}
246+
/>
247+
);
248+
}
249+
231250
return (
232251
<SafeAreaView style={{ flex: 1, backgroundColor: '#09090b' }}>
233252
<StatusBar style="light" />
@@ -313,7 +332,7 @@ export default function Connected({ pairing, onUnpair, onBack }) {
313332
{!branchCollapsed && (
314333
<View style={styles.sessionList}>
315334
{g.sessions.map((s) => (
316-
<SessionRow key={s.id} session={s} />
335+
<SessionRow key={s.id} session={s} onPress={() => setSelectedSessionId(s.id)} />
317336
))}
318337
</View>
319338
)}
@@ -374,11 +393,16 @@ function Chevron({ open, size = 'normal' }) {
374393
);
375394
}
376395

377-
function SessionRow({ session }) {
396+
function SessionRow({ session, onPress }) {
378397
const kind = session.closed ? 'closed' : (session.status || 'idle');
379398
const dotColor = session.closed ? '#3a3a40' : STATUS_COLOR[kind === 'closed' ? 'done' : kind];
380399
return (
381-
<View style={[styles.sessionRow, session.closed && { opacity: 0.5 }]}>
400+
<Pressable
401+
style={({ pressed }) => [styles.sessionRow, session.closed && { opacity: 0.5 }, pressed && { backgroundColor: '#18181b' }]}
402+
onPress={session.closed ? undefined : onPress}
403+
disabled={session.closed}
404+
hitSlop={4}
405+
>
382406
<View style={[styles.sessionDot, { backgroundColor: dotColor }]} />
383407
<Text style={styles.sessionName} numberOfLines={1}>
384408
{session.name}
@@ -392,7 +416,7 @@ function SessionRow({ session }) {
392416
{session.needsPermission ? (
393417
<View style={styles.approvalDot} />
394418
) : null}
395-
</View>
419+
</Pressable>
396420
);
397421
}
398422

0 commit comments

Comments
 (0)