@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.
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  // @vitest-environment jsdom
3
3
  /** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
6
  import { ChatView } from "./ChatView.js";
7
7
  // The api module is fully mocked; App.tsx's history wrapper is overridden to
8
8
  // feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
@@ -213,3 +213,93 @@ describe('ChatView model sheet', () => {
213
213
  expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
214
214
  });
215
215
  });
216
+ describe('ChatView scrolling', () => {
217
+ // Controllable scrollHeight + a write log for the chat-scroll element. The
218
+ // accessors live on Element.prototype, so patching them here lets every
219
+ // scrollToBottom assignment drive a deterministic assertion regardless of
220
+ // when the effect runs relative to mount.
221
+ let scrollHeightMock = 0;
222
+ let scrollWrites = [];
223
+ const origScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight');
224
+ const origScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
225
+ /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
226
+ class FakeMux {
227
+ listeners = new Set();
228
+ onFrame(listener) {
229
+ this.listeners.add(listener);
230
+ return () => { this.listeners.delete(listener); };
231
+ }
232
+ emit(frame) {
233
+ for (const listener of this.listeners)
234
+ listener(frame);
235
+ }
236
+ }
237
+ beforeAll(() => {
238
+ Object.defineProperty(Element.prototype, 'scrollHeight', {
239
+ configurable: true,
240
+ get() { return scrollHeightMock; },
241
+ });
242
+ Object.defineProperty(Element.prototype, 'scrollTop', {
243
+ configurable: true,
244
+ get() { const stored = this['__scrollTop']; return typeof stored === 'number' ? stored : 0; },
245
+ set(value) { scrollWrites.push(value); this['__scrollTop'] = value; },
246
+ });
247
+ });
248
+ afterAll(() => {
249
+ if (origScrollHeight)
250
+ Object.defineProperty(Element.prototype, 'scrollHeight', origScrollHeight);
251
+ if (origScrollTop)
252
+ Object.defineProperty(Element.prototype, 'scrollTop', origScrollTop);
253
+ });
254
+ beforeEach(() => { scrollHeightMock = 0; scrollWrites = []; });
255
+ /** A final assistant/message event (non-pending) appended live after the history turn. */
256
+ const liveFinalEvent = (seq) => makeEntry('assistant/message', {
257
+ turn: 1,
258
+ step: 0,
259
+ message: { id: 'a-2', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] },
260
+ }, seq);
261
+ it('positions to the latest message when a session is opened', async () => {
262
+ scrollHeightMock = 400;
263
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
264
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
265
+ // The tail page renders, then the commit-time effect pins scrollTop to the tail.
266
+ expect(await screen.findByText('已完成修改')).toBeTruthy();
267
+ expect(scrollWrites.at(-1)).toBe(400);
268
+ });
269
+ it('auto-scrolls to the bottom when a new live message arrives', async () => {
270
+ scrollHeightMock = 400;
271
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
272
+ const mux = new FakeMux();
273
+ render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
274
+ await screen.findByText('已完成修改');
275
+ expect(scrollWrites.at(-1)).toBe(400);
276
+ // A real-time message grows content; the newly appended (non-pending) last
277
+ // message must still pull the view down to the new bottom.
278
+ scrollHeightMock = 800;
279
+ await act(async () => {
280
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
281
+ });
282
+ expect(await screen.findByText('实时新消息')).toBeTruthy();
283
+ expect(scrollWrites.at(-1)).toBe(800);
284
+ });
285
+ it('keeps the current scroll position when older messages are loaded', async () => {
286
+ scrollHeightMock = 400;
287
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), { hasMore: true }));
288
+ const mux = new FakeMux();
289
+ render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
290
+ await screen.findByText('已完成修改');
291
+ // Move to the bottom (streaming), as a stable baseline to preserve.
292
+ scrollHeightMock = 800;
293
+ await act(async () => {
294
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
295
+ });
296
+ await screen.findByText('实时新消息');
297
+ const writesBefore = scrollWrites.length;
298
+ expect(scrollWrites.at(-1)).toBe(800);
299
+ // Prepend an older page; the last message is untouched, so no re-scroll fires.
300
+ scrollHeightMock = 900;
301
+ loadHistoryMock.mockResolvedValueOnce(historyPage(turnEvents(), { hasMore: false }));
302
+ fireEvent.click(screen.getByRole('button', { name: /加载更早的消息/ }));
303
+ await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
304
+ });
305
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -0,0 +1,172 @@
1
+ /** mux: live-event client, SSE delivery + stall-driven polling fallback. */
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { MuxClient, type EventSourceLike } from './mux.ts'
4
+ import type { HistoryPage } from './api.ts'
5
+
6
+ /** A recorded fake EventSource (delivery driven by the test). */
7
+ interface FakeSource extends EventSourceLike {
8
+ url: string
9
+ closed: boolean
10
+ close: () => void
11
+ }
12
+
13
+ /** Create an EventSource factory that records every opened source. */
14
+ function makeSources(): { factory: (url: string) => EventSourceLike; sources: FakeSource[] } {
15
+ const sources: FakeSource[] = []
16
+ const factory = (url: string): EventSourceLike => {
17
+ const source: FakeSource = {
18
+ url,
19
+ onmessage: null,
20
+ onerror: null,
21
+ closed: false,
22
+ close: () => { source.closed = true },
23
+ }
24
+ sources.push(source)
25
+ return source
26
+ }
27
+ return { factory, sources }
28
+ }
29
+
30
+ /** One history page whose events carry sequential ids. */
31
+ function pageOf(seqs: readonly number[]): HistoryPage {
32
+ return {
33
+ hasMore: false,
34
+ events: seqs.map(seq => ({
35
+ event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
36
+ })),
37
+ } as unknown as HistoryPage
38
+ }
39
+
40
+ /** A server-request envelope carrying one mux frame (the SSE wire shape). */
41
+ function envelopeWith(payload: unknown): string {
42
+ return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload })
43
+ }
44
+
45
+ /** Options common to every test: tight clocks, injected data source. */
46
+ function baseOptions(pollLatest: (sessionId: string) => Promise<HistoryPage>, factory: (url: string) => EventSourceLike) {
47
+ return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 }
48
+ }
49
+
50
+ describe('MuxClient polling fallback', () => {
51
+ beforeEach(() => {
52
+ vi.useFakeTimers()
53
+ })
54
+
55
+ it('does not poll while the SSE channel is fresh', async () => {
56
+ const { factory } = makeSources()
57
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
58
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
59
+ const frames: unknown[] = []
60
+ client.onFrame(frame => { frames.push(frame) })
61
+ client.start()
62
+ client.observe('s1')
63
+ await vi.advanceTimersByTimeAsync(400) // well under the stall threshold
64
+ expect(pollLatest).not.toHaveBeenCalled()
65
+ expect(frames).toHaveLength(0)
66
+ client.stop()
67
+ })
68
+
69
+ it('starts polling after silence and emits appended events as session/event frames', async () => {
70
+ const { factory } = makeSources()
71
+ const pages = [pageOf([0]), pageOf([0, 1])]
72
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
73
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
74
+ const frames: Array<{ type: string; sessionId: string; event?: { seq: number } }> = []
75
+ client.onFrame(frame => { frames.push(frame as never) })
76
+ client.start()
77
+ client.observe('s1')
78
+
79
+ // Nothing live within a poll interval until the stall window passes.
80
+ await vi.advanceTimersByTimeAsync(400)
81
+ expect(pollLatest).not.toHaveBeenCalled()
82
+
83
+ // Past the stall threshold the first poll runs and emits seq 0.
84
+ await vi.advanceTimersByTimeAsync(700) // 1100ms total -> first stall-checker tick
85
+ expect(pollLatest).toHaveBeenCalledWith('s1')
86
+ expect(frames).toHaveLength(1)
87
+ expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
88
+ expect(frames[0]?.event).toMatchObject({ seq: 0 })
89
+
90
+ // The next poll emits only the appended event (seq 1), not seq 0 again.
91
+ await vi.advanceTimersByTimeAsync(400)
92
+ expect(frames).toHaveLength(2)
93
+ expect(frames[1]?.event).toMatchObject({ seq: 1 })
94
+ client.stop()
95
+ })
96
+
97
+ it('keeps the watermark so a repeated page never re-emits old events', async () => {
98
+ const { factory } = makeSources()
99
+ // Two calls return the same page: the second must emit nothing.
100
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0, 1, 2]))
101
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
102
+ const frames: Array<{ type: string; event?: { seq: number } }> = []
103
+ client.onFrame(frame => { frames.push(frame as never) })
104
+ client.start()
105
+ client.observe('s1')
106
+
107
+ await vi.advanceTimersByTimeAsync(1100) // first poll -> 3 events
108
+ expect(frames).toHaveLength(3)
109
+
110
+ await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
111
+ expect(frames).toHaveLength(3)
112
+ client.stop()
113
+ })
114
+
115
+ it('stops polling when observe is cleared and keeps it stopped', async () => {
116
+ const { factory } = makeSources()
117
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
118
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
119
+ client.start()
120
+ client.observe('s1')
121
+
122
+ await vi.advanceTimersByTimeAsync(1100)
123
+ expect(pollLatest).toHaveBeenCalled()
124
+
125
+ client.observe(undefined)
126
+ const callsAfterClear = pollLatest.mock.calls.length
127
+ await vi.advanceTimersByTimeAsync(2000)
128
+ expect(pollLatest.mock.calls.length).toBe(callsAfterClear)
129
+ client.stop()
130
+ })
131
+
132
+ it('stops polling on stop(), closing any live source', async () => {
133
+ const { factory, sources } = makeSources()
134
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
135
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
136
+ client.start()
137
+ client.observe('s1')
138
+
139
+ await vi.advanceTimersByTimeAsync(1100)
140
+ expect(pollLatest).toHaveBeenCalled()
141
+
142
+ client.stop()
143
+ const callsAfterStop = pollLatest.mock.calls.length
144
+ await vi.advanceTimersByTimeAsync(2000)
145
+ expect(pollLatest.mock.calls.length).toBe(callsAfterStop)
146
+ expect(sources[0]?.closed).toBe(true)
147
+ })
148
+
149
+ it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
150
+ const { factory, sources } = makeSources()
151
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
152
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
153
+ const frames: unknown[] = []
154
+ client.onFrame(frame => { frames.push(frame) })
155
+ client.start()
156
+ client.observe('s1')
157
+
158
+ // Stall into polling.
159
+ await vi.advanceTimersByTimeAsync(1100)
160
+ expect(pollLatest).toHaveBeenCalledTimes(1)
161
+
162
+ // A live mux frame proves SSE delivers again -> fallback stops.
163
+ sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
164
+
165
+ await vi.advanceTimersByTimeAsync(2000)
166
+ expect(pollLatest.mock.calls.length).toBe(1) // polling stopped after the live frame
167
+ const live = frames.filter(frame => (frame as { type?: string })?.type === 'session/subscribed')
168
+ expect(live).toHaveLength(1)
169
+ expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
170
+ client.stop()
171
+ })
172
+ })
package/src/mobile/mux.ts CHANGED
@@ -7,17 +7,44 @@
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
 
