@cjhyy/code-shell-web 0.9.8 → 0.9.10

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.
@@ -6,6 +6,16 @@ export interface SessionReplaySelection {
6
6
  events: unknown[];
7
7
  cursor: number;
8
8
  }
9
+ /** The Main process owns one sequence domain; legacy peers omit its epoch. */
10
+ export interface SessionStreamCursor {
11
+ epoch?: string;
12
+ appliedSeq: number;
13
+ observedSeq: number;
14
+ awaitingSnapshot: boolean;
15
+ /** A selected durable history has no cursor linking it to this live log. */
16
+ historyUnpaired?: boolean;
17
+ }
18
+ export declare function alignSessionEpoch(cursor: SessionStreamCursor, epoch?: string): boolean;
9
19
  export declare function selectSessionReplayEntries(entries: SessionReplayEntry[], appliedSeq: number): SessionReplaySelection;
10
20
  export declare function rawApprovalResolvedRequestId(raw: unknown): string | null;
11
21
  export declare function removeResolvedApproval<T extends {
@@ -1,3 +1,17 @@
1
+ export function alignSessionEpoch(cursor, epoch) {
2
+ if (!epoch || epoch === cursor.epoch)
3
+ return false;
4
+ // A legacy cursor is also untrusted when the peer first supplies an epoch.
5
+ // Missing epoch on a later legacy packet must not erase a known domain.
6
+ cursor.epoch = epoch;
7
+ cursor.appliedSeq = 0;
8
+ cursor.observedSeq = 0;
9
+ // A history read without an associated cursor remains unpaired even when
10
+ // the next packet is the first one to identify another Main lifetime.
11
+ if (!cursor.historyUnpaired)
12
+ cursor.historyUnpaired = undefined;
13
+ return true;
14
+ }
1
15
  export function selectSessionReplayEntries(entries, appliedSeq) {
2
16
  let cursor = appliedSeq;
3
17
  const events = [];
@@ -93,4 +93,9 @@ export type MobileSessionCreateTarget = {
93
93
  } | {
94
94
  projectId: null;
95
95
  } | string | null;
96
- export declare function useRemoteApp(): RemoteApp;
96
+ export interface RemoteAppOptions {
97
+ /** Browser host features may observe notifications without opening a second
98
+ * transport or changing the session/room reducer's ownership. */
99
+ onNotification?: (method: string, params: Record<string, unknown>) => void;
100
+ }
101
+ export declare function useRemoteApp(options?: RemoteAppOptions): RemoteApp;
@@ -6,7 +6,7 @@ import { roomMsgToEvent, roomHistoryToEvents, ccHistoryToEvents, extractAskUserO
6
6
  import { projectForCwd } from "../lib/format.js";
7
7
  import { useRemoteSocket } from "./useRemoteSocket.js";
8
8
  import { prepareMobileAttachments, } from "../lib/mobileAttachments.js";
9
- import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, noteSessionSeq, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
9
+ import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, alignSessionEpoch, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
10
10
  /** Tools whose grants can be remembered with a path scope (file/dir). */
11
11
  const PATH_SCOPED = new Set([
12
12
  "Read",
@@ -30,13 +30,30 @@ function chatReducer(state, action) {
30
30
  return appendUserMessage(state, action.text, action.attachments);
31
31
  case "reset":
32
32
  return initialChatState();
33
+ case "stream_epoch_changed": {
34
+ const seq = state.seq + 1;
35
+ return {
36
+ ...state,
37
+ seq,
38
+ run: "idle",
39
+ liveByAgent: {},
40
+ orphanResults: undefined,
41
+ items: state.items.map((item) => item.kind === "assistant"
42
+ ? { ...item, done: true }
43
+ : item.kind === "tool"
44
+ ? { ...item, id: `prior-${seq}-${item.id}`, done: true }
45
+ : item),
46
+ };
47
+ }
33
48
  case "replay":
34
49
  return action.events.reduce(reduceStream, initialChatState());
35
50
  case "append":
36
51
  return action.events.reduce(reduceStream, state);
37
52
  }
38
53
  }
39
- export function useRemoteApp() {
54
+ export function useRemoteApp(options = {}) {
55
+ const optionsRef = useRef(options);
56
+ optionsRef.current = options;
40
57
  const [chat, dispatchChat] = useReducer(chatReducer, undefined, initialChatState);
41
58
  const [sessions, setSessions] = useState([]);
42
59
  const [unreadSessionIds, setUnreadSessionIds] = useState(() => new Set());
@@ -77,6 +94,8 @@ export function useRemoteApp() {
77
94
  const sendRef = useRef(null);
78
95
  const connectionGenerationRef = useRef(0);
79
96
  const uploadWaitersRef = useRef(new Map());
97
+ const conversationRevisionRef = useRef(0);
98
+ const pendingCreateRef = useRef(null);
80
99
  const messageAckWaitersRef = useRef(new Map());
81
100
  const [permissionMode, setPermissionModeState] = useState("default");
82
101
  /** Transient banner message (errors, room-missing, …). Auto-clears. */
@@ -116,7 +135,7 @@ export function useRemoteApp() {
116
135
  }
117
136
  });
118
137
  }, []);
119
- const waitForMessageAck = useCallback((clientMessageId, connectionGeneration) => new Promise((resolve, reject) => {
138
+ const waitForMessageAck = useCallback((clientMessageId, connectionGeneration, conversationRevision) => new Promise((resolve, reject) => {
120
139
  const timer = setTimeout(() => {
121
140
  messageAckWaitersRef.current.delete(clientMessageId);
122
141
  reject(new Error("Message acknowledgement timed out"));
@@ -126,6 +145,7 @@ export function useRemoteApp() {
126
145
  reject,
127
146
  timer,
128
147
  connectionGeneration,
148
+ conversationRevision,
129
149
  });
130
150
  }), []);
131
151
  const settleMessageAck = useCallback((clientMessageId, error) => {
@@ -153,10 +173,18 @@ export function useRemoteApp() {
153
173
  }, []);
154
174
  /** Which session id the chat view is bound to (for filtering live stream). */
155
175
  const boundSessionRef = useRef(undefined);
156
- /** Highest mobile snapshot seq applied per desktop session. */
157
- const appliedSeqRef = useRef(new Map());
158
- /** Highest live/snapshot seq observed per desktop session, including sessions not being viewed. */
159
- const lastSessionSeqRef = useRef(new Map());
176
+ /** Observed/applied cursors always belong to the same Main process epoch. */
177
+ const sessionCursorsRef = useRef(new Map());
178
+ /** A newly selected epoch must load its durable history before live recovery. */
179
+ const epochHistorySessionRef = useRef(undefined);
180
+ const sessionCursor = useCallback((sessionId) => {
181
+ let cursor = sessionCursorsRef.current.get(sessionId);
182
+ if (!cursor) {
183
+ cursor = { appliedSeq: 0, observedSeq: 0, awaitingSnapshot: false };
184
+ sessionCursorsRef.current.set(sessionId, cursor);
185
+ }
186
+ return cursor;
187
+ }, []);
160
188
  /** Highest room message seq seen per active room. Used as room.history cursor. */
161
189
  const lastRoomSeqRef = useRef(new Map());
162
190
  /** Recent room seqs already folded into the reducer, for idempotent history/live merge. */
@@ -253,24 +281,39 @@ export function useRemoteApp() {
253
281
  case "auth.ok":
254
282
  // Pull the world on connect.
255
283
  break;
256
- case "chat.accepted":
284
+ case "chat.accepted": {
285
+ const waiter = event.clientMessageId
286
+ ? messageAckWaitersRef.current.get(event.clientMessageId)
287
+ : undefined;
288
+ const creation = pendingCreateRef.current;
289
+ const mayBind = event.clientRequestId
290
+ ? Boolean(creation &&
291
+ creation.clientRequestId === event.clientRequestId &&
292
+ creation.conversationRevision === conversationRevisionRef.current &&
293
+ creation.connectionGeneration === connectionGenerationRef.current)
294
+ : !event.clientMessageId ||
295
+ Boolean(waiter &&
296
+ waiter.conversationRevision === conversationRevisionRef.current &&
297
+ waiter.connectionGeneration === connectionGenerationRef.current);
298
+ if (event.clientRequestId && creation?.clientRequestId === event.clientRequestId)
299
+ pendingCreateRef.current = null;
257
300
  if (event.clientMessageId) {
258
- const waiter = messageAckWaitersRef.current.get(event.clientMessageId);
259
301
  settleMessageAck(event.clientMessageId, waiter && waiter.connectionGeneration !== connectionGenerationRef.current
260
302
  ? new Error("Message acknowledgement arrived on a different connection")
261
303
  : undefined);
262
304
  }
263
- if (event.sessionId) {
305
+ if (mayBind && event.sessionId) {
264
306
  setActiveSessionId(event.sessionId);
265
307
  boundSessionRef.current = event.sessionId;
266
308
  clearSessionUnread(event.sessionId);
267
309
  }
268
- if ("cwd" in event)
310
+ if (mayBind && "cwd" in event)
269
311
  setActiveSessionCwd(event.cwd ?? null);
270
312
  // A freshly minted session won't be on disk until its first turn, so
271
313
  // pull the list now so the new conversation shows up as a row.
272
314
  sendRef.current?.({ type: "session.list" });
273
315
  break;
316
+ }
274
317
  case "attachment.upload.ready": {
275
318
  const waiter = uploadWaitersRef.current.get(event.clientId);
276
319
  if (!waiter)
@@ -308,7 +351,12 @@ export function useRemoteApp() {
308
351
  return clearUnreadSession(pruned, event.activeSessionId);
309
352
  });
310
353
  setLoadingKey("sessions", false);
311
- if (event.activeSessionId) {
354
+ if (event.activeSessionId &&
355
+ (conversationRevisionRef.current === 0 ||
356
+ event.activeSessionId === boundSessionRef.current)) {
357
+ // A refresh may reflect an earlier async create or another tab's
358
+ // device selection. Only an explicit acknowledgement/select owns
359
+ // navigation once this browser has chosen its conversation.
312
360
  setActiveSessionId(event.activeSessionId);
313
361
  const active = event.sessions.find((s) => s.id === event.activeSessionId);
314
362
  if (active)
@@ -319,27 +367,80 @@ export function useRemoteApp() {
319
367
  break;
320
368
  case "session.history.ok":
321
369
  // Only apply if it's the session we're currently viewing.
322
- if (event.sessionId === boundSessionRef.current) {
370
+ if (event.sessionId === boundSessionRef.current && !activeRoomIdRef.current) {
371
+ const cursor = sessionCursor(event.sessionId);
372
+ // An earlier retry may finish after a fresh live turn starts.
373
+ // Only an explicitly requested history can replace that projection.
374
+ if (cursor.historyUnpaired !== undefined &&
375
+ epochHistorySessionRef.current !== event.sessionId)
376
+ break;
323
377
  dispatchChat({ kind: "replay", events: event.events });
378
+ if (epochHistorySessionRef.current === event.sessionId) {
379
+ epochHistorySessionRef.current = undefined;
380
+ dispatchChat({ kind: "stream_epoch_changed" });
381
+ // History carries no live cursor. A full snapshot could repeat
382
+ // a reply already saved here, so keep the durable projection
383
+ // until a subsequent top-level start proves a fresh boundary.
384
+ cursor.historyUnpaired = true;
385
+ cursor.awaitingSnapshot = true;
386
+ setNotice(t("mobile.notice.sessionRecoveryHistoryOnly"));
387
+ }
324
388
  setLoadingKey("sessionHistory", false);
325
389
  }
326
390
  break;
327
391
  case "session.snapshot": {
328
392
  if (event.sessionId !== boundSessionRef.current || activeRoomIdRef.current)
329
393
  break;
330
- const appliedSeq = appliedSeqRef.current.get(event.sessionId) ?? 0;
394
+ if (epochHistorySessionRef.current === event.sessionId)
395
+ break;
396
+ const session = sessionCursor(event.sessionId);
397
+ const changed = alignSessionEpoch(session, event.epoch);
398
+ if (session.historyUnpaired)
399
+ break;
400
+ if (changed) {
401
+ dispatchChat({ kind: "stream_epoch_changed" });
402
+ setApprovals([]);
403
+ session.awaitingSnapshot = true;
404
+ }
405
+ // After a restart only a full new-domain prefix can release the
406
+ // barrier. An older filtered reply (or an evicted prefix) cannot.
407
+ if (session.awaitingSnapshot &&
408
+ event.nextSeq > 1 &&
409
+ !event.entries.some((entry) => entry.seq === 1)) {
410
+ if (changed) {
411
+ sendRef.current?.({
412
+ type: "session.sync",
413
+ sessionId: event.sessionId,
414
+ sinceSeq: 0,
415
+ epoch: session.epoch,
416
+ });
417
+ }
418
+ else {
419
+ setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
420
+ }
421
+ setLoadingKey("sessionHistory", false);
422
+ break;
423
+ }
424
+ const appliedSeq = session.appliedSeq;
331
425
  const { events, cursor } = selectSessionReplayEntries(event.entries, appliedSeq);
332
- if (cursor > appliedSeq)
333
- appliedSeqRef.current.set(event.sessionId, cursor);
334
- if (cursor > 0)
335
- noteSessionSeq(lastSessionSeqRef.current, event.sessionId, cursor);
426
+ session.appliedSeq = cursor;
427
+ session.observedSeq = Math.max(session.observedSeq, cursor);
428
+ session.awaitingSnapshot = false;
336
429
  appendSessionEvents(event.sessionId, events);
337
430
  setLoadingKey("sessionHistory", false);
338
431
  break;
339
432
  }
340
433
  case "session.stream": {
341
- if (!noteSessionSeq(lastSessionSeqRef.current, event.sessionId, event.seq))
434
+ if (!Number.isSafeInteger(event.seq) || event.seq < 1)
435
+ break;
436
+ const session = sessionCursor(event.sessionId);
437
+ const hadCursor = session.epoch !== undefined || session.appliedSeq > 0 || session.observedSeq > 0;
438
+ const changed = alignSessionEpoch(session, event.epoch);
439
+ if (changed)
440
+ session.awaitingSnapshot = hadCursor || event.seq > 1;
441
+ if (event.seq <= session.observedSeq)
342
442
  break;
443
+ session.observedSeq = event.seq;
343
444
  if (activeRoomIdRef.current) {
344
445
  setUnreadSessionIds((prev) => markSessionUnread(prev, event.sessionId));
345
446
  break;
@@ -356,10 +457,36 @@ export function useRemoteApp() {
356
457
  setActiveSessionCwd(sessionCwdById.get(event.sessionId));
357
458
  }
358
459
  clearSessionUnread(event.sessionId);
359
- const appliedSeq = appliedSeqRef.current.get(event.sessionId) ?? 0;
360
- if (event.seq <= appliedSeq)
460
+ if (changed) {
461
+ dispatchChat({ kind: "stream_epoch_changed" });
462
+ setApprovals([]);
463
+ session.awaitingSnapshot = hadCursor || event.seq > 1;
464
+ if (session.awaitingSnapshot && epochHistorySessionRef.current !== event.sessionId) {
465
+ // Main captures/replies synchronously on this ordered socket.
466
+ // Its full snapshot covers every live frame preceding the reply,
467
+ // so waiting needs no unbounded client-side event journal.
468
+ sendRef.current?.({
469
+ type: "session.sync",
470
+ sessionId: event.sessionId,
471
+ sinceSeq: 0,
472
+ epoch: session.epoch,
473
+ });
474
+ }
475
+ }
476
+ if (session.historyUnpaired && epochHistorySessionRef.current !== event.sessionId) {
477
+ const raw = event.event;
478
+ if (raw?.type === "stream_request_start" && !raw.agentId) {
479
+ session.historyUnpaired = false;
480
+ session.awaitingSnapshot = false;
481
+ // Every older snapshot entry is either already in history or
482
+ // unprovable. None may be replayed into this new live turn.
483
+ session.appliedSeq = event.seq - 1;
484
+ setNoticeState((notice) => notice === t("mobile.notice.sessionRecoveryHistoryOnly") ? undefined : notice);
485
+ }
486
+ }
487
+ if (session.awaitingSnapshot || event.seq <= session.appliedSeq)
361
488
  break;
362
- appliedSeqRef.current.set(event.sessionId, event.seq);
489
+ session.appliedSeq = event.seq;
363
490
  appendSessionEvents(event.sessionId, [event.event]);
364
491
  break;
365
492
  }
@@ -621,9 +748,15 @@ export function useRemoteApp() {
621
748
  setLoadingKey,
622
749
  settleMessageAck,
623
750
  setNotice,
751
+ sessionCursor,
624
752
  ]);
625
753
  const onRawLine = useCallback((raw) => {
626
754
  const obj = raw;
755
+ if (typeof obj.method === "string") {
756
+ optionsRef.current.onNotification?.(obj.method, obj.params && typeof obj.params === "object"
757
+ ? obj.params
758
+ : {});
759
+ }
627
760
  // Permission requests arrive as agent/approvalRequest worker lines.
628
761
  if (obj.method === "agent/approvalRequest" && obj.params) {
629
762
  const params = obj.params;
@@ -720,12 +853,20 @@ export function useRemoteApp() {
720
853
  const sessionId = boundSessionRef.current;
721
854
  if (!sessionId)
722
855
  return;
856
+ const cursor = sessionCursor(sessionId);
857
+ if (epochHistorySessionRef.current === sessionId || cursor.historyUnpaired) {
858
+ epochHistorySessionRef.current = sessionId;
859
+ setLoadingKey("sessionHistory", true);
860
+ sendRef.current?.({ type: "session.history", sessionId });
861
+ return;
862
+ }
723
863
  sendRef.current?.({
724
864
  type: "session.sync",
725
865
  sessionId,
726
- sinceSeq: appliedSeqRef.current.get(sessionId) ?? 0,
866
+ sinceSeq: cursor.awaitingSnapshot ? 0 : cursor.appliedSeq,
867
+ ...(cursor.epoch ? { epoch: cursor.epoch } : {}),
727
868
  });
728
- }, [setLoadingKey]);
869
+ }, [setLoadingKey, sessionCursor]);
729
870
  const socket = useRemoteSocket({
730
871
  onServerEvent,
731
872
  onRawLine,
@@ -756,6 +897,7 @@ export function useRemoteApp() {
756
897
  if (socket.status !== "online")
757
898
  return false;
758
899
  const connectionGeneration = socket.connectionGeneration;
900
+ const conversationRevision = conversationRevisionRef.current;
759
901
  const roomId = activeRoomIdRef.current;
760
902
  const sessionId = boundSessionRef.current;
761
903
  const clientMessageId = globalThis.crypto?.randomUUID?.() ??
@@ -777,8 +919,10 @@ export function useRemoteApp() {
777
919
  }));
778
920
  return false;
779
921
  }
922
+ if (conversationRevision !== conversationRevisionRef.current)
923
+ return false;
780
924
  const summaries = attachments.map(({ name, mime, size }) => ({ name, mime, size }));
781
- const acknowledged = waitForMessageAck(clientMessageId, connectionGeneration);
925
+ const acknowledged = waitForMessageAck(clientMessageId, connectionGeneration, conversationRevision);
782
926
  let queued;
783
927
  if (roomId) {
784
928
  // NO optimistic echo for rooms: RoomManager persists the user line and
@@ -817,6 +961,7 @@ export function useRemoteApp() {
817
961
  return false;
818
962
  }
819
963
  if (!roomId &&
964
+ conversationRevision === conversationRevisionRef.current &&
820
965
  !activeRoomIdRef.current &&
821
966
  (sessionId === undefined || boundSessionRef.current === sessionId)) {
822
967
  // Sessions don't echo the user turn back over the stream, so add the
@@ -833,6 +978,7 @@ export function useRemoteApp() {
833
978
  socket.send({ type: "session.list" });
834
979
  }, [socket, setLoadingKey]);
835
980
  const selectSession = useCallback((id) => {
981
+ conversationRevisionRef.current += 1;
836
982
  if (activeRoomIdRef.current) {
837
983
  socket.send({
838
984
  type: "ccRoom.unsubscribeTranscript",
@@ -843,6 +989,9 @@ export function useRemoteApp() {
843
989
  clearSessionUnread(id);
844
990
  setActiveSessionCwd(sessionCwdById.get(id) ?? null);
845
991
  boundSessionRef.current = id;
992
+ const cursor = sessionCursor(id);
993
+ epochHistorySessionRef.current =
994
+ cursor.awaitingSnapshot || cursor.historyUnpaired !== undefined ? id : undefined;
846
995
  ccHistorySessionRef.current = undefined;
847
996
  ccHistoryCwdRef.current = undefined;
848
997
  ccBacklogLoadedRef.current = false;
@@ -852,7 +1001,7 @@ export function useRemoteApp() {
852
1001
  dispatchChat({ kind: "reset" });
853
1002
  socket.send({ type: "session.select", sessionId: id });
854
1003
  socket.send({ type: "session.history", sessionId: id });
855
- }, [clearSessionUnread, socket, sessionCwdById, setLoadingKey]);
1004
+ }, [clearSessionUnread, socket, sessionCwdById, setLoadingKey, sessionCursor]);
856
1005
  /** Select a project (like the desktop sidebar): set the one-true-source cwd and
857
1006
  * pull that project's chat sessions + external cc sessions. */
858
1007
  const selectProject = useCallback((projectIdOrCwd) => {
@@ -868,6 +1017,7 @@ export function useRemoteApp() {
868
1017
  // Doing it here too would double-fetch.
869
1018
  }, [socket, setLoadingKey]);
870
1019
  const openCcSession = useCallback((sessionId, cwd, mode) => {
1020
+ conversationRevisionRef.current += 1;
871
1021
  // Pin the CLI this session belongs to so its on-disk backlog is read with
872
1022
  // the right reader even if the user switches the pane's CLI afterward.
873
1023
  ccHistoryKindRef.current = ccCliKindRef.current;
@@ -883,6 +1033,14 @@ export function useRemoteApp() {
883
1033
  setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
884
1034
  }, [socket]);
885
1035
  const newSession = useCallback((target, name) => {
1036
+ conversationRevisionRef.current += 1;
1037
+ const clientRequestId = globalThis.crypto?.randomUUID?.() ??
1038
+ `mobile-create-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1039
+ pendingCreateRef.current = {
1040
+ clientRequestId,
1041
+ conversationRevision: conversationRevisionRef.current,
1042
+ connectionGeneration: socket.connectionGeneration,
1043
+ };
886
1044
  if (activeRoomIdRef.current) {
887
1045
  socket.send({
888
1046
  type: "ccRoom.unsubscribeTranscript",
@@ -932,12 +1090,22 @@ export function useRemoteApp() {
932
1090
  setLoadingKey("sessionHistory", false);
933
1091
  dispatchChat({ kind: "reset" });
934
1092
  if (stableTarget && typeof stableTarget === "object") {
935
- socket.send({ type: "session.create", ...stableTarget, ...(name ? { name } : {}) });
1093
+ socket.send({
1094
+ type: "session.create",
1095
+ clientRequestId,
1096
+ ...stableTarget,
1097
+ ...(name ? { name } : {}),
1098
+ });
936
1099
  }
937
1100
  else {
938
1101
  socket.send(stableTarget === undefined
939
- ? { type: "session.create", ...(name ? { name } : {}) }
940
- : { type: "session.create", cwd: stableTarget, ...(name ? { name } : {}) });
1102
+ ? { type: "session.create", clientRequestId, ...(name ? { name } : {}) }
1103
+ : {
1104
+ type: "session.create",
1105
+ clientRequestId,
1106
+ cwd: stableTarget,
1107
+ ...(name ? { name } : {}),
1108
+ });
941
1109
  }
942
1110
  }, [socket, setLoadingKey]);
943
1111
  const respondApproval = useCallback((requestId, decision, opts) => {
@@ -972,6 +1140,7 @@ export function useRemoteApp() {
972
1140
  // is no user-facing room create/open/close — CC sessions are opened via
973
1141
  // openCcSession; the room is internal transport.)
974
1142
  const leaveRoom = useCallback(() => {
1143
+ conversationRevisionRef.current += 1;
975
1144
  if (activeRoomIdRef.current) {
976
1145
  socket.send({
977
1146
  type: "ccRoom.unsubscribeTranscript",
@@ -91,6 +91,8 @@ export declare const mobile: {
91
91
  readonly notice: {
92
92
  readonly roomMissing: "会话不存在或未就绪";
93
93
  readonly roomError: "会话错误";
94
+ readonly sessionRecoveryIncomplete: "当前回复的开头尚未恢复,请重新打开会话以读取已保存的内容。";
95
+ readonly sessionRecoveryHistoryOnly: "已恢复保存的聊天记录。当前回复的实时部分暂时无法接续,请稍后重新打开会话。";
94
96
  readonly goalExtendFailed: "延长目标失败";
95
97
  readonly ccRoomOpenFailed: "cc 会话无法打开";
96
98
  readonly ccRoomObserving: "此 CC 会话当前为只读查看,消息未发送";
@@ -253,6 +255,8 @@ export declare const mobile: {
253
255
  readonly notice: {
254
256
  readonly roomMissing: "Session does not exist or is not ready";
255
257
  readonly roomError: "Session error";
258
+ readonly sessionRecoveryIncomplete: "The beginning of this reply could not be restored. Reopen the conversation to read its saved content.";
259
+ readonly sessionRecoveryHistoryOnly: "Saved chat history has been restored. Reopen the conversation later to see the rest of this reply.";
256
260
  readonly goalExtendFailed: "Failed to extend goal";
257
261
  readonly ccRoomOpenFailed: "Could not open cc session";
258
262
  readonly ccRoomObserving: "This CC session is read-only; the message was not sent";
@@ -91,6 +91,8 @@ export const mobile = {
91
91
  notice: {
92
92
  roomMissing: "会话不存在或未就绪",
93
93
  roomError: "会话错误",
94
+ sessionRecoveryIncomplete: "当前回复的开头尚未恢复,请重新打开会话以读取已保存的内容。",
95
+ sessionRecoveryHistoryOnly: "已恢复保存的聊天记录。当前回复的实时部分暂时无法接续,请稍后重新打开会话。",
94
96
  goalExtendFailed: "延长目标失败",
95
97
  ccRoomOpenFailed: "cc 会话无法打开",
96
98
  ccRoomObserving: "此 CC 会话当前为只读查看,消息未发送",
@@ -253,6 +255,8 @@ export const mobile = {
253
255
  notice: {
254
256
  roomMissing: "Session does not exist or is not ready",
255
257
  roomError: "Session error",
258
+ sessionRecoveryIncomplete: "The beginning of this reply could not be restored. Reopen the conversation to read its saved content.",
259
+ sessionRecoveryHistoryOnly: "Saved chat history has been restored. Reopen the conversation later to see the rest of this reply.",
256
260
  goalExtendFailed: "Failed to extend goal",
257
261
  ccRoomOpenFailed: "Could not open cc session",
258
262
  ccRoomObserving: "This CC session is read-only; the message was not sent",
@@ -101,6 +101,8 @@ export declare const webMessages: {
101
101
  readonly notice: {
102
102
  readonly roomMissing: "会话不存在或未就绪";
103
103
  readonly roomError: "会话错误";
104
+ readonly sessionRecoveryIncomplete: "当前回复的开头尚未恢复,请重新打开会话以读取已保存的内容。";
105
+ readonly sessionRecoveryHistoryOnly: "已恢复保存的聊天记录。当前回复的实时部分暂时无法接续,请稍后重新打开会话。";
104
106
  readonly goalExtendFailed: "延长目标失败";
105
107
  readonly ccRoomOpenFailed: "cc 会话无法打开";
106
108
  readonly ccRoomObserving: "此 CC 会话当前为只读查看,消息未发送";
@@ -263,6 +265,8 @@ export declare const webMessages: {
263
265
  readonly notice: {
264
266
  readonly roomMissing: "Session does not exist or is not ready";
265
267
  readonly roomError: "Session error";
268
+ readonly sessionRecoveryIncomplete: "The beginning of this reply could not be restored. Reopen the conversation to read its saved content.";
269
+ readonly sessionRecoveryHistoryOnly: "Saved chat history has been restored. Reopen the conversation later to see the rest of this reply.";
266
270
  readonly goalExtendFailed: "Failed to extend goal";
267
271
  readonly ccRoomOpenFailed: "Could not open cc session";
268
272
  readonly ccRoomObserving: "This CC session is read-only; the message was not sent";
package/dist/index.d.ts CHANGED
@@ -8,10 +8,11 @@
8
8
  * The desktop mobile UI (packages/desktop/src/mobile) renders on top of this;
9
9
  * a future standalone browser client is meant to grow from this package.
10
10
  */
11
- export { useRemoteApp, type CcCliKind, type PendingApproval, type RemoteApp, type MobileSessionCreateTarget, } from "./hooks/useRemoteApp.js";
11
+ export { useRemoteApp, type CcCliKind, type PendingApproval, type RemoteApp, type RemoteAppOptions, type MobileSessionCreateTarget, } from "./hooks/useRemoteApp.js";
12
12
  export { useRemoteSocket, type ConnStatus, type ResyncReason, type RemoteSocket, type RemoteSocketHandlers, } from "./hooks/useRemoteSocket.js";
13
13
  export * from "./hooks/remoteAppSync.js";
14
14
  export * from "./lib/streamReducer.js";
15
+ export * from "./lib/transcriptReplay.js";
15
16
  export * from "./lib/messageMappers.js";
16
17
  export * from "./lib/riskClassify.js";
17
18
  export * from "./lib/format.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { useRemoteSocket, } from "./hooks/useRemoteSocket.js";
14
14
  export * from "./hooks/remoteAppSync.js";
15
15
  // ── Stream folding (shared with the desktop renderer's CC room view) ────────
16
16
  export * from "./lib/streamReducer.js";
17
+ export * from "./lib/transcriptReplay.js";
17
18
  export * from "./lib/messageMappers.js";
18
19
  // ── Pure helpers ─────────────────────────────────────────────────────────────
19
20
  export * from "./lib/riskClassify.js";
@@ -20,15 +20,18 @@ export interface UserAttachmentSummary {
20
20
  name: string;
21
21
  mime?: string;
22
22
  size: number;
23
+ path?: string;
23
24
  }
24
25
  export type ChatItem = {
25
26
  kind: "user";
26
27
  id: string;
27
28
  text: string;
28
29
  attachments?: UserAttachmentSummary[];
30
+ clientMessageId?: string;
29
31
  } | {
30
32
  kind: "assistant";
31
33
  id: string;
34
+ messageId?: string;
32
35
  text: string;
33
36
  reasoning: string;
34
37
  done: boolean;
@@ -87,4 +90,4 @@ export interface ChatState {
87
90
  export declare function initialChatState(): ChatState;
88
91
  export declare function reduceStream(state: ChatState, raw: unknown): ChatState;
89
92
  /** Append a local user echo (the phone shows its own message immediately). */
90
- export declare function appendUserMessage(state: ChatState, text: string, attachments?: UserAttachmentSummary[]): ChatState;
93
+ export declare function appendUserMessage(state: ChatState, text: string, attachments?: UserAttachmentSummary[], clientMessageId?: string): ChatState;