@linxin666/dsh-remote-web-ui 0.1.11 → 0.1.13

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.
Files changed (68) hide show
  1. package/README.i18n.yaml +6 -0
  2. package/README.md +12 -1
  3. package/README.zh.md +171 -0
  4. package/lib/client.js +170 -99
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +224 -4
  7. package/lib/mobile.js +109 -33
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +3 -2
  11. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  12. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  13. package/lib/types/client/RemoteSettingsCard.js +3 -1
  14. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  15. package/lib/types/client/UpdateEntry.js +10 -2
  16. package/lib/types/client/deep-link.js +10 -5
  17. package/lib/types/client/index.d.ts +13 -1
  18. package/lib/types/client/index.d.ts.map +1 -1
  19. package/lib/types/client/index.js +2 -1
  20. package/lib/types/client/locales.d.ts +8 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -0
  23. package/lib/types/client/update-api.d.ts +10 -0
  24. package/lib/types/client/update-api.d.ts.map +1 -1
  25. package/lib/types/client/update-api.js +22 -2
  26. package/lib/types/index.d.ts +7 -0
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +7 -1
  29. package/lib/types/mobile/api.d.ts +9 -2
  30. package/lib/types/mobile/api.d.ts.map +1 -1
  31. package/lib/types/mobile/api.js +7 -3
  32. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  33. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  34. package/lib/types/mobile/mobile-styles.js +3 -0
  35. package/lib/types/mobile/views/App.d.ts +3 -1
  36. package/lib/types/mobile/views/App.d.ts.map +1 -1
  37. package/lib/types/mobile/views/App.js +18 -3
  38. package/lib/types/mobile/views/ChatView.d.ts +6 -0
  39. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  40. package/lib/types/mobile/views/ChatView.js +95 -7
  41. package/lib/types/mobile/views/ChatView.test.js +161 -3
  42. package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +9 -2
  45. package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
  46. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
  47. package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
  48. package/lib/types/mobile-api.d.ts +2 -0
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +17 -2
  51. package/package.json +2 -12
  52. package/src/client/PluginSettingsCard.tsx +30 -5
  53. package/src/client/RemoteSettingsCard.tsx +18 -0
  54. package/src/client/UpdateEntry.tsx +10 -2
  55. package/src/client/deep-link.ts +10 -4
  56. package/src/client/index.ts +15 -2
  57. package/src/client/locales.ts +8 -0
  58. package/src/client/settings-card.module.css +139 -144
  59. package/src/client/update-api.ts +22 -2
  60. package/src/index.ts +14 -1
  61. package/src/mobile/api.ts +14 -2
  62. package/src/mobile/mobile-styles.ts +3 -0
  63. package/src/mobile/views/App.tsx +22 -3
  64. package/src/mobile/views/ChatView.test.tsx +185 -3
  65. package/src/mobile/views/ChatView.tsx +102 -8
  66. package/src/mobile/views/WorkspaceView.test.tsx +66 -0
  67. package/src/mobile/views/WorkspaceView.tsx +11 -2
  68. package/src/mobile-api.ts +20 -2
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { setInterval as setInterval$1 } from "node:timers";
3
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import { Service } from "@deepseek-ai/cordis";
4
4
  import z from "schemastery";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { readFile } from "node:fs/promises";
@@ -11,6 +11,203 @@ import { networkInterfaces } from "node:os";
11
11
  import { Tunnel, bin, install } from "cloudflared";
12
12
  import { dirname, join } from "node:path";
13
13
  import { spawn } from "node:child_process";
