@cjhyy/code-shell-web 0.9.7 → 0.9.9
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/dist/hooks/remoteAppSync.d.ts +10 -0
- package/dist/hooks/remoteAppSync.js +14 -0
- package/dist/hooks/useRemoteApp.d.ts +6 -1
- package/dist/hooks/useRemoteApp.js +198 -29
- package/dist/i18n/mobile.d.ts +4 -0
- package/dist/i18n/mobile.js +4 -0
- package/dist/i18n/translate.d.ts +4 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/lib/streamReducer.d.ts +4 -1
- package/dist/lib/streamReducer.js +61 -15
- package/dist/lib/transcriptReplay.d.ts +18 -0
- package/dist/lib/transcriptReplay.js +291 -0
- package/dist-app/assets/index-CPteW9-v.js +50 -0
- package/dist-app/assets/index-Dz4kCU3p.css +1 -0
- package/dist-app/icon.svg +1 -0
- package/dist-app/index.html +6 -3
- package/dist-app/manifest.webmanifest +15 -0
- package/dist-app/offline.html +51 -0
- package/dist-app/sw.js +48 -0
- package/package.json +5 -2
- package/dist-app/assets/index-BGyEiCap.js +0 -11
- package/dist-app/assets/index-xwzva9ZI.css +0 -1
|
@@ -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
|
|
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,
|
|
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
|
-
/**
|
|
157
|
-
const
|
|
158
|
-
/**
|
|
159
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 (!
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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:
|
|
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({
|
|
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
|
-
: {
|
|
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",
|
package/dist/i18n/mobile.d.ts
CHANGED
|
@@ -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";
|
package/dist/i18n/mobile.js
CHANGED
|
@@ -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",
|
package/dist/i18n/translate.d.ts
CHANGED
|
@@ -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;
|