@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.1

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
@@ -10,6 +10,7 @@
10
10
  import type { IncomingMessage } from 'node:http';
11
11
  import type { Context } from '@deepseek-ai/cordis';
12
12
  import z from 'schemastery';
13
+ import { type PairingConfig } from './pairing.ts';
13
14
  declare module '@deepseek-ai/cordis' {
14
15
  interface Events {
15
16
  /**
@@ -57,6 +58,14 @@ export interface Config {
57
58
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
58
59
  */
59
60
  publicBaseUrl?: string;
61
+ /**
62
+ * Absolute path to a JSON file where paired device sessions are persisted.
63
+ * When set, a paired phone keeps its session across `dsh web` restarts
64
+ * (the phone-side cookie already lives 365 days), so re-scanning the QR
65
+ * after each restart is unnecessary. Unset keeps sessions memory-only
66
+ * (previous behavior).
67
+ */
68
+ devicesFile?: string;
60
69
  /**
61
70
  * When true, the plugin runs its own Cloudflare quick tunnel (the
62
71
  * cloudflared binary ships with the package — no user-side install) and
@@ -76,6 +85,22 @@ export interface Config {
76
85
  enabled?: boolean;
77
86
  }
78
87
  export declare const Config: z<Config>;
88
+ /**
89
+ * Fully resolved config: every field non-optional except `publicBaseUrl`,
90
+ * which legitimately resolves to `undefined` when unset (the schema keeps it
91
+ * optional, so `Required` alone would over-narrow it to `string`).
92
+ */
93
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
94
+ publicBaseUrl: string | undefined;
95
+ devicesFile: string | undefined;
96
+ };
97
+ /**
98
+ * The single mapping from resolved plugin config to the pairing service
99
+ * config. Both the constructed service and every live settings sync reuse
100
+ * it, so no field can be silently dropped when the web settings surface
101
+ * pushes a new value into the running service.
102
+ */
103
+ export declare function pairingConfigOf(resolved: Pick<ResolvedConfig, 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'>): PairingConfig;
79
104
  /**
80
105
  * Mount the pairing service, routes, gate listener, and presence sweep.
81
106
  * @param ctx - host plugin context carrying webServer.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAoB3B,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAA4B,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAU3B,CAAA;AAyBF;;;;GAIG;AACH,eAAO,MAAM,KAAK,kBAAuD,CAAA;AAEzE,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAkOtD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAE3B,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AAmBjE,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAA4B,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAW3B,CAAA;AAKF;;;;GAIG;AACH,KAAK,cAAc,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,GAAG;IAC9E,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;CAChC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,IAAI,CAC5C,cAAc,EACd,YAAY,GAAG,gBAAgB,GAAG,YAAY,GAAG,YAAY,GAAG,aAAa,CAC9E,GAAG,aAAa,CAQhB;AAgBD;;;;GAIG;AACH,eAAO,MAAM,KAAK,kBAAuD,CAAA;AAEzE,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAgOtD"}
@@ -12,7 +12,8 @@ import { setInterval as nodeSetInterval } from 'node:timers';
12
12
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
13
13
  import z from 'schemastery';
14
14
  import { PairingService } from './pairing.js';
15
- import { makeGateListener } from './gate.js';
15
+ import { isPairedDeviceRequest, makeGateListener } from './gate.js';
16
+ import { RemoteWebUiPairing } from './pairing-access.js';
16
17
  import { isTrustedApiRequest, makeRoutes } from './routes.js';
17
18
  import { makeMobileRoutes } from './mobile-routes.js';
18
19
  import { makeMobileApiRoutes } from './mobile-api.js';
@@ -38,12 +39,28 @@ export const Config = z.object({
38
39
  cookieName: z.string().min(1).default('dsh_pair'),
39
40
  requirePairingForLan: z.boolean().default(true),
40
41
  publicBaseUrl: z.string(),
42
+ devicesFile: z.string(),
41
43
  autoTunnel: z.boolean().default(false),
42
44
  mobileEnterToSend: z.boolean().default(true),
43
45
  enabled: z.boolean().default(true),
44
46
  });
45
47
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
46
48
  const SWEEP_INTERVAL_MS = 10_000;
49
+ /**
50
+ * The single mapping from resolved plugin config to the pairing service
51
+ * config. Both the constructed service and every live settings sync reuse
52
+ * it, so no field can be silently dropped when the web settings surface
53
+ * pushes a new value into the running service.
54
+ */
55
+ export function pairingConfigOf(resolved) {
56
+ return {
57
+ tokenTtlMs: resolved.tokenTtlMs,
58
+ offlineAfterMs: resolved.offlineAfterMs,
59
+ maxDevices: resolved.maxDevices,
60
+ cookieName: resolved.cookieName,
61
+ devicesFile: resolved.devicesFile,
62
+ };
63
+ }
47
64
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
48
65
  const DEFAULTS = {
49
66
  tokenTtlMs: 10 * 60_000,
@@ -52,6 +69,7 @@ const DEFAULTS = {
52
69
  cookieName: 'dsh_pair',
53
70
  requirePairingForLan: true,
54
71
  publicBaseUrl: undefined,
72
+ devicesFile: undefined,
55
73
  autoTunnel: false,
56
74
  mobileEnterToSend: true,
57
75
  enabled: true,
@@ -70,6 +88,7 @@ function applyImpl(ctx, config) {
70
88
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
71
89
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
72
90
  publicBaseUrl: config?.publicBaseUrl,
91
+ devicesFile: config?.devicesFile,
73
92
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
74
93
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
75
94
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -87,17 +106,13 @@ function applyImpl(ctx, config) {
87
106
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
88
107
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
89
108
  publicBaseUrl: value.publicBaseUrl,
109
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
90
110
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
91
111
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
92
112
  enabled: value.enabled ?? DEFAULTS.enabled,
93
113
  };
94
114
  };
95
- const service = new PairingService({
96
- tokenTtlMs: resolved.tokenTtlMs,
97
- offlineAfterMs: resolved.offlineAfterMs,
98
- maxDevices: resolved.maxDevices,
99
- cookieName: resolved.cookieName,
100
- });
115
+ const service = new PairingService(pairingConfigOf(resolved));
101
116
  // ── auto tunnel ─────────────────────────────────────────────────────────
102
117
  // The minted public URL becomes the QR base (and the pairing fence's
103
118
  // trusted host). Phone /api traffic rides the plugin's own /m/api channel,
@@ -228,14 +243,16 @@ function applyImpl(ctx, config) {
228
243
  ];
229
244
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
230
245
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate');
246
+ // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
247
+ // plugin is not installed; stop() / enabled=false still refuse cookies.
248
+ new RemoteWebUiPairing(ctx, (request) => {
249
+ if (!resolve().enabled)
250
+ return false;
251
+ return isPairedDeviceRequest(service, request);
252
+ });
231
253
  const sync = () => {
232
254
  const value = resolve();
233
- service.config = {
234
- tokenTtlMs: value.tokenTtlMs,
235
- offlineAfterMs: value.offlineAfterMs,
236
- maxDevices: value.maxDevices,
237
- cookieName: value.cookieName,
238
- };
255
+ service.config = pairingConfigOf(value);
239
256
  // The auto tunnel owns the public base while enabled: the minted URL
240
257
  // lands in the service through the tunnel's phase listener. The manual
241
258
  // publicBaseUrl applies only when the auto tunnel is off.
@@ -0,0 +1,7 @@
1
+ export interface PairRequiredViewProps {
2
+ initialError?: string;
3
+ onPaired(path: string): void;
4
+ }
5
+ /** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
6
+ export declare function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps): import("react").JSX.Element;
7
+ //# sourceMappingURL=PairRequiredView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PairRequiredView.d.ts","sourceRoot":"","sources":["../../../src/mobile/PairRequiredView.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,qBAAqB;IACpC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAED,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,qBAAqB,+BA+CjF"}
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
3
+ import { useState } from 'react';
4
+ import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.js';
5
+ /** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
6
+ export function PairRequiredView({ initialError, onPaired }) {
7
+ const [value, setValue] = useState('');
8
+ const [error, setError] = useState(initialError);
9
+ const [submitting, setSubmitting] = useState(false);
10
+ const submit = async (event) => {
11
+ event.preventDefault();
12
+ const input = parseMobilePairInput(value);
13
+ if (input === undefined) {
14
+ setError('请输入有效的配对链接。');
15
+ return;
16
+ }
17
+ setSubmitting(true);
18
+ setError(undefined);
19
+ const result = await acceptMobilePair(input.token);
20
+ setSubmitting(false);
21
+ if (!result.ok) {
22
+ setError(result.message);
23
+ return;
24
+ }
25
+ onPaired(mobilePairPath(input.workspaceId));
26
+ };
27
+ return (_jsx("main", { className: "mobile mobile-pair", "aria-labelledby": "mobile-pair-title", children: _jsxs("form", { className: "mobile-pairCard", onSubmit: (event) => { void submit(event); }, children: [_jsx("h1", { id: "mobile-pair-title", className: "mobile-title", children: "\u8BBE\u5907\u914D\u5BF9" }), _jsx("p", { className: "mobile-muted", children: "\u7C98\u8D34\u684C\u9762\u7AEF\u590D\u5236\u7684\u914D\u5BF9\u94FE\u63A5\u4EE5\u8FDE\u63A5\u6B64\u8BBE\u5907\u3002" }), _jsx("label", { className: "mobile-pairLabel", htmlFor: "mobile-pair-link", children: "\u914D\u5BF9\u94FE\u63A5" }), _jsx("input", { id: "mobile-pair-link", className: "mobile-pairInput", value: value, onChange: (event) => { setValue(event.target.value); }, autoComplete: "off", autoCapitalize: "off", spellCheck: false }), error === undefined ? null : _jsx("p", { className: "mobile-error", role: "alert", children: error }), _jsx("button", { className: "mobile-new mobile-pairSubmit", type: "submit", disabled: submitting, children: submitting ? '正在配对' : '配对' })] }) }));
28
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=PairRequiredView.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PairRequiredView.test.d.ts","sourceRoot":"","sources":["../../../src/mobile/PairRequiredView.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { PairRequiredView } from './PairRequiredView.js';
6
+ afterEach(() => {
7
+ cleanup();
8
+ vi.unstubAllGlobals();
9
+ });
10
+ describe('PairRequiredView', () => {
11
+ it('accepts a pasted link in the installed app context', async () => {
12
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
13
+ vi.stubGlobal('fetch', fetch);
14
+ const onPaired = vi.fn();
15
+ render(_jsx(PairRequiredView, { onPaired: onPaired }));
16
+ fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } });
17
+ fireEvent.click(screen.getByRole('button', { name: '配对' }));
18
+ await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'));
19
+ });
20
+ it('shows an initial QR failure without starting the mobile data channel', () => {
21
+ render(_jsx(PairRequiredView, { initialError: "\u914D\u5BF9\u94FE\u63A5\u5DF2\u88AB\u4F7F\u7528\u3002", onPaired: vi.fn() }));
22
+ expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。');
23
+ });
24
+ });
@@ -5,6 +5,7 @@
5
5
  * rpc/mux layers).
6
6
  */
7
7
  import type { WorkspaceView } from '@deepseek-ai/dsh-host-apiproxy/api/workspace';
8
+ import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets';
8
9
  import type { SessionSummary, SessionModels, SessionProjectionsBlock } from '@deepseek-ai/dsh-host-apiproxy/api/sessions';
9
10
  /** One session.list page. */
10
11
  export interface SessionPage {
@@ -19,6 +20,12 @@ export interface CreatedSession {
19
20
  /** The composition the new session runs (echoed so the caller can label it). */
20
21
  agentPreset?: string;
21
22
  }
23
+ /** Agent presets available when composing a new session. */
24
+ export interface AgentPresetRoster {
25
+ presets: readonly AgentPresetEntry[];
26
+ authorable: boolean;
27
+ hasDocument: boolean;
28
+ }
22
29
  /** One history page (already bounded to whole messages by the host). */
23
30
  export interface HistoryPage {
24
31
  events: import('@deepseek-ai/dsh-host-apiproxy/api/sessions').HistoryEntry[];
@@ -40,6 +47,8 @@ export declare function listWorkspaces(): Promise<WorkspaceView[]>;
40
47
  export declare function fetchMobilePreferences(): Promise<MobilePreferences>;
41
48
  /** One session.list page; omit the cursor for the first page. */
42
49
  export declare function listSessions(cursor?: string): Promise<SessionPage>;
50
+ /** Read the available agent compositions for a new session. */
51
+ export declare function listAgentPresets(): Promise<AgentPresetRoster>;
43
52
  /**
44
53
  * Create a blank session (entity birth precedes the first message). Name a
45
54
  * workspace to attach it there, or a cwd; omitting both uses the host cwd.
@@ -47,6 +56,7 @@ export declare function listSessions(cursor?: string): Promise<SessionPage>;
47
56
  export declare function createSession(options?: {
48
57
  workspaceId?: string;
49
58
  cwd?: string;
59
+ agentPreset?: string;
50
60
  }): Promise<CreatedSession>;
51
61
  /** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
52
62
  export declare function history(sessionId: string, beforeSeq?: number, maxMessages?: number, signal?: AbortSignal): Promise<HistoryPage>;
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/mobile/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAA;AACjF,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6CAA6C,CAAA;AAGzH,6BAA6B;AAC7B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,6CAA6C,EAAE,YAAY,EAAE,CAAA;IAC5E,OAAO,EAAE,OAAO,CAAA;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,uBAAuB,CAAA;CACtC;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,iBAAiB,EAAE,OAAO,CAAA;CAC3B;AAED,kEAAkE;AAClE,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAG/D;AAED,yFAAyF;AACzF,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEzE;AAED,iEAAiE;AACjE,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAExE;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GACnD,OAAO,CAAC,cAAc,CAAC,CAEzB;AAED,oFAAoF;AACpF,wBAAsB,OAAO,CAC3B,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,EAClB,WAAW,SAAK,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,WAAW,CAAC,CAMtB;AAED,qEAAqE;AACrE,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM3E;AAED,wEAAwE;AACxE,wBAAsB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMnF;AAED,oFAAoF;AACpF,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAEtE;AAED,2FAA2F;AAC3F,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GACvE,OAAO,CAAC;IAAE,QAAQ,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,CAOtF"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/mobile/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAA;AACjF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kDAAkD,CAAA;AACxF,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6CAA6C,CAAA;AAGzH,6BAA6B;AAC7B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,4DAA4D;AAC5D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;IACpC,UAAU,EAAE,OAAO,CAAA;IACnB,WAAW,EAAE,OAAO,CAAA;CACrB;AAED,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,6CAA6C,EAAE,YAAY,EAAE,CAAA;IAC5E,OAAO,EAAE,OAAO,CAAA;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,uBAAuB,CAAA;CACtC;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,iBAAiB,EAAE,OAAO,CAAA;CAC3B;AAED,kEAAkE;AAClE,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAG/D;AAED,yFAAyF;AACzF,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEzE;AAED,iEAAiE;AACjE,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAExE;AAED,+DAA+D;AAC/D,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEnE;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,GACzE,OAAO,CAAC,cAAc,CAAC,CAEzB;AAED,oFAAoF;AACpF,wBAAsB,OAAO,CAC3B,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,EAClB,WAAW,SAAK,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,WAAW,CAAC,CAMtB;AAED,qEAAqE;AACrE,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM3E;AAED,wEAAwE;AACxE,wBAAsB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMnF;AAED,oFAAoF;AACpF,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAEtE;AAED,2FAA2F;AAC3F,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GACvE,OAAO,CAAC;IAAE,QAAQ,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,CAOtF"}
@@ -18,6 +18,10 @@ export async function fetchMobilePreferences() {
18
18
  export async function listSessions(cursor) {
19
19
  return await callUnary('session.list', cursor === undefined ? {} : { cursor });
20
20
  }
21
+ /** Read the available agent compositions for a new session. */
22
+ export async function listAgentPresets() {
23
+ return await callUnary('agentPreset.list', {});
24
+ }
21
25
  /**
22
26
  * Create a blank session (entity birth precedes the first message). Name a
23
27
  * workspace to attach it there, or a cwd; omitting both uses the host cwd.
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client';
11
11
  import { App } from './views/App.js';
12
12
  import { mobileCss } from './mobile-styles.js';
13
13
  import { initMobileTheme } from './mobile-theme.js';
14
+ import { registerMobilePwa } from '../mobile-pwa.js';
15
+ import { consumeMobilePairUrl } from './pairing.js';
14
16
  // Apply the persisted (or default light) theme before first paint, so the
15
17
  // page never flashes the wrong palette.
16
18
  initMobileTheme();
@@ -22,4 +24,15 @@ document.head.appendChild(style);
22
24
  const root = document.getElementById('root');
23
25
  if (root === null)
24
26
  throw new Error('mobile: #root missing');
25
- createRoot(root).render(_jsx(App, {}));
27
+ void bootMobile(root);
28
+ async function bootMobile(mount) {
29
+ const pair = await consumeMobilePairUrl(window.location.href);
30
+ if (pair.kind === 'accepted') {
31
+ window.location.replace(pair.path);
32
+ return;
33
+ }
34
+ if (pair.kind === 'failed')
35
+ window.history.replaceState(null, '', pair.path);
36
+ void registerMobilePwa();
37
+ createRoot(mount).render(_jsx(App, { initialPairError: pair.kind === 'failed' ? pair.message : undefined }));
38
+ }
@@ -114,4 +114,28 @@ export interface WireEvent {
114
114
  * @returns messages sorted by seq.
115
115
  */
116
116
  export declare function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[];
117
+ /**
118
+ * Incremental folder for one message stream. Live chat folds one event at a
119
+ * time; rebuilding the five index maps by scanning every message per event
120
+ * made that path O(n) per event (O(n * events) per turn). A folder keeps the
121
+ * indexes alive across folds, applies each event in O(1) map operations, and
122
+ * returns the previous snapshot identity unchanged when nothing applied, so
123
+ * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
124
+ * watermark advanced by the first application skips them, which also makes a
125
+ * double-invoked React state updater harmless.
126
+ */
127
+ export declare class EventFolder {
128
+ private state;
129
+ private snapshotList;
130
+ /** @param initial - seed rows (history tail load); omit for an empty stream. */
131
+ constructor(initial?: readonly RenderMessage[]);
132
+ /** Fold one batch incrementally; returns the current snapshot list. */
133
+ fold(events: readonly WireEvent[]): RenderMessage[];
134
+ /** Replace the whole stream (history reload / session switch). */
135
+ seed(messages: readonly RenderMessage[]): void;
136
+ /** Prepend an older history page (exact seam; no overlapping seqs). */
137
+ prepend(older: readonly RenderMessage[]): void;
138
+ /** Current snapshot list; a fresh copy whenever the folder changed. */
139
+ snapshot(): RenderMessage[];
140
+ }
117
141
  //# sourceMappingURL=messages.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AA6eD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAsB7G"}
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAweD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;IAChF,YAAY,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,EAE7C;IAED,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE,CAWlD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG7C;IAED,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG7C;IAED,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE,CAI1B;CACF"}
@@ -83,17 +83,6 @@ function chunkTarget(data) {
83
83
  result.step = step;
84
84
  return result;
85
85
  }
86
- /** Max message seq across an existing list, or -1 for an empty list. */
87
- function watermarkOf(existing) {
88
- if (existing === undefined)
89
- return -1;
90
- let maxSeq = -1;
91
- for (const message of existing) {
92
- if (message.seq > maxSeq)
93
- maxSeq = message.seq;
94
- }
95
- return maxSeq;
96
- }
97
86
  function createState(existing) {
98
87
  const messages = existing === undefined ? [] : [...existing];
99
88
  const state = {
@@ -103,8 +92,11 @@ function createState(existing) {
103
92
  turnStepMessage: new Map(),
104
93
  messageTurn: new Map(),
105
94
  toolNames: new Map(),
95
+ maxSeq: -1,
106
96
  };
107
97
  for (const message of messages) {
98
+ if (message.seq > state.maxSeq)
99
+ state.maxSeq = message.seq;
108
100
  state.byId.set(message.id, message);
109
101
  if (message.kind !== 'assistant')
110
102
  continue;
@@ -167,6 +159,8 @@ function retargetTurnStep(state, key, oldMessage, next) {
167
159
  }
168
160
  /** Fold one event into the working state. Assumes the event passes the watermark. */
169
161
  function applyEvent(state, event) {
162
+ if (event.seq > state.maxSeq)
163
+ state.maxSeq = event.seq;
170
164
  switch (event.type) {
171
165
  case 'user/message':
172
166
  applyUserMessage(state, event);
@@ -489,18 +483,61 @@ function applyTurnEnd(state, event) {
489
483
  * @returns messages sorted by seq.
490
484
  */
491
485
  export function foldEvents(events, existing) {
492
- const sorted = [...events].sort((a, b) => a.seq - b.seq);
493
- const watermark = watermarkOf(existing);
494
- const state = createState(existing);
495
- for (const event of sorted) {
496
- if (event.seq <= watermark)
497
- continue;
498
- applyEvent(state, event);
486
+ return new EventFolder(existing).fold(events);
487
+ }
488
+ /**
489
+ * Incremental folder for one message stream. Live chat folds one event at a
490
+ * time; rebuilding the five index maps by scanning every message per event
491
+ * made that path O(n) per event (O(n * events) per turn). A folder keeps the
492
+ * indexes alive across folds, applies each event in O(1) map operations, and
493
+ * returns the previous snapshot identity unchanged when nothing applied, so
494
+ * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
495
+ * watermark advanced by the first application skips them, which also makes a
496
+ * double-invoked React state updater harmless.
497
+ */
498
+ export class EventFolder {
499
+ state;
500
+ snapshotList;
501
+ /** @param initial - seed rows (history tail load); omit for an empty stream. */
502
+ constructor(initial) {
503
+ this.state = createState(initial);
499
504
  }
500
- // Incremental tails (the live-stream hot path) are appended in seq order
501
- // or updated in place, so the list is usually ordered already — verify in
502
- // O(n) and skip the O(n log n) re-sort when it is.
503
- const out = state.messages;
505
+ /** Fold one batch incrementally; returns the current snapshot list. */
506
+ fold(events) {
507
+ const sorted = [...events].sort((a, b) => a.seq - b.seq);
508
+ let applied = false;
509
+ for (const event of sorted) {
510
+ if (event.seq <= this.state.maxSeq)
511
+ continue;
512
+ applyEvent(this.state, event);
513
+ applied = true;
514
+ }
515
+ if (!applied && this.snapshotList !== undefined)
516
+ return this.snapshotList;
517
+ this.snapshotList = snapshotOf(this.state);
518
+ return this.snapshotList;
519
+ }
520
+ /** Replace the whole stream (history reload / session switch). */
521
+ seed(messages) {
522
+ this.state = createState(messages);
523
+ this.snapshotList = undefined;
524
+ }
525
+ /** Prepend an older history page (exact seam; no overlapping seqs). */
526
+ prepend(older) {
527
+ this.state = createState([...older, ...this.state.messages]);
528
+ this.snapshotList = undefined;
529
+ }
530
+ /** Current snapshot list; a fresh copy whenever the folder changed. */
531
+ snapshot() {
532
+ if (this.snapshotList !== undefined)
533
+ return this.snapshotList;
534
+ this.snapshotList = snapshotOf(this.state);
535
+ return this.snapshotList;
536
+ }
537
+ }
538
+ /** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
539
+ function snapshotOf(state) {
540
+ const out = [...state.messages];
504
541
  let ordered = true;
505
542
  for (let index = 1; index < out.length; index += 1) {
506
543
  const prev = out[index - 1];
@@ -510,5 +547,5 @@ export function foldEvents(events, existing) {
510
547
  break;
511
548
  }
512
549
  }
513
- return ordered ? out : [...out].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
550
+ return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
514
551
  }
@@ -1,6 +1,6 @@
1
1
  /** foldEvents: message-list folding from a session event stream. */
2
2
  import { describe, expect, it } from 'vitest';
3
- import { foldEvents } from './messages.js';
3
+ import { EventFolder, foldEvents } from './messages.js';
4
4
  /** Assemble one event with an auto-incrementing seq / time. */
5
5
  function makeEvent(type, data, seq, time = seq * 1_000) {
6
6
  return { type, seq, time, data };
@@ -286,3 +286,51 @@ describe('foldEvents', () => {
286
286
  expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' });
287
287
  });
288
288
  });
289
+ describe('EventFolder incremental folding', () => {
290
+ it('matches a one-shot fold when events arrive one at a time', () => {
291
+ const stream = [
292
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
293
+ textChunk(0, 0, '你', 1),
294
+ textChunk(0, 0, '好', 2),
295
+ makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, '你好'), 3),
296
+ makeEvent('message/update', { id: 'u-1', text: '更新' }, 4),
297
+ ];
298
+ const oneShot = foldEvents(stream);
299
+ const folder = new EventFolder();
300
+ let incremental = [];
301
+ for (const event of stream)
302
+ incremental = folder.fold([event]);
303
+ expect(incremental).toEqual(oneShot);
304
+ });
305
+ it('replays and re-folds are no-ops and reuse the previous snapshot identity', () => {
306
+ const folder = new EventFolder(foldEvents([
307
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
308
+ textChunk(0, 0, '你', 1),
309
+ ]));
310
+ const first = folder.fold([textChunk(0, 0, '好', 2)]);
311
+ expect(first[first.length - 1]).toMatchObject({ text: '你好', pending: true });
312
+ // Same event again (wire replay or a double-invoked state updater): no change, same identity.
313
+ const replay = folder.fold([textChunk(0, 0, '好', 2)]);
314
+ expect(replay).toBe(first);
315
+ // A no-op batch over an already folded stream also keeps the identity.
316
+ expect(folder.fold([])).toBe(first);
317
+ });
318
+ it('prepends older pages and keeps folding live events on top', () => {
319
+ const folder = new EventFolder(foldEvents([
320
+ makeEvent('user/message', userMessageData('u-2', '第二页'), 10),
321
+ ]));
322
+ folder.prepend(foldEvents([makeEvent('user/message', userMessageData('u-1', '第一页'), 5)]));
323
+ const withLive = folder.fold([textChunk(0, 0, '新', 11)]);
324
+ expect(withLive.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11']);
325
+ // The live fold must not lose the prepended rows on later events.
326
+ const later = folder.fold([textChunk(0, 0, '续', 12)]);
327
+ expect(later.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11']);
328
+ expect(later[2]).toMatchObject({ text: '新续', pending: true });
329
+ });
330
+ it('seed replaces the whole stream', () => {
331
+ const folder = new EventFolder(foldEvents([makeEvent('user/message', userMessageData('u-1', '旧'), 0)]));
332
+ folder.seed(foldEvents([makeEvent('user/message', userMessageData('u-2', '新'), 5)]));
333
+ expect(folder.snapshot().map(message => message.id)).toEqual(['u-2']);
334
+ expect(folder.fold([textChunk(0, 0, '追加', 6)]).map(message => message.id)).toEqual(['u-2', 'assistant,0.0#6']);
335
+ });
336
+ });