@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.
@@ -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
+ }