13
27
  import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
14
28
  import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
15
29
  import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
30
+ import { history as fetchHistory, type HistoryPage } from './api.ts'
16
31
 
17
32
  /** Injectable seams for tests. */
18
33
  export interface MuxClientOptions {
19
34
  /** EventSource factory (defaults to the browser EventSource). */
20
35
  sourceFactory?: (url: string) => EventSourceLike
36
+ /**
37
+ * Fetch one history page (tail) for a session — the polling fallback's
38
+ * data source. Defaults to the mobile `session.history` RPC, which rides
39
+ * the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
40
+ */
41
+ pollLatest?: (sessionId: string) => Promise<HistoryPage>
42
+ /** Poll cadence while SSE is stalled (default 3000 ms). */
43
+ pollIntervalMs?: number
44
+ /** How long SSE must go without a frame before fallback kicks in (default 12000 ms). */
45
+ stallThresholdMs?: number
46
+ /** Clock seam for tests (defaults to Date.now). */
47
+ now?: () => number
21
48
  }
22
49
 
23
50
  /** The EventSource subset this client uses (browser EventSource fits). */
@@ -34,18 +61,47 @@ function browserSource(url: string): EventSourceLike {
34
61
  return new EventSource(url) as unknown as EventSourceLike
35
62
  }
