@cjhyy/code-shell-web 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/hooks/remoteAppSync.d.ts +21 -0
- package/dist/hooks/remoteAppSync.js +102 -0
- package/dist/hooks/useRemoteApp.d.ts +89 -0
- package/dist/hooks/useRemoteApp.js +1015 -0
- package/dist/hooks/useRemoteSocket.d.ts +25 -0
- package/dist/hooks/useRemoteSocket.js +267 -0
- package/dist/i18n/mobile.d.ts +323 -0
- package/dist/i18n/mobile.js +323 -0
- package/dist/i18n/translate.d.ts +351 -0
- package/dist/i18n/translate.js +35 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +28 -0
- package/dist/lib/deviceCredential.d.ts +14 -0
- package/dist/lib/deviceCredential.js +19 -0
- package/dist/lib/format.d.ts +26 -0
- package/dist/lib/format.js +96 -0
- package/dist/lib/messageMappers.d.ts +37 -0
- package/dist/lib/messageMappers.js +145 -0
- package/dist/lib/mobileAttachments.d.ts +23 -0
- package/dist/lib/mobileAttachments.js +121 -0
- package/dist/lib/pairing.d.ts +6 -0
- package/dist/lib/pairing.js +8 -0
- package/dist/lib/riskClassify.d.ts +5 -0
- package/dist/lib/riskClassify.js +56 -0
- package/dist/lib/storage.d.ts +10 -0
- package/dist/lib/storage.js +53 -0
- package/dist/lib/streamReducer.d.ts +90 -0
- package/dist/lib/streamReducer.js +564 -0
- package/dist/lib/uiLanguage.d.ts +15 -0
- package/dist/lib/uiLanguage.js +34 -0
- package/dist-app/assets/index-CbC5kEGZ.js +11 -0
- package/dist-app/assets/index-xwzva9ZI.css +1 -0
- package/dist-app/index.html +13 -0
- package/package.json +55 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile chat reducer: folds the worker→renderer JSON-RPC stream (the SAME
|
|
3
|
+
* lines the desktop renderer receives, mirrored via RemoteHostManager.
|
|
4
|
+
* broadcastRaw) into a flat list of view items. It also consumes `session.
|
|
5
|
+
* history` replay, which emits the identical `agent/streamEvent` envelopes, so
|
|
6
|
+
* one reducer serves both live and replay (design §4).
|
|
7
|
+
*
|
|
8
|
+
* Event names mirror core's StreamEvent union (packages/core/src/types.ts):
|
|
9
|
+
* stream_request_start | text_delta | thinking_delta | tool_use_start |
|
|
10
|
+
* tool_use_args_delta | tool_result | tool_summary | assistant_message |
|
|
11
|
+
* turn_complete | goal_progress | task_update | agent_start | agent_end |
|
|
12
|
+
* error | session_title …
|
|
13
|
+
*
|
|
14
|
+
* Subagent events carry an optional `agentId`. We tag items with it so a future
|
|
15
|
+
* UI can scope subagent rows (see commit fbe6f68 — task_update must be isolated
|
|
16
|
+
* by agentId so a subagent never clobbers the main list).
|
|
17
|
+
*/
|
|
18
|
+
/** Strip <system-reminder>…</system-reminder> blocks from tool output before
|
|
19
|
+
* rendering — they're context for the model, not status for the user. */
|
|
20
|
+
function stripSystemReminders(s) {
|
|
21
|
+
return s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "");
|
|
22
|
+
}
|
|
23
|
+
export function initialChatState() {
|
|
24
|
+
return { items: [], run: "idle", seq: 0, liveByAgent: {} };
|
|
25
|
+
}
|
|
26
|
+
/** The agentId bucket key for an event ("" = main agent). */
|
|
27
|
+
function agentKey(event) {
|
|
28
|
+
return event.agentId ?? "";
|
|
29
|
+
}
|
|
30
|
+
function staleGoalEvent(state, event, allowLegacyIdentityUpgrade = false) {
|
|
31
|
+
const goalId = event.goalId;
|
|
32
|
+
if (state.goalId !== goalId) {
|
|
33
|
+
if (!(allowLegacyIdentityUpgrade && state.goalId === undefined && goalId !== undefined)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const revision = event.revision;
|
|
38
|
+
return (state.goalRevision !== undefined && (revision === undefined || revision < state.goalRevision));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Map a core TerminalReason (types.ts) to a phone run-state. Only genuine
|
|
42
|
+
* failures (prompt_too_long / model_error / image_error) are "error"; budget/
|
|
43
|
+
* turn/hook limits are EXPECTED stops and must not flash a red error on the
|
|
44
|
+
* StatusBar. Aborts (user-initiated) drop back to idle. Unknown future reasons
|
|
45
|
+
* default to "completed" rather than "error" — a stop we don't recognize is
|
|
46
|
+
* still a stop, and mislabeling it a failure is the worse default.
|
|
47
|
+
*/
|
|
48
|
+
function runStateForReason(reason) {
|
|
49
|
+
switch (reason) {
|
|
50
|
+
case "aborted_streaming":
|
|
51
|
+
case "aborted_tools":
|
|
52
|
+
return "idle";
|
|
53
|
+
case "prompt_too_long":
|
|
54
|
+
case "model_error":
|
|
55
|
+
case "image_error":
|
|
56
|
+
return "error";
|
|
57
|
+
// "completed", "max_turns", "goal_budget_exhausted", "stop_hook_prevented",
|
|
58
|
+
// "hook_stopped", and any future/unknown terminal reason: a normal stop.
|
|
59
|
+
default:
|
|
60
|
+
return "completed";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Unwrap an incoming line into the inner StreamEvent, or null if it isn't an
|
|
64
|
+
* agent/streamEvent envelope. Accepts both the JSON-RPC notification shape
|
|
65
|
+
* ({method, params:{event, sessionId}}) and a bare event (replay convenience). */
|
|
66
|
+
function asStreamEvent(raw) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const obj = raw;
|
|
70
|
+
if (obj.method === "agent/streamEvent" && obj.params && typeof obj.params === "object") {
|
|
71
|
+
const params = obj.params;
|
|
72
|
+
if (params.event && typeof params.event === "object") {
|
|
73
|
+
return {
|
|
74
|
+
event: params.event,
|
|
75
|
+
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Bare event (history replay may hand us {type, …} directly).
|
|
80
|
+
if (typeof obj.type === "string")
|
|
81
|
+
return { event: obj };
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
export function reduceStream(state, raw) {
|
|
85
|
+
const unwrapped = asStreamEvent(raw);
|
|
86
|
+
if (!unwrapped)
|
|
87
|
+
return state;
|
|
88
|
+
const { event, sessionId } = unwrapped;
|
|
89
|
+
const type = event.type;
|
|
90
|
+
let s = state;
|
|
91
|
+
if (sessionId && sessionId !== s.sessionId) {
|
|
92
|
+
s = { ...s, sessionId };
|
|
93
|
+
}
|
|
94
|
+
const freshId = (prefix) => {
|
|
95
|
+
const next = s.seq + 1;
|
|
96
|
+
return [`${prefix}-${next}`, { ...s, seq: next }];
|
|
97
|
+
};
|
|
98
|
+
switch (type) {
|
|
99
|
+
case "session_user_message": {
|
|
100
|
+
const [id, s2] = freshId("u");
|
|
101
|
+
return {
|
|
102
|
+
...s2,
|
|
103
|
+
items: [
|
|
104
|
+
...s2.items,
|
|
105
|
+
{ kind: "user", id, text: typeof event.text === "string" ? event.text : "" },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case "stream_request_start": {
|
|
110
|
+
// Open a fresh assistant message (for THIS agent) to accumulate text into.
|
|
111
|
+
const key = agentKey(event);
|
|
112
|
+
const [id, s2] = freshId("a");
|
|
113
|
+
const item = {
|
|
114
|
+
kind: "assistant",
|
|
115
|
+
id,
|
|
116
|
+
text: "",
|
|
117
|
+
reasoning: "",
|
|
118
|
+
done: false,
|
|
119
|
+
agentId: event.agentId,
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
...s2,
|
|
123
|
+
items: [...s2.items, item],
|
|
124
|
+
liveByAgent: { ...s2.liveByAgent, [key]: id },
|
|
125
|
+
run: "running",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
case "text_delta": {
|
|
129
|
+
const text = event.text ?? "";
|
|
130
|
+
const key = agentKey(event);
|
|
131
|
+
const liveId = s.liveByAgent[key];
|
|
132
|
+
const live = liveId && s.items.find((i) => i.id === liveId && i.kind === "assistant");
|
|
133
|
+
if (live) {
|
|
134
|
+
return {
|
|
135
|
+
...s,
|
|
136
|
+
run: "running",
|
|
137
|
+
items: s.items.map((i) => i.id === liveId && i.kind === "assistant" ? { ...i, text: i.text + text } : i),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// No open assistant for this agent (e.g. text before stream_request_start)
|
|
141
|
+
// → open one scoped to this agent.
|
|
142
|
+
const [id, s2] = freshId("a");
|
|
143
|
+
const item = {
|
|
144
|
+
kind: "assistant",
|
|
145
|
+
id,
|
|
146
|
+
text,
|
|
147
|
+
reasoning: "",
|
|
148
|
+
done: false,
|
|
149
|
+
agentId: event.agentId,
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
...s2,
|
|
153
|
+
items: [...s2.items, item],
|
|
154
|
+
liveByAgent: { ...s2.liveByAgent, [key]: id },
|
|
155
|
+
run: "running",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
case "thinking_delta": {
|
|
159
|
+
const text = event.text ?? "";
|
|
160
|
+
const key = agentKey(event);
|
|
161
|
+
const liveId = s.liveByAgent[key];
|
|
162
|
+
if (liveId && s.items.some((i) => i.id === liveId && i.kind === "assistant")) {
|
|
163
|
+
return {
|
|
164
|
+
...s,
|
|
165
|
+
run: "running",
|
|
166
|
+
items: s.items.map((i) => i.id === liveId && i.kind === "assistant" ? { ...i, reasoning: i.reasoning + text } : i),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const [id, s2] = freshId("a");
|
|
170
|
+
const item = {
|
|
171
|
+
kind: "assistant",
|
|
172
|
+
id,
|
|
173
|
+
text: "",
|
|
174
|
+
reasoning: text,
|
|
175
|
+
done: false,
|
|
176
|
+
agentId: event.agentId,
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
...s2,
|
|
180
|
+
items: [...s2.items, item],
|
|
181
|
+
liveByAgent: { ...s2.liveByAgent, [key]: id },
|
|
182
|
+
run: "running",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
case "tool_use_start": {
|
|
186
|
+
const call = event.toolCall;
|
|
187
|
+
if (!call)
|
|
188
|
+
return s;
|
|
189
|
+
// Idempotent: a duplicate start for the same call id is a no-op.
|
|
190
|
+
if (s.items.some((i) => i.kind === "tool" && i.id === call.id))
|
|
191
|
+
return s;
|
|
192
|
+
// A tool_result may have arrived BEFORE this start (out-of-order); apply
|
|
193
|
+
// the buffered orphan now so the card isn't stuck open with no result.
|
|
194
|
+
const orphan = s.orphanResults?.[call.id];
|
|
195
|
+
const item = {
|
|
196
|
+
kind: "tool",
|
|
197
|
+
id: call.id,
|
|
198
|
+
name: call.toolName,
|
|
199
|
+
args: call.args,
|
|
200
|
+
// Room tool messages carry a human summary instead of structured args.
|
|
201
|
+
summary: call.summary,
|
|
202
|
+
done: orphan ? true : false,
|
|
203
|
+
result: orphan?.result,
|
|
204
|
+
error: orphan?.error,
|
|
205
|
+
agentId: event.agentId,
|
|
206
|
+
};
|
|
207
|
+
let orphanResults = s.orphanResults;
|
|
208
|
+
if (orphan) {
|
|
209
|
+
const { [call.id]: _used, ...restOrphans } = s.orphanResults;
|
|
210
|
+
orphanResults = Object.keys(restOrphans).length > 0 ? restOrphans : undefined;
|
|
211
|
+
}
|
|
212
|
+
return { ...s, run: "running", items: [...s.items, item], orphanResults };
|
|
213
|
+
}
|
|
214
|
+
case "tool_use_args_delta": {
|
|
215
|
+
// Tool args stream incrementally (large Edit/Write/Bash payloads). Merge
|
|
216
|
+
// each delta into the matching tool item, mirroring the desktop renderer's
|
|
217
|
+
// argsLive accumulation (renderer/types.ts) — otherwise the tool card and
|
|
218
|
+
// the approval summary derived from these args would show only the partial
|
|
219
|
+
// snapshot tool_use_start carried.
|
|
220
|
+
const callId = event.toolCallId;
|
|
221
|
+
const delta = event.args;
|
|
222
|
+
if (!callId || !delta)
|
|
223
|
+
return s;
|
|
224
|
+
return {
|
|
225
|
+
...s,
|
|
226
|
+
items: s.items.map((i) => i.kind === "tool" && i.id === callId
|
|
227
|
+
? { ...i, args: { ...(i.args ?? {}), ...delta } }
|
|
228
|
+
: i),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
case "tool_result": {
|
|
232
|
+
const result = event.result;
|
|
233
|
+
if (!result)
|
|
234
|
+
return s;
|
|
235
|
+
const matched = s.items.some((i) => i.kind === "tool" && i.id === result.id);
|
|
236
|
+
if (!matched) {
|
|
237
|
+
// tool_result arrived before its tool_use_start (out-of-order) — buffer it
|
|
238
|
+
// so tool_use_start can apply it, instead of silently dropping (which would
|
|
239
|
+
// leave the card stuck done:false with no result).
|
|
240
|
+
return {
|
|
241
|
+
...s,
|
|
242
|
+
orphanResults: {
|
|
243
|
+
...(s.orphanResults ?? {}),
|
|
244
|
+
[result.id]: {
|
|
245
|
+
result: result.result ?? result.error ?? "",
|
|
246
|
+
error: Boolean(result.isError || result.error),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...s,
|
|
253
|
+
items: s.items.map((i) => i.kind === "tool" && i.id === result.id
|
|
254
|
+
? {
|
|
255
|
+
...i,
|
|
256
|
+
done: true,
|
|
257
|
+
result: stripSystemReminders(result.result ?? result.error ?? ""),
|
|
258
|
+
error: Boolean(result.isError || result.error),
|
|
259
|
+
}
|
|
260
|
+
: i),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
case "room_tool_result": {
|
|
264
|
+
// Room transcripts emit a coarse tool_result with no id linking it to its
|
|
265
|
+
// start; seal the most recent open tool item with the summary + error.
|
|
266
|
+
const summary = event.summary ?? "";
|
|
267
|
+
const resultText = stripSystemReminders(summary);
|
|
268
|
+
const isError = Boolean(event.isError);
|
|
269
|
+
let idx = -1;
|
|
270
|
+
for (let i = s.items.length - 1; i >= 0; i--) {
|
|
271
|
+
if (s.items[i].kind === "tool" &&
|
|
272
|
+
!s.items[i].done) {
|
|
273
|
+
idx = i;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (idx === -1)
|
|
278
|
+
return s;
|
|
279
|
+
const items = s.items.slice();
|
|
280
|
+
const tool = items[idx];
|
|
281
|
+
items[idx] = {
|
|
282
|
+
...tool,
|
|
283
|
+
done: true,
|
|
284
|
+
result: resultText,
|
|
285
|
+
summary: resultText || tool.summary,
|
|
286
|
+
error: isError,
|
|
287
|
+
};
|
|
288
|
+
return { ...s, items };
|
|
289
|
+
}
|
|
290
|
+
case "tool_summary": {
|
|
291
|
+
const summary = event.summary ?? "";
|
|
292
|
+
const ids = Array.isArray(event.toolCallIds)
|
|
293
|
+
? event.toolCallIds.filter((id) => typeof id === "string")
|
|
294
|
+
: [];
|
|
295
|
+
const hasTargetIds = ids.length > 0;
|
|
296
|
+
const targetToolId = hasTargetIds ? ids[ids.length - 1] : undefined;
|
|
297
|
+
const agentId = event.agentId;
|
|
298
|
+
if (targetToolId) {
|
|
299
|
+
let found = false;
|
|
300
|
+
const items = s.items.map((i) => {
|
|
301
|
+
if (i.kind !== "tool" || i.id !== targetToolId || i.agentId !== agentId)
|
|
302
|
+
return i;
|
|
303
|
+
found = true;
|
|
304
|
+
return { ...i, summary };
|
|
305
|
+
});
|
|
306
|
+
return found ? { ...s, items } : s;
|
|
307
|
+
}
|
|
308
|
+
if (agentId) {
|
|
309
|
+
let idx = -1;
|
|
310
|
+
for (let i = s.items.length - 1; i >= 0; i--) {
|
|
311
|
+
const item = s.items[i];
|
|
312
|
+
if (item.kind === "tool" && item.agentId === agentId) {
|
|
313
|
+
idx = i;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (idx === -1)
|
|
318
|
+
return s;
|
|
319
|
+
const items = s.items.slice();
|
|
320
|
+
items[idx] = { ...items[idx], summary };
|
|
321
|
+
return { ...s, items };
|
|
322
|
+
}
|
|
323
|
+
// Legacy worker/snapshot compatibility: old tool_summary events carried
|
|
324
|
+
// no ids, so keep attaching them to the most recent top-level tool.
|
|
325
|
+
let idx = -1;
|
|
326
|
+
for (let i = s.items.length - 1; i >= 0; i--) {
|
|
327
|
+
const item = s.items[i];
|
|
328
|
+
if (item.kind === "tool" && item.agentId === undefined) {
|
|
329
|
+
idx = i;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (idx === -1)
|
|
334
|
+
return s;
|
|
335
|
+
const items = s.items.slice();
|
|
336
|
+
items[idx] = { ...items[idx], summary };
|
|
337
|
+
return { ...s, items };
|
|
338
|
+
}
|
|
339
|
+
case "assistant_message": {
|
|
340
|
+
// Final assistant message for the request — seal THIS agent's live bubble
|
|
341
|
+
// AND mark its open assistant items done. Without the latter, a finalized
|
|
342
|
+
// message keeps rendering the streaming cursor (▋) until turn_complete
|
|
343
|
+
// arrives; if turn_complete is delayed/dropped (network drop, buffering) it
|
|
344
|
+
// never clears. Mirror turn_complete's item seal (desktop types.ts does the
|
|
345
|
+
// same). run-state is left to turn_complete.
|
|
346
|
+
const key = agentKey(event);
|
|
347
|
+
if (!(key in s.liveByAgent))
|
|
348
|
+
return s;
|
|
349
|
+
const { [key]: _gone, ...rest } = s.liveByAgent;
|
|
350
|
+
const items = s.items.map((i) => i.kind === "assistant" && !i.done && (i.agentId ?? "") === key ? { ...i, done: true } : i);
|
|
351
|
+
return { ...s, liveByAgent: rest, items };
|
|
352
|
+
}
|
|
353
|
+
case "turn_complete": {
|
|
354
|
+
const reason = event.reason ?? "completed";
|
|
355
|
+
const key = agentKey(event);
|
|
356
|
+
// Seal this agent's live bubble and mark its open assistant items done.
|
|
357
|
+
const { [key]: _gone, ...rest } = s.liveByAgent;
|
|
358
|
+
const items = s.items.map((i) => i.kind === "assistant" && !i.done && (i.agentId ?? "") === key ? { ...i, done: true } : i);
|
|
359
|
+
// A SUBAGENT's turn_complete (agentId present) must NOT flip the global run
|
|
360
|
+
// state — the parent turn is still running (mirrors renderer App.tsx).
|
|
361
|
+
if (event.agentId) {
|
|
362
|
+
return { ...s, liveByAgent: rest, items };
|
|
363
|
+
}
|
|
364
|
+
return { ...s, liveByAgent: rest, items, run: runStateForReason(reason) };
|
|
365
|
+
}
|
|
366
|
+
case "goal_set": {
|
|
367
|
+
// Persistent goal established/replaced. Show the objective directly.
|
|
368
|
+
const objective = event.objective ?? "";
|
|
369
|
+
const paused = Boolean(event.paused);
|
|
370
|
+
if (s.goal && s.goalId === event.goalId && staleGoalEvent(s, event)) {
|
|
371
|
+
return s;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
...s,
|
|
375
|
+
goal: objective ? `◎ ${objective}${paused ? " (已暂停)" : ""}` : "◎ 目标",
|
|
376
|
+
goalId: event.goalId,
|
|
377
|
+
goalPaused: paused,
|
|
378
|
+
goalRevision: event.revision,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
case "goal_updated": {
|
|
382
|
+
if (s.goal && staleGoalEvent(s, event, true))
|
|
383
|
+
return s;
|
|
384
|
+
const goalId = event.goalId;
|
|
385
|
+
const objective = event.objective ?? "";
|
|
386
|
+
const paused = Boolean(event.paused);
|
|
387
|
+
return {
|
|
388
|
+
...s,
|
|
389
|
+
goal: objective ? `◎ ${objective}${paused ? " (已暂停)" : ""}` : s.goal,
|
|
390
|
+
goalId: goalId ?? s.goalId,
|
|
391
|
+
goalPaused: paused,
|
|
392
|
+
goalRevision: event.revision ?? s.goalRevision,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
case "goal_cleared": {
|
|
396
|
+
if (s.goal && staleGoalEvent(s, event))
|
|
397
|
+
return s;
|
|
398
|
+
return {
|
|
399
|
+
...s,
|
|
400
|
+
goal: undefined,
|
|
401
|
+
goalId: undefined,
|
|
402
|
+
goalPaused: undefined,
|
|
403
|
+
goalRevision: undefined,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
case "goal_progress": {
|
|
407
|
+
if (s.goal && staleGoalEvent(s, event))
|
|
408
|
+
return s;
|
|
409
|
+
const status = event.status ?? "";
|
|
410
|
+
// Goal achieved / gave up → drop the banner.
|
|
411
|
+
if (status === "met" || status === "exhausted") {
|
|
412
|
+
return {
|
|
413
|
+
...s,
|
|
414
|
+
goal: undefined,
|
|
415
|
+
goalId: undefined,
|
|
416
|
+
goalPaused: undefined,
|
|
417
|
+
goalRevision: undefined,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const round = event.round;
|
|
421
|
+
const label = round ? `目标 · 第 ${round} 轮 (${status})` : `目标 (${status})`;
|
|
422
|
+
return {
|
|
423
|
+
...s,
|
|
424
|
+
goal: label,
|
|
425
|
+
goalRevision: event.revision ?? s.goalRevision,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
case "task_update": {
|
|
429
|
+
// Only SUBAGENT task lists render as a compact status row, keyed by
|
|
430
|
+
// agentId (fbe6f68). The MAIN agent's own TodoWrite emits task_update with
|
|
431
|
+
// NO agentId (core task.ts) — like the desktop renderer (renderer/types.ts
|
|
432
|
+
// drops agentId-less task_updates from the subagent path), we must NOT turn
|
|
433
|
+
// the user's own todo list into a spurious "sub-main" child-agent row.
|
|
434
|
+
const agentId = event.agentId;
|
|
435
|
+
if (!agentId)
|
|
436
|
+
return s;
|
|
437
|
+
const tasks = event.tasks ?? [];
|
|
438
|
+
const done = tasks.filter((t) => t.status === "completed").length;
|
|
439
|
+
const label = `任务 ${done}/${tasks.length}`;
|
|
440
|
+
const existing = s.items.findIndex((i) => i.kind === "subagent" && i.agentId === agentId);
|
|
441
|
+
const sub = {
|
|
442
|
+
kind: "subagent",
|
|
443
|
+
id: `sub-${agentId}`,
|
|
444
|
+
agentId,
|
|
445
|
+
label,
|
|
446
|
+
// Empty list ⇒ not started, not "completed" (Array.every is vacuously
|
|
447
|
+
// true on []). Only call it completed when there is at least one task and
|
|
448
|
+
// all are done.
|
|
449
|
+
status: tasks.length > 0 && done === tasks.length ? "completed" : "running",
|
|
450
|
+
};
|
|
451
|
+
if (existing >= 0) {
|
|
452
|
+
const items = s.items.slice();
|
|
453
|
+
items[existing] = sub;
|
|
454
|
+
return { ...s, items };
|
|
455
|
+
}
|
|
456
|
+
return { ...s, items: [...s.items, sub] };
|
|
457
|
+
}
|
|
458
|
+
case "agent_start": {
|
|
459
|
+
const agentId = event.agentId;
|
|
460
|
+
if (!agentId)
|
|
461
|
+
return s;
|
|
462
|
+
const sub = {
|
|
463
|
+
kind: "subagent",
|
|
464
|
+
id: `sub-${agentId}`,
|
|
465
|
+
agentId,
|
|
466
|
+
label: event.name || event.description || "子代理",
|
|
467
|
+
status: "running",
|
|
468
|
+
};
|
|
469
|
+
if (s.items.some((i) => i.kind === "subagent" && i.agentId === agentId))
|
|
470
|
+
return s;
|
|
471
|
+
return { ...s, items: [...s.items, sub] };
|
|
472
|
+
}
|
|
473
|
+
case "agent_end": {
|
|
474
|
+
const agentId = event.agentId;
|
|
475
|
+
return {
|
|
476
|
+
...s,
|
|
477
|
+
items: s.items.map((i) => i.kind === "subagent" && i.agentId === agentId
|
|
478
|
+
? { ...i, status: event.error ? "error" : "completed" }
|
|
479
|
+
: i),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
case "error": {
|
|
483
|
+
const [id, s2] = freshId("err");
|
|
484
|
+
return {
|
|
485
|
+
...s2,
|
|
486
|
+
run: "error",
|
|
487
|
+
liveByAgent: {},
|
|
488
|
+
items: [
|
|
489
|
+
...s2.items,
|
|
490
|
+
{ kind: "system_error", id, text: event.error || "运行出错" },
|
|
491
|
+
],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
case "session_title": {
|
|
495
|
+
return { ...s, title: event.title };
|
|
496
|
+
}
|
|
497
|
+
case "user_message": {
|
|
498
|
+
// History replay surfaces past user turns as a synthetic event so the
|
|
499
|
+
// same reducer rebuilds the full conversation (the live path uses
|
|
500
|
+
// appendUserMessage instead, since the phone echoes locally).
|
|
501
|
+
const [id, s2] = freshId("u");
|
|
502
|
+
const attachments = Array.isArray(event.attachments)
|
|
503
|
+
? event.attachments
|
|
504
|
+
.map((item) => item)
|
|
505
|
+
.filter((item) => typeof item.name === "string" && typeof item.size === "number")
|
|
506
|
+
: undefined;
|
|
507
|
+
return {
|
|
508
|
+
...s2,
|
|
509
|
+
items: [
|
|
510
|
+
...s2.items,
|
|
511
|
+
{
|
|
512
|
+
kind: "user",
|
|
513
|
+
id,
|
|
514
|
+
text: event.text ?? "",
|
|
515
|
+
...(attachments?.length ? { attachments } : {}),
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
case "assistant_text": {
|
|
521
|
+
// A WHOLE assistant text message (not a token delta). Used by CC-room
|
|
522
|
+
// replay/live, where each `type:"text"` room message is a complete,
|
|
523
|
+
// self-contained chunk that claude emitted between tool calls. Each one
|
|
524
|
+
// becomes its OWN finished bubble (done:true) and is NOT registered in
|
|
525
|
+
// liveByAgent — so a later text message opens a fresh bubble instead of
|
|
526
|
+
// being appended to this one. That restores the "说一句 → 干活 → 再说一
|
|
527
|
+
// 句" progression (the糊成一坨 / "卡住最后才出" complaint came from
|
|
528
|
+
// text_delta folding every chunk into one open bubble). Streaming token
|
|
529
|
+
// deltas still go through text_delta.
|
|
530
|
+
const [id, s2] = freshId("a");
|
|
531
|
+
return {
|
|
532
|
+
...s2,
|
|
533
|
+
run: "running",
|
|
534
|
+
items: [
|
|
535
|
+
...s2.items,
|
|
536
|
+
{
|
|
537
|
+
kind: "assistant",
|
|
538
|
+
id,
|
|
539
|
+
text: event.text ?? "",
|
|
540
|
+
reasoning: "",
|
|
541
|
+
done: true,
|
|
542
|
+
agentId: event.agentId,
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
default:
|
|
548
|
+
// Unknown / not-displayed events (session_started, usage_update,
|
|
549
|
+
// context_compact, tombstone, …) are no-ops.
|
|
550
|
+
return s;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Append a local user echo (the phone shows its own message immediately). */
|
|
554
|
+
export function appendUserMessage(state, text, attachments) {
|
|
555
|
+
const id = `u-${state.seq + 1}`;
|
|
556
|
+
return {
|
|
557
|
+
...state,
|
|
558
|
+
seq: state.seq + 1,
|
|
559
|
+
items: [
|
|
560
|
+
...state.items,
|
|
561
|
+
{ kind: "user", id, text, ...(attachments?.length ? { attachments } : {}) },
|
|
562
|
+
],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type UILanguage = "zh" | "en";
|
|
2
|
+
export declare const UI_LANGUAGE_STORAGE_KEY = "codeshell.uiLanguage";
|
|
3
|
+
/** Read the stored UI language preference. Defaults to Chinese.
|
|
4
|
+
* Guards against environments with no localStorage (node tests, non-DOM
|
|
5
|
+
* callers) — translate() reaches here via helpers like bgCompletionText, so a
|
|
6
|
+
* bare localStorage access would throw "localStorage is not defined". */
|
|
7
|
+
export declare function loadUILanguage(): UILanguage;
|
|
8
|
+
/**
|
|
9
|
+
* Persist the UI language preference and broadcast a change event so
|
|
10
|
+
* any listeners can react. This only records the preference — actual
|
|
11
|
+
* UI string translation (i18n) is a separate, future effort.
|
|
12
|
+
*/
|
|
13
|
+
export declare function saveUILanguage(lang: UILanguage): void;
|
|
14
|
+
/** Human label for a language, shown in the language switcher. */
|
|
15
|
+
export declare function languageLabel(lang: UILanguage): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const UI_LANGUAGE_STORAGE_KEY = "codeshell.uiLanguage";
|
|
2
|
+
/** Read the stored UI language preference. Defaults to Chinese.
|
|
3
|
+
* Guards against environments with no localStorage (node tests, non-DOM
|
|
4
|
+
* callers) — translate() reaches here via helpers like bgCompletionText, so a
|
|
5
|
+
* bare localStorage access would throw "localStorage is not defined". */
|
|
6
|
+
export function loadUILanguage() {
|
|
7
|
+
try {
|
|
8
|
+
const raw = globalThis.localStorage?.getItem(UI_LANGUAGE_STORAGE_KEY);
|
|
9
|
+
if (raw === "zh" || raw === "en")
|
|
10
|
+
return raw;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
/* no localStorage (node/test) → fall through to default */
|
|
14
|
+
}
|
|
15
|
+
return "zh";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Persist the UI language preference and broadcast a change event so
|
|
19
|
+
* any listeners can react. This only records the preference — actual
|
|
20
|
+
* UI string translation (i18n) is a separate, future effort.
|
|
21
|
+
*/
|
|
22
|
+
export function saveUILanguage(lang) {
|
|
23
|
+
try {
|
|
24
|
+
globalThis.localStorage?.setItem(UI_LANGUAGE_STORAGE_KEY, lang);
|
|
25
|
+
globalThis.dispatchEvent?.(new Event("codeshell:language-changed"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* no localStorage/DOM (node/test) — preference just isn't persisted */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Human label for a language, shown in the language switcher. */
|
|
32
|
+
export function languageLabel(lang) {
|
|
33
|
+
return lang === "zh" ? "中文" : "English";
|
|
34
|
+
}
|