@cjhyy/code-shell-web 0.9.44 → 0.9.47

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.
@@ -4,6 +4,7 @@ import { compareOutputCursors } from "../lib/outputJournalRecovery.js";
4
4
  export class MobileOutputClient {
5
5
  deps;
6
6
  enabled = false;
7
+ basePages = false;
7
8
  auth = 0;
8
9
  sequence = 0;
9
10
  selection;
@@ -17,13 +18,25 @@ export class MobileOutputClient {
17
18
  clearTimeout(old.readyTimer);
18
19
  if (old?.capacityTimer)
19
20
  clearTimeout(old.capacityTimer);
20
- if (old?.waiter) {
21
- clearTimeout(old.waiter.timer);
22
- old.waiter.reject(new Error("Recovery cancelled"));
23
- }
21
+ if (old)
22
+ this.clearPending(old, "Recovery cancelled");
24
23
  if (notify && old)
25
24
  this.deps.send({ type: "session.recovery.cancel" });
26
25
  }
26
+ clearPending(selection, message) {
27
+ if (selection.waiter) {
28
+ const waiter = selection.waiter;
29
+ selection.waiter = undefined;
30
+ clearTimeout(waiter.timer);
31
+ waiter.reject(new Error(message));
32
+ }
33
+ if (selection.retry) {
34
+ const retry = selection.retry;
35
+ selection.retry = undefined;
36
+ clearTimeout(retry.timer);
37
+ retry.reject(new Error(message));
38
+ }
39
+ }
27
40
  begin(sessionId) {
28
41
  if (!this.enabled)
29
42
  return false;
@@ -61,7 +74,7 @@ export class MobileOutputClient {
61
74
  return true;
62
75
  }
63
76
  fail(selection) {
64
- if (!this.current(selection))
77
+ if (!this.current(selection) || selection.failed)
65
78
  return;
66
79
  if (selection.readyTimer)
67
80
  clearTimeout(selection.readyTimer);
@@ -72,6 +85,7 @@ export class MobileOutputClient {
72
85
  selection.readyTimer = undefined;
73
86
  selection.recovering = false;
74
87
  selection.failed = true;
88
+ this.clearPending(selection, "Recovery failed");
75
89
  this.deps.failed(selection.sessionId);
76
90
  }
77
91
  observe(event) {
@@ -79,9 +93,12 @@ export class MobileOutputClient {
79
93
  this.cancel(false);
80
94
  this.auth++;
81
95
  this.enabled = event.capabilities?.outputJournal === 1;
96
+ this.basePages = this.enabled && event.capabilities?.legacyBasePages === 1;
82
97
  return false;
83
98
  }
84
- if (event.type !== "session.recovery.ready" && event.type !== "session.outputJournal")
99
+ if (event.type !== "session.recovery.ready" &&
100
+ event.type !== "session.outputJournal" &&
101
+ event.type !== "session.legacyBase")
85
102
  return false;
86
103
  const selection = this.selection;
87
104
  if (!selection ||
@@ -107,7 +124,7 @@ export class MobileOutputClient {
107
124
  else
108
125
  void this.recover(selection);
109
126
  }
110
- else if (selection.waiter?.id === event.requestId) {
127
+ else if (selection.waiter?.id === event.requestId && selection.waiter.type === event.type) {
111
128
  const waiter = selection.waiter;
112
129
  selection.waiter = undefined;
113
130
  clearTimeout(waiter.timer);
@@ -115,6 +132,60 @@ export class MobileOutputClient {
115
132
  }
116
133
  return true;
117
134
  }
135
+ request(selection, type, options) {
136
+ return new Promise((resolve, reject) => {
137
+ if (!this.current(selection) || selection.failed || selection.waiter || selection.retry)
138
+ return reject(new Error("Recovery cancelled"));
139
+ const id = `page-${this.auth}-${++this.sequence}`;
140
+ const timer = setTimeout(() => {
141
+ selection.waiter = undefined;
142
+ reject(new Error("Recovery timed out"));
143
+ }, 10_000);
144
+ selection.waiter = {
145
+ id,
146
+ type,
147
+ timer,
148
+ reject,
149
+ resolve: (page) => resolve(page),
150
+ };
151
+ try {
152
+ if (!this.deps.send({
153
+ ...options,
154
+ type,
155
+ sessionId: selection.sessionId,
156
+ recoveryId: selection.id,
157
+ requestId: id,
158
+ }))
159
+ throw new Error("Recovery disconnected");
160
+ }
161
+ catch (error) {
162
+ clearTimeout(timer);
163
+ selection.waiter = undefined;
164
+ reject(error);
165
+ }
166
+ });
167
+ }
168
+ async readBase(selection, options) {
169
+ for (let retries = 0;; retries++) {
170
+ const reply = await this.request(selection, "session.legacyBase", options);
171
+ if (new TextEncoder().encode(JSON.stringify(reply)).length > 1024 * 1024 + 4096 ||
172
+ (reply.ok === false &&
173
+ ((reply.reason !== "busy" && reply.reason !== "incomplete") ||
174
+ Object.keys(reply).some((key) => !["type", "sessionId", "recoveryId", "requestId", "ok", "reason"].includes(key)))))
175
+ throw new Error("Invalid recovery base reply");
176
+ if (reply.ok !== false || reply.reason !== "busy" || retries >= 8)
177
+ return reply;
178
+ await new Promise((resolve, reject) => {
179
+ if (!this.current(selection) || selection.failed)
180
+ return reject(new Error("Recovery cancelled"));
181
+ const timer = setTimeout(() => {
182
+ selection.retry = undefined;
183
+ resolve();
184
+ }, 25);
185
+ selection.retry = { timer, reject };
186
+ });
187
+ }
188
+ }
118
189
  async recover(selection, resume) {
119
190
  try {
120
191
  const result = await (resume
@@ -123,28 +194,12 @@ export class MobileOutputClient {
123
194
  requireJournal: selection.journalRequired,
124
195
  canContinue: () => this.current(selection) && !selection.failed,
125
196
  latestCursor: () => selection.latest,
126
- read: (options) => new Promise((resolve, reject) => {
127
- if (!this.current(selection) || selection.waiter)
128
- return reject(new Error("Recovery cancelled"));
129
- const id = `page-${this.auth}-${++this.sequence}`;
130
- const timer = setTimeout(() => {
131
- selection.waiter = undefined;
132
- reject(new Error("Recovery timed out"));
133
- }, 10_000);
134
- selection.waiter = { id, resolve, reject, timer };
135
- if (!this.deps.send({
136
- type: "session.outputJournal",
137
- sessionId: selection.sessionId,
138
- recoveryId: selection.id,
139
- requestId: id,
140
- after: options.after,
141
- through: options.through,
142
- })) {
143
- clearTimeout(timer);
144
- selection.waiter = undefined;
145
- reject(new Error("Recovery disconnected"));
197
+ read: (options) => this.request(selection, "session.outputJournal", options),
198
+ ...(this.basePages
199
+ ? {
200
+ readBase: (options) => this.readBase(selection, options),
146
201
  }
147
- }),
202
+ : {}),
148
203
  }));
149
204
  if (!this.current(selection))
150
205
  return;
@@ -19,6 +19,14 @@ export interface PendingApproval {
19
19
  options?: string[];
20
20
  /** When true, the user must pick an option (no free text). */
21
21
  optionsOnly?: boolean;
22
+ /** CLI AskUserQuestion asking several questions in one request. They are
23
+ * answered together: the request accepts exactly one response. */
24
+ questions?: Array<{
25
+ question: string;
26
+ header?: string;
27
+ options: string[];
28
+ multiSelect: boolean;
29
+ }>;
22
30
  /** A nonblocking question remains answerable after the run ends. */
23
31
  asynchronous?: boolean;
24
32
  /** True for path-scoped tools (Read/Edit/Write/…) → show file/dir scope. */
@@ -36,6 +44,8 @@ export interface RemoteApp {
36
44
  /** The bound room — for the user this is always an external CC (Claude Code)
37
45
  * session. The room is internal transport; there is no user-facing room list. */
38
46
  activeRoom?: RoomPublic;
47
+ /** Set while a CLI room is bound, even before its metadata has loaded. */
48
+ activeRoomId?: string;
39
49
  approvals: PendingApproval[];
40
50
  permissionMode: PermissionMode;
41
51
  loading: {
@@ -50,9 +60,17 @@ export interface RemoteApp {
50
60
  };
51
61
  notice?: string;
52
62
  logout: () => void;
63
+ /** Resolves true once the host accepted the message. */
53
64
  sendChat: (input: {
54
65
  text: string;
55
66
  attachments: MobileComposerAttachment[];
67
+ modelTraceSource?: string;
68
+ /** Retry an earlier intent whose delivery could not be confirmed. The host
69
+ * ignores an ID it already recorded, so the task never runs twice. */
70
+ clientMessageId?: string;
71
+ /** The frame reached the socket but neither acceptance nor rejection
72
+ * arrived (lost connection or timeout): the host may have run it. */
73
+ onUncertain?: (clientMessageId: string) => void;
56
74
  }) => Promise<boolean>;
57
75
  stopRun: () => void;
58
76
  selectSession: (id: string) => void;
@@ -81,14 +99,11 @@ export interface RemoteApp {
81
99
  ccCliKind: CcCliKind;
82
100
  setCcCliKind: (kind: CcCliKind) => void;
83
101
  openCcSession: (sessionId: string, cwd: string, mode: PermissionMode) => void;
84
- respondCcApproval: (roomId: string, requestId: string, decision: {
85
- behavior: "allow";
86
- updatedInput?: unknown;
87
- } | {
88
- behavior: "deny";
89
- message: string;
90
- }) => void;
102
+ respondCcApproval: (roomId: string, requestId: string, decision: CcApprovalDecision) => void;
91
103
  }
104
+ type CcApprovalDecision = Extract<import("@cjhyy/code-shell-core").MobileClientEvent, {
105
+ type: "ccRoom.respondApproval";
106
+ }>["decision"];
92
107
  export type MobileSessionCreateTarget = {
93
108
  projectId: string;
94
109
  rootId?: string;
@@ -101,3 +116,4 @@ export interface RemoteAppOptions {
101
116
  onNotification?: (method: string, params: Record<string, unknown>) => void;
102
117
  }
103
118
  export declare function useRemoteApp(options?: RemoteAppOptions): RemoteApp;
119
+ export {};
@@ -9,6 +9,9 @@ import { MobileOutputClient } from "./mobileOutputClient.js";
9
9
  import { useRemoteSocket } from "./useRemoteSocket.js";
10
10
  import { prepareMobileAttachments, } from "../lib/mobileAttachments.js";
11
11
  import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, alignSessionEpoch, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
12
+ /** The host explicitly refused a message, so it was not admitted. */
13
+ class MessageRejectedError extends Error {
14
+ }
12
15
  /** Tools whose grants can be remembered with a path scope (file/dir). */
13
16
  const PATH_SCOPED = new Set([
14
17
  "Read",
@@ -80,6 +83,10 @@ export function useRemoteApp(options = {}) {
80
83
  const approvalsRef = useRef(approvals);
81
84
  approvalsRef.current = approvals;
82
85
  const submittingAsyncApprovalsRef = useRef(new Set());
86
+ /** Synchronous approvals whose card was consumed on send. A correlated
87
+ * delivery error asks Main to replay them if Core still holds the request. */
88
+ const submittedApprovalsRef = useRef(new Set());
89
+ const requestActiveResyncRef = useRef(() => undefined);
83
90
  // The SELECTED project (one-true-source for "what am I looking at"), distinct
84
91
  // from activeSessionCwd which is derived from the bound session. Drives session
85
92
  // list filtering AND ccRoom.listSessions.
@@ -101,6 +108,8 @@ export function useRemoteApp(options = {}) {
101
108
  * socket's callback), so it can't close over `socket` directly. */
102
109
  const sendRef = useRef(null);
103
110
  const connectionGenerationRef = useRef(0);
111
+ /** Image PUTs in flight; a lost connection aborts them so sending recovers. */
112
+ const uploadAbortsRef = useRef(new Set());
104
113
  const uploadWaitersRef = useRef(new Map());
105
114
  const conversationRevisionRef = useRef(0);
106
115
  const pendingCreateRef = useRef(null);
@@ -185,6 +194,9 @@ export function useRemoteApp(options = {}) {
185
194
  waiter.resolve();
186
195
  }, []);
187
196
  useEffect(() => () => {
197
+ for (const upload of uploadAbortsRef.current)
198
+ upload.abort(new Error("Remote app closed"));
199
+ uploadAbortsRef.current.clear();
188
200
  for (const waiter of uploadWaitersRef.current.values()) {
189
201
  clearTimeout(waiter.timer);
190
202
  waiter.reject(new Error("Remote app closed"));
@@ -226,8 +238,10 @@ export function useRemoteApp(options = {}) {
226
238
  * subscription cwd from activeProjectCwd. */
227
239
  const ccHistoryCwdRef = useRef(undefined);
228
240
  /** openSession replies do not echo cwd. Keep it keyed by the requested
229
- * session until ccRoom.opened confirms which room acquired it. */
230
- const pendingCcOpenCwdsRef = useRef(new Map());
241
+ * session until ccRoom.opened confirms which room acquired it. The
242
+ * conversation revision of the request lets a reply that arrives after the
243
+ * user navigated elsewhere be discarded instead of navigating back. */
244
+ const pendingCcOpensRef = useRef(new Map());
231
245
  const ccBacklogLoadedRef = useRef(false);
232
246
  /** Which CLI the currently-open cc session belongs to — selects the on-disk
233
247
  * history reader (claude vs codex rollout) for ccRoom.readHistory. Captured
@@ -247,6 +261,7 @@ export function useRemoteApp(options = {}) {
247
261
  }, []);
248
262
  const clearApproval = useCallback((requestId) => {
249
263
  submittingAsyncApprovalsRef.current.delete(requestId);
264
+ submittedApprovalsRef.current.delete(requestId);
250
265
  approvalsRef.current = removeResolvedApproval(approvalsRef.current, requestId);
251
266
  setApprovals((prev) => removeResolvedApproval(prev, requestId));
252
267
  }, []);
@@ -317,6 +332,9 @@ export function useRemoteApp(options = {}) {
317
332
  if (a.sessionId) {
318
333
  if (boundSessionRef.current && a.sessionId !== boundSessionRef.current)
319
334
  return;
335
+ // A pending creation owns the unbound view; only its acceptance binds it.
336
+ if (!boundSessionRef.current && pendingCreateRef.current)
337
+ return;
320
338
  if (!boundSessionRef.current) {
321
339
  boundSessionRef.current = a.sessionId;
322
340
  setActiveSessionId(a.sessionId);
@@ -558,6 +576,12 @@ export function useRemoteApp(options = {}) {
558
576
  break;
559
577
  }
560
578
  }
579
+ else if (pendingCreateRef.current) {
580
+ // Another task's output while a new session awaits its acceptance:
581
+ // binding it would move the new conversation (and its draft) there.
582
+ setUnreadSessionIds((prev) => markSessionUnread(prev, event.sessionId));
583
+ break;
584
+ }
561
585
  else {
562
586
  boundSessionRef.current = event.sessionId;
563
587
  setActiveSessionId(event.sessionId);
@@ -753,6 +777,11 @@ export function useRemoteApp(options = {}) {
753
777
  case "room.closed":
754
778
  // Keep the internal room snapshot in sync (resolves activeRoom metadata).
755
779
  setRooms((prev) => prev.map((r) => (r.id === event.roomId ? { ...r, open: false } : r)));
780
+ // The room's process is gone, so no turn is running any more. Its
781
+ // stopped process cannot report its own turn end.
782
+ if (event.roomId === activeRoomIdRef.current) {
783
+ dispatchChat({ kind: "raw", raw: { type: "turn_complete", reason: "aborted_tools" } });
784
+ }
756
785
  break;
757
786
  case "room.recovery":
758
787
  if (event.roomId === activeRoomIdRef.current) {
@@ -772,16 +801,21 @@ export function useRemoteApp(options = {}) {
772
801
  setLoadingKey("roomHistory", false);
773
802
  }
774
803
  if (event.clientMessageId) {
775
- settleMessageAck(event.clientMessageId, new Error(event.message));
804
+ settleMessageAck(event.clientMessageId, new MessageRejectedError(event.message));
776
805
  }
777
806
  setNotice(event.message || t("mobile.notice.roomError"));
778
807
  break;
779
808
  case "error":
780
809
  if (event.approvalId) {
781
810
  submittingAsyncApprovalsRef.current.delete(event.approvalId);
811
+ // The decision did not reach Core. The request may still be
812
+ // pending there; an authoritative resync replays its card.
813
+ if (submittedApprovalsRef.current.delete(event.approvalId)) {
814
+ requestActiveResyncRef.current();
815
+ }
782
816
  }
783
817
  if (event.clientMessageId) {
784
- settleMessageAck(event.clientMessageId, new Error(event.message));
818
+ settleMessageAck(event.clientMessageId, new MessageRejectedError(event.message));
785
819
  }
786
820
  setNotice(event.message);
787
821
  setLoading({
@@ -829,9 +863,13 @@ export function useRemoteApp(options = {}) {
829
863
  setLoadingKey("ccSessions", false);
830
864
  }
831
865
  break;
832
- case "ccRoom.opened":
866
+ case "ccRoom.opened": {
867
+ const request = pendingCcOpensRef.current.get(event.sessionId);
868
+ pendingCcOpensRef.current.delete(event.sessionId);
869
+ // Only this browser's latest open request may change navigation.
870
+ if (!request || request.revision !== conversationRevisionRef.current)
871
+ break;
833
872
  if (event.status === "missing") {
834
- pendingCcOpenCwdsRef.current.delete(event.sessionId);
835
873
  setNotice(t("mobile.notice.ccRoomOpenFailed"));
836
874
  break;
837
875
  }
@@ -854,17 +892,14 @@ export function useRemoteApp(options = {}) {
854
892
  ...room,
855
893
  open: event.status === "running",
856
894
  observing: event.status === "observing",
895
+ ...(event.permissionMode ? { permissionMode: event.permissionMode } : {}),
857
896
  }
858
897
  : room));
859
898
  // Refresh metadata even when this room was created after the last
860
899
  // room.list snapshot, so the mobile view can display read-only state.
861
900
  sendRef.current?.({ type: "room.list" });
862
901
  ccHistorySessionRef.current = event.sessionId;
863
- ccHistoryCwdRef.current =
864
- pendingCcOpenCwdsRef.current.get(event.sessionId) ??
865
- activeProjectCwdRef.current ??
866
- undefined;
867
- pendingCcOpenCwdsRef.current.delete(event.sessionId);
902
+ ccHistoryCwdRef.current = request.cwd;
868
903
  ccBacklogLoadedRef.current = false;
869
904
  boundSessionRef.current = undefined;
870
905
  setApprovals([]);
@@ -887,6 +922,7 @@ export function useRemoteApp(options = {}) {
887
922
  requestRoomCatchup(event.roomId, 0);
888
923
  }
889
924
  break;
925
+ }
890
926
  case "ccRoom.transcriptSubscribed":
891
927
  // Atomic snapshot + live subscription. roomCursor identifies exactly
892
928
  // where the snapshot ended in the shared room log; the incremental
@@ -948,6 +984,9 @@ export function useRemoteApp(options = {}) {
948
984
  risk,
949
985
  options: askUser?.options,
950
986
  optionsOnly: false,
987
+ ...(askUser?.questions && askUser.questions.length > 1
988
+ ? { questions: askUser.questions }
989
+ : {}),
951
990
  pathScoped: PATH_SCOPED.has(event.req.toolName),
952
991
  },
953
992
  ]);
@@ -1024,6 +1063,9 @@ export function useRemoteApp(options = {}) {
1024
1063
  if (sid && sid !== boundSessionRef.current)
1025
1064
  return;
1026
1065
  }
1066
+ else if (pendingCreateRef.current) {
1067
+ return; // the pending creation's acceptance decides the binding
1068
+ }
1027
1069
  else if (sid) {
1028
1070
  // Auto-bind to the first session we see so the feed is coherent.
1029
1071
  boundSessionRef.current = sid;
@@ -1084,6 +1126,7 @@ export function useRemoteApp(options = {}) {
1084
1126
  ...(cursor.epoch ? { epoch: cursor.epoch } : {}),
1085
1127
  });
1086
1128
  }, [setLoadingKey, sessionCursor, requestRoomCatchup]);
1129
+ requestActiveResyncRef.current = requestActiveResync;
1087
1130
  const socket = useRemoteSocket({
1088
1131
  onServerEvent,
1089
1132
  onRawLine,
@@ -1096,7 +1139,11 @@ export function useRemoteApp(options = {}) {
1096
1139
  return;
1097
1140
  outputClient.cancel(false);
1098
1141
  submittingAsyncApprovalsRef.current.clear();
1142
+ submittedApprovalsRef.current.clear();
1099
1143
  const error = new Error("Remote connection was lost");
1144
+ for (const upload of uploadAbortsRef.current)
1145
+ upload.abort(error);
1146
+ uploadAbortsRef.current.clear();
1100
1147
  for (const waiter of uploadWaitersRef.current.values()) {
1101
1148
  clearTimeout(waiter.timer);
1102
1149
  waiter.reject(error);
@@ -1119,17 +1166,21 @@ export function useRemoteApp(options = {}) {
1119
1166
  const conversationRevision = conversationRevisionRef.current;
1120
1167
  const roomId = activeRoomIdRef.current;
1121
1168
  const sessionId = boundSessionRef.current;
1122
- const clientMessageId = globalThis.crypto?.randomUUID?.() ??
1169
+ const clientMessageId = input.clientMessageId ??
1170
+ globalThis.crypto?.randomUUID?.() ??
1123
1171
  `mobile-message-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1124
1172
  let attachments;
1125
1173
  if (roomId && roomsRef.current.find((room) => room.id === roomId)?.observing) {
1126
1174
  setNotice(t("mobile.notice.ccRoomObserving"));
1127
1175
  return false;
1128
1176
  }
1177
+ const upload = new AbortController();
1178
+ uploadAbortsRef.current.add(upload);
1129
1179
  try {
1130
1180
  attachments = await prepareMobileAttachments(input.attachments, {
1131
1181
  beginUpload: (metadata) => beginMobileUpload(metadata, connectionGeneration),
1132
1182
  fetch: window.fetch.bind(window),
1183
+ signal: upload.signal,
1133
1184
  });
1134
1185
  }
1135
1186
  catch (error) {
@@ -1138,6 +1189,9 @@ export function useRemoteApp(options = {}) {
1138
1189
  }));
1139
1190
  return false;
1140
1191
  }
1192
+ finally {
1193
+ uploadAbortsRef.current.delete(upload);
1194
+ }
1141
1195
  if (conversationRevision !== conversationRevisionRef.current)
1142
1196
  return false;
1143
1197
  const summaries = attachments.map(({ name, mime, size }) => ({ name, mime, size }));
@@ -1159,6 +1213,7 @@ export function useRemoteApp(options = {}) {
1159
1213
  else {
1160
1214
  queued = socket.send({
1161
1215
  type: "chat.send",
1216
+ ...(input.modelTraceSource ? { modelTraceSource: input.modelTraceSource } : {}),
1162
1217
  text,
1163
1218
  sessionId,
1164
1219
  clientMessageId,
@@ -1174,6 +1229,9 @@ export function useRemoteApp(options = {}) {
1174
1229
  await acknowledged;
1175
1230
  }
1176
1231
  catch (error) {
1232
+ // Only an explicit host rejection proves the message was not admitted.
1233
+ if (!(error instanceof MessageRejectedError))
1234
+ input.onUncertain?.(clientMessageId);
1177
1235
  setNotice(t("mobile.notice.attachmentSendFailed", {
1178
1236
  error: error instanceof Error ? error.message : String(error),
1179
1237
  }));
@@ -1190,6 +1248,14 @@ export function useRemoteApp(options = {}) {
1190
1248
  return true;
1191
1249
  }, [beginMobileUpload, setNotice, settleMessageAck, socket, waitForMessageAck]);
1192
1250
  const stopRun = useCallback(() => {
1251
+ const roomId = activeRoomIdRef.current;
1252
+ if (roomId) {
1253
+ // A CLI room runs in its own resident process, not a native session.
1254
+ // Closing it stops the process; the next message resumes the same CLI
1255
+ // conversation.
1256
+ socket.send({ type: "room.close", roomId });
1257
+ return;
1258
+ }
1193
1259
  socket.send({ type: "run.stop", sessionId: boundSessionRef.current });
1194
1260
  }, [socket]);
1195
1261
  const refreshSessions = useCallback(() => {
@@ -1246,22 +1312,51 @@ export function useRemoteApp(options = {}) {
1246
1312
  // Pin the CLI this session belongs to so its on-disk backlog is read with
1247
1313
  // the right reader even if the user switches the pane's CLI afterward.
1248
1314
  ccHistoryKindRef.current = ccCliKindRef.current;
1249
- pendingCcOpenCwdsRef.current.set(sessionId, cwd);
1315
+ pendingCcOpensRef.current.set(sessionId, {
1316
+ cwd,
1317
+ revision: conversationRevisionRef.current,
1318
+ });
1250
1319
  socket.send({ type: "ccRoom.openSession", sessionId, cwd, mode, kind: ccCliKindRef.current });
1251
1320
  }, [socket]);
1252
1321
  const respondCcApproval = useCallback((roomId, requestId, decision) => {
1253
1322
  const a = approvalsRef.current.find((p) => p.requestId === requestId);
1254
1323
  if (!a)
1255
1324
  return; // approve-once: already resolved
1256
- socket.send({ type: "ccRoom.respondApproval", roomId, requestId, decision });
1325
+ // An unsent response never reaches the CLI, which is still waiting.
1326
+ // Keep the card so the user can retry once the connection recovers.
1327
+ if (!socket.send({ type: "ccRoom.respondApproval", roomId, requestId, decision })) {
1328
+ setNotice(t("mobile.notice.approvalSendFailed"));
1329
+ return;
1330
+ }
1257
1331
  approvalsRef.current = approvalsRef.current.filter((p) => p.requestId !== requestId);
1258
1332
  setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
1259
- }, [socket]);
1333
+ }, [setNotice, socket]);
1260
1334
  const newSession = useCallback((target, name) => {
1261
- outputClient.cancel();
1262
- conversationRevisionRef.current += 1;
1263
1335
  const clientRequestId = globalThis.crypto?.randomUUID?.() ??
1264
1336
  `mobile-create-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1337
+ const stableTarget = target === undefined && activeProjectIdRef.current
1338
+ ? { projectId: activeProjectIdRef.current }
1339
+ : target;
1340
+ // Creation needs the host's acceptance. Send it before switching the
1341
+ // view: offline, a dropped frame used to leave the view on the new target
1342
+ // while a later first send fell back to the previous (possibly another
1343
+ // project's) session.
1344
+ const created = socket.send(stableTarget && typeof stableTarget === "object"
1345
+ ? { type: "session.create", clientRequestId, ...stableTarget, ...(name ? { name } : {}) }
1346
+ : stableTarget === undefined
1347
+ ? { type: "session.create", clientRequestId, ...(name ? { name } : {}) }
1348
+ : {
1349
+ type: "session.create",
1350
+ clientRequestId,
1351
+ cwd: stableTarget,
1352
+ ...(name ? { name } : {}),
1353
+ });
1354
+ if (!created) {
1355
+ setNotice(t("mobile.notice.createOffline"));
1356
+ return;
1357
+ }
1358
+ outputClient.cancel();
1359
+ conversationRevisionRef.current += 1;
1265
1360
  pendingCreateRef.current = {
1266
1361
  clientRequestId,
1267
1362
  conversationRevision: conversationRevisionRef.current,
@@ -1273,9 +1368,6 @@ export function useRemoteApp(options = {}) {
1273
1368
  roomId: activeRoomIdRef.current,
1274
1369
  });
1275
1370
  }
1276
- const stableTarget = target === undefined && activeProjectIdRef.current
1277
- ? { projectId: activeProjectIdRef.current }
1278
- : target;
1279
1371
  const selectedProject = stableTarget && typeof stableTarget === "object" && stableTarget.projectId
1280
1372
  ? projectsRef.current.find((project) => project.id === stableTarget.projectId)
1281
1373
  : undefined;
@@ -1315,25 +1407,7 @@ export function useRemoteApp(options = {}) {
1315
1407
  setApprovals([]);
1316
1408
  setLoadingKey("sessionHistory", false);
1317
1409
  dispatchChat({ kind: "reset" });
1318
- if (stableTarget && typeof stableTarget === "object") {
1319
- socket.send({
1320
- type: "session.create",
1321
- clientRequestId,
1322
- ...stableTarget,
1323
- ...(name ? { name } : {}),
1324
- });
1325
- }
1326
- else {
1327
- socket.send(stableTarget === undefined
1328
- ? { type: "session.create", clientRequestId, ...(name ? { name } : {}) }
1329
- : {
1330
- type: "session.create",
1331
- clientRequestId,
1332
- cwd: stableTarget,
1333
- ...(name ? { name } : {}),
1334
- });
1335
- }
1336
- }, [socket, setLoadingKey]);
1410
+ }, [socket, setLoadingKey, setNotice]);
1337
1411
  const respondApproval = useCallback((requestId, decision, opts) => {
1338
1412
  const a = approvalsRef.current.find((p) => p.requestId === requestId);
1339
1413
  // Dedup at the source: if this approval is already gone from the ref it was
@@ -1361,12 +1435,17 @@ export function useRemoteApp(options = {}) {
1361
1435
  submittingAsyncApprovalsRef.current.add(requestId);
1362
1436
  return;
1363
1437
  }
1438
+ submittedApprovalsRef.current.add(requestId);
1364
1439
  // Update the ref synchronously too so a second tap in the same tick (before
1365
1440
  // the async setApprovals commits) sees it gone and no-ops above.
1366
1441
  approvalsRef.current = approvalsRef.current.filter((p) => p.requestId !== requestId);
1367
1442
  setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
1368
1443
  }, [socket]);
1369
1444
  const setPermissionMode = useCallback((mode) => {
1445
+ // A CLI room's mode is a spawn-time argument of its process; the native
1446
+ // session mode does not govern it. Never pretend to change it here.
1447
+ if (activeRoomIdRef.current)
1448
+ return;
1370
1449
  setPermissionModeState(mode);
1371
1450
  socket.send({ type: "permission.setMode", sessionId: boundSessionRef.current, mode });
1372
1451
  }, [socket]);
@@ -1388,6 +1467,11 @@ export function useRemoteApp(options = {}) {
1388
1467
  ccHistorySessionRef.current = undefined;
1389
1468
  ccHistoryCwdRef.current = undefined;
1390
1469
  ccBacklogLoadedRef.current = false;
1470
+ // The room's cards belong to the conversation being left and must not
1471
+ // block other conversations. The host keeps the requests pending and
1472
+ // replays them when the room is opened again.
1473
+ approvalsRef.current = approvalsRef.current.filter((approval) => !approval.roomId);
1474
+ setApprovals((prev) => prev.filter((approval) => !approval.roomId));
1391
1475
  setLoadingKey("roomHistory", false);
1392
1476
  dispatchChat({ kind: "reset" });
1393
1477
  }, [setLoadingKey, socket]);
@@ -1439,6 +1523,7 @@ export function useRemoteApp(options = {}) {
1439
1523
  activeCwd,
1440
1524
  projects,
1441
1525
  activeRoom,
1526
+ activeRoomId,
1442
1527
  approvals,
1443
1528
  permissionMode,
1444
1529
  loading,
@@ -97,6 +97,8 @@ export declare const mobile: {
97
97
  readonly ccRoomOpenFailed: "cc 会话无法打开";
98
98
  readonly ccRoomObserving: "此 CC 会话当前为只读查看,消息未发送";
99
99
  readonly attachmentSendFailed: "图片发送失败:{error}";
100
+ readonly createOffline: "连接已断开,重新连接后才能新建任务。";
101
+ readonly approvalSendFailed: "审批未送达,请检查连接后重试";
100
102
  };
101
103
  readonly permissionMode: {
102
104
  readonly default: "默认";
@@ -261,6 +263,8 @@ export declare const mobile: {
261
263
  readonly ccRoomOpenFailed: "Could not open cc session";
262
264
  readonly ccRoomObserving: "This CC session is read-only; the message was not sent";
263
265
  readonly attachmentSendFailed: "Could not send images: {error}";
266
+ readonly createOffline: "Disconnected. Reconnect before starting a new task.";
267
+ readonly approvalSendFailed: "The approval was not delivered. Check the connection and try again.";
264
268
  };
265
269
  readonly permissionMode: {
266
270
  readonly default: "Default";
@@ -97,6 +97,8 @@ export const mobile = {
97
97
  ccRoomOpenFailed: "cc 会话无法打开",
98
98
  ccRoomObserving: "此 CC 会话当前为只读查看,消息未发送",
99
99
  attachmentSendFailed: "图片发送失败:{error}",
100
+ createOffline: "连接已断开,重新连接后才能新建任务。",
101
+ approvalSendFailed: "审批未送达,请检查连接后重试",
100
102
  },
101
103
  permissionMode: {
102
104
  default: "默认",
@@ -261,6 +263,8 @@ export const mobile = {
261
263
  ccRoomOpenFailed: "Could not open cc session",
262
264
  ccRoomObserving: "This CC session is read-only; the message was not sent",
263
265
  attachmentSendFailed: "Could not send images: {error}",
266
+ createOffline: "Disconnected. Reconnect before starting a new task.",
267
+ approvalSendFailed: "The approval was not delivered. Check the connection and try again.",
264
268
  },
265
269
  permissionMode: {
266
270
  default: "Default",