36
63
 
64
+ /** The `session/event` arm of the mux frame union. */
65
+ type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
66
+
67
+ const DEFAULT_POLL_INTERVAL_MS = 3000
68
+ const DEFAULT_STALL_THRESHOLD_MS = 12000
69
+ /** Poll window: enough recent events to cover a few seconds of agent output. */
70
+ const DEFAULT_POLL_PAGE_SIZE = 50
71
+
37
72
  /**
38
73
  * Keep one SSE subscription open, fanning validated frames out to
39
74
  * subscribers. EventSource owns reconnection (with its own backoff); this
40
- * class only manages the subscription lifecycle.
75
+ * class only manages the subscription lifecycle, plus a polling fallback
76
+ * that keeps the open session live when the SSE channel cannot deliver.
41
77
  */
42
78
  export class MuxClient {
43
79
  private readonly sourceFactory: (url: string) => EventSourceLike
80
+ private readonly pollLatest: (sessionId: string) => Promise<HistoryPage>
81
+ private readonly pollIntervalMs: number
82
+ private readonly stallThresholdMs: number
83
+ private readonly now: () => number
44
84
  private readonly listeners = new Set<(frame: MuxFrame) => void>()
45
85
  private source: EventSourceLike | undefined
46
86
  private stopped = false
47
87
  private readonly url: string
48
88
 
89
+ /** The session to keep live via fallback polling (undefined = none). */
90
+ private observeSessionId: string | undefined
91
+ /** Last epoch ms the SSE channel produced a frame (or the stream opened). */
92
+ private lastDataAt = 0
93
+ /**
94
+ * Whether the SSE channel has ever delivered a frame in this stream (a
95
+ * delivered frame proves the tunnel forwards SSE; silence alone then means
96
+ * the agent idle, not a dead channel — only an onerror re-arms fallback).
97
+ */
98
+ private sseAlive = false
99
+ /** Per-session highest event seq already emitted, for poll dedup. */
100
+ private readonly pollWatermark = new Map<string, number>()
101
+ private stallTimer: ReturnType<typeof setInterval> | undefined
102
+ private pollTimer: ReturnType<typeof setInterval> | undefined
103
+ private polling = false
104
+
49
105
  /**
50
106
  * @param url - the mobile events endpoint (browser-relative).
51
107
  * @param options - seams.
@@ -53,19 +109,27 @@ export class MuxClient {
53
109
  constructor(url = '/m/api/events.mux', options: MuxClientOptions = {}) {
54
110
  this.url = url
55
111
  this.sourceFactory = options.sourceFactory ?? browserSource
112
+ this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE))
113
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
114
+ this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS
115
+ this.now = options.now ?? (() => Date.now())
56
116
  }
57
117
 
58
118
  /** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
59
119
  start(): void {
60
120
  this.stopped = false
61
- if (this.source !== undefined) return
62
- this.connect()
121
+ this.lastDataAt = this.now()
122
+ if (this.source === undefined) this.connect()
123
+ this.startStallChecker()
63
124
  }
64
125
 
65
126
  /** Close for good. */
66
127
  stop(): void {
67
128
  this.stopped = true
129
+ this.stopStallChecker()
130
+ this.stopPolling()
68
131
  this.closeSource()
132
+ this.observeSessionId = undefined
69
133
  }
70
134
 
71
135
  /** Subscribe to validated frames; returns an unsubscribe function. */
@@ -74,7 +138,26 @@ export class MuxClient {
74
138
  return () => { this.listeners.delete(listener) }
75
139
  }
76
140
 
141
+ /**
142
+ * Point the fallback at one open session (or `undefined` to stop it).
143
+ * While the SSE channel is stalled this client polls that session's
144
+ * history and re-emits new events as `session/event` frames.
145
+ */
146
+ observe(sessionId: string | undefined): void {
147
+ this.observeSessionId = sessionId
148
+ if (sessionId === undefined) {
149
+ this.stopPolling()
150
+ return
151
+ }
152
+ // If SSE is already stalled for this session, start patching right away.
153
+ if (!this.polling && !this.stopped && !this.sseAlive && (this.now() - this.lastDataAt) > this.stallThresholdMs) {
154
+ this.startPolling()
155
+ }
156
+ }
157
+
77
158
  private connect(): void {
159
+ // A fresh stream starts unknown; only a delivered frame proves it works.
160
+ this.sseAlive = false
78
161
  const source = this.sourceFactory(this.url)
79
162
  this.source = source
80
163
  source.onmessage = (event) => {
@@ -82,8 +165,77 @@ export class MuxClient {
82
165
  }
83
166
  source.onerror = () => {
84
167
  // EventSource reconnects by itself; when we are closing, detach first
85
- // so the native reconnect cannot outlive stop().
86
- if (this.stopped && this.source === source) this.closeSource()
168
+ // so the native reconnect cannot outlive stop(). Otherwise an error is
169
+ // a strong signal the transport is not delivering — degrade to polling.
170
+ if (this.stopped && this.source === source) {
171
+ this.closeSource()
172
+ return
173
+ }
174
+ this.sseAlive = false
175
+ if (this.observeSessionId !== undefined) this.startPolling()
176
+ }
177
+ }
178
+
179
+ private startStallChecker(): void {
180
+ this.stopStallChecker()
181
+ this.stallTimer = setInterval(() => {
182
+ if (this.stopped) return
183
+ if (this.observeSessionId === undefined) return
184
+ if (this.polling) return
185
+ // A live SSE channel only goes silent while the agent idles; never
186
+ // poll against it. Fallback arms again only via onerror or a stream
187
+ // that has never delivered.
188
+ if (this.sseAlive) return
189
+ if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
190
+ }, 1000)
191
+ }
192
+
193
+ private stopStallChecker(): void {
194
+ if (this.stallTimer !== undefined) {
195
+ clearInterval(this.stallTimer)
196
+ this.stallTimer = undefined
197
+ }
198
+ }
199
+
200
+ private startPolling(): void {
201
+ if (this.polling || this.stopped) return
202
+ this.polling = true
203
+ void this.pollTick()
204
+ this.pollTimer = setInterval(() => { void this.pollTick() }, this.pollIntervalMs)
205
+ }
206
+
207
+ private stopPolling(): void {
208
+ this.polling = false
209
+ if (this.pollTimer !== undefined) {
210
+ clearInterval(this.pollTimer)
211
+ this.pollTimer = undefined
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Fetch the latest history page for the observed session and re-emit any
217
+ * event above the per-session watermark as a `session/event` frame.
218
+ * Idempotent by seq: listeners (and the fold) never see a duplicate.
219
+ */
220
+ private async pollTick(): Promise<void> {
221
+ const sessionId = this.observeSessionId
222
+ if (sessionId === undefined) {
223
+ this.stopPolling()
224
+ return
225
+ }
226
+ try {
227
+ const page = await this.pollLatest(sessionId)
228
+ let maxSeq = this.pollWatermark.get(sessionId) ?? -1
229
+ for (const entry of page.events) {
230
+ const event = entry.event
231
+ const seq = typeof event?.seq === 'number' ? event.seq : -1
232
+ if (seq <= maxSeq) continue
233
+ maxSeq = seq
234
+ this.emit({ type: 'session/event', sessionId: sessionId as SessionEventFrame['sessionId'], event } as SessionEventFrame)
235
+ }
236
+ this.pollWatermark.set(sessionId, maxSeq)
237
+ } catch {
238
+ // Transient (network, pairing, history paging); the next tick retries.
87
239
  }
88
240
  }
89
241
 
@@ -101,9 +253,19 @@ export class MuxClient {
101
253
  if (!envelope.success) return
102
254
  const frame = muxFrameSchema.safeParse(envelope.data.payload)
103
255
  if (!frame.success) return
256
+ // A delivered frame proves the SSE channel is live (the tunnel forwards
257
+ // it) and delivers again — drop any fallback polling so the live stream
258
+ // takes over without double delivery.
259
+ this.sseAlive = true
260
+ this.lastDataAt = this.now()
261
+ if (this.polling) this.stopPolling()
262
+ this.emit(frame.data)
263
+ }
264
+
265
+ private emit(frame: MuxFrame): void {
104
266
  for (const listener of this.listeners) {
105
267
  try {
106
- listener(frame.data)
268
+ listener(frame)
107
269
  } catch {
108
270
  // A throwing subscriber must not break the emit loop.
109
271
  }
@@ -123,4 +285,4 @@ export class MuxClient {
123
285
  }
124
286
  }
125
287
  }
126
- }
288
+ }
@@ -94,6 +94,13 @@ export function App() {
94
94
  return () => { mux.stop() }
95
95
  }, [])
96
96
 
97
+ // Keep the live-event client pointed at the session currently on screen so
98
+ // its polling fallback can keep that chat fresh over SSE-impairing tunnels
99
+ // (quick tunnel / Tailscale Serve do not forward Server-Sent Events).
100
+ useEffect(() => {
101
+ muxRef.current?.observe(route.kind === 'chat' ? route.session.sessionId : undefined)
102
+ }, [route])
103
+
97
104
  const back = useCallback(() => {
98
105
  setRoute(previous => {
99
106
  if (previous.kind === 'chat') return { kind: 'sessions', workspace: previous.workspace }
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  /** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
5
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
6
6
  import { ChatView } from './ChatView.tsx'
7
7
  import { type SessionView } from './App.tsx'
@@ -230,7 +230,6 @@ describe('ChatView model sheet', () => {
230
230
  expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' })
231
231
  })
232
232
  })
233
-
234
233
  it('explains a transport 403 on the model channel as a stale host', async () => {
235
234
  loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
236
235
  modelsMock.mockRejectedValue(new Error('HTTP 403'))
@@ -241,3 +240,99 @@ describe('ChatView model sheet', () => {
241
240
  expect(await screen.findByText(/重启 dsh web/)).toBeTruthy()
242
241
  })
243
242
  })
243
+
244
+ describe('ChatView scrolling', () => {
245
+ // Controllable scrollHeight + a write log for the chat-scroll element. The
246
+ // accessors live on Element.prototype, so patching them here lets every
247
+ // scrollToBottom assignment drive a deterministic assertion regardless of
248
+ // when the effect runs relative to mount.
249
+ let scrollHeightMock = 0
250
+ let scrollWrites: number[] = []
251
+
252
+ const origScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight')
253
+ const origScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
254
+
255
+ /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
256
+ class FakeMux {
257
+ listeners = new Set<(frame: unknown) => void>()
258
+ onFrame(listener: (frame: unknown) => void): () => void {
259
+ this.listeners.add(listener)
260
+ return () => { this.listeners.delete(listener) }
261
+ }
262
+ emit(frame: unknown): void {
263
+ for (const listener of this.listeners) listener(frame)
264
+ }
265
+ }
266
+
267
+ beforeAll(() => {
268
+ Object.defineProperty(Element.prototype, 'scrollHeight', {
269
+ configurable: true,
270
+ get() { return scrollHeightMock },
271
+ })
272
+ Object.defineProperty(Element.prototype, 'scrollTop', {
273
+ configurable: true,
274
+ get(this: Element) { const stored = (this as unknown as Record<string, unknown>)['__scrollTop']; return typeof stored === 'number' ? stored : 0 },
275
+ set(this: Element, value: number) { scrollWrites.push(value); (this as unknown as Record<string, unknown>)['__scrollTop'] = value },
276
+ })
277
+ })
278
+ afterAll(() => {
279
+ if (origScrollHeight) Object.defineProperty(Element.prototype, 'scrollHeight', origScrollHeight)
280
+ if (origScrollTop) Object.defineProperty(Element.prototype, 'scrollTop', origScrollTop)
281
+ })
282
+
283
+ beforeEach(() => { scrollHeightMock = 0; scrollWrites = [] })
284
+
285
+ /** A final assistant/message event (non-pending) appended live after the history turn. */
286
+ const liveFinalEvent = (seq: number) => makeEntry('assistant/message', {
287
+ turn: 1,
288
+ step: 0,
289
+ message: { id: 'a-2', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] },
290
+ }, seq)
291
+
292
+ it('positions to the latest message when a session is opened', async () => {
293
+ scrollHeightMock = 400
294
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
295
+ render(<ChatView session={session} onBack={() => {}} />)
296
+ // The tail page renders, then the commit-time effect pins scrollTop to the tail.
297
+ expect(await screen.findByText('已完成修改')).toBeTruthy()
298
+ expect(scrollWrites.at(-1)).toBe(400)
299
+ })
300
+
301
+ it('auto-scrolls to the bottom when a new live message arrives', async () => {
302
+ scrollHeightMock = 400
303
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
304
+ const mux = new FakeMux()
305
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
306
+ await screen.findByText('已完成修改')
307
+ expect(scrollWrites.at(-1)).toBe(400)
308
+ // A real-time message grows content; the newly appended (non-pending) last
309
+ // message must still pull the view down to the new bottom.
310
+ scrollHeightMock = 800
311
+ await act(async () => {
312
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event })
313
+ })
314
+ expect(await screen.findByText('实时新消息')).toBeTruthy()
315
+ expect(scrollWrites.at(-1)).toBe(800)
316
+ })
317
+
318
+ it('keeps the current scroll position when older messages are loaded', async () => {
319
+ scrollHeightMock = 400
320
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), { hasMore: true }))
321
+ const mux = new FakeMux()
322
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
323
+ await screen.findByText('已完成修改')
324
+ // Move to the bottom (streaming), as a stable baseline to preserve.
325
+ scrollHeightMock = 800
326
+ await act(async () => {
327
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event })
328
+ })
329
+ await screen.findByText('实时新消息')
330
+ const writesBefore = scrollWrites.length
331
+ expect(scrollWrites.at(-1)).toBe(800)
332
+ // Prepend an older page; the last message is untouched, so no re-scroll fires.
333
+ scrollHeightMock = 900
334
+ loadHistoryMock.mockResolvedValueOnce(historyPage(turnEvents(), { hasMore: false }))
335
+ fireEvent.click(screen.getByRole('button', { name: /加载更早的消息/ }))
336
+ await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore) })
337
+ })
338
+ })