@cjhyy/code-shell-web 0.9.28 → 0.9.30
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/mobileOutputClient.d.ts +37 -0
- package/dist/hooks/mobileOutputClient.js +351 -0
- package/dist/hooks/useRemoteApp.js +114 -4
- package/dist/lib/mobileOutputRecovery.d.ts +34 -0
- package/dist/lib/mobileOutputRecovery.js +123 -0
- package/dist-app/assets/index-DLKEXorj.js +57 -0
- package/dist-app/assets/{index-EIfqZrcz.css → index-kPe-x_IE.css} +1 -1
- package/dist-app/index.html +2 -2
- package/package.json +3 -3
- package/dist-app/assets/index-Do7zJ7tU.js +0 -57
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MobileClientEvent, MobileServerEvent } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { recoverMobileOutput } from "../lib/mobileOutputRecovery.js";
|
|
3
|
+
type Result = NonNullable<Awaited<ReturnType<typeof recoverMobileOutput>>>;
|
|
4
|
+
/** One bounded recovery request per selected conversation, tied to this authenticated socket. */
|
|
5
|
+
export declare class MobileOutputClient {
|
|
6
|
+
private readonly deps;
|
|
7
|
+
private enabled;
|
|
8
|
+
private auth;
|
|
9
|
+
private sequence;
|
|
10
|
+
private selection?;
|
|
11
|
+
constructor(deps: {
|
|
12
|
+
send: (event: MobileClientEvent) => boolean;
|
|
13
|
+
current: () => {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
roomId?: string;
|
|
16
|
+
revision: number;
|
|
17
|
+
};
|
|
18
|
+
commit: (sessionId: string, result: Result) => void;
|
|
19
|
+
legacy: (sessionId: string) => void;
|
|
20
|
+
failed: (sessionId: string) => void;
|
|
21
|
+
awaitingJournal?: (sessionId: string) => void;
|
|
22
|
+
});
|
|
23
|
+
cancel(notify?: boolean): void;
|
|
24
|
+
begin(sessionId: string): boolean;
|
|
25
|
+
private current;
|
|
26
|
+
owns(sessionId: string): boolean;
|
|
27
|
+
rejectUnsequenced(sessionId: string): boolean;
|
|
28
|
+
private fail;
|
|
29
|
+
observe(event: MobileServerEvent): boolean;
|
|
30
|
+
private recover;
|
|
31
|
+
/** Live output is withheld until it is covered by the frozen candidate. */
|
|
32
|
+
hold(sessionId: string, raw: unknown): false | "pending" | "covered";
|
|
33
|
+
applied(sessionId: string, raw: unknown): void;
|
|
34
|
+
/** A coalesced committed head requests pages; it never invents visible output. */
|
|
35
|
+
advanceSnapshot(sessionId: string, outputCursor: string | undefined): void;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { recoverMobileOutput, MobilePendingInput, MobileOutputCatchupPending, MobileAwaitingJournal, } from "../lib/mobileOutputRecovery.js";
|
|
2
|
+
import { compareOutputCursors } from "../lib/outputJournalRecovery.js";
|
|
3
|
+
/** One bounded recovery request per selected conversation, tied to this authenticated socket. */
|
|
4
|
+
export class MobileOutputClient {
|
|
5
|
+
deps;
|
|
6
|
+
enabled = false;
|
|
7
|
+
auth = 0;
|
|
8
|
+
sequence = 0;
|
|
9
|
+
selection;
|
|
10
|
+
constructor(deps) {
|
|
11
|
+
this.deps = deps;
|
|
12
|
+
}
|
|
13
|
+
cancel(notify = true) {
|
|
14
|
+
const old = this.selection;
|
|
15
|
+
this.selection = undefined;
|
|
16
|
+
if (old?.readyTimer)
|
|
17
|
+
clearTimeout(old.readyTimer);
|
|
18
|
+
if (old?.capacityTimer)
|
|
19
|
+
clearTimeout(old.capacityTimer);
|
|
20
|
+
if (old?.waiter) {
|
|
21
|
+
clearTimeout(old.waiter.timer);
|
|
22
|
+
old.waiter.reject(new Error("Recovery cancelled"));
|
|
23
|
+
}
|
|
24
|
+
if (notify && old)
|
|
25
|
+
this.deps.send({ type: "session.recovery.cancel" });
|
|
26
|
+
}
|
|
27
|
+
begin(sessionId) {
|
|
28
|
+
if (!this.enabled)
|
|
29
|
+
return false;
|
|
30
|
+
this.cancel();
|
|
31
|
+
const selection = {
|
|
32
|
+
id: `recovery-${this.auth}-${++this.sequence}`,
|
|
33
|
+
sessionId,
|
|
34
|
+
revision: this.deps.current().revision,
|
|
35
|
+
auth: this.auth,
|
|
36
|
+
recovering: true,
|
|
37
|
+
failed: false,
|
|
38
|
+
};
|
|
39
|
+
this.selection = selection;
|
|
40
|
+
selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
|
|
41
|
+
if (!this.deps.send({ type: "session.select", sessionId, recoveryId: selection.id }))
|
|
42
|
+
this.fail(selection);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
current(selection) {
|
|
46
|
+
const current = this.deps.current();
|
|
47
|
+
return (this.selection === selection &&
|
|
48
|
+
selection.auth === this.auth &&
|
|
49
|
+
current.sessionId === selection.sessionId &&
|
|
50
|
+
!current.roomId &&
|
|
51
|
+
current.revision === selection.revision);
|
|
52
|
+
}
|
|
53
|
+
owns(sessionId) {
|
|
54
|
+
return this.selection?.sessionId === sessionId;
|
|
55
|
+
}
|
|
56
|
+
rejectUnsequenced(sessionId) {
|
|
57
|
+
const selection = this.selection;
|
|
58
|
+
if (!selection || selection.sessionId !== sessionId)
|
|
59
|
+
return false;
|
|
60
|
+
this.fail(selection);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
fail(selection) {
|
|
64
|
+
if (!this.current(selection))
|
|
65
|
+
return;
|
|
66
|
+
if (selection.readyTimer)
|
|
67
|
+
clearTimeout(selection.readyTimer);
|
|
68
|
+
if (selection.capacityTimer)
|
|
69
|
+
clearTimeout(selection.capacityTimer);
|
|
70
|
+
selection.capacityTimer = undefined;
|
|
71
|
+
selection.capacityPending = false;
|
|
72
|
+
selection.readyTimer = undefined;
|
|
73
|
+
selection.recovering = false;
|
|
74
|
+
selection.failed = true;
|
|
75
|
+
this.deps.failed(selection.sessionId);
|
|
76
|
+
}
|
|
77
|
+
observe(event) {
|
|
78
|
+
if (event.type === "auth.ok" || event.type === "pair.ok") {
|
|
79
|
+
this.cancel(false);
|
|
80
|
+
this.auth++;
|
|
81
|
+
this.enabled = event.capabilities?.outputJournal === 1;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (event.type !== "session.recovery.ready" && event.type !== "session.outputJournal")
|
|
85
|
+
return false;
|
|
86
|
+
const selection = this.selection;
|
|
87
|
+
if (!selection ||
|
|
88
|
+
!this.current(selection) ||
|
|
89
|
+
event.sessionId !== selection.sessionId ||
|
|
90
|
+
event.recoveryId !== selection.id)
|
|
91
|
+
return true;
|
|
92
|
+
if (event.type === "session.recovery.ready") {
|
|
93
|
+
// A later Main revocation also invalidates an already joined live stream.
|
|
94
|
+
if (!event.ok) {
|
|
95
|
+
this.fail(selection);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (selection.started || selection.failed)
|
|
99
|
+
return true;
|
|
100
|
+
selection.started = true;
|
|
101
|
+
selection.journalRequired = event.journalRequired === true;
|
|
102
|
+
if (selection.readyTimer)
|
|
103
|
+
clearTimeout(selection.readyTimer);
|
|
104
|
+
selection.readyTimer = undefined;
|
|
105
|
+
if (!event.ok)
|
|
106
|
+
this.fail(selection);
|
|
107
|
+
else
|
|
108
|
+
void this.recover(selection);
|
|
109
|
+
}
|
|
110
|
+
else if (selection.waiter?.id === event.requestId) {
|
|
111
|
+
const waiter = selection.waiter;
|
|
112
|
+
selection.waiter = undefined;
|
|
113
|
+
clearTimeout(waiter.timer);
|
|
114
|
+
waiter.resolve(event);
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
async recover(selection, resume) {
|
|
119
|
+
try {
|
|
120
|
+
const result = await (resume
|
|
121
|
+
? resume()
|
|
122
|
+
: recoverMobileOutput({
|
|
123
|
+
requireJournal: selection.journalRequired,
|
|
124
|
+
canContinue: () => this.current(selection) && !selection.failed,
|
|
125
|
+
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"));
|
|
146
|
+
}
|
|
147
|
+
}),
|
|
148
|
+
}));
|
|
149
|
+
if (!this.current(selection))
|
|
150
|
+
return;
|
|
151
|
+
if (!result) {
|
|
152
|
+
this.selection = undefined;
|
|
153
|
+
this.deps.send({ type: "session.recovery.cancel" });
|
|
154
|
+
this.deps.legacy(selection.sessionId);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (selection.pendingInputIds &&
|
|
158
|
+
[...selection.pendingInputIds].some((id) => !result.chat.items.some((item) => item.kind === "user" && item.clientMessageId === id))) {
|
|
159
|
+
// An input can arrive after Main captured this reply. Keep the older
|
|
160
|
+
// display until its matching recorded boundary starts a fresh join.
|
|
161
|
+
if (!selection.readyTimer)
|
|
162
|
+
selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
selection.recovering = false;
|
|
166
|
+
selection.capacityPending = false;
|
|
167
|
+
if (selection.readyTimer)
|
|
168
|
+
clearTimeout(selection.readyTimer);
|
|
169
|
+
selection.readyTimer = undefined;
|
|
170
|
+
selection.pendingInputIds = undefined;
|
|
171
|
+
selection.pendingInputBytes = undefined;
|
|
172
|
+
selection.applied = result.outputCursor;
|
|
173
|
+
selection.latest = result.outputCursor;
|
|
174
|
+
this.deps.commit(selection.sessionId, result);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof MobileAwaitingJournal) {
|
|
178
|
+
if (!this.current(selection) || selection.failed)
|
|
179
|
+
return;
|
|
180
|
+
selection.recovering = false;
|
|
181
|
+
selection.awaitingJournal = true;
|
|
182
|
+
// No prefix exists yet. Retain the selected producer without polling,
|
|
183
|
+
// publishing an empty success, or retiring its independent command selection.
|
|
184
|
+
this.deps.awaitingJournal?.(selection.sessionId);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (error instanceof MobileOutputCatchupPending) {
|
|
188
|
+
if (!this.current(selection) || selection.failed)
|
|
189
|
+
return;
|
|
190
|
+
if (selection.capacityCursor &&
|
|
191
|
+
compareOutputCursors(error.cursor, selection.capacityCursor) !== 1) {
|
|
192
|
+
this.fail(selection);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
selection.capacityCursor = error.cursor;
|
|
196
|
+
// Keep the old display behind its barrier. One delayed follow-up also
|
|
197
|
+
// consumes a terminal/head already observed during the last round; it
|
|
198
|
+
// never waits forever for an event that has already arrived.
|
|
199
|
+
selection.recovering = false;
|
|
200
|
+
selection.capacityPending = true;
|
|
201
|
+
this.deps.failed(selection.sessionId);
|
|
202
|
+
selection.capacityTimer = setTimeout(() => {
|
|
203
|
+
selection.capacityTimer = undefined;
|
|
204
|
+
if (!this.current(selection) || selection.failed)
|
|
205
|
+
return;
|
|
206
|
+
selection.capacityPending = false;
|
|
207
|
+
selection.recovering = true;
|
|
208
|
+
void this.recover(selection, error.resume);
|
|
209
|
+
}, 25);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (error instanceof MobilePendingInput &&
|
|
213
|
+
error.ids.every((id) => selection.pendingInputIds?.has(id))) {
|
|
214
|
+
if (!this.current(selection) || selection.failed)
|
|
215
|
+
return;
|
|
216
|
+
if (!selection.readyTimer)
|
|
217
|
+
selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.fail(selection);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Live output is withheld until it is covered by the frozen candidate. */
|
|
224
|
+
hold(sessionId, raw) {
|
|
225
|
+
const selection = this.selection;
|
|
226
|
+
if (!selection || selection.sessionId !== sessionId)
|
|
227
|
+
return false;
|
|
228
|
+
const event = raw;
|
|
229
|
+
const priorCursor = selection.latest ?? selection.applied;
|
|
230
|
+
if (typeof event?.outputCursor === "string") {
|
|
231
|
+
const order = selection.latest
|
|
232
|
+
? compareOutputCursors(event.outputCursor, selection.latest)
|
|
233
|
+
: 1;
|
|
234
|
+
if (order === undefined)
|
|
235
|
+
this.fail(selection);
|
|
236
|
+
else if (order > 0)
|
|
237
|
+
selection.latest = event.outputCursor;
|
|
238
|
+
}
|
|
239
|
+
if (selection.failed)
|
|
240
|
+
return "pending";
|
|
241
|
+
if (selection.awaitingJournal && typeof event?.outputCursor === "string") {
|
|
242
|
+
selection.awaitingJournal = false;
|
|
243
|
+
if (selection.readyTimer)
|
|
244
|
+
clearTimeout(selection.readyTimer);
|
|
245
|
+
selection.readyTimer = undefined;
|
|
246
|
+
selection.recovering = true;
|
|
247
|
+
void this.recover(selection);
|
|
248
|
+
return "pending";
|
|
249
|
+
}
|
|
250
|
+
if (event?.type === "session_user_message" &&
|
|
251
|
+
!event.outputCursor &&
|
|
252
|
+
!event.agentId &&
|
|
253
|
+
event.injected !== true &&
|
|
254
|
+
!["agent", "system", "policy"].includes(String(event.authority))) {
|
|
255
|
+
const ids = (selection.pendingInputIds ??= new Set());
|
|
256
|
+
if (typeof event.clientMessageId !== "string" ||
|
|
257
|
+
!event.clientMessageId.trim() ||
|
|
258
|
+
event.clientMessageId.length > 512)
|
|
259
|
+
this.fail(selection);
|
|
260
|
+
else {
|
|
261
|
+
if (!ids.has(event.clientMessageId)) {
|
|
262
|
+
ids.add(event.clientMessageId);
|
|
263
|
+
selection.pendingInputBytes =
|
|
264
|
+
(selection.pendingInputBytes ?? 0) +
|
|
265
|
+
new TextEncoder().encode(event.clientMessageId).length;
|
|
266
|
+
}
|
|
267
|
+
if (ids.size > 128 || (selection.pendingInputBytes ?? 0) > 32 * 1024)
|
|
268
|
+
this.fail(selection);
|
|
269
|
+
if (!selection.failed && !selection.recovering && !selection.capacityPending) {
|
|
270
|
+
const latest = selection.latest;
|
|
271
|
+
this.begin(sessionId);
|
|
272
|
+
if (this.selection) {
|
|
273
|
+
this.selection.pendingInputIds = ids;
|
|
274
|
+
this.selection.pendingInputBytes = selection.pendingInputBytes;
|
|
275
|
+
this.selection.latest = latest;
|
|
276
|
+
}
|
|
277
|
+
return "pending";
|
|
278
|
+
}
|
|
279
|
+
if (!selection.readyTimer)
|
|
280
|
+
selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
|
|
281
|
+
}
|
|
282
|
+
return "pending";
|
|
283
|
+
}
|
|
284
|
+
if (selection.pendingInputIds?.size &&
|
|
285
|
+
event?.type === "session_started" &&
|
|
286
|
+
!event.agentId &&
|
|
287
|
+
event.outputCursor &&
|
|
288
|
+
typeof event.clientMessageId === "string" &&
|
|
289
|
+
selection.pendingInputIds.has(event.clientMessageId) &&
|
|
290
|
+
(!priorCursor || compareOutputCursors(event.outputCursor, priorCursor) === 1)) {
|
|
291
|
+
// The real protocol emits preliminary input before the Engine owns a
|
|
292
|
+
// durable Run. Retry only at its recorded boundary, never on arbitrary
|
|
293
|
+
// suffix text; Main proves every pending submit ID in the candidate.
|
|
294
|
+
const latest = selection.latest;
|
|
295
|
+
this.begin(sessionId);
|
|
296
|
+
if (this.selection) {
|
|
297
|
+
this.selection.pendingInputIds = selection.pendingInputIds;
|
|
298
|
+
this.selection.pendingInputBytes = selection.pendingInputBytes;
|
|
299
|
+
this.selection.latest = latest;
|
|
300
|
+
}
|
|
301
|
+
return "pending";
|
|
302
|
+
}
|
|
303
|
+
if (selection.pendingInputIds?.size)
|
|
304
|
+
return "pending";
|
|
305
|
+
if (selection.failed || selection.recovering || selection.capacityPending)
|
|
306
|
+
return "pending";
|
|
307
|
+
if (event?.type === "session_title")
|
|
308
|
+
return false;
|
|
309
|
+
const order = event?.outputCursor && selection.applied
|
|
310
|
+
? compareOutputCursors(event.outputCursor, selection.applied)
|
|
311
|
+
: undefined;
|
|
312
|
+
if (order === undefined) {
|
|
313
|
+
this.fail(selection);
|
|
314
|
+
return "pending";
|
|
315
|
+
}
|
|
316
|
+
return order <= 0 ? "covered" : false;
|
|
317
|
+
}
|
|
318
|
+
applied(sessionId, raw) {
|
|
319
|
+
const event = raw;
|
|
320
|
+
if (this.selection?.sessionId === sessionId && typeof event?.outputCursor === "string")
|
|
321
|
+
this.selection.applied = event.outputCursor;
|
|
322
|
+
}
|
|
323
|
+
/** A coalesced committed head requests pages; it never invents visible output. */
|
|
324
|
+
advanceSnapshot(sessionId, outputCursor) {
|
|
325
|
+
const selection = this.selection;
|
|
326
|
+
if (!selection || selection.sessionId !== sessionId || !outputCursor || selection.failed)
|
|
327
|
+
return;
|
|
328
|
+
const previous = selection.latest ?? selection.applied;
|
|
329
|
+
const order = previous ? compareOutputCursors(outputCursor, previous) : 1;
|
|
330
|
+
if (order === undefined) {
|
|
331
|
+
this.fail(selection);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (order <= 0)
|
|
335
|
+
return;
|
|
336
|
+
selection.latest = outputCursor;
|
|
337
|
+
if (selection.awaitingJournal) {
|
|
338
|
+
selection.awaitingJournal = false;
|
|
339
|
+
if (selection.readyTimer)
|
|
340
|
+
clearTimeout(selection.readyTimer);
|
|
341
|
+
selection.readyTimer = undefined;
|
|
342
|
+
selection.recovering = true;
|
|
343
|
+
void this.recover(selection);
|
|
344
|
+
}
|
|
345
|
+
else if (!selection.recovering && !selection.capacityPending) {
|
|
346
|
+
this.begin(sessionId);
|
|
347
|
+
if (this.selection)
|
|
348
|
+
this.selection.latest = outputCursor;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -4,6 +4,7 @@ import { t } from "../i18n/translate.js";
|
|
|
4
4
|
import { summarizeApproval } from "../lib/riskClassify.js";
|
|
5
5
|
import { roomMsgToEvent, roomHistoryToEvents, ccHistoryToEvents, extractAskUserOptions, } from "../lib/messageMappers.js";
|
|
6
6
|
import { projectForCwd } from "../lib/format.js";
|
|
7
|
+
import { MobileOutputClient } from "./mobileOutputClient.js";
|
|
7
8
|
import { useRemoteSocket } from "./useRemoteSocket.js";
|
|
8
9
|
import { prepareMobileAttachments, } from "../lib/mobileAttachments.js";
|
|
9
10
|
import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, alignSessionEpoch, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
|
|
@@ -27,7 +28,12 @@ function chatReducer(state, action) {
|
|
|
27
28
|
case "raw":
|
|
28
29
|
return reduceStream(state, action.raw);
|
|
29
30
|
case "user":
|
|
30
|
-
return appendUserMessage(state, action.text, action.attachments);
|
|
31
|
+
return appendUserMessage(state, action.text, action.attachments, action.clientMessageId);
|
|
32
|
+
case "hydrate": {
|
|
33
|
+
const ids = new Set(action.chat.items.flatMap((item) => item.kind === "user" && item.clientMessageId ? [item.clientMessageId] : []));
|
|
34
|
+
const pending = state.items.filter((item) => item.kind === "user" && item.clientMessageId && !ids.has(item.clientMessageId));
|
|
35
|
+
return { ...action.chat, items: [...action.chat.items, ...pending] };
|
|
36
|
+
}
|
|
31
37
|
case "reset":
|
|
32
38
|
return initialChatState();
|
|
33
39
|
case "stream_epoch_changed": {
|
|
@@ -280,7 +286,47 @@ export function useRemoteApp(options = {}) {
|
|
|
280
286
|
}
|
|
281
287
|
setApprovals((prev) => (prev.some((p) => p.requestId === a.requestId) ? prev : [...prev, a]));
|
|
282
288
|
}, []);
|
|
289
|
+
const outputClientRef = useRef(null);
|
|
290
|
+
const outputClient = (outputClientRef.current ??= new MobileOutputClient({
|
|
291
|
+
send: (event) => sendRef.current?.(event) ?? false,
|
|
292
|
+
current: () => ({
|
|
293
|
+
sessionId: boundSessionRef.current,
|
|
294
|
+
roomId: activeRoomIdRef.current,
|
|
295
|
+
revision: conversationRevisionRef.current,
|
|
296
|
+
}),
|
|
297
|
+
commit: (sessionId, result) => {
|
|
298
|
+
const cursor = sessionCursor(sessionId);
|
|
299
|
+
cursor.epoch = result.snapshot.epoch;
|
|
300
|
+
cursor.appliedSeq = result.snapshot.nextSeq - 1;
|
|
301
|
+
cursor.observedSeq = cursor.appliedSeq;
|
|
302
|
+
cursor.awaitingSnapshot = false;
|
|
303
|
+
cursor.historyUnpaired = undefined;
|
|
304
|
+
epochHistorySessionRef.current = undefined;
|
|
305
|
+
dispatchChat({ kind: "hydrate", chat: result.chat });
|
|
306
|
+
setLoadingKey("sessionHistory", false);
|
|
307
|
+
setNoticeState(undefined);
|
|
308
|
+
},
|
|
309
|
+
legacy: (sessionId) => {
|
|
310
|
+
epochHistorySessionRef.current = sessionId;
|
|
311
|
+
sessionCursor(sessionId).historyUnpaired = true;
|
|
312
|
+
sendRef.current?.({ type: "session.history", sessionId });
|
|
313
|
+
},
|
|
314
|
+
awaitingJournal: () => {
|
|
315
|
+
// A selected live producer has no prior output. No read/request remains
|
|
316
|
+
// pending while the user composes its first input.
|
|
317
|
+
setLoadingKey("sessionHistory", false);
|
|
318
|
+
},
|
|
319
|
+
failed: (sessionId) => {
|
|
320
|
+
sessionCursor(sessionId).awaitingSnapshot = true;
|
|
321
|
+
dispatchChat({ kind: "stream_epoch_changed" });
|
|
322
|
+
setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
|
|
323
|
+
setLoadingKey("sessionHistory", false);
|
|
324
|
+
},
|
|
325
|
+
}));
|
|
326
|
+
useEffect(() => () => outputClient.cancel(false), [outputClient]);
|
|
283
327
|
const onServerEvent = useCallback((event) => {
|
|
328
|
+
if (outputClient.observe(event))
|
|
329
|
+
return;
|
|
284
330
|
switch (event.type) {
|
|
285
331
|
case "auth.ok":
|
|
286
332
|
// Pull the world on connect.
|
|
@@ -370,6 +416,8 @@ export function useRemoteApp(options = {}) {
|
|
|
370
416
|
}
|
|
371
417
|
break;
|
|
372
418
|
case "session.history.ok":
|
|
419
|
+
if (outputClient.owns(event.sessionId))
|
|
420
|
+
break;
|
|
373
421
|
// Only apply if it's the session we're currently viewing.
|
|
374
422
|
if (event.sessionId === boundSessionRef.current && !activeRoomIdRef.current) {
|
|
375
423
|
const cursor = sessionCursor(event.sessionId);
|
|
@@ -393,11 +441,19 @@ export function useRemoteApp(options = {}) {
|
|
|
393
441
|
}
|
|
394
442
|
break;
|
|
395
443
|
case "session.snapshot": {
|
|
444
|
+
if (outputClient.owns(event.sessionId)) {
|
|
445
|
+
outputClient.advanceSnapshot(event.sessionId, event.outputCursor);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
396
448
|
if (event.sessionId !== boundSessionRef.current || activeRoomIdRef.current)
|
|
397
449
|
break;
|
|
398
450
|
if (epochHistorySessionRef.current === event.sessionId)
|
|
399
451
|
break;
|
|
400
452
|
const session = sessionCursor(event.sessionId);
|
|
453
|
+
if (event.outputCursor && outputClient.begin(event.sessionId)) {
|
|
454
|
+
session.awaitingSnapshot = true;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
401
457
|
const changed = alignSessionEpoch(session, event.epoch);
|
|
402
458
|
if (session.historyUnpaired)
|
|
403
459
|
break;
|
|
@@ -425,6 +481,15 @@ export function useRemoteApp(options = {}) {
|
|
|
425
481
|
setLoadingKey("sessionHistory", false);
|
|
426
482
|
break;
|
|
427
483
|
}
|
|
484
|
+
if (event.entries.some((entry, index) => entry.seq > session.appliedSeq &&
|
|
485
|
+
entry.seq !==
|
|
486
|
+
(index > 0 ? event.entries[index - 1].seq + 1 : session.appliedSeq + 1))) {
|
|
487
|
+
session.awaitingSnapshot = true;
|
|
488
|
+
if (!outputClient.begin(event.sessionId))
|
|
489
|
+
setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
|
|
490
|
+
setLoadingKey("sessionHistory", false);
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
428
493
|
const appliedSeq = session.appliedSeq;
|
|
429
494
|
const { events, cursor } = selectSessionReplayEntries(event.entries, appliedSeq);
|
|
430
495
|
session.appliedSeq = cursor;
|
|
@@ -461,6 +526,32 @@ export function useRemoteApp(options = {}) {
|
|
|
461
526
|
setActiveSessionCwd(sessionCwdById.get(event.sessionId));
|
|
462
527
|
}
|
|
463
528
|
clearSessionUnread(event.sessionId);
|
|
529
|
+
const rawOutput = event.event;
|
|
530
|
+
if (changed && outputClient.owns(event.sessionId))
|
|
531
|
+
outputClient.begin(event.sessionId);
|
|
532
|
+
if (!outputClient.owns(event.sessionId) &&
|
|
533
|
+
rawOutput?.outputCursor &&
|
|
534
|
+
outputClient.begin(event.sessionId)) {
|
|
535
|
+
session.awaitingSnapshot = true;
|
|
536
|
+
}
|
|
537
|
+
const held = outputClient.hold(event.sessionId, event.event);
|
|
538
|
+
if (held === "pending")
|
|
539
|
+
break;
|
|
540
|
+
if (held === "covered") {
|
|
541
|
+
if (event.seq === session.appliedSeq + 1)
|
|
542
|
+
session.appliedSeq = event.seq;
|
|
543
|
+
else if (event.seq > session.appliedSeq + 1) {
|
|
544
|
+
session.awaitingSnapshot = true;
|
|
545
|
+
outputClient.begin(event.sessionId);
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
if (!changed && !session.awaitingSnapshot && event.seq > session.appliedSeq + 1) {
|
|
550
|
+
session.awaitingSnapshot = true;
|
|
551
|
+
if (!outputClient.begin(event.sessionId))
|
|
552
|
+
setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
464
555
|
if (changed) {
|
|
465
556
|
dispatchChat({ kind: "stream_epoch_changed" });
|
|
466
557
|
setApprovals([]);
|
|
@@ -492,6 +583,7 @@ export function useRemoteApp(options = {}) {
|
|
|
492
583
|
break;
|
|
493
584
|
session.appliedSeq = event.seq;
|
|
494
585
|
appendSessionEvents(event.sessionId, [event.event]);
|
|
586
|
+
outputClient.applied(event.sessionId, event.event);
|
|
495
587
|
break;
|
|
496
588
|
}
|
|
497
589
|
case "permission.mode":
|
|
@@ -620,6 +712,7 @@ export function useRemoteApp(options = {}) {
|
|
|
620
712
|
setNotice(t("mobile.notice.ccRoomOpenFailed"));
|
|
621
713
|
break;
|
|
622
714
|
}
|
|
715
|
+
outputClient.cancel();
|
|
623
716
|
// An opened cc room behaves like room.open — bind the room feed. But the
|
|
624
717
|
// resident room only carries messages from open-time onward; the prior
|
|
625
718
|
// external-CLI transcript lives on disk, so subscribeTranscript returns
|
|
@@ -631,6 +724,7 @@ export function useRemoteApp(options = {}) {
|
|
|
631
724
|
roomId: activeRoomIdRef.current,
|
|
632
725
|
});
|
|
633
726
|
}
|
|
727
|
+
activeRoomIdRef.current = event.roomId;
|
|
634
728
|
setActiveRoomId(event.roomId);
|
|
635
729
|
setRooms((previous) => previous.map((room) => room.id === event.roomId
|
|
636
730
|
? {
|
|
@@ -818,6 +912,8 @@ export function useRemoteApp(options = {}) {
|
|
|
818
912
|
else {
|
|
819
913
|
return;
|
|
820
914
|
}
|
|
915
|
+
if (outputClient.rejectUnsequenced(boundSessionRef.current))
|
|
916
|
+
return;
|
|
821
917
|
dispatchChat({ kind: "raw", raw });
|
|
822
918
|
clearApprovalsIfTerminal([params.event]);
|
|
823
919
|
}
|
|
@@ -853,6 +949,11 @@ export function useRemoteApp(options = {}) {
|
|
|
853
949
|
if (!sessionId)
|
|
854
950
|
return;
|
|
855
951
|
const cursor = sessionCursor(sessionId);
|
|
952
|
+
if (outputClient.begin(sessionId)) {
|
|
953
|
+
cursor.awaitingSnapshot = true;
|
|
954
|
+
setLoadingKey("sessionHistory", true);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
856
957
|
if (epochHistorySessionRef.current === sessionId || cursor.historyUnpaired) {
|
|
857
958
|
epochHistorySessionRef.current = sessionId;
|
|
858
959
|
setLoadingKey("sessionHistory", true);
|
|
@@ -876,6 +977,7 @@ export function useRemoteApp(options = {}) {
|
|
|
876
977
|
useEffect(() => {
|
|
877
978
|
if (socket.status === "online")
|
|
878
979
|
return;
|
|
980
|
+
outputClient.cancel(false);
|
|
879
981
|
submittingAsyncApprovalsRef.current.clear();
|
|
880
982
|
const error = new Error("Remote connection was lost");
|
|
881
983
|
for (const waiter of uploadWaitersRef.current.values()) {
|
|
@@ -966,7 +1068,7 @@ export function useRemoteApp(options = {}) {
|
|
|
966
1068
|
(sessionId === undefined || boundSessionRef.current === sessionId)) {
|
|
967
1069
|
// Sessions don't echo the user turn back over the stream, so add the
|
|
968
1070
|
// local bubble only after the server has accepted this exact message.
|
|
969
|
-
dispatchChat({ kind: "user", text, attachments: summaries });
|
|
1071
|
+
dispatchChat({ kind: "user", text, attachments: summaries, clientMessageId });
|
|
970
1072
|
}
|
|
971
1073
|
return true;
|
|
972
1074
|
}, [beginMobileUpload, setNotice, settleMessageAck, socket, waitForMessageAck]);
|
|
@@ -999,12 +1101,17 @@ export function useRemoteApp(options = {}) {
|
|
|
999
1101
|
setApprovals([]);
|
|
1000
1102
|
setLoadingKey("sessionHistory", true);
|
|
1001
1103
|
dispatchChat({ kind: "reset" });
|
|
1002
|
-
|
|
1003
|
-
|
|
1104
|
+
activeRoomIdRef.current = undefined;
|
|
1105
|
+
if (!outputClient.begin(id)) {
|
|
1106
|
+
socket.send({ type: "session.select", sessionId: id });
|
|
1107
|
+
socket.send({ type: "session.history", sessionId: id });
|
|
1108
|
+
}
|
|
1004
1109
|
}, [clearSessionUnread, socket, sessionCwdById, setLoadingKey, sessionCursor]);
|
|
1005
1110
|
/** Select a project (like the desktop sidebar): set the one-true-source cwd and
|
|
1006
1111
|
* pull that project's chat sessions + external cc sessions. */
|
|
1007
1112
|
const selectProject = useCallback((projectIdOrCwd) => {
|
|
1113
|
+
outputClient.cancel();
|
|
1114
|
+
conversationRevisionRef.current += 1;
|
|
1008
1115
|
const project = projectsRef.current.find((candidate) => candidate.id === projectIdOrCwd || candidate.path === projectIdOrCwd);
|
|
1009
1116
|
const cwd = project?.path ?? projectIdOrCwd;
|
|
1010
1117
|
setActiveProjectId(project?.id ?? null);
|
|
@@ -1017,6 +1124,7 @@ export function useRemoteApp(options = {}) {
|
|
|
1017
1124
|
// Doing it here too would double-fetch.
|
|
1018
1125
|
}, [socket, setLoadingKey]);
|
|
1019
1126
|
const openCcSession = useCallback((sessionId, cwd, mode) => {
|
|
1127
|
+
outputClient.cancel();
|
|
1020
1128
|
conversationRevisionRef.current += 1;
|
|
1021
1129
|
// Pin the CLI this session belongs to so its on-disk backlog is read with
|
|
1022
1130
|
// the right reader even if the user switches the pane's CLI afterward.
|
|
@@ -1033,6 +1141,7 @@ export function useRemoteApp(options = {}) {
|
|
|
1033
1141
|
setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
|
|
1034
1142
|
}, [socket]);
|
|
1035
1143
|
const newSession = useCallback((target, name) => {
|
|
1144
|
+
outputClient.cancel();
|
|
1036
1145
|
conversationRevisionRef.current += 1;
|
|
1037
1146
|
const clientRequestId = globalThis.crypto?.randomUUID?.() ??
|
|
1038
1147
|
`mobile-create-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
@@ -1150,6 +1259,7 @@ export function useRemoteApp(options = {}) {
|
|
|
1150
1259
|
// is no user-facing room create/open/close — CC sessions are opened via
|
|
1151
1260
|
// openCcSession; the room is internal transport.)
|
|
1152
1261
|
const leaveRoom = useCallback(() => {
|
|
1262
|
+
outputClient.cancel();
|
|
1153
1263
|
conversationRevisionRef.current += 1;
|
|
1154
1264
|
if (activeRoomIdRef.current) {
|
|
1155
1265
|
socket.send({
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MobileServerEvent } from "@cjhyy/code-shell-core";
|
|
2
|
+
import type { OutputJournalOptions } from "@cjhyy/code-shell-core/internal";
|
|
3
|
+
import { initialChatState } from "./streamReducer.js";
|
|
4
|
+
type Reply = Extract<MobileServerEvent, {
|
|
5
|
+
type: "session.outputJournal";
|
|
6
|
+
}>;
|
|
7
|
+
/** Valid identities may arrive before their new Core Run acquires a durable anchor. */
|
|
8
|
+
export declare class MobilePendingInput extends Error {
|
|
9
|
+
readonly ids: string[];
|
|
10
|
+
constructor(ids: string[]);
|
|
11
|
+
}
|
|
12
|
+
/** No journal has ever been published by this explicitly journal-owned producer. */
|
|
13
|
+
export declare class MobileAwaitingJournal extends Error {
|
|
14
|
+
constructor();
|
|
15
|
+
}
|
|
16
|
+
/** Verified pages progressed, but the observed live head kept moving within the bound. */
|
|
17
|
+
export declare class MobileOutputCatchupPending extends Error {
|
|
18
|
+
readonly cursor: string;
|
|
19
|
+
readonly resume: () => Promise<MobileOutputRecoveryResult>;
|
|
20
|
+
constructor(cursor: string, resume: () => Promise<MobileOutputRecoveryResult>);
|
|
21
|
+
}
|
|
22
|
+
type MobileOutputRecoveryResult = {
|
|
23
|
+
chat: ReturnType<typeof initialChatState>;
|
|
24
|
+
outputCursor: string;
|
|
25
|
+
snapshot: NonNullable<Reply["snapshot"]>;
|
|
26
|
+
};
|
|
27
|
+
/** Build a private candidate, then join its frozen prefix to an actual Main snapshot. */
|
|
28
|
+
export declare function recoverMobileOutput(args: {
|
|
29
|
+
read: (options: OutputJournalOptions) => Promise<Reply>;
|
|
30
|
+
canContinue: () => boolean;
|
|
31
|
+
latestCursor: () => string | undefined;
|
|
32
|
+
requireJournal?: boolean;
|
|
33
|
+
}): Promise<MobileOutputRecoveryResult | null>;
|
|
34
|
+
export {};
|