@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.6

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.
@@ -118,11 +118,19 @@ export declare function foldEvents(events: readonly WireEvent[], existing?: read
118
118
  * Incremental folder for one message stream. Live chat folds one event at a
119
119
  * time; rebuilding the five index maps by scanning every message per event
120
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.
121
+ * indexes alive across folds, applies each event in (near-)O(1) map
122
+ * operations, and returns the previous snapshot identity unchanged when
123
+ * nothing applied, so React skips the re-render entirely. Replayed events
124
+ * are no-ops: the maxSeq watermark advanced by the first application skips
125
+ * them, which also makes a double-invoked React state updater harmless.
126
+ *
127
+ * Cost contract: every fold that applies at least one event rebuilds the
128
+ * snapshot as one fresh O(messages) array copy (React needs the new
129
+ * identity), and replacement paths locate rows by an O(messages) identity
130
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
131
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
132
+ * id-to-index map was measured and deliberately rejected: at realistic
133
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
126
134
  */
127
135
  export declare class EventFolder {
128
136
  private state;
@@ -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;AAyeD;;;;;;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;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
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;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
@@ -490,11 +490,19 @@ export function foldEvents(events, existing) {
490
490
  * Incremental folder for one message stream. Live chat folds one event at a
491
491
  * time; rebuilding the five index maps by scanning every message per event
492
492
  * made that path O(n) per event (O(n * events) per turn). A folder keeps the
493
- * indexes alive across folds, applies each event in O(1) map operations, and
494
- * returns the previous snapshot identity unchanged when nothing applied, so
495
- * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
496
- * watermark advanced by the first application skips them, which also makes a
497
- * double-invoked React state updater harmless.
493
+ * indexes alive across folds, applies each event in (near-)O(1) map
494
+ * operations, and returns the previous snapshot identity unchanged when
495
+ * nothing applied, so React skips the re-render entirely. Replayed events
496
+ * are no-ops: the maxSeq watermark advanced by the first application skips
497
+ * them, which also makes a double-invoked React state updater harmless.
498
+ *
499
+ * Cost contract: every fold that applies at least one event rebuilds the
500
+ * snapshot as one fresh O(messages) array copy (React needs the new
501
+ * identity), and replacement paths locate rows by an O(messages) identity
502
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
503
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
504
+ * id-to-index map was measured and deliberately rejected: at realistic
505
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
498
506
  */
499
507
  export class EventFolder {
500
508
  state;
@@ -25,6 +25,13 @@ export interface ChatViewProps {
25
25
  * history tail re-pull closes the seam.
26
26
  */
27
27
  export declare const MAX_TAIL_BUFFER_EVENTS = 500;
28
+ /**
29
+ * Cadence for folding buffered live events into the message list. Streaming
30
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
31
+ * and re-render work per second without visible lag, and keeps working in
32
+ * background tabs where requestAnimationFrame is paused.
33
+ */
34
+ export declare const FOLD_FLUSH_MS = 50;
28
35
  /** One switchable permission preset (the `permissions` projection shape). */
29
36
  export interface PermissionOption {
30
37
  value: string;
@@ -1 +1 @@
1
- {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAqCzC,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BAoe/D;AA2GD;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AA6F5C,eAAO,MAAM,eAAe,OAAO,CAAA"}
1
+ {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,KAAK,CAAA;AAqC/B,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BA2gB/D;AA2GD;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AA6F5C,eAAO,MAAM,eAAe,OAAO,CAAA"}
@@ -24,6 +24,13 @@ import { ThemeToggle } from "../theme-toggle.js";
24
24
  * history tail re-pull closes the seam.
25
25
  */
26
26
  export const MAX_TAIL_BUFFER_EVENTS = 500;
27
+ /**
28
+ * Cadence for folding buffered live events into the message list. Streaming
29
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
30
+ * and re-render work per second without visible lag, and keeps working in
31
+ * background tabs where requestAnimationFrame is paused.
32
+ */
33
+ export const FOLD_FLUSH_MS = 50;
27
34
  /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
28
35
  const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls';
29
36
  /** localStorage key for the injected-system-message display toggle. */
@@ -121,6 +128,14 @@ export function ChatView({ session, mux, onBack }) {
121
128
  const liveBufferRef = useRef([]);
122
129
  /** Incremental folder for this session's stream (indexes stay hot across events). */
123
130
  const folderRef = useRef(undefined);
131
+ /**
132
+ * Live events buffered between fold flushes. Folding every single event
133
+ * costs one O(messages) snapshot copy per event; flushing at a fixed
134
+ * cadence folds each burst once while keeping streaming text fluid.
135
+ */
136
+ const foldBufferRef = useRef([]);
137
+ /** Timer id of the scheduled fold flush, if any. */
138
+ const foldFlushTimerRef = useRef(undefined);
124
139
  /** True once the live buffer hit its cap (oldest events were dropped). */
125
140
  const liveBufferOverflowRef = useRef(false);
126
141
  /** The session's permission select (absent = capability not composed). */
@@ -238,7 +253,22 @@ export function ChatView({ session, mux, onBack }) {
238
253
  useEffect(() => {
239
254
  if (mux === undefined)
240
255
  return;
241
- return mux.onFrame((frame) => {
256
+ /** Fold every buffered live event in one batch and publish the snapshot. */
257
+ const flushFoldBuffer = () => {
258
+ if (foldFlushTimerRef.current !== undefined) {
259
+ clearTimeout(foldFlushTimerRef.current);
260
+ foldFlushTimerRef.current = undefined;
261
+ }
262
+ const events = foldBufferRef.current;
263
+ if (events.length === 0)
264
+ return;
265
+ foldBufferRef.current = [];
266
+ setMessages(previous => {
267
+ const folder = folderRef.current;
268
+ return folder === undefined ? foldEvents(events, previous) : folder.fold(events);
269
+ });
270
+ };
271
+ const unsubscribe = mux.onFrame((frame) => {
242
272
  if (frame.type === 'session/event') {
243
273
  if (frame.sessionId !== session.sessionId)
244
274
  return;
@@ -264,10 +294,17 @@ export function ChatView({ session, mux, onBack }) {
264
294
  liveBufferRef.current.push(event);
265
295
  return;
266
296
  }
267
- setMessages(previous => {
268
- const folder = folderRef.current;
269
- return folder === undefined ? foldEvents([event], previous) : folder.fold([event]);
270
- });
297
+ foldBufferRef.current.push(event);
298
+ // Only chunk streams batch on the flush cadence; every other event
299
+ // (final message, turn close, user echo, update/delete) flushes the
300
+ // buffer synchronously so settled UI states never wait for a timer.
301
+ const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk';
302
+ if (!isChunk) {
303
+ flushFoldBuffer();
304
+ }
305
+ else if (foldFlushTimerRef.current === undefined) {
306
+ foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS);
307
+ }
271
308
  return;
272
309
  }
273
310
  // Live projection pushes keep the permission picker current.
@@ -307,6 +344,17 @@ export function ChatView({ session, mux, onBack }) {
307
344
  return;
308
345
  }
309
346
  });
347
+ return () => {
348
+ unsubscribe();
349
+ // Drop the pending flush: its events belong to the session (or
350
+ // component instance) being torn down, and the next session reseeds
351
+ // its folder from a fresh history tail.
352
+ if (foldFlushTimerRef.current !== undefined) {
353
+ clearTimeout(foldFlushTimerRef.current);
354
+ foldFlushTimerRef.current = undefined;
355
+ }
356
+ foldBufferRef.current = [];
357
+ };
310
358
  }, [mux, session.sessionId]);
311
359
  // Weak-network polling fallback: when the assistant is running, poll for
312
360
  // pending approvals/questions every 1.5 s so the phone can act even if the
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  */
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import { act, cleanup, render, screen } from '@testing-library/react';
12
- import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from "./ChatView.js";
12
+ import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from "./ChatView.js";
13
13
  import { renderMarkdown } from "../markdown.js";
14
14
  // The api module is fully mocked; App.tsx's history wrapper is overridden to
15
15
  // feed a fixed empty history page, its pure helpers stay real.
@@ -134,8 +134,13 @@ describe('ChatView streaming markdown throttle', () => {
134
134
  '第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
135
135
  ];
136
136
  const full = parts.join('');
137
- // The first chunk mounts the pending message; the mount render parses it.
138
- await act(async () => { chunk(mux, parts[0] ?? '', 6); });
137
+ // The first chunk mounts the pending message on the next fold flush
138
+ // (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
139
+ // render parses it.
140
+ await act(async () => {
141
+ chunk(mux, parts[0] ?? '', 6);
142
+ vi.advanceTimersByTime(FOLD_FLUSH_MS + 5);
143
+ });
139
144
  const afterMount = renderMarkdownMock.mock.calls.length;
140
145
  expect(afterMount).toBeGreaterThanOrEqual(1);
141
146
  // A burst of further chunks does not re-parse per chunk: at most one
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.3.5",
4
+ "version": "0.3.6",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -611,11 +611,19 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
611
611
  * Incremental folder for one message stream. Live chat folds one event at a
612
612
  * time; rebuilding the five index maps by scanning every message per event
613
613
  * made that path O(n) per event (O(n * events) per turn). A folder keeps the
614
- * indexes alive across folds, applies each event in O(1) map operations, and
615
- * returns the previous snapshot identity unchanged when nothing applied, so
616
- * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
617
- * watermark advanced by the first application skips them, which also makes a
618
- * double-invoked React state updater harmless.
614
+ * indexes alive across folds, applies each event in (near-)O(1) map
615
+ * operations, and returns the previous snapshot identity unchanged when
616
+ * nothing applied, so React skips the re-render entirely. Replayed events
617
+ * are no-ops: the maxSeq watermark advanced by the first application skips
618
+ * them, which also makes a double-invoked React state updater harmless.
619
+ *
620
+ * Cost contract: every fold that applies at least one event rebuilds the
621
+ * snapshot as one fresh O(messages) array copy (React needs the new
622
+ * identity), and replacement paths locate rows by an O(messages) identity
623
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
624
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
625
+ * id-to-index map was measured and deliberately rejected: at realistic
626
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
619
627
  */
620
628
  export class EventFolder {
621
629
  private state: FoldState
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
10
  import { act, cleanup, render, screen } from '@testing-library/react'
11
- import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
11
+ import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from './ChatView.tsx'
12
12
  import { type SessionView } from './App.tsx'
13
13
  import { renderMarkdown } from '../markdown.ts'
14
14
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
@@ -150,8 +150,13 @@ describe('ChatView streaming markdown throttle', () => {
150
150
  ]
151
151
  const full = parts.join('')
152
152
 
153
- // The first chunk mounts the pending message; the mount render parses it.
154
- await act(async () => { chunk(mux, parts[0] ?? '', 6) })
153
+ // The first chunk mounts the pending message on the next fold flush
154
+ // (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
155
+ // render parses it.
156
+ await act(async () => {
157
+ chunk(mux, parts[0] ?? '', 6)
158
+ vi.advanceTimersByTime(FOLD_FLUSH_MS + 5)
159
+ })
155
160
  const afterMount = renderMarkdownMock.mock.calls.length
156
161
  expect(afterMount).toBeGreaterThanOrEqual(1)
157
162
 
@@ -38,6 +38,14 @@ export interface ChatViewProps {
38
38
  */
39
39
  export const MAX_TAIL_BUFFER_EVENTS = 500
40
40
 
41
+ /**
42
+ * Cadence for folding buffered live events into the message list. Streaming
43
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
44
+ * and re-render work per second without visible lag, and keeps working in
45
+ * background tabs where requestAnimationFrame is paused.
46
+ */
47
+ export const FOLD_FLUSH_MS = 50
48
+
41
49
  /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
42
50
  const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls'
43
51
  /** localStorage key for the injected-system-message display toggle. */
@@ -147,6 +155,14 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
147
155
  const liveBufferRef = useRef<WireEvent[]>([])
148
156
  /** Incremental folder for this session's stream (indexes stay hot across events). */
149
157
  const folderRef = useRef<EventFolder | undefined>(undefined)
158
+ /**
159
+ * Live events buffered between fold flushes. Folding every single event
160
+ * costs one O(messages) snapshot copy per event; flushing at a fixed
161
+ * cadence folds each burst once while keeping streaming text fluid.
162
+ */
163
+ const foldBufferRef = useRef<WireEvent[]>([])
164
+ /** Timer id of the scheduled fold flush, if any. */
165
+ const foldFlushTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
150
166
  /** True once the live buffer hit its cap (oldest events were dropped). */
151
167
  const liveBufferOverflowRef = useRef(false)
152
168
 
@@ -273,7 +289,21 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
273
289
  // Live frames: fold session events for this session in as they arrive.
274
290
  useEffect(() => {
275
291
  if (mux === undefined) return
276
- return mux.onFrame((frame: MuxFrame) => {
292
+ /** Fold every buffered live event in one batch and publish the snapshot. */
293
+ const flushFoldBuffer = (): void => {
294
+ if (foldFlushTimerRef.current !== undefined) {
295
+ clearTimeout(foldFlushTimerRef.current)
296
+ foldFlushTimerRef.current = undefined
297
+ }
298
+ const events = foldBufferRef.current
299
+ if (events.length === 0) return
300
+ foldBufferRef.current = []
301
+ setMessages(previous => {
302
+ const folder = folderRef.current
303
+ return folder === undefined ? foldEvents(events, previous) : folder.fold(events)
304
+ })
305
+ }
306
+ const unsubscribe = mux.onFrame((frame: MuxFrame) => {
277
307
  if (frame.type === 'session/event') {
278
308
  if (frame.sessionId !== session.sessionId) return
279
309
  const event = frame.event as WireEvent
@@ -298,10 +328,16 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
298
328
  liveBufferRef.current.push(event)
299
329
  return
300
330
  }
301
- setMessages(previous => {
302
- const folder = folderRef.current
303
- return folder === undefined ? foldEvents([event], previous) : folder.fold([event])
304
- })
331
+ foldBufferRef.current.push(event)
332
+ // Only chunk streams batch on the flush cadence; every other event
333
+ // (final message, turn close, user echo, update/delete) flushes the
334
+ // buffer synchronously so settled UI states never wait for a timer.
335
+ const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk'
336
+ if (!isChunk) {
337
+ flushFoldBuffer()
338
+ } else if (foldFlushTimerRef.current === undefined) {
339
+ foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS)
340
+ }
305
341
  return
306
342
  }
307
343
  // Live projection pushes keep the permission picker current.
@@ -342,6 +378,17 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
342
378
  return
343
379
  }
344
380
  })
381
+ return () => {
382
+ unsubscribe()
383
+ // Drop the pending flush: its events belong to the session (or
384
+ // component instance) being torn down, and the next session reseeds
385
+ // its folder from a fresh history tail.
386
+ if (foldFlushTimerRef.current !== undefined) {
387
+ clearTimeout(foldFlushTimerRef.current)
388
+ foldFlushTimerRef.current = undefined
389
+ }
390
+ foldBufferRef.current = []
391
+ }
345
392
  }, [mux, session.sessionId])
346
393
 
347
394
  // Weak-network polling fallback: when the assistant is running, poll for