14
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.6_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_52495af71738a1095726249ceff4fd7b/node_modules/@deepseek-ai/dsh-settings/lib/index.js
15
+ /**
16
+ * Structural secret redaction for settings values. `role('secret')` fields are
17
+ * removed from a value before it crosses a wire boundary; a sidecar records
18
+ * each schema-declared secret position and whether it currently holds a value,
19
+ * so a configuration surface can render a write-only input without ever
20
+ * receiving the secret itself.
21
+ * @module @deepseek-ai/dsh-settings/redact
22
+ */
23
+ /** Whether a value is a plain data object the walker may recurse into. */
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function walk(node, value, path, secrets) {
28
+ if (node === void 0) return value;
29
+ if (node.meta?.role === "secret") {
30
+ secrets.push({
31
+ path,
32
+ set: value !== void 0
33
+ });
34
+ return;
35
+ }
36
+ switch (node.type) {
37
+ case "object": {
38
+ const properties = node.dict ?? {};
39
+ const source = isRecord(value) ? value : void 0;
40
+ const rebuilt = {};
41
+ if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
42
+ if (key in properties) continue;
43
+ rebuilt[key] = entry;
44
+ }
45
+ for (const [key, child] of Object.entries(properties)) {
46
+ const stripped = walk(child, source?.[key], [...path, key], secrets);
47
+ if (stripped !== void 0) rebuilt[key] = stripped;
48
+ }
49
+ return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
50
+ }
51
+ case "dict": {
52
+ if (!isRecord(value)) return value;
53
+ const rebuilt = {};
54
+ for (const [key, entry] of Object.entries(value)) {
55
+ const stripped = walk(node.inner, entry, [...path, key], secrets);
56
+ if (stripped !== void 0) rebuilt[key] = stripped;
57
+ }
58
+ return rebuilt;
59
+ }
60
+ case "array":
61
+ if (!Array.isArray(value)) return value;
62
+ return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
63
+ default: return value;
64
+ }
65
+ }
66
+ /**
67
+ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
68
+ * per-namespace sections; plugins register a namespace schema and read the
69
+ * resolved value, which layers schema defaults, the registrant's composition
70
+ * `base`, and the user document section, in that order.
71
+ * @module @deepseek-ai/dsh-settings
72
+ */
73
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
74
+ /**
75
+ * Brand a raw string as a {@link SettingsNamespace}.
76
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
77
+ * @returns the branded namespace.
78
+ */
79
+ function settingsNamespace(value) {
80
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
81
+ return value;
82
+ }
83
+ /**
84
+ * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
85
+ * Service Definition's single change-detection predicate, exported so the invariant
86
+ * companion checks exactly the implementation's relation.
87
+ * @param a - one JSON-compatible value.
88
+ * @param b - the other JSON-compatible value.
89
+ * @returns whether the two values are structurally equal.
90
+ */
91
+ function deepEqualJson(a, b) {
92
+ if (a === b) return true;
93
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
94
+ if (Array.isArray(a) || Array.isArray(b)) {
95
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
96
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
97
+ }
98
+ const left = a;
99
+ const right = b;
100
+ const keys = Object.keys(left);
101
+ if (keys.length !== Object.keys(right).length) return false;
102
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
103
+ }
104
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
105
+ function isPlainObject(value) {
106
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
107
+ const proto = Object.getPrototypeOf(value);
108
+ return proto === Object.prototype || proto === null;
109
+ }
110
+ /** Apply one path op to a detached section, returning the next section. */
111
+ function applyPathOp(section, op) {
112
+ const [head, ...rest] = op.path;
113
+ if (head === void 0) {
114
+ if (op.op === "unset") return {};
115
+ if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
116
+ return { ...op.value };
117
+ }
118
+ if (rest.length === 0) {
119
+ if (op.op === "set") return {
120
+ ...section,
121
+ [head]: op.value
122
+ };
123
+ const { [head]: _removed, ...kept } = section;
124
+ return kept;
125
+ }
126
+ const child = section[head];
127
+ if (!isPlainObject(child)) {
128
+ if (op.op === "unset") return section;
129
+ return {
130
+ ...section,
131
+ [head]: applyPathOp({}, {
132
+ ...op,
133
+ path: rest
134
+ })
135
+ };
136
+ }
137
+ return {
138
+ ...section,
139
+ [head]: applyPathOp(child, {
140
+ ...op,
141
+ path: rest
142
+ })
143
+ };
144
+ }
145
+ /**
146
+ * Layer `over` onto `under`: plain objects merge recursively, every other
147
+ * value (arrays included) replaces the lower layer wholesale. `over` never
148
+ * carries `undefined` entries — sections come from parsed documents and write
149
+ * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
150
+ * cannot erase lower keys.
151
+ */
152
+ function mergeLayers(under, over) {
153
+ if (over === void 0) return under;
154
+ if (!isPlainObject(under) || !isPlainObject(over)) return over;
155
+ const merged = { ...under };
156
+ for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
157
+ return merged;
158
+ }
159
+ /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
160
+ function deepFreeze(value) {
161
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
162
+ for (const entry of Object.values(value)) deepFreeze(entry);
163
+ return Object.freeze(value);
164
+ }
165
+ Service.init;
166
+ /**
167
+ * Value mirror of the `FiberState` members {@link isUnloading} compares
168
+ * against: a const enum has no runtime object to import, and the value is
169
+ * needed at runtime (same rationale as the CLI boot driver's mirror).
170
+ */
171
+ const FIBER_DISPOSED = 4;
172
+ const FIBER_UNLOADING = 5;
173
+ /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
174
+ function isUnloading(ctx) {
175
+ const state = ctx.fiber.state;
176
+ return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
177
+ }
178
+ /**
179
+ * Install the canonical optional-settings consumer wiring: while a settings
180
+ * service exists, register `ns` with the consumer's composition entry as the
181
+ * `base` layer and point the source thunk at the resolved scope; when the
182
+ * service goes away (disposal, provider reload), fall back to the entry so
183
+ * the consumer keeps working exactly as composed. The registration rides the
184
+ * scoped fiber, so no settings service ever mounted means none of this runs.
185
+ * @param ctx - consumer plugin context owning the wiring.
186
+ * @param ns - the consumer-owned settings namespace.
187
+ * @param schema - schema resolving the namespace (typically the plugin Config).
188
+ * @param entry - the consumer's composition entry config, used as `base`.
189
+ * @param hooks - source sink and change notification.
190
+ */
191
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
192
+ ctx.inject(["settings"], (sctx) => {
193
+ const scope = sctx.settings.register(ns, schema, {
194
+ base: entry,
195
+ ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
196
+ });
197
+ hooks.setSource(() => scope.get());
198
+ sctx.effect(() => () => {
199
+ if (isUnloading(ctx)) return;
200
+ hooks.setSource(() => entry);
201
+ hooks.onChange();
202
+ });
203
+ hooks.onChange();
204
+ scope.watch(() => {
205
+ if (isUnloading(ctx)) return;
206
+ hooks.onChange();
207
+ });
208
+ });
209
+ }
210
+ //#endregion
14
211
  //#region src/pairing.ts
15
212
  /**
16
213
  * Pairing state machine: one active one-time token, a device-session table,
@@ -803,6 +1000,12 @@ const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
803
1000
  "session.selectModel",
804
1001
  "session.rename"
805
1002
  ]);
1003
+ /**
1004
+ * Locally answered display-preference method (the phone's read-only
1005
+ * surface preferences; never proxied to the host ApiProxy and never a
1006
+ * settings-domain write).
1007
+ */
1008
+ const MOBILE_PREFERENCES_METHOD = "mobile.preferences";
806
1009
  /** One session.list page (thin phones load incrementally). */
807
1010
  const SESSION_PAGE_SIZE = 20;
808
1011
  /** Encode one list position as an opaque continuation cursor. */
@@ -836,7 +1039,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
836
1039
  * @returns the routes to register on webServer.
837
1040
  */
838
1041
  function makeMobileApiRoutes(deps) {
839
- const { service, apiProxy } = deps;
1042
+ const { service, apiProxy, mobileEnterToSend } = deps;
840
1043
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
841
1044
  const gateOk = (req) => {
842
1045
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
@@ -874,7 +1077,8 @@ function makeMobileApiRoutes(deps) {
874
1077
  return;
875
1078
  }
876
1079
  const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
877
- if (!MOBILE_ALLOWLIST.has(method)) {
1080
+ const local = method === MOBILE_PREFERENCES_METHOD;
1081
+ if (!MOBILE_ALLOWLIST.has(method) && !local) {
878
1082
  writeJson(res, 403, {
879
1083
  ok: false,
880
1084
  error: {
@@ -909,6 +1113,17 @@ function makeMobileApiRoutes(deps) {
909
1113
  });
910
1114
  return;
911
1115
  }
1116
+ if (local) {
1117
+ writeJson(res, 200, {
1118
+ type: "server-response",
1119
+ rpcId,
1120
+ result: {
1121
+ ok: true,
1122
+ value: { mobileEnterToSend: mobileEnterToSend() }
1123
+ }
1124
+ });
1125
+ return;
1126
+ }
912
1127
  try {
913
1128
  const response = await dispatch(apiProxy, method, parsed?.payload, rpcId);
914
1129
  writeJson(res, 200, response);
@@ -1645,6 +1860,7 @@ const Config = z.object({
1645
1860
  requirePairingForLan: z.boolean().default(true),
1646
1861
  publicBaseUrl: z.string(),
1647
1862
  autoTunnel: z.boolean().default(false),
1863
+ mobileEnterToSend: z.boolean().default(true),
1648
1864
  enabled: z.boolean().default(true)
1649
1865
  });
1650
1866
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
@@ -1658,6 +1874,7 @@ const DEFAULTS = {
1658
1874
  requirePairingForLan: true,
1659
1875
  publicBaseUrl: void 0,
1660
1876
  autoTunnel: false,
1877
+ mobileEnterToSend: true,
1661
1878
  enabled: true
1662
1879
  };
1663
1880
  /**
@@ -1674,6 +1891,7 @@ function apply(ctx, config) {
1674
1891
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
1675
1892
  publicBaseUrl: config?.publicBaseUrl,
1676
1893
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
1894
+ mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
1677
1895
  enabled: config?.enabled ?? DEFAULTS.enabled
1678
1896
  };
1679
1897
  let current = () => config ?? {};
@@ -1687,6 +1905,7 @@ function apply(ctx, config) {
1687
1905
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
1688
1906
  publicBaseUrl: value.publicBaseUrl,
1689
1907
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
1908
+ mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
1690
1909
  enabled: value.enabled ?? DEFAULTS.enabled
1691
1910
  };
1692
1911
  };
@@ -1771,7 +1990,8 @@ function apply(ctx, config) {
1771
1990
  ...makeMobileRoutes(),
1772
1991
  ...apiProxy !== void 0 ? makeMobileApiRoutes({
1773
1992
  service,
1774
- apiProxy
1993
+ apiProxy,
1994
+ mobileEnterToSend: () => resolve().mobileEnterToSend
1775
1995
  }) : [],
1776
1996
  ...updateRoutes
1777
1997
  ];
package/lib/mobile.js CHANGED
@@ -7570,6 +7570,10 @@ async function listWorkspaces() {
7570
7570
  const { items } = await callUnary("workspace.list", {});
7571
7571
  return items;
7572
7572
  }
7573
+ /** Read-only mobile display preferences (answered by the plugin, not the host proxy). */
7574
+ async function fetchMobilePreferences() {
7575
+ return await callUnary("mobile.preferences", {});
7576
+ }
7573
7577
  /** One session.list page; omit the cursor for the first page. */
7574
7578
  async function listSessions(cursor) {
7575
7579
  return await callUnary("session.list", cursor === void 0 ? {} : { cursor });
@@ -7581,13 +7585,13 @@ async function listSessions(cursor) {
7581
7585
  async function createSession(options = {}) {
7582
7586
  return await callUnary("session.create", options);
7583
7587
  }
7584
- /** One history window; omit beforeSeq for the tail page. */
7585
- async function history(sessionId, beforeSeq, maxMessages = 30) {
7588
+ /** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
7589
+ async function history(sessionId, beforeSeq, maxMessages = 30, signal) {
7586
7590
  return await callUnary("session.history", {
7587
7591
  sessionId,
7588
7592
  maxMessages,
7589
7593
  ...beforeSeq !== void 0 ? { beforeSeq } : {}
7590
- });
7594
+ }, signal);
7591
7595
  }
7592
7596
  /** Send one text prompt (queued: the agent picks it up in order). */
7593
7597
  async function prompt(sessionId, text) {
@@ -13526,20 +13530,6 @@ function ThemeToggle() {
13526
13530
  children: dark ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SunIcon, {}) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MoonIcon, {})
13527
13531
  });
13528
13532
  }
13529
- //#endregion
13530
- //#region src/mobile/views/ChatView.tsx
13531
- /**
13532
- * Chat level: one session. Loads the history tail page on open, appends
13533
- * pages upward (loadOlder), folds live mux frames in as they arrive, and
13534
- * sends prompts through session.prompt.
13535
- *
13536
- * Rendering mirrors the desktop web UI's fold discipline on a small screen:
13537
- * - reasoning text hides behind a collapsed "深度思考" disclosure,
13538
- * - tool calls behind a collapsed tool disclosure (name + arguments),
13539
- * - very long assistant text collapses with an explicit expand toggle,
13540
- * - a toolbar above the composer carries the model (+ thinking effort) and
13541
- * permission pickers, both as bottom sheets.
13542
- */
13543
13533
  /** Extract the raw event from one history entry (the fold consumes events only). */
13544
13534
  function eventOf(entry) {
13545
13535
  return entry.event;
@@ -13597,26 +13587,74 @@ function ChatView({ session, mux, onBack }) {
13597
13587
  const [sending, setSending] = (0, import_react.useState)(false);
13598
13588
  const scrollRef = (0, import_react.useRef)(void 0);
13599
13589
  const pendingRef = (0, import_react.useRef)(false);
13590
+ /**
13591
+ * True while the initial tail page is in flight. Live events arriving in
13592
+ * that window go to {@link liveBufferRef} instead of the message list: the
13593
+ * tail load replaces the list wholesale, so a directly folded event would
13594
+ * flash once, be discarded by the snapshot, and then be skipped forever by
13595
+ * the seq watermark.
13596
+ */
13597
+ const tailLoadingRef = (0, import_react.useRef)(true);
13598
+ /** Live session events buffered while the initial tail page loads. */
13599
+ const liveBufferRef = (0, import_react.useRef)([]);
13600
+ /** True once the live buffer hit its cap (oldest events were dropped). */
13601
+ const liveBufferOverflowRef = (0, import_react.useRef)(false);
13600
13602
  /** The session's permission select (absent = capability not composed). */
13601
13603
  const [permissions, setPermissions] = (0, import_react.useState)(void 0);
13602
13604
  /** The current model selection for the toolbar chip (best-effort label). */
13603
13605
  const [currentModel, setCurrentModel] = (0, import_react.useState)(void 0);
13604
13606
  /** Which bottom sheet is open. */
13605
13607
  const [sheet, setSheet] = (0, import_react.useState)(null);
13608
+ /**
13609
+ * Composer preference from the plugin's host settings (default true keeps
13610
+ * the legacy Enter-to-send behavior until the preference loads).
13611
+ */
13612
+ const [mobileEnterToSend, setMobileEnterToSend] = (0, import_react.useState)(true);
13613
+ (0, import_react.useEffect)(() => {
13614
+ let cancelled = false;
13615
+ fetchMobilePreferences().then((preferences) => {
13616
+ if (!cancelled) setMobileEnterToSend(preferences.mobileEnterToSend !== false);
13617
+ }, () => {});
13618
+ return () => {
13619
+ cancelled = true;
13620
+ };
13621
+ }, []);
13606
13622
  (0, import_react.useEffect)(() => {
13607
13623
  let cancelled = false;
13624
+ const controller = new AbortController();
13625
+ const timeout = setTimeout(() => {
13626
+ controller.abort(new DOMException("history load timed out", "TimeoutError"));
13627
+ }, 15e3);
13628
+ tailLoadingRef.current = true;
13629
+ liveBufferRef.current = [];
13630
+ liveBufferOverflowRef.current = false;
13608
13631
  setLoading(true);
13609
13632
  setError(void 0);
13610
13633
  setMessages([]);
13611
- loadHistory(session.sessionId).then((page) => {
13634
+ loadHistory(session.sessionId, void 0, controller.signal).then((page) => {
13612
13635
  if (cancelled) return;
13613
- setMessages(foldEvents(page.events.map(eventOf)));
13636
+ const buffered = liveBufferRef.current;
13637
+ liveBufferRef.current = [];
13638
+ tailLoadingRef.current = false;
13639
+ setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))));
13614
13640
  setHasOlder(page.hasMore);
13615
13641
  setLoading(false);
13616
13642
  const projections = page.projections?.values;
13617
13643
  setPermissions(parsePermissionSelect(projections?.["permissions"]));
13644
+ if (liveBufferOverflowRef.current) loadHistory(session.sessionId, void 0, controller.signal).then((fresh) => {
13645
+ if (cancelled) return;
13646
+ setMessages((previous) => foldEvents(fresh.events.map(eventOf), previous));
13647
+ liveBufferOverflowRef.current = false;
13648
+ }, (reason) => {
13649
+ if (cancelled) return;
13650
+ console.warn("history tail re-pull after live buffer overflow failed", reason);
13651
+ });
13618
13652
  }, (reason) => {
13619
13653
  if (cancelled) return;
13654
+ const buffered = liveBufferRef.current;
13655
+ liveBufferRef.current = [];
13656
+ tailLoadingRef.current = false;
13657
+ if (buffered.length > 0) setMessages(foldEvents(buffered));
13620
13658
  setError(errorText(reason));
13621
13659
  setLoading(false);
13622
13660
  });
@@ -13625,6 +13663,8 @@ function ChatView({ session, mux, onBack }) {
13625
13663
  }, () => {});
13626
13664
  return () => {
13627
13665
  cancelled = true;
13666
+ clearTimeout(timeout);
13667
+ controller.abort();
13628
13668
  };
13629
13669
  }, [session.sessionId]);
13630
13670
  (0, import_react.useEffect)(() => {
@@ -13632,7 +13672,19 @@ function ChatView({ session, mux, onBack }) {
13632
13672
  return mux.onFrame((frame) => {
13633
13673
  if (frame.type === "session/event") {
13634
13674
  if (frame.sessionId !== session.sessionId) return;
13635
- setMessages((previous) => foldEvents([frame.event], previous));
13675
+ const event = frame.event;
13676
+ if (tailLoadingRef.current) {
13677
+ if (liveBufferRef.current.length >= 500) {
13678
+ liveBufferRef.current.shift();
13679
+ if (!liveBufferOverflowRef.current) {
13680
+ console.warn(`history tail is slow: live buffer reached 500 events; oldest buffered events will be re-fetched`);
13681
+ liveBufferOverflowRef.current = true;
13682
+ }
13683
+ }
13684
+ liveBufferRef.current.push(event);
13685
+ return;
13686
+ }
13687
+ setMessages((previous) => foldEvents([event], previous));
13636
13688
  return;
13637
13689
  }
13638
13690
  if (frame.type === "session/projection" && frame.sessionId === session.sessionId && frame.key === "permissions") setPermissions(parsePermissionSelect(frame.value));
@@ -13800,13 +13852,13 @@ function ChatView({ session, mux, onBack }) {
13800
13852
  className: "chat-input",
13801
13853
  rows: 1,
13802
13854
  value: input,
13803
- placeholder: "输入消息,Enter 发送…",
13804
- enterKeyHint: "send",
13855
+ placeholder: mobileEnterToSend ? "输入消息,Enter 发送…" : "输入消息,Enter 换行,点按钮发送…",
13856
+ enterKeyHint: mobileEnterToSend ? "send" : "enter",
13805
13857
  onChange: (event) => {
13806
13858
  setInput(event.target.value);
13807
13859
  },
13808
13860
  onKeyDown: (event) => {
13809
- if (event.key === "Enter" && !event.shiftKey) {
13861
+ if (mobileEnterToSend && event.key === "Enter" && !event.shiftKey) {
13810
13862
  event.preventDefault();
13811
13863
  send();
13812
13864
  }
@@ -14503,13 +14555,18 @@ function SessionListView({ workspace, onBack, onPick }) {
14503
14555
  * @param props - the pick action.
14504
14556
  * @returns the roster.
14505
14557
  */
14506
- function WorkspaceView({ onPick }) {
14558
+ function WorkspaceView({ initialWorkspaceId, onPick }) {
14507
14559
  const [items, setItems] = (0, import_react.useState)(void 0);
14508
14560
  const [error, setError] = (0, import_react.useState)(void 0);
14509
14561
  (0, import_react.useEffect)(() => {
14510
14562
  let cancelled = false;
14511
14563
  listWorkspaces().then((rows) => {
14512
14564
  if (cancelled) return;
14565
+ const target = initialWorkspaceId === void 0 ? void 0 : rows.find((workspace) => workspace.workspaceId === initialWorkspaceId);
14566
+ if (target !== void 0) {
14567
+ onPick(target);
14568
+ return;
14569
+ }
14513
14570
  setItems(rows);
14514
14571
  }, (reason) => {
14515
14572
  if (cancelled) return;
@@ -14518,7 +14575,7 @@ function WorkspaceView({ onPick }) {
14518
14575
  return () => {
14519
14576
  cancelled = true;
14520
14577
  };
14521
- }, []);
14578
+ }, [initialWorkspaceId, onPick]);
14522
14579
  if (error !== void 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
14523
14580
  className: "mobile",
14524
14581
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", {
@@ -14617,6 +14674,11 @@ function WorkspaceView({ onPick }) {
14617
14674
  * router, no state library: the surface is three fixed levels with a back
14618
14675
  * affordance, and every piece of data is fetched on demand.
14619
14676
  */
14677
+ /** Read the optional workspace target carried from the pairing QR flow. */
14678
+ function mobileWorkspaceTarget(search) {
14679
+ const value = new URLSearchParams(search).get("workspace");
14680
+ return value === null || value === "" ? void 0 : value;
14681
+ }
14620
14682
  /** Map a list row to the surface model; the title comes from projections when present. */
14621
14683
  function toSessionView(item) {
14622
14684
  const titleValue = item.projections?.values?.title;
@@ -14647,6 +14709,7 @@ function formatTime(epochMs) {
14647
14709
  */
14648
14710
  function App() {
14649
14711
  const [route, setRoute] = (0, import_react.useState)({ kind: "workspaces" });
14712
+ const [initialWorkspaceId, setInitialWorkspaceId] = (0, import_react.useState)(() => mobileWorkspaceTarget(window.location.search));
14650
14713
  const muxRef = (0, import_react.useRef)(void 0);
14651
14714
  (0, import_react.useEffect)(() => {
14652
14715
  const mux = new MuxClient();
@@ -14676,14 +14739,24 @@ function App() {
14676
14739
  workspace
14677
14740
  });
14678
14741
  }, []);
14742
+ const openWorkspace = (0, import_react.useCallback)((workspace) => {
14743
+ if (initialWorkspaceId !== void 0) {
14744
+ const url = new URL(window.location.href);
14745
+ url.searchParams.delete("workspace");
14746
+ window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
14747
+ setInitialWorkspaceId(void 0);
14748
+ }
14749
+ setRoute({
14750
+ kind: "sessions",
14751
+ workspace
14752
+ });
14753
+ }, [initialWorkspaceId]);
14679
14754
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
14680
14755
  className: "mobile",
14681
- children: route.kind === "workspaces" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceView, { onPick: (workspace) => {
14682
- setRoute({
14683
- kind: "sessions",
14684
- workspace
14685
- });
14686
- } }) : route.kind === "sessions" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SessionListView, {
14756
+ children: route.kind === "workspaces" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceView, {
14757
+ initialWorkspaceId,
14758
+ onPick: openWorkspace
14759
+ }) : route.kind === "sessions" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SessionListView, {
14687
14760
  workspace: route.workspace,
14688
14761
  onBack: back,
14689
14762
  onPick: (session) => {
@@ -14714,8 +14787,8 @@ function staleHostHint(message) {
14714
14787
  return /^HTTP 403/.test(message) ? "宿主端插件可能仍在运行旧版本:请重启 dsh web 后再试。" : void 0;
14715
14788
  }
14716
14789
  /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
14717
- function loadHistory(sessionId, beforeSeq) {
14718
- return history(sessionId, beforeSeq);
14790
+ function loadHistory(sessionId, beforeSeq, signal) {
14791
+ return history(sessionId, beforeSeq, void 0, signal);
14719
14792
  }
14720
14793
  //#endregion
14721
14794
  //#region src/mobile/mobile-styles.ts
@@ -15486,6 +15559,9 @@ body {
15486
15559
  min-width: 0;
15487
15560
  min-height: 40px;
15488
15561
  max-height: 120px;
15562
+ /* Grow with the content (still bounded by min/max-height); browsers
15563
+ without field-sizing support ignore this line and keep the old size. */
15564
+ field-sizing: content;
15489
15565
  padding: 9px 12px;
15490
15566
  border: 1px solid var(--m-border);
15491
15567
  border-radius: 10px;