@cjhyy/code-shell-web 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1015 @@
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import { reduceStream, initialChatState, appendUserMessage, } from "../lib/streamReducer.js";
3
+ import { t } from "../i18n/translate.js";
4
+ import { summarizeApproval } from "../lib/riskClassify.js";
5
+ import { roomMsgToEvent, roomHistoryToEvents, ccHistoryToEvents, extractAskUserOptions, } from "../lib/messageMappers.js";
6
+ import { projectForCwd } from "../lib/format.js";
7
+ import { useRemoteSocket } from "./useRemoteSocket.js";
8
+ import { prepareMobileAttachments, } from "../lib/mobileAttachments.js";
9
+ import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, noteSessionSeq, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
10
+ /** Tools whose grants can be remembered with a path scope (file/dir). */
11
+ const PATH_SCOPED = new Set([
12
+ "Read",
13
+ "Edit",
14
+ "Write",
15
+ "NotebookEdit",
16
+ "Glob",
17
+ "Grep",
18
+ "ApplyPatch",
19
+ ]);
20
+ function projectContextCwd(cwd, projects) {
21
+ if (!cwd)
22
+ return cwd;
23
+ return projectForCwd(cwd, projects)?.path ?? cwd;
24
+ }
25
+ function chatReducer(state, action) {
26
+ switch (action.kind) {
27
+ case "raw":
28
+ return reduceStream(state, action.raw);
29
+ case "user":
30
+ return appendUserMessage(state, action.text, action.attachments);
31
+ case "reset":
32
+ return initialChatState();
33
+ case "replay":
34
+ return action.events.reduce(reduceStream, initialChatState());
35
+ case "append":
36
+ return action.events.reduce(reduceStream, state);
37
+ }
38
+ }
39
+ export function useRemoteApp() {
40
+ const [chat, dispatchChat] = useReducer(chatReducer, undefined, initialChatState);
41
+ const [sessions, setSessions] = useState([]);
42
+ const [unreadSessionIds, setUnreadSessionIds] = useState(() => new Set());
43
+ const [activeSessionId, setActiveSessionId] = useState();
44
+ const [activeSessionCwd, setActiveSessionCwd] = useState();
45
+ const [rooms, setRooms] = useState([]);
46
+ const roomsRef = useRef(rooms);
47
+ roomsRef.current = rooms;
48
+ const [projects, setProjects] = useState([]);
49
+ const [activeRoomId, setActiveRoomId] = useState();
50
+ // activeRoomId via ref so socket callbacks see the latest value.
51
+ const activeRoomIdRef = useRef(undefined);
52
+ activeRoomIdRef.current = activeRoomId;
53
+ const [approvals, setApprovals] = useState([]);
54
+ const approvalsRef = useRef(approvals);
55
+ approvalsRef.current = approvals;
56
+ // The SELECTED project (one-true-source for "what am I looking at"), distinct
57
+ // from activeSessionCwd which is derived from the bound session. Drives session
58
+ // list filtering AND ccRoom.listSessions.
59
+ const [activeProjectCwd, setActiveProjectCwd] = useState(null);
60
+ const activeProjectCwdRef = useRef(activeProjectCwd);
61
+ activeProjectCwdRef.current = activeProjectCwd;
62
+ // Which external CLI the CC pane is showing (Claude Code or Codex). Switching
63
+ // re-probes + re-lists for that CLI (mirrors desktop CCRoomView's cliKind).
64
+ const [ccCliKind, setCcCliKind] = useState("claude-code");
65
+ const ccCliKindRef = useRef(ccCliKind);
66
+ ccCliKindRef.current = ccCliKind;
67
+ // External-CLI sessions discovered for activeProjectCwd (under ccCliKind).
68
+ const [ccSessions, setCcSessions] = useState([]);
69
+ const [ccProbe, setCcProbe] = useState(null);
70
+ /** socket.send via ref — onServerEvent is created BEFORE the socket (it's the
71
+ * socket's callback), so it can't close over `socket` directly. */
72
+ const sendRef = useRef(null);
73
+ const connectionGenerationRef = useRef(0);
74
+ const uploadWaitersRef = useRef(new Map());
75
+ const messageAckWaitersRef = useRef(new Map());
76
+ const [permissionMode, setPermissionModeState] = useState("default");
77
+ /** Transient banner message (errors, room-missing, …). Auto-clears. */
78
+ const [notice, setNoticeState] = useState();
79
+ const noticeTimer = useRef(undefined);
80
+ const setNotice = useCallback((msg) => {
81
+ setNoticeState(msg);
82
+ if (noticeTimer.current)
83
+ clearTimeout(noticeTimer.current);
84
+ noticeTimer.current = setTimeout(() => setNoticeState(undefined), 5000);
85
+ }, []);
86
+ const beginMobileUpload = useCallback((metadata, expectedGeneration) => {
87
+ return new Promise((resolve, reject) => {
88
+ if (!sendRef.current) {
89
+ reject(new Error("Remote connection is unavailable"));
90
+ return;
91
+ }
92
+ const previous = uploadWaitersRef.current.get(metadata.clientId);
93
+ if (previous) {
94
+ clearTimeout(previous.timer);
95
+ previous.reject(new Error("Attachment upload was replaced"));
96
+ }
97
+ const timer = setTimeout(() => {
98
+ uploadWaitersRef.current.delete(metadata.clientId);
99
+ reject(new Error("Attachment upload ticket timed out"));
100
+ }, 10_000);
101
+ uploadWaitersRef.current.set(metadata.clientId, {
102
+ resolve,
103
+ reject,
104
+ timer,
105
+ connectionGeneration: expectedGeneration,
106
+ });
107
+ if (!sendRef.current({ type: "attachment.upload.begin", ...metadata }, expectedGeneration)) {
108
+ uploadWaitersRef.current.delete(metadata.clientId);
109
+ clearTimeout(timer);
110
+ reject(new Error("Remote connection changed before upload ticket request"));
111
+ }
112
+ });
113
+ }, []);
114
+ const waitForMessageAck = useCallback((clientMessageId, connectionGeneration) => new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ messageAckWaitersRef.current.delete(clientMessageId);
117
+ reject(new Error("Message acknowledgement timed out"));
118
+ }, 15_000);
119
+ messageAckWaitersRef.current.set(clientMessageId, {
120
+ resolve,
121
+ reject,
122
+ timer,
123
+ connectionGeneration,
124
+ });
125
+ }), []);
126
+ const settleMessageAck = useCallback((clientMessageId, error) => {
127
+ const waiter = messageAckWaitersRef.current.get(clientMessageId);
128
+ if (!waiter)
129
+ return;
130
+ messageAckWaitersRef.current.delete(clientMessageId);
131
+ clearTimeout(waiter.timer);
132
+ if (error)
133
+ waiter.reject(error);
134
+ else
135
+ waiter.resolve();
136
+ }, []);
137
+ useEffect(() => () => {
138
+ for (const waiter of uploadWaitersRef.current.values()) {
139
+ clearTimeout(waiter.timer);
140
+ waiter.reject(new Error("Remote app closed"));
141
+ }
142
+ uploadWaitersRef.current.clear();
143
+ for (const waiter of messageAckWaitersRef.current.values()) {
144
+ clearTimeout(waiter.timer);
145
+ waiter.reject(new Error("Remote app closed"));
146
+ }
147
+ messageAckWaitersRef.current.clear();
148
+ }, []);
149
+ /** Which session id the chat view is bound to (for filtering live stream). */
150
+ const boundSessionRef = useRef(undefined);
151
+ /** Highest mobile snapshot seq applied per desktop session. */
152
+ const appliedSeqRef = useRef(new Map());
153
+ /** Highest live/snapshot seq observed per desktop session, including sessions not being viewed. */
154
+ const lastSessionSeqRef = useRef(new Map());
155
+ /** Highest room message seq seen per active room. Used as room.history cursor. */
156
+ const lastRoomSeqRef = useRef(new Map());
157
+ /** Recent room seqs already folded into the reducer, for idempotent history/live merge. */
158
+ const appliedRoomSeqsRef = useRef(new Map());
159
+ /** The cc (external claude CLI) session id whose on-disk transcript is the
160
+ * backlog for the current room view. Set on ccRoom.opened, cleared whenever we
161
+ * switch to a plain session/room so late disk-history replies cannot replay
162
+ * into a different conversation. */
163
+ const ccHistorySessionRef = useRef(undefined);
164
+ /** Cwd bound to the currently-open CC room. Project selection may change
165
+ * independently while this room stays open, so reconnect must not derive the
166
+ * subscription cwd from activeProjectCwd. */
167
+ const ccHistoryCwdRef = useRef(undefined);
168
+ /** openSession replies do not echo cwd. Keep it keyed by the requested
169
+ * session until ccRoom.opened confirms which room acquired it. */
170
+ const pendingCcOpenCwdsRef = useRef(new Map());
171
+ const ccBacklogLoadedRef = useRef(false);
172
+ /** Which CLI the currently-open cc session belongs to — selects the on-disk
173
+ * history reader (claude vs codex rollout) for ccRoom.readHistory. Captured
174
+ * at open time so a later CLI switch can't misroute this session's backlog. */
175
+ const ccHistoryKindRef = useRef("claude-code");
176
+ const activeCwdRef = useRef(undefined);
177
+ const [loading, setLoading] = useState({
178
+ sessions: false,
179
+ sessionHistory: false,
180
+ rooms: false,
181
+ projects: false,
182
+ roomHistory: false,
183
+ ccSessions: false,
184
+ });
185
+ const setLoadingKey = useCallback((key, value) => {
186
+ setLoading((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
187
+ }, []);
188
+ const clearApproval = useCallback((requestId) => {
189
+ approvalsRef.current = removeResolvedApproval(approvalsRef.current, requestId);
190
+ setApprovals((prev) => removeResolvedApproval(prev, requestId));
191
+ }, []);
192
+ const rememberRoomMessages = useCallback((roomId, messages, latestSeq) => {
193
+ const current = lastRoomSeqRef.current.get(roomId) ?? 0;
194
+ lastRoomSeqRef.current.set(roomId, maxRoomSeq(current, messages, latestSeq));
195
+ for (const msg of messages) {
196
+ const seq = roomMessageSeq(msg);
197
+ if (seq !== undefined)
198
+ markRoomSeqApplied(appliedRoomSeqsRef.current, roomId, seq);
199
+ }
200
+ }, []);
201
+ const wrapSessionEvents = useCallback((sessionId, events) => events.map((event) => ({
202
+ method: "agent/streamEvent",
203
+ params: { sessionId, event },
204
+ })), []);
205
+ const clearApprovalsIfTerminal = useCallback((events) => {
206
+ if (events.some((event) => {
207
+ const type = event?.type;
208
+ return type === "turn_complete" || type === "error";
209
+ })) {
210
+ approvalsRef.current = [];
211
+ setApprovals([]);
212
+ }
213
+ }, []);
214
+ const appendSessionEvents = useCallback((sessionId, events) => {
215
+ if (events.length === 0)
216
+ return;
217
+ dispatchChat({ kind: "append", events: wrapSessionEvents(sessionId, events) });
218
+ clearApprovalsIfTerminal(events);
219
+ }, [clearApprovalsIfTerminal, wrapSessionEvents]);
220
+ const clearSessionUnread = useCallback((sessionId) => {
221
+ setUnreadSessionIds((prev) => clearUnreadSession(prev, sessionId));
222
+ }, []);
223
+ const sessionCwdById = useMemo(() => {
224
+ const out = new Map();
225
+ for (const s of sessions)
226
+ out.set(s.id, s.cwd || null);
227
+ return out;
228
+ }, [sessions]);
229
+ const addApproval = useCallback((a) => {
230
+ // Isolate to the bound session: an approval for some OTHER desktop session
231
+ // must not pop on this phone's feed. If nothing is bound yet, auto-bind to
232
+ // the approval's session (same first-coherent-conversation rule as streams)
233
+ // so the request the user sees belongs to the conversation they're driving.
234
+ if (activeRoomIdRef.current)
235
+ return; // rooms don't surface chat approvals
236
+ if (a.sessionId) {
237
+ if (boundSessionRef.current && a.sessionId !== boundSessionRef.current)
238
+ return;
239
+ if (!boundSessionRef.current) {
240
+ boundSessionRef.current = a.sessionId;
241
+ setActiveSessionId(a.sessionId);
242
+ }
243
+ }
244
+ setApprovals((prev) => (prev.some((p) => p.requestId === a.requestId) ? prev : [...prev, a]));
245
+ }, []);
246
+ const onServerEvent = useCallback((event) => {
247
+ switch (event.type) {
248
+ case "auth.ok":
249
+ // Pull the world on connect.
250
+ break;
251
+ case "chat.accepted":
252
+ if (event.clientMessageId) {
253
+ const waiter = messageAckWaitersRef.current.get(event.clientMessageId);
254
+ settleMessageAck(event.clientMessageId, waiter && waiter.connectionGeneration !== connectionGenerationRef.current
255
+ ? new Error("Message acknowledgement arrived on a different connection")
256
+ : undefined);
257
+ }
258
+ if (event.sessionId) {
259
+ setActiveSessionId(event.sessionId);
260
+ boundSessionRef.current = event.sessionId;
261
+ clearSessionUnread(event.sessionId);
262
+ }
263
+ if ("cwd" in event)
264
+ setActiveSessionCwd(event.cwd ?? null);
265
+ // A freshly minted session won't be on disk until its first turn, so
266
+ // pull the list now so the new conversation shows up as a row.
267
+ sendRef.current?.({ type: "session.list" });
268
+ break;
269
+ case "attachment.upload.ready": {
270
+ const waiter = uploadWaitersRef.current.get(event.clientId);
271
+ if (!waiter)
272
+ break;
273
+ uploadWaitersRef.current.delete(event.clientId);
274
+ clearTimeout(waiter.timer);
275
+ if (waiter.connectionGeneration !== connectionGenerationRef.current) {
276
+ waiter.reject(new Error("Upload ticket arrived on a different connection"));
277
+ }
278
+ else {
279
+ waiter.resolve(event);
280
+ }
281
+ break;
282
+ }
283
+ case "attachment.upload.failed": {
284
+ const waiter = uploadWaitersRef.current.get(event.clientId);
285
+ if (!waiter)
286
+ break;
287
+ uploadWaitersRef.current.delete(event.clientId);
288
+ clearTimeout(waiter.timer);
289
+ waiter.reject(new Error(event.message));
290
+ break;
291
+ }
292
+ case "room.accepted": {
293
+ const waiter = messageAckWaitersRef.current.get(event.clientMessageId);
294
+ settleMessageAck(event.clientMessageId, waiter && waiter.connectionGeneration !== connectionGenerationRef.current
295
+ ? new Error("Message acknowledgement arrived on a different connection")
296
+ : undefined);
297
+ break;
298
+ }
299
+ case "session.list.ok":
300
+ setSessions(event.sessions);
301
+ setUnreadSessionIds((prev) => {
302
+ const pruned = pruneUnreadSessions(prev, event.sessions.map((s) => s.id));
303
+ return clearUnreadSession(pruned, event.activeSessionId);
304
+ });
305
+ setLoadingKey("sessions", false);
306
+ if (event.activeSessionId) {
307
+ setActiveSessionId(event.activeSessionId);
308
+ const active = event.sessions.find((s) => s.id === event.activeSessionId);
309
+ if (active)
310
+ setActiveSessionCwd(active.cwd || null);
311
+ else
312
+ setActiveSessionCwd(undefined);
313
+ }
314
+ break;
315
+ case "session.history.ok":
316
+ // Only apply if it's the session we're currently viewing.
317
+ if (event.sessionId === boundSessionRef.current) {
318
+ dispatchChat({ kind: "replay", events: event.events });
319
+ setLoadingKey("sessionHistory", false);
320
+ }
321
+ break;
322
+ case "session.snapshot": {
323
+ if (event.sessionId !== boundSessionRef.current || activeRoomIdRef.current)
324
+ break;
325
+ const appliedSeq = appliedSeqRef.current.get(event.sessionId) ?? 0;
326
+ const { events, cursor } = selectSessionReplayEntries(event.entries, appliedSeq);
327
+ if (cursor > appliedSeq)
328
+ appliedSeqRef.current.set(event.sessionId, cursor);
329
+ if (cursor > 0)
330
+ noteSessionSeq(lastSessionSeqRef.current, event.sessionId, cursor);
331
+ appendSessionEvents(event.sessionId, events);
332
+ setLoadingKey("sessionHistory", false);
333
+ break;
334
+ }
335
+ case "session.stream": {
336
+ if (!noteSessionSeq(lastSessionSeqRef.current, event.sessionId, event.seq))
337
+ break;
338
+ if (activeRoomIdRef.current) {
339
+ setUnreadSessionIds((prev) => markSessionUnread(prev, event.sessionId));
340
+ break;
341
+ }
342
+ if (boundSessionRef.current) {
343
+ if (event.sessionId !== boundSessionRef.current) {
344
+ setUnreadSessionIds((prev) => markSessionUnread(prev, event.sessionId, boundSessionRef.current));
345
+ break;
346
+ }
347
+ }
348
+ else {
349
+ boundSessionRef.current = event.sessionId;
350
+ setActiveSessionId(event.sessionId);
351
+ setActiveSessionCwd(sessionCwdById.get(event.sessionId));
352
+ }
353
+ clearSessionUnread(event.sessionId);
354
+ const appliedSeq = appliedSeqRef.current.get(event.sessionId) ?? 0;
355
+ if (event.seq <= appliedSeq)
356
+ break;
357
+ appliedSeqRef.current.set(event.sessionId, event.seq);
358
+ appendSessionEvents(event.sessionId, [event.event]);
359
+ break;
360
+ }
361
+ case "permission.mode":
362
+ if (!event.sessionId ||
363
+ !boundSessionRef.current ||
364
+ event.sessionId === boundSessionRef.current) {
365
+ setPermissionModeState(event.mode);
366
+ }
367
+ break;
368
+ case "approval.resolved":
369
+ clearApproval(event.approvalId);
370
+ break;
371
+ case "room.list.ok":
372
+ setRooms(event.rooms);
373
+ setLoadingKey("rooms", false);
374
+ break;
375
+ case "room.projects.ok":
376
+ setProjects(event.projects);
377
+ setLoadingKey("projects", false);
378
+ break;
379
+ case "room.message":
380
+ if (event.roomId === activeRoomIdRef.current && event.msg) {
381
+ // CC disk backlog is replayed with `replay` below. Folding live room
382
+ // messages before that replay would display them briefly and then
383
+ // wipe them while also advancing sinceSeq, so let room.history pull
384
+ // them after the backlog has landed.
385
+ if (ccHistorySessionRef.current && !ccBacklogLoadedRef.current)
386
+ break;
387
+ const freshMessages = filterNewRoomMessages(event.roomId, [event.msg], appliedRoomSeqsRef.current);
388
+ if (freshMessages.length === 0)
389
+ break;
390
+ rememberRoomMessages(event.roomId, freshMessages);
391
+ dispatchChat({ kind: "raw", raw: roomMsgToEvent(freshMessages[0]) });
392
+ }
393
+ break;
394
+ case "room.history.ok":
395
+ if (event.roomId === activeRoomIdRef.current) {
396
+ const messages = Array.isArray(event.messages) ? event.messages : [];
397
+ const freshMessages = filterNewRoomMessages(event.roomId, messages, appliedRoomSeqsRef.current);
398
+ rememberRoomMessages(event.roomId, freshMessages, event.latestSeq);
399
+ dispatchChat({ kind: "append", events: roomHistoryToEvents(freshMessages) });
400
+ setLoadingKey("roomHistory", false);
401
+ }
402
+ break;
403
+ case "room.opened":
404
+ // The room is internal CC-session transport; surface it as a 会话 to the
405
+ // user (no room concept in the UI).
406
+ if (event.status === "missing") {
407
+ setNotice(t("mobile.notice.roomMissing"));
408
+ setActiveRoomId(undefined);
409
+ setLoadingKey("roomHistory", false);
410
+ }
411
+ break;
412
+ case "room.closed":
413
+ // Keep the internal room snapshot in sync (resolves activeRoom metadata).
414
+ setRooms((prev) => prev.map((r) => (r.id === event.roomId ? { ...r, open: false } : r)));
415
+ break;
416
+ case "room.error":
417
+ if (event.clientMessageId) {
418
+ settleMessageAck(event.clientMessageId, new Error(event.message));
419
+ }
420
+ setNotice(event.message || t("mobile.notice.roomError"));
421
+ break;
422
+ case "error":
423
+ if (event.clientMessageId) {
424
+ settleMessageAck(event.clientMessageId, new Error(event.message));
425
+ }
426
+ setNotice(event.message);
427
+ setLoading({
428
+ sessions: false,
429
+ sessionHistory: false,
430
+ rooms: false,
431
+ projects: false,
432
+ roomHistory: false,
433
+ ccSessions: false,
434
+ });
435
+ break;
436
+ case "goal.extended":
437
+ // Surface a real failure; success is silent (the run simply continues).
438
+ if (!event.ok)
439
+ setNotice(event.message || t("mobile.notice.goalExtendFailed"));
440
+ break;
441
+ case "model.current":
442
+ // The worker confirmed the model actually applied. (No model display in
443
+ // the phone UI yet; handled explicitly so it isn't a silent default.)
444
+ break;
445
+ case "approval.request": {
446
+ // Legacy server-shaped approval (kept for back-compat).
447
+ const { summary, risk } = summarizeApproval(undefined, event.risk);
448
+ addApproval({
449
+ requestId: event.approvalId,
450
+ toolName: event.title,
451
+ description: event.body,
452
+ summary: event.body || summary,
453
+ risk,
454
+ pathScoped: false,
455
+ });
456
+ break;
457
+ }
458
+ case "ccRoom.probe.ok":
459
+ // kind guard: ignore a probe reply for a CLI we've since switched away
460
+ // from (else a slow claude probe could overwrite a codex result).
461
+ if (event.kind === ccCliKindRef.current) {
462
+ setCcProbe({ available: event.available, reason: event.reason });
463
+ }
464
+ break;
465
+ case "ccRoom.listSessions.ok":
466
+ // cwd + kind echo guard: ignore a reply for a project/CLI we've left.
467
+ if (event.cwd === activeProjectCwdRef.current && event.kind === ccCliKindRef.current) {
468
+ setCcSessions(event.sessions);
469
+ setLoadingKey("ccSessions", false);
470
+ }
471
+ break;
472
+ case "ccRoom.opened":
473
+ if (event.status === "missing") {
474
+ pendingCcOpenCwdsRef.current.delete(event.sessionId);
475
+ setNotice(t("mobile.notice.ccRoomOpenFailed"));
476
+ break;
477
+ }
478
+ // An opened cc room behaves like room.open — bind the room feed. But the
479
+ // resident room only carries messages from open-time onward; the prior
480
+ // external-CLI transcript lives on disk, so subscribeTranscript returns
481
+ // its atomic backlog first, then keeps appending new lines through the
482
+ // room feed. Without the snapshot the detail view looks empty.
483
+ if (activeRoomIdRef.current && activeRoomIdRef.current !== event.roomId) {
484
+ sendRef.current?.({
485
+ type: "ccRoom.unsubscribeTranscript",
486
+ roomId: activeRoomIdRef.current,
487
+ });
488
+ }
489
+ setActiveRoomId(event.roomId);
490
+ setRooms((previous) => previous.map((room) => room.id === event.roomId
491
+ ? {
492
+ ...room,
493
+ open: event.status === "running",
494
+ observing: event.status === "observing",
495
+ }
496
+ : room));
497
+ // Refresh metadata even when this room was created after the last
498
+ // room.list snapshot, so the mobile view can display read-only state.
499
+ sendRef.current?.({ type: "room.list" });
500
+ ccHistorySessionRef.current = event.sessionId;
501
+ ccHistoryCwdRef.current =
502
+ pendingCcOpenCwdsRef.current.get(event.sessionId) ??
503
+ activeProjectCwdRef.current ??
504
+ undefined;
505
+ pendingCcOpenCwdsRef.current.delete(event.sessionId);
506
+ ccBacklogLoadedRef.current = false;
507
+ boundSessionRef.current = undefined;
508
+ setApprovals([]);
509
+ setLoadingKey("roomHistory", true);
510
+ dispatchChat({ kind: "reset" });
511
+ lastRoomSeqRef.current.set(event.roomId, 0);
512
+ appliedRoomSeqsRef.current.delete(event.roomId);
513
+ if (ccHistoryCwdRef.current) {
514
+ sendRef.current?.({
515
+ type: "ccRoom.subscribeTranscript",
516
+ roomId: event.roomId,
517
+ cwd: ccHistoryCwdRef.current,
518
+ sessionId: event.sessionId,
519
+ limit: 150,
520
+ kind: ccHistoryKindRef.current,
521
+ });
522
+ }
523
+ else {
524
+ ccBacklogLoadedRef.current = true;
525
+ sendRef.current?.({ type: "room.history", roomId: event.roomId, sinceSeq: 0 });
526
+ }
527
+ break;
528
+ case "ccRoom.transcriptSubscribed":
529
+ // Atomic snapshot + live subscription. roomCursor identifies exactly
530
+ // where the snapshot ended in the shared room log; the incremental
531
+ // history request closes response-delivery races and uses the same seq
532
+ // dedupe as reconnect recovery.
533
+ if (event.roomId === activeRoomIdRef.current &&
534
+ event.sessionId === ccHistorySessionRef.current) {
535
+ dispatchChat({ kind: "replay", events: ccHistoryToEvents(event.messages) });
536
+ setLoadingKey("roomHistory", false);
537
+ ccBacklogLoadedRef.current = true;
538
+ lastRoomSeqRef.current.set(event.roomId, event.roomCursor);
539
+ appliedRoomSeqsRef.current.delete(event.roomId);
540
+ setLoadingKey("roomHistory", true);
541
+ sendRef.current?.({
542
+ type: "room.history",
543
+ roomId: event.roomId,
544
+ sinceSeq: event.roomCursor,
545
+ });
546
+ }
547
+ break;
548
+ case "ccRoom.readHistory.ok":
549
+ // Prior on-disk Claude Code transcript for the cc session we just opened.
550
+ // Replay it as the conversation backlog. Guard on the session ref so a
551
+ // late reply for a session we've since left can't clobber the feed.
552
+ if (event.sessionId === ccHistorySessionRef.current) {
553
+ dispatchChat({ kind: "replay", events: ccHistoryToEvents(event.messages) });
554
+ setLoadingKey("roomHistory", false);
555
+ ccBacklogLoadedRef.current = true;
556
+ const roomId = activeRoomIdRef.current;
557
+ if (roomId) {
558
+ setLoadingKey("roomHistory", true);
559
+ sendRef.current?.({
560
+ type: "room.history",
561
+ roomId,
562
+ sinceSeq: lastRoomSeqRef.current.get(roomId) ?? 0,
563
+ });
564
+ }
565
+ }
566
+ break;
567
+ case "ccRoom.approvalRequest": {
568
+ // Only surface for the room this phone is viewing (rooms are shared, but
569
+ // an approval card for some other room would be confusing here).
570
+ if (event.roomId !== activeRoomIdRef.current)
571
+ break;
572
+ const { summary, risk } = summarizeApproval(event.req.input, undefined);
573
+ // AskUserQuestion is parsed in main → `askUser` carries the question +
574
+ // option labels. Render a choice card; the chosen label is sent back as
575
+ // the decision's `answer` (main bakes it into the CLI's `answers`
576
+ // record). CC AskUser always allows a free-text "Other", so optionsOnly
577
+ // stays false.
578
+ const askUser = event.req.askUser;
579
+ setApprovals((prev) => prev.some((p) => p.requestId === event.req.requestId)
580
+ ? prev
581
+ : [
582
+ ...prev,
583
+ {
584
+ requestId: event.req.requestId,
585
+ roomId: event.roomId,
586
+ toolName: event.req.displayName ?? event.req.toolName,
587
+ description: askUser?.question ?? event.req.description ?? "",
588
+ summary: askUser ? askUser.question : summary,
589
+ risk,
590
+ options: askUser?.options,
591
+ optionsOnly: false,
592
+ pathScoped: PATH_SCOPED.has(event.req.toolName),
593
+ },
594
+ ]);
595
+ break;
596
+ }
597
+ case "ccRoom.approvalResolved":
598
+ clearApproval(event.requestId);
599
+ break;
600
+ default:
601
+ break;
602
+ }
603
+ }, [
604
+ addApproval,
605
+ appendSessionEvents,
606
+ clearApproval,
607
+ clearSessionUnread,
608
+ rememberRoomMessages,
609
+ sessionCwdById,
610
+ setLoadingKey,
611
+ settleMessageAck,
612
+ setNotice,
613
+ ]);
614
+ const onRawLine = useCallback((raw) => {
615
+ const obj = raw;
616
+ // Permission requests arrive as agent/approvalRequest worker lines.
617
+ if (obj.method === "agent/approvalRequest" && obj.params) {
618
+ const params = obj.params;
619
+ const rq = params.request;
620
+ if (rq) {
621
+ const { summary, risk } = summarizeApproval(rq.args, rq.riskLevel, rq.toolName);
622
+ const askOptions = extractAskUserOptions(rq.args);
623
+ addApproval({
624
+ requestId: String(params.requestId),
625
+ sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
626
+ toolName: rq.toolName ?? t("mobile.tool.fallbackName"),
627
+ description: rq.description ?? "",
628
+ summary: askOptions ? (rq.description ?? summary) : summary,
629
+ risk,
630
+ options: askOptions?.options,
631
+ optionsOnly: askOptions?.optionsOnly,
632
+ pathScoped: PATH_SCOPED.has(rq.toolName ?? ""),
633
+ });
634
+ }
635
+ return;
636
+ }
637
+ const resolvedRequestId = rawApprovalResolvedRequestId(raw);
638
+ if (resolvedRequestId) {
639
+ clearApproval(resolvedRequestId);
640
+ return;
641
+ }
642
+ // Everything else (agent/streamEvent) folds through the reducer, but it
643
+ // MUST be isolated to ONE session — otherwise concurrent desktop sessions
644
+ // interleave their text_deltas into one garbled feed ("不是同一个会话推到
645
+ // 一起"). Rule:
646
+ // - in a room → ignore session streams entirely (room has its own feed)
647
+ // - bound to a session → only that session's events
648
+ // - nothing bound yet, event has a sessionId → AUTO-BIND to it (follow
649
+ // the first coherent conversation, never merge several)
650
+ // - event with no sessionId while unbound → drop (can't attribute it)
651
+ if (obj.method === "agent/streamEvent" && obj.params) {
652
+ if (activeRoomIdRef.current)
653
+ return; // room view owns the feed
654
+ const params = obj.params;
655
+ const sid = typeof params.sessionId === "string" ? params.sessionId : undefined;
656
+ if (boundSessionRef.current) {
657
+ if (sid && sid !== boundSessionRef.current)
658
+ return;
659
+ }
660
+ else if (sid) {
661
+ // Auto-bind to the first session we see so the feed is coherent.
662
+ boundSessionRef.current = sid;
663
+ setActiveSessionId(sid);
664
+ setActiveSessionCwd(sessionCwdById.get(sid));
665
+ }
666
+ else {
667
+ return;
668
+ }
669
+ dispatchChat({ kind: "raw", raw });
670
+ // A turn ending (or erroring) resolves any pending approval for this
671
+ // session — clear stale cards so a request the user already handled (or
672
+ // that the desktop answered) doesn't linger ("点了还存在").
673
+ const ev = params.event?.type;
674
+ if (ev === "turn_complete" || ev === "error") {
675
+ // We only ever hold bound-session approvals, so the turn ending clears
676
+ // them all (the request can no longer be answered).
677
+ approvalsRef.current = [];
678
+ setApprovals([]);
679
+ }
680
+ }
681
+ }, [addApproval, clearApproval, sessionCwdById]);
682
+ const requestActiveResync = useCallback(() => {
683
+ const roomId = activeRoomIdRef.current;
684
+ if (roomId) {
685
+ const ccSessionId = ccHistorySessionRef.current;
686
+ const ccCwd = ccHistoryCwdRef.current;
687
+ if (ccSessionId && ccCwd) {
688
+ setLoadingKey("roomHistory", true);
689
+ sendRef.current?.({
690
+ type: "ccRoom.subscribeTranscript",
691
+ roomId,
692
+ cwd: ccCwd,
693
+ sessionId: ccSessionId,
694
+ limit: 150,
695
+ kind: ccHistoryKindRef.current,
696
+ });
697
+ return;
698
+ }
699
+ if (ccHistorySessionRef.current && !ccBacklogLoadedRef.current)
700
+ return;
701
+ setLoadingKey("roomHistory", true);
702
+ sendRef.current?.({
703
+ type: "room.history",
704
+ roomId,
705
+ sinceSeq: lastRoomSeqRef.current.get(roomId) ?? 0,
706
+ });
707
+ return;
708
+ }
709
+ const sessionId = boundSessionRef.current;
710
+ if (!sessionId)
711
+ return;
712
+ sendRef.current?.({
713
+ type: "session.sync",
714
+ sessionId,
715
+ sinceSeq: appliedSeqRef.current.get(sessionId) ?? 0,
716
+ });
717
+ }, [setLoadingKey]);
718
+ const socket = useRemoteSocket({
719
+ onServerEvent,
720
+ onRawLine,
721
+ onResyncNeeded: requestActiveResync,
722
+ });
723
+ sendRef.current = socket.send;
724
+ connectionGenerationRef.current = socket.connectionGeneration;
725
+ useEffect(() => {
726
+ if (socket.status === "online")
727
+ return;
728
+ const error = new Error("Remote connection was lost");
729
+ for (const waiter of uploadWaitersRef.current.values()) {
730
+ clearTimeout(waiter.timer);
731
+ waiter.reject(error);
732
+ }
733
+ uploadWaitersRef.current.clear();
734
+ for (const waiter of messageAckWaitersRef.current.values()) {
735
+ clearTimeout(waiter.timer);
736
+ waiter.reject(error);
737
+ }
738
+ messageAckWaitersRef.current.clear();
739
+ }, [socket.status]);
740
+ // ── actions ────────────────────────────────────────────────────────────
741
+ const sendChat = useCallback(async (input) => {
742
+ const text = input.text.trim();
743
+ if (!text && input.attachments.length === 0)
744
+ return false;
745
+ if (socket.status !== "online")
746
+ return false;
747
+ const connectionGeneration = socket.connectionGeneration;
748
+ const roomId = activeRoomIdRef.current;
749
+ const sessionId = boundSessionRef.current;
750
+ const clientMessageId = globalThis.crypto?.randomUUID?.() ??
751
+ `mobile-message-${Date.now()}-${Math.random().toString(36).slice(2)}`;
752
+ let attachments;
753
+ if (roomId && roomsRef.current.find((room) => room.id === roomId)?.observing) {
754
+ setNotice(t("mobile.notice.ccRoomObserving"));
755
+ return false;
756
+ }
757
+ try {
758
+ attachments = await prepareMobileAttachments(input.attachments, {
759
+ beginUpload: (metadata) => beginMobileUpload(metadata, connectionGeneration),
760
+ fetch: window.fetch.bind(window),
761
+ });
762
+ }
763
+ catch (error) {
764
+ setNotice(t("mobile.notice.attachmentSendFailed", {
765
+ error: error instanceof Error ? error.message : String(error),
766
+ }));
767
+ return false;
768
+ }
769
+ const summaries = attachments.map(({ name, mime, size }) => ({ name, mime, size }));
770
+ const acknowledged = waitForMessageAck(clientMessageId, connectionGeneration);
771
+ let queued;
772
+ if (roomId) {
773
+ // NO optimistic echo for rooms: RoomManager persists the user line and
774
+ // broadcasts it back to every client (incl. us) as a `room.message`,
775
+ // which the reducer renders. Echoing locally too would double the
776
+ // bubble. The broadcast round-trips over loopback/LAN ~instantly.
777
+ queued = socket.send({
778
+ type: "room.send",
779
+ roomId,
780
+ text,
781
+ clientMessageId,
782
+ attachments,
783
+ }, connectionGeneration);
784
+ }
785
+ else {
786
+ queued = socket.send({
787
+ type: "chat.send",
788
+ text,
789
+ sessionId,
790
+ clientMessageId,
791
+ attachments,
792
+ }, connectionGeneration);
793
+ }
794
+ if (!queued) {
795
+ settleMessageAck(clientMessageId, new Error("Remote connection changed before send"));
796
+ await acknowledged.catch(() => undefined);
797
+ return false;
798
+ }
799
+ try {
800
+ await acknowledged;
801
+ }
802
+ catch (error) {
803
+ setNotice(t("mobile.notice.attachmentSendFailed", {
804
+ error: error instanceof Error ? error.message : String(error),
805
+ }));
806
+ return false;
807
+ }
808
+ if (!roomId &&
809
+ !activeRoomIdRef.current &&
810
+ (sessionId === undefined || boundSessionRef.current === sessionId)) {
811
+ // Sessions don't echo the user turn back over the stream, so add the
812
+ // local bubble only after the server has accepted this exact message.
813
+ dispatchChat({ kind: "user", text, attachments: summaries });
814
+ }
815
+ return true;
816
+ }, [beginMobileUpload, setNotice, settleMessageAck, socket, waitForMessageAck]);
817
+ const stopRun = useCallback(() => {
818
+ socket.send({ type: "run.stop", sessionId: boundSessionRef.current });
819
+ }, [socket]);
820
+ const refreshSessions = useCallback(() => {
821
+ setLoadingKey("sessions", true);
822
+ socket.send({ type: "session.list" });
823
+ }, [socket, setLoadingKey]);
824
+ const selectSession = useCallback((id) => {
825
+ if (activeRoomIdRef.current) {
826
+ socket.send({
827
+ type: "ccRoom.unsubscribeTranscript",
828
+ roomId: activeRoomIdRef.current,
829
+ });
830
+ }
831
+ setActiveSessionId(id);
832
+ clearSessionUnread(id);
833
+ setActiveSessionCwd(sessionCwdById.get(id) ?? null);
834
+ boundSessionRef.current = id;
835
+ ccHistorySessionRef.current = undefined;
836
+ ccHistoryCwdRef.current = undefined;
837
+ ccBacklogLoadedRef.current = false;
838
+ setActiveRoomId(undefined);
839
+ setApprovals([]);
840
+ setLoadingKey("sessionHistory", true);
841
+ dispatchChat({ kind: "reset" });
842
+ socket.send({ type: "session.select", sessionId: id });
843
+ socket.send({ type: "session.history", sessionId: id });
844
+ }, [clearSessionUnread, socket, sessionCwdById, setLoadingKey]);
845
+ /** Select a project (like the desktop sidebar): set the one-true-source cwd and
846
+ * pull that project's chat sessions + external cc sessions. */
847
+ const selectProject = useCallback((cwd) => {
848
+ setActiveProjectCwd(cwd);
849
+ setLoadingKey("sessions", true);
850
+ socket.send({ type: "session.list" });
851
+ socket.send({ type: "room.projects" });
852
+ // CC discovery (probe + listSessions) is driven by the auto-discover effect
853
+ // keyed on the effective cwd — setting activeProjectCwd above triggers it.
854
+ // Doing it here too would double-fetch.
855
+ }, [socket, setLoadingKey]);
856
+ const openCcSession = useCallback((sessionId, cwd, mode) => {
857
+ // Pin the CLI this session belongs to so its on-disk backlog is read with
858
+ // the right reader even if the user switches the pane's CLI afterward.
859
+ ccHistoryKindRef.current = ccCliKindRef.current;
860
+ pendingCcOpenCwdsRef.current.set(sessionId, cwd);
861
+ socket.send({ type: "ccRoom.openSession", sessionId, cwd, mode, kind: ccCliKindRef.current });
862
+ }, [socket]);
863
+ const respondCcApproval = useCallback((roomId, requestId, decision) => {
864
+ const a = approvalsRef.current.find((p) => p.requestId === requestId);
865
+ if (!a)
866
+ return; // approve-once: already resolved
867
+ socket.send({ type: "ccRoom.respondApproval", roomId, requestId, decision });
868
+ approvalsRef.current = approvalsRef.current.filter((p) => p.requestId !== requestId);
869
+ setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
870
+ }, [socket]);
871
+ const newSession = useCallback((cwd, name) => {
872
+ if (activeRoomIdRef.current) {
873
+ socket.send({
874
+ type: "ccRoom.unsubscribeTranscript",
875
+ roomId: activeRoomIdRef.current,
876
+ });
877
+ }
878
+ const nextCwd = cwd === undefined ? activeCwdRef.current : cwd;
879
+ boundSessionRef.current = undefined;
880
+ ccHistorySessionRef.current = undefined;
881
+ ccHistoryCwdRef.current = undefined;
882
+ ccBacklogLoadedRef.current = false;
883
+ // Enter a visible "fresh conversation" state immediately — clear the active
884
+ // session + chat so the user sees the new-conversation surface right away
885
+ // (the drawer closes after this; without it the screen looks unchanged and
886
+ // tapping 新建 feels like a no-op). The real id arrives via chat.accepted.
887
+ setActiveSessionId(undefined);
888
+ // The real id arrives via chat.accepted, which binds boundSessionRef. A
889
+ // chat.send sent before then carries sessionId:undefined, which the server
890
+ // resolves to this device's just-minted session (WS-ordered: session.create
891
+ // is processed before the following chat.send) — so no id race to guard.
892
+ setActiveRoomId(undefined);
893
+ setActiveSessionCwd(nextCwd === undefined ? undefined : (nextCwd ?? null));
894
+ setApprovals([]);
895
+ setLoadingKey("sessionHistory", false);
896
+ dispatchChat({ kind: "reset" });
897
+ socket.send(cwd === undefined
898
+ ? { type: "session.create", ...(name ? { name } : {}) }
899
+ : { type: "session.create", cwd, ...(name ? { name } : {}) });
900
+ }, [socket, setLoadingKey]);
901
+ const respondApproval = useCallback((requestId, decision, opts) => {
902
+ const a = approvalsRef.current.find((p) => p.requestId === requestId);
903
+ // Dedup at the source: if this approval is already gone from the ref it was
904
+ // already responded to (rapid double-tap before setApprovals flushes / the
905
+ // card unmounts). Sending again would violate the approve-once invariant.
906
+ if (!a)
907
+ return;
908
+ socket.send({
909
+ type: "approval.respond",
910
+ approvalId: requestId,
911
+ decision,
912
+ sessionId: a?.sessionId,
913
+ reason: opts?.reason,
914
+ answer: opts?.answer,
915
+ scope: opts?.scope,
916
+ pathScope: opts?.pathScope,
917
+ });
918
+ // Update the ref synchronously too so a second tap in the same tick (before
919
+ // the async setApprovals commits) sees it gone and no-ops above.
920
+ approvalsRef.current = approvalsRef.current.filter((p) => p.requestId !== requestId);
921
+ setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
922
+ }, [socket]);
923
+ const setPermissionMode = useCallback((mode) => {
924
+ setPermissionModeState(mode);
925
+ socket.send({ type: "permission.setMode", sessionId: boundSessionRef.current, mode });
926
+ }, [socket]);
927
+ const extendGoal = useCallback((sessionId) => socket.send({ type: "goal.extend", sessionId, addTurns: 100 }), [socket]);
928
+ const clearGoal = useCallback((sessionId) => socket.send({ type: "goal.clear", sessionId }), [socket]);
929
+ // Leave the bound CC session: drop the room binding and clear the feed. (There
930
+ // is no user-facing room create/open/close — CC sessions are opened via
931
+ // openCcSession; the room is internal transport.)
932
+ const leaveRoom = useCallback(() => {
933
+ if (activeRoomIdRef.current) {
934
+ socket.send({
935
+ type: "ccRoom.unsubscribeTranscript",
936
+ roomId: activeRoomIdRef.current,
937
+ });
938
+ }
939
+ setActiveRoomId(undefined);
940
+ ccHistorySessionRef.current = undefined;
941
+ ccHistoryCwdRef.current = undefined;
942
+ ccBacklogLoadedRef.current = false;
943
+ setLoadingKey("roomHistory", false);
944
+ dispatchChat({ kind: "reset" });
945
+ }, [setLoadingKey, socket]);
946
+ // activeRoom resolves the bound room's metadata (name/cwd) from the room.list
947
+ // snapshot — used for the CC session's title/subtitle and the cc-history cwd.
948
+ // There is no rendered room list; this lookup is internal.
949
+ const activeRoom = useMemo(() => rooms.find((r) => r.id === activeRoomId), [rooms, activeRoomId]);
950
+ const activeCwd = activeRoom?.cwd || activeSessionCwd;
951
+ const activeContextCwd = projectContextCwd(activeCwd, projects);
952
+ activeCwdRef.current = activeContextCwd;
953
+ useEffect(() => {
954
+ if (socket.status !== "online")
955
+ return;
956
+ setLoadingKey("rooms", true);
957
+ socket.send({ type: "room.list" });
958
+ // Pull the disk project list on connect; main re-broadcasts room.projects.ok
959
+ // on every disk change, so a desktop add/remove/pin reaches us live too.
960
+ socket.send({ type: "room.projects" });
961
+ }, [socket.status, socket.send, activeCwd, setLoadingKey]);
962
+ // Auto-discover CC (external claude CLI) sessions for the effective project cwd
963
+ // — mirrors the desktop CCRoomView, which probes + lists on mount/cwd-change.
964
+ // Without this, the CcSessionList shows (its cwd derives from activeCwd) but the
965
+ // probe is never sent unless the user happens to tap a project via
966
+ // selectProject, so it hangs on "正在检测…" forever. selectProject still does an
967
+ // eager refresh on explicit project switches; this covers the implicit cwd.
968
+ const ccDiscoverCwd = activeProjectCwd ?? activeContextCwd ?? null;
969
+ useEffect(() => {
970
+ if (socket.status !== "online" || !ccDiscoverCwd)
971
+ return;
972
+ // Adopt this cwd as the project context so the listSessions echo guard
973
+ // (event.cwd === activeProjectCwdRef.current) matches the reply.
974
+ setActiveProjectCwd(ccDiscoverCwd);
975
+ setCcProbe(null);
976
+ setCcSessions([]);
977
+ setLoadingKey("ccSessions", true);
978
+ socket.send({ type: "ccRoom.probe", kind: ccCliKind });
979
+ socket.send({ type: "ccRoom.listSessions", cwd: ccDiscoverCwd, kind: ccCliKind });
980
+ }, [socket.status, socket.send, ccDiscoverCwd, ccCliKind, setLoadingKey]);
981
+ return {
982
+ status: socket.status,
983
+ deviceName: socket.deviceName,
984
+ chat,
985
+ sessions,
986
+ unreadSessionIds,
987
+ activeSessionId,
988
+ activeCwd,
989
+ projects,
990
+ activeRoom,
991
+ approvals,
992
+ permissionMode,
993
+ loading,
994
+ notice,
995
+ logout: socket.logout,
996
+ sendChat,
997
+ stopRun,
998
+ selectSession,
999
+ newSession,
1000
+ refreshSessions,
1001
+ respondApproval,
1002
+ setPermissionMode,
1003
+ extendGoal,
1004
+ clearGoal,
1005
+ leaveRoom,
1006
+ activeProjectCwd,
1007
+ selectProject,
1008
+ ccSessions,
1009
+ ccProbe,
1010
+ ccCliKind,
1011
+ setCcCliKind,
1012
+ openCcSession,
1013
+ respondCcApproval,
1014
+ };
1015
+ }