@linxin666/dsh-remote-web-ui 0.1.6 → 0.1.8

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.
@@ -7,13 +7,40 @@
7
7
  * opens — no subscription handshake is needed. Frames arrive as
8
8
  * server-request envelopes whose payload is the mux frame; unknown frame
9
9
  * types are dropped so a newer host never breaks this client.
10
- * EventSource reconnects automatically.
10
+ *
11
+ * EventSource reconnects automatically — but only over a tunnel that
12
+ * actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
13
+ * Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
14
+ * HTTP works, yet the SSE connection stays open or reconnects with zero
15
+ * bytes, so no live frame ever arrives. That is a transport-layer limit of
16
+ * the tunnel, not something the host can fix. This client therefore
17
+ * degrades gracefully: once the SSE channel has silently stalled (no frame
18
+ * for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
19
+ * an error), it starts polling the open session's history over plain HTTP
20
+ * (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
21
+ * and re-emits freshly appended events as `session/event` frames through
22
+ * the same subscriber contract, so listeners (and the message fold) behave
23
+ * exactly as if the frames had arrived over SSE. When the SSE channel
24
+ * delivers again, fallback polling stops and the live stream takes over.
11
25
  */
12
26
  import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events';
27
+ import { type HistoryPage } from './api.ts';
13
28
  /** Injectable seams for tests. */
14
29
  export interface MuxClientOptions {
15
30
  /** EventSource factory (defaults to the browser EventSource). */
16
31
  sourceFactory?: (url: string) => EventSourceLike;
32
+ /**
33
+ * Fetch one history page (tail) for a session — the polling fallback's
34
+ * data source. Defaults to the mobile `session.history` RPC, which rides
35
+ * the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
36
+ */
37
+ pollLatest?: (sessionId: string) => Promise<HistoryPage>;
38
+ /** Poll cadence while SSE is stalled (default 3000 ms). */
39
+ pollIntervalMs?: number;
40
+ /** How long SSE must go without a frame before fallback kicks in (default 12000 ms). */
41
+ stallThresholdMs?: number;
42
+ /** Clock seam for tests (defaults to Date.now). */
43
+ now?: () => number;
17
44
  }
18
45
  /** The EventSource subset this client uses (browser EventSource fits). */
19
46
  export interface EventSourceLike {
@@ -26,14 +53,34 @@ export interface EventSourceLike {
26
53
  /**
27
54
  * Keep one SSE subscription open, fanning validated frames out to
28
55
  * subscribers. EventSource owns reconnection (with its own backoff); this
29
- * class only manages the subscription lifecycle.
56
+ * class only manages the subscription lifecycle, plus a polling fallback
57
+ * that keeps the open session live when the SSE channel cannot deliver.
30
58
  */
31
59
  export declare class MuxClient {
32
60
  private readonly sourceFactory;
61
+ private readonly pollLatest;
62
+ private readonly pollIntervalMs;
63
+ private readonly stallThresholdMs;
64
+ private readonly now;
33
65
  private readonly listeners;
34
66
  private source;
35
67
  private stopped;
36
68
  private readonly url;
69
+ /** The session to keep live via fallback polling (undefined = none). */
70
+ private observeSessionId;
71
+ /** Last epoch ms the SSE channel produced a frame (or the stream opened). */
72
+ private lastDataAt;
73
+ /**
74
+ * Whether the SSE channel has ever delivered a frame in this stream (a
75
+ * delivered frame proves the tunnel forwards SSE; silence alone then means
76
+ * the agent idle, not a dead channel — only an onerror re-arms fallback).
77
+ */
78
+ private sseAlive;
79
+ /** Per-session highest event seq already emitted, for poll dedup. */
80
+ private readonly pollWatermark;
81
+ private stallTimer;
82
+ private pollTimer;
83
+ private polling;
37
84
  /**
38
85
  * @param url - the mobile events endpoint (browser-relative).
39
86
  * @param options - seams.
@@ -45,8 +92,25 @@ export declare class MuxClient {
45
92
  stop(): void;
46
93
  /** Subscribe to validated frames; returns an unsubscribe function. */
47
94
  onFrame(listener: (frame: MuxFrame) => void): () => void;
95
+ /**
96
+ * Point the fallback at one open session (or `undefined` to stop it).
97
+ * While the SSE channel is stalled this client polls that session's
98
+ * history and re-emits new events as `session/event` frames.
99
+ */
100
+ observe(sessionId: string | undefined): void;
48
101
  private connect;
102
+ private startStallChecker;
103
+ private stopStallChecker;
104
+ private startPolling;
105
+ private stopPolling;
106
+ /**
107
+ * Fetch the latest history page for the observed session and re-emit any
108
+ * event above the per-session watermark as a `session/event` frame.
109
+ * Idempotent by seq: listeners (and the fold) never see a duplicate.
110
+ */
111
+ private pollTick;
49
112
  private handleMessage;
113
+ private emit;
50
114
  private closeSource;
51
115
  }
52
116
  //# sourceMappingURL=mux.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAA;AAIzE,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAA;CACjD;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACrD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,KAAK,IAAI,IAAI,CAAA;CACd;AASD;;;;GAIG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuC;IACjE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAE5B;;;OAGG;gBACS,GAAG,SAAsB,EAAE,OAAO,GAAE,gBAAqB;IAKrE,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAMb,sBAAsB;IACtB,IAAI,IAAI,IAAI;IAKZ,sEAAsE;IACtE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,WAAW;CAapB"}
1
+ {"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAA;AAGzE,OAAO,EAA2B,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpE,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAA;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAA;IACxD,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,wFAAwF;IACxF,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,mDAAmD;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACrD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,KAAK,IAAI,IAAI,CAAA;CACd;AAiBD;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAChE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6C;IACxE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuC;IACjE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAE5B,wEAAwE;IACxE,OAAO,CAAC,gBAAgB,CAAoB;IAC5C,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAI;IACtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAQ;IACxB,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,OAAO,CAAQ;IAEvB;;;OAGG;gBACS,GAAG,SAAsB,EAAE,OAAO,GAAE,gBAAqB;IASrE,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAOb,sBAAsB;IACtB,IAAI,IAAI,IAAI;IAQZ,sEAAsE;IACtE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAY5C,OAAO,CAAC,OAAO;IAqBf,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,gBAAgB;IAOxB,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,WAAW;IAQnB;;;;OAIG;YACW,QAAQ;IAsBtB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,IAAI;IAUZ,OAAO,CAAC,WAAW;CAapB"}
@@ -7,27 +7,66 @@
7
7
  * opens — no subscription handshake is needed. Frames arrive as
8
8
  * server-request envelopes whose payload is the mux frame; unknown frame
9
9
  * types are dropped so a newer host never breaks this client.
10
- * EventSource reconnects automatically.
10
+ *
11
+ * EventSource reconnects automatically — but only over a tunnel that
12
+ * actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
13
+ * Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
14
+ * HTTP works, yet the SSE connection stays open or reconnects with zero
15
+ * bytes, so no live frame ever arrives. That is a transport-layer limit of
16
+ * the tunnel, not something the host can fix. This client therefore
17
+ * degrades gracefully: once the SSE channel has silently stalled (no frame
18
+ * for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
19
+ * an error), it starts polling the open session's history over plain HTTP
20
+ * (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
21
+ * and re-emits freshly appended events as `session/event` frames through
22
+ * the same subscriber contract, so listeners (and the message fold) behave
23
+ * exactly as if the frames had arrived over SSE. When the SSE channel
24
+ * delivers again, fallback polling stops and the live stream takes over.
11
25
  */
12
26
  import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema';
13
27
  import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema';
28
+ import { history as fetchHistory } from "./api.js";
14
29
  /** Browser default source factory. */
15
30
  function browserSource(url) {
16
31
  // The DOM EventSource is structurally compatible; the `this`-typed handler
17
32
  // signatures differ, so the narrow face takes it through an adapter cast.
18
33
  return new EventSource(url);
19
34
  }
35
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
36
+ const DEFAULT_STALL_THRESHOLD_MS = 12000;
37
+ /** Poll window: enough recent events to cover a few seconds of agent output. */
38
+ const DEFAULT_POLL_PAGE_SIZE = 50;
20
39
  /**
21
40
  * Keep one SSE subscription open, fanning validated frames out to
22
41
  * subscribers. EventSource owns reconnection (with its own backoff); this
23
- * class only manages the subscription lifecycle.
42
+ * class only manages the subscription lifecycle, plus a polling fallback
43
+ * that keeps the open session live when the SSE channel cannot deliver.
24
44
  */
25
45
  export class MuxClient {
26
46
  sourceFactory;
47
+ pollLatest;
48
+ pollIntervalMs;
49
+ stallThresholdMs;
50
+ now;
27
51
  listeners = new Set();
28
52
  source;
29
53
  stopped = false;
30
54
  url;
55
+ /** The session to keep live via fallback polling (undefined = none). */
56
+ observeSessionId;
57
+ /** Last epoch ms the SSE channel produced a frame (or the stream opened). */
58
+ lastDataAt = 0;
59
+ /**
60
+ * Whether the SSE channel has ever delivered a frame in this stream (a
61
+ * delivered frame proves the tunnel forwards SSE; silence alone then means
62
+ * the agent idle, not a dead channel — only an onerror re-arms fallback).
63
+ */
64
+ sseAlive = false;
65
+ /** Per-session highest event seq already emitted, for poll dedup. */
66
+ pollWatermark = new Map();
67
+ stallTimer;
68
+ pollTimer;
69
+ polling = false;
31
70
  /**
32
71
  * @param url - the mobile events endpoint (browser-relative).
33
72
  * @param options - seams.
@@ -35,25 +74,51 @@ export class MuxClient {
35
74
  constructor(url = '/m/api/events.mux', options = {}) {
36
75
  this.url = url;
37
76
  this.sourceFactory = options.sourceFactory ?? browserSource;
77
+ this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE));
78
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
79
+ this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
80
+ this.now = options.now ?? (() => Date.now());
38
81
  }
39
82
  /** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
40
83
  start() {
41
84
  this.stopped = false;
42
- if (this.source !== undefined)
43
- return;
44
- this.connect();
85
+ this.lastDataAt = this.now();
86
+ if (this.source === undefined)
87
+ this.connect();
88
+ this.startStallChecker();
45
89
  }
46
90
  /** Close for good. */
47
91
  stop() {
48
92
  this.stopped = true;
93
+ this.stopStallChecker();
94
+ this.stopPolling();
49
95
  this.closeSource();
96
+ this.observeSessionId = undefined;
50
97
  }
51
98
  /** Subscribe to validated frames; returns an unsubscribe function. */
52
99
  onFrame(listener) {
53
100
  this.listeners.add(listener);
54
101
  return () => { this.listeners.delete(listener); };
55
102
  }
103
+ /**
104
+ * Point the fallback at one open session (or `undefined` to stop it).
105
+ * While the SSE channel is stalled this client polls that session's
106
+ * history and re-emits new events as `session/event` frames.
107
+ */
108
+ observe(sessionId) {
109
+ this.observeSessionId = sessionId;
110
+ if (sessionId === undefined) {
111
+ this.stopPolling();
112
+ return;
113
+ }
114
+ // If SSE is already stalled for this session, start patching right away.
115
+ if (!this.polling && !this.stopped && !this.sseAlive && (this.now() - this.lastDataAt) > this.stallThresholdMs) {
116
+ this.startPolling();
117
+ }
118
+ }
56
119
  connect() {
120
+ // A fresh stream starts unknown; only a delivered frame proves it works.
121
+ this.sseAlive = false;
57
122
  const source = this.sourceFactory(this.url);
58
123
  this.source = source;
59
124
  source.onmessage = (event) => {
@@ -61,11 +126,83 @@ export class MuxClient {
61
126
  };
62
127
  source.onerror = () => {
63
128
  // EventSource reconnects by itself; when we are closing, detach first
64
- // so the native reconnect cannot outlive stop().
65
- if (this.stopped && this.source === source)
129
+ // so the native reconnect cannot outlive stop(). Otherwise an error is
130
+ // a strong signal the transport is not delivering — degrade to polling.
131
+ if (this.stopped && this.source === source) {
66
132
  this.closeSource();
133
+ return;
134
+ }
135
+ this.sseAlive = false;
136
+ if (this.observeSessionId !== undefined)
137
+ this.startPolling();
67
138
  };
68
139
  }
140
+ startStallChecker() {
141
+ this.stopStallChecker();
142
+ this.stallTimer = setInterval(() => {
143
+ if (this.stopped)
144
+ return;
145
+ if (this.observeSessionId === undefined)
146
+ return;
147
+ if (this.polling)
148
+ return;
149
+ // A live SSE channel only goes silent while the agent idles; never
150
+ // poll against it. Fallback arms again only via onerror or a stream
151
+ // that has never delivered.
152
+ if (this.sseAlive)
153
+ return;
154
+ if ((this.now() - this.lastDataAt) > this.stallThresholdMs)
155
+ this.startPolling();
156
+ }, 1000);
157
+ }
158
+ stopStallChecker() {
159
+ if (this.stallTimer !== undefined) {
160
+ clearInterval(this.stallTimer);
161
+ this.stallTimer = undefined;
162
+ }
163
+ }
164
+ startPolling() {
165
+ if (this.polling || this.stopped)
166
+ return;
167
+ this.polling = true;
168
+ void this.pollTick();
169
+ this.pollTimer = setInterval(() => { void this.pollTick(); }, this.pollIntervalMs);
170
+ }
171
+ stopPolling() {
172
+ this.polling = false;
173
+ if (this.pollTimer !== undefined) {
174
+ clearInterval(this.pollTimer);
175
+ this.pollTimer = undefined;
176
+ }
177
+ }
178
+ /**
179
+ * Fetch the latest history page for the observed session and re-emit any
180
+ * event above the per-session watermark as a `session/event` frame.
181
+ * Idempotent by seq: listeners (and the fold) never see a duplicate.
182
+ */
183
+ async pollTick() {
184
+ const sessionId = this.observeSessionId;
185
+ if (sessionId === undefined) {
186
+ this.stopPolling();
187
+ return;
188
+ }
189
+ try {
190
+ const page = await this.pollLatest(sessionId);
191
+ let maxSeq = this.pollWatermark.get(sessionId) ?? -1;
192
+ for (const entry of page.events) {
193
+ const event = entry.event;
194
+ const seq = typeof event?.seq === 'number' ? event.seq : -1;
195
+ if (seq <= maxSeq)
196
+ continue;
197
+ maxSeq = seq;
198
+ this.emit({ type: 'session/event', sessionId: sessionId, event });
199
+ }
200
+ this.pollWatermark.set(sessionId, maxSeq);
201
+ }
202
+ catch {
203
+ // Transient (network, pairing, history paging); the next tick retries.
204
+ }
205
+ }
69
206
  handleMessage(data) {
70
207
  if (typeof data !== 'string' || data === '')
71
208
  return;
@@ -84,9 +221,19 @@ export class MuxClient {
84
221
  const frame = muxFrameSchema.safeParse(envelope.data.payload);
85
222
  if (!frame.success)
86
223
  return;
224
+ // A delivered frame proves the SSE channel is live (the tunnel forwards
225
+ // it) and delivers again — drop any fallback polling so the live stream
226
+ // takes over without double delivery.
227
+ this.sseAlive = true;
228
+ this.lastDataAt = this.now();
229
+ if (this.polling)
230
+ this.stopPolling();
231
+ this.emit(frame.data);
232
+ }
233
+ emit(frame) {
87
234
  for (const listener of this.listeners) {
88
235
  try {
89
- listener(frame.data);
236
+ listener(frame);
90
237
  }
91
238
  catch {
92
239
  // A throwing subscriber must not break the emit loop.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mux.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mux.test.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,141 @@
1
+ /** mux: live-event client, SSE delivery + stall-driven polling fallback. */
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { MuxClient } from "./mux.js";
4
+ /** Create an EventSource factory that records every opened source. */
5
+ function makeSources() {
6
+ const sources = [];
7
+ const factory = (url) => {
8
+ const source = {
9
+ url,
10
+ onmessage: null,
11
+ onerror: null,
12
+ closed: false,
13
+ close: () => { source.closed = true; },
14
+ };
15
+ sources.push(source);
16
+ return source;
17
+ };
18
+ return { factory, sources };
19
+ }
20
+ /** One history page whose events carry sequential ids. */
21
+ function pageOf(seqs) {
22
+ return {
23
+ hasMore: false,
24
+ events: seqs.map(seq => ({
25
+ event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
26
+ })),
27
+ };
28
+ }
29
+ /** A server-request envelope carrying one mux frame (the SSE wire shape). */
30
+ function envelopeWith(payload) {
31
+ return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload });
32
+ }
33
+ /** Options common to every test: tight clocks, injected data source. */
34
+ function baseOptions(pollLatest, factory) {
35
+ return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 };
36
+ }
37
+ describe('MuxClient polling fallback', () => {
38
+ beforeEach(() => {
39
+ vi.useFakeTimers();
40
+ });
41
+ it('does not poll while the SSE channel is fresh', async () => {
42
+ const { factory } = makeSources();
43
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
44
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
45
+ const frames = [];
46
+ client.onFrame(frame => { frames.push(frame); });
47
+ client.start();
48
+ client.observe('s1');
49
+ await vi.advanceTimersByTimeAsync(400); // well under the stall threshold
50
+ expect(pollLatest).not.toHaveBeenCalled();
51
+ expect(frames).toHaveLength(0);
52
+ client.stop();
53
+ });
54
+ it('starts polling after silence and emits appended events as session/event frames', async () => {
55
+ const { factory } = makeSources();
56
+ const pages = [pageOf([0]), pageOf([0, 1])];
57
+ const pollLatest = vi.fn(async (_sessionId) => pages.shift() ?? pageOf([]));
58
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
59
+ const frames = [];
60
+ client.onFrame(frame => { frames.push(frame); });
61
+ client.start();
62
+ client.observe('s1');
63
+ // Nothing live within a poll interval until the stall window passes.
64
+ await vi.advanceTimersByTimeAsync(400);
65
+ expect(pollLatest).not.toHaveBeenCalled();
66
+ // Past the stall threshold the first poll runs and emits seq 0.
67
+ await vi.advanceTimersByTimeAsync(700); // 1100ms total -> first stall-checker tick
68
+ expect(pollLatest).toHaveBeenCalledWith('s1');
69
+ expect(frames).toHaveLength(1);
70
+ expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' });
71
+ expect(frames[0]?.event).toMatchObject({ seq: 0 });
72
+ // The next poll emits only the appended event (seq 1), not seq 0 again.
73
+ await vi.advanceTimersByTimeAsync(400);
74
+ expect(frames).toHaveLength(2);
75
+ expect(frames[1]?.event).toMatchObject({ seq: 1 });
76
+ client.stop();
77
+ });
78
+ it('keeps the watermark so a repeated page never re-emits old events', async () => {
79
+ const { factory } = makeSources();
80
+ // Two calls return the same page: the second must emit nothing.
81
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([0, 1, 2]));
82
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
83
+ const frames = [];
84
+ client.onFrame(frame => { frames.push(frame); });
85
+ client.start();
86
+ client.observe('s1');
87
+ await vi.advanceTimersByTimeAsync(1100); // first poll -> 3 events
88
+ expect(frames).toHaveLength(3);
89
+ await vi.advanceTimersByTimeAsync(400); // second poll -> same page, nothing new
90
+ expect(frames).toHaveLength(3);
91
+ client.stop();
92
+ });
93
+ it('stops polling when observe is cleared and keeps it stopped', async () => {
94
+ const { factory } = makeSources();
95
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
96
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
97
+ client.start();
98
+ client.observe('s1');
99
+ await vi.advanceTimersByTimeAsync(1100);
100
+ expect(pollLatest).toHaveBeenCalled();
101
+ client.observe(undefined);
102
+ const callsAfterClear = pollLatest.mock.calls.length;
103
+ await vi.advanceTimersByTimeAsync(2000);
104
+ expect(pollLatest.mock.calls.length).toBe(callsAfterClear);
105
+ client.stop();
106
+ });
107
+ it('stops polling on stop(), closing any live source', async () => {
108
+ const { factory, sources } = makeSources();
109
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
110
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
111
+ client.start();
112
+ client.observe('s1');
113
+ await vi.advanceTimersByTimeAsync(1100);
114
+ expect(pollLatest).toHaveBeenCalled();
115
+ client.stop();
116
+ const callsAfterStop = pollLatest.mock.calls.length;
117
+ await vi.advanceTimersByTimeAsync(2000);
118
+ expect(pollLatest.mock.calls.length).toBe(callsAfterStop);
119
+ expect(sources[0]?.closed).toBe(true);
120
+ });
121
+ it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
122
+ const { factory, sources } = makeSources();
123
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
124
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
125
+ const frames = [];
126
+ client.onFrame(frame => { frames.push(frame); });
127
+ client.start();
128
+ client.observe('s1');
129
+ // Stall into polling.
130
+ await vi.advanceTimersByTimeAsync(1100);
131
+ expect(pollLatest).toHaveBeenCalledTimes(1);
132
+ // A live mux frame proves SSE delivers again -> fallback stops.
133
+ sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) });
134
+ await vi.advanceTimersByTimeAsync(2000);
135
+ expect(pollLatest.mock.calls.length).toBe(1); // polling stopped after the live frame
136
+ const live = frames.filter(frame => frame?.type === 'session/subscribed');
137
+ expect(live).toHaveLength(1);
138
+ expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' });
139
+ client.stop();
140
+ });
141
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,gCAwClB;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,4CAEhE;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,CAAA"}
1
+ {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,gCA+ClB;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,4CAEhE;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,CAAA"}
@@ -55,6 +55,12 @@ export function App() {
55
55
  mux.start();
56
56
  return () => { mux.stop(); };
57
57
  }, []);
58
+ // Keep the live-event client pointed at the session currently on screen so
59
+ // its polling fallback can keep that chat fresh over SSE-impairing tunnels
60
+ // (quick tunnel / Tailscale Serve do not forward Server-Sent Events).
61
+ useEffect(() => {
62
+ muxRef.current?.observe(route.kind === 'chat' ? route.session.sessionId : undefined);
63
+ }, [route]);
58
64
  const back = useCallback(() => {
59
65
  setRoute(previous => {
60
66
  if (previous.kind === 'chat')
@@ -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;AAIjE,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;AAYD,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,+BA6M/D"}
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;AAIjE,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;AAYD,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,+BA2N/D"}
@@ -100,7 +100,6 @@ export function ChatView({ session, mux, onBack }) {
100
100
  // plugin (augmentation), so the base SDK map is indexed loosely.
101
101
  const projections = page.projections?.values;
102
102
  setPermissions(parsePermissionSelect(projections?.['permissions']));
103
- scrollToBottom();
104
103
  }, (reason) => {
105
104
  if (cancelled)
106
105
  return;
@@ -134,17 +133,32 @@ export function ChatView({ session, mux, onBack }) {
134
133
  }
135
134
  });
136
135
  }, [mux, session.sessionId]);
137
- // Keep the newest content visible while streaming.
138
- useEffect(() => {
139
- if (messages.some(message => message.pending === true))
140
- scrollToBottom();
141
- }, [messages]);
142
136
  const scrollToBottom = useCallback(() => {
143
137
  const el = scrollRef.current;
144
138
  if (el === undefined)
145
139
  return;
146
140
  el.scrollTop = el.scrollHeight;
147
141
  }, []);
142
+ // Track the last message's fold key so scrolling only fires when the
143
+ // newest message actually changes (seq bump and/or pending flip). Runs
144
+ // after React has committed the render, so scrollHeight reflects the
145
+ // freshly appended content.
146
+ const lastMessageKeyRef = useRef(undefined);
147
+ // Keep the newest content visible. This covers the initial tail page (the
148
+ // effect runs after commit, fixing the stale scrollHeight from the old
149
+ // open-time scrollToBottom), live streaming chunks on the pending message,
150
+ // and finalized/appended messages. Prepending older pages via loadOlder
151
+ // leaves the last message untouched, so it never disturbs the scroll position.
152
+ useEffect(() => {
153
+ const last = messages[messages.length - 1];
154
+ if (last === undefined)
155
+ return;
156
+ const key = last.seq + ':' + (last.pending === true ? 'p' : 'f');
157
+ if (key === lastMessageKeyRef.current)
158
+ return;
159
+ lastMessageKeyRef.current = key;
160
+ scrollToBottom();
161
+ }, [messages, scrollToBottom]);
148
162
  /** Load one older page and prepend it. The fold is directional (incremental
149
163
  * tails only), so the older page folds standalone and concatenates ahead —
150
164
  * host page boundaries never cut a message, so the seam is exact. */