@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.
- package/README.md +42 -0
- package/dist/hooks/remoteAppSync.d.ts +21 -0
- package/dist/hooks/remoteAppSync.js +102 -0
- package/dist/hooks/useRemoteApp.d.ts +89 -0
- package/dist/hooks/useRemoteApp.js +1015 -0
- package/dist/hooks/useRemoteSocket.d.ts +25 -0
- package/dist/hooks/useRemoteSocket.js +267 -0
- package/dist/i18n/mobile.d.ts +323 -0
- package/dist/i18n/mobile.js +323 -0
- package/dist/i18n/translate.d.ts +351 -0
- package/dist/i18n/translate.js +35 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +28 -0
- package/dist/lib/deviceCredential.d.ts +14 -0
- package/dist/lib/deviceCredential.js +19 -0
- package/dist/lib/format.d.ts +26 -0
- package/dist/lib/format.js +96 -0
- package/dist/lib/messageMappers.d.ts +37 -0
- package/dist/lib/messageMappers.js +145 -0
- package/dist/lib/mobileAttachments.d.ts +23 -0
- package/dist/lib/mobileAttachments.js +121 -0
- package/dist/lib/pairing.d.ts +6 -0
- package/dist/lib/pairing.js +8 -0
- package/dist/lib/riskClassify.d.ts +5 -0
- package/dist/lib/riskClassify.js +56 -0
- package/dist/lib/storage.d.ts +10 -0
- package/dist/lib/storage.js +53 -0
- package/dist/lib/streamReducer.d.ts +90 -0
- package/dist/lib/streamReducer.js +564 -0
- package/dist/lib/uiLanguage.d.ts +15 -0
- package/dist/lib/uiLanguage.js +34 -0
- package/dist-app/assets/index-CbC5kEGZ.js +11 -0
- package/dist-app/assets/index-xwzva9ZI.css +1 -0
- package/dist-app/index.html +13 -0
- package/package.json +55 -0
|
@@ -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
|
+
}
|