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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.i18n.yaml +6 -0
  2. package/README.md +12 -1
  3. package/README.zh.md +171 -0
  4. package/lib/client.js +170 -99
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +224 -4
  7. package/lib/mobile.js +109 -33
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +3 -2
  11. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  12. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  13. package/lib/types/client/RemoteSettingsCard.js +3 -1
  14. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  15. package/lib/types/client/UpdateEntry.js +10 -2
  16. package/lib/types/client/deep-link.js +10 -5
  17. package/lib/types/client/index.d.ts +13 -1
  18. package/lib/types/client/index.d.ts.map +1 -1
  19. package/lib/types/client/index.js +2 -1
  20. package/lib/types/client/locales.d.ts +8 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -0
  23. package/lib/types/client/update-api.d.ts +10 -0
  24. package/lib/types/client/update-api.d.ts.map +1 -1
  25. package/lib/types/client/update-api.js +22 -2
  26. package/lib/types/index.d.ts +7 -0
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +7 -1
  29. package/lib/types/mobile/api.d.ts +9 -2
  30. package/lib/types/mobile/api.d.ts.map +1 -1
  31. package/lib/types/mobile/api.js +7 -3
  32. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  33. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  34. package/lib/types/mobile/mobile-styles.js +3 -0
  35. package/lib/types/mobile/views/App.d.ts +3 -1
  36. package/lib/types/mobile/views/App.d.ts.map +1 -1
  37. package/lib/types/mobile/views/App.js +18 -3
  38. package/lib/types/mobile/views/ChatView.d.ts +6 -0
  39. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  40. package/lib/types/mobile/views/ChatView.js +95 -7
  41. package/lib/types/mobile/views/ChatView.test.js +161 -3
  42. package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +9 -2
  45. package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
  46. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
  47. package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
  48. package/lib/types/mobile-api.d.ts +2 -0
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +17 -2
  51. package/package.json +2 -12
  52. package/src/client/PluginSettingsCard.tsx +30 -5
  53. package/src/client/RemoteSettingsCard.tsx +18 -0
  54. package/src/client/UpdateEntry.tsx +10 -2
  55. package/src/client/deep-link.ts +10 -4
  56. package/src/client/index.ts +15 -2
  57. package/src/client/locales.ts +8 -0
  58. package/src/client/settings-card.module.css +139 -144
  59. package/src/client/update-api.ts +22 -2
  60. package/src/index.ts +14 -1
  61. package/src/mobile/api.ts +14 -2
  62. package/src/mobile/mobile-styles.ts +3 -0
  63. package/src/mobile/views/App.tsx +22 -3
  64. package/src/mobile/views/ChatView.test.tsx +185 -3
  65. package/src/mobile/views/ChatView.tsx +102 -8
  66. package/src/mobile/views/WorkspaceView.test.tsx +66 -0
  67. package/src/mobile/views/WorkspaceView.tsx +11 -2
  68. package/src/mobile-api.ts +20 -2
@@ -3,7 +3,7 @@
3
3
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
4
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
- import { ChatView } from './ChatView.tsx'
6
+ import { ChatView, MAX_TAIL_BUFFER_EVENTS } from './ChatView.tsx'
7
7
  import { type SessionView } from './App.tsx'
8
8
  import type { HistoryPage } from '../api.ts'
9
9
  import type { WireEvent } from '../messages.ts'
@@ -11,6 +11,7 @@ import type { WireEvent } from '../messages.ts'
11
11
  // The api module is fully mocked; App.tsx's history wrapper is overridden to
12
12
  // feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
13
13
  vi.mock('../api.ts', () => ({
14
+ fetchMobilePreferences: vi.fn(),
14
15
  models: vi.fn(),
15
16
  selectModel: vi.fn(),
16
17
  sendCommand: vi.fn(),
@@ -23,8 +24,8 @@ vi.mock('./App.tsx', async importOriginal => {
23
24
  prompt: vi.fn(async () => {}),
24
25
  }
25
26
  })
26
- import { models, selectModel, sendCommand } from '../api.ts'
27
- import { loadHistory } from './App.tsx'
27
+ import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
28
+ import { loadHistory, prompt } from './App.tsx'
28
29
 
29
30
  const session: SessionView = {
30
31
  sessionId: 's-1',
@@ -67,12 +68,16 @@ function turnEvents(): Array<{ event: WireEvent }> {
67
68
  ]
68
69
  }
69
70
 
71
+ const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
70
72
  const modelsMock = vi.mocked(models)
71
73
  const selectModelMock = vi.mocked(selectModel)
72
74
  const sendCommandMock = vi.mocked(sendCommand)
73
75
  const loadHistoryMock = vi.mocked(loadHistory)
76
+ const promptMock = vi.mocked(prompt)
74
77
 
75
78
  beforeEach(() => {
79
+ fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true })
80
+ promptMock.mockResolvedValue(undefined)
76
81
  modelsMock.mockResolvedValue({
77
82
  current: { provider: 'fx', model: 'fx-1' },
78
83
  routable: true,
@@ -95,6 +100,7 @@ beforeEach(() => {
95
100
  afterEach(() => {
96
101
  cleanup()
97
102
  vi.clearAllMocks()
103
+ vi.restoreAllMocks()
98
104
  })
99
105
 
100
106
  describe('ChatView message folds', () => {
@@ -187,6 +193,115 @@ describe('ChatView message folds', () => {
187
193
  })
188
194
  })
189
195
 
196
+ describe('ChatView initial-load race', () => {
197
+ /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
198
+ class FakeMux {
199
+ listeners = new Set<(frame: unknown) => void>()
200
+ onFrame(listener: (frame: unknown) => void): () => void {
201
+ this.listeners.add(listener)
202
+ return () => { this.listeners.delete(listener) }
203
+ }
204
+ emit(frame: unknown): void {
205
+ for (const listener of this.listeners) listener(frame)
206
+ }
207
+ }
208
+
209
+ it('keeps live events that arrive while the tail page is still loading', async () => {
210
+ let resolveHistory: (page: HistoryPage) => void = () => {}
211
+ loadHistoryMock.mockReturnValue(new Promise<HistoryPage>((resolve) => { resolveHistory = resolve }))
212
+ const mux = new FakeMux()
213
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
214
+
215
+ // A live turn starts before the snapshot resolves: chunk, tool call, final.
216
+ await act(async () => {
217
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', text: '正在' } }, 6).event })
218
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('tool/call', { turn: 1, step: 0, callId: 'c9', name: 'bash', arguments: '{"cmd":"ls"}' }, 7).event })
219
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/message', { turn: 1, step: 0, message: { id: 'a-9', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] } }, 8).event })
220
+ })
221
+ // The snapshot predates those events; resolving it must not drop them.
222
+ await act(async () => { resolveHistory(historyPage(turnEvents())) })
223
+
224
+ expect(await screen.findByText('实时新消息')).toBeTruthy()
225
+ // The history turn's tool disclosure plus the live one both render.
226
+ expect((await screen.findAllByRole('button', { name: /工具/ })).length).toBe(2)
227
+ })
228
+
229
+ it('caps the tail-load live buffer and re-pulls the history tail after an overflow', async () => {
230
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
231
+ let resolveHistory: (page: HistoryPage) => void = () => {}
232
+ loadHistoryMock
233
+ .mockReturnValueOnce(new Promise<HistoryPage>((resolve) => { resolveHistory = resolve }))
234
+ // The overflow follow-up load resolves a page newer than the buffered burst.
235
+ .mockResolvedValueOnce(historyPage([
236
+ makeEntry('assistant/message', {
237
+ id: 'a-refetch',
238
+ role: 'assistant',
239
+ content: [{ type: 'text', text: '补拉恢复' }],
240
+ }, 700),
241
+ ]))
242
+ const mux = new FakeMux()
243
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
244
+
245
+ // 501 live final messages arrive before the snapshot resolves: the first one
246
+ // is dropped by the 500-event cap, the remaining 500 stay buffered.
247
+ await act(async () => {
248
+ for (let index = 0; index <= MAX_TAIL_BUFFER_EVENTS; index++) {
249
+ mux.emit({
250
+ type: 'session/event',
251
+ sessionId: 's-1',
252
+ event: makeEntry('assistant/message', {
253
+ id: `a-burst-${index}`,
254
+ role: 'assistant',
255
+ content: [{ type: 'text', text: `突发消息 ${index}` }],
256
+ }, 100 + index).event,
257
+ })
258
+ }
259
+ })
260
+
261
+ // The overflow is logged exactly once, and the cap mentions the limit.
262
+ expect(warnSpy).toHaveBeenCalledTimes(1)
263
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain(String(MAX_TAIL_BUFFER_EVENTS))
264
+
265
+ await act(async () => { resolveHistory(historyPage(turnEvents())) })
266
+
267
+ // The capped buffer keeps the render bounded: the dropped oldest burst
268
+ // message is gone, while the newest buffered one survives the snapshot fold.
269
+ expect(await screen.findByText('突发消息 500')).toBeTruthy()
270
+ expect(screen.queryByText('突发消息 0')).toBeNull()
271
+
272
+ // The overflow triggered exactly one follow-up tail load, still carrying the
273
+ // same abort signal as the initial load.
274
+ await waitFor(() => { expect(loadHistoryMock).toHaveBeenCalledTimes(2) })
275
+ expect(loadHistoryMock.mock.calls[1]?.[0]).toBe('s-1')
276
+ expect(loadHistoryMock.mock.calls[1]?.[1]).toBeUndefined()
277
+ expect(loadHistoryMock.mock.calls[1]?.[2]).toBeInstanceOf(AbortSignal)
278
+ expect(loadHistoryMock.mock.calls[1]?.[2]).toBe(loadHistoryMock.mock.calls[0]?.[2])
279
+
280
+ // The re-pulled page folds into the already-rendered messages.
281
+ expect(await screen.findByText('补拉恢复')).toBeTruthy()
282
+ })
283
+
284
+ it('passes an AbortSignal to loadHistory and aborts it on unmount', async () => {
285
+ let capturedSignal: AbortSignal | undefined
286
+ loadHistoryMock.mockImplementation((_sessionId, _beforeSeq, signal) => {
287
+ capturedSignal = signal
288
+ return Promise.resolve(historyPage(turnEvents()))
289
+ })
290
+ const view = render(<ChatView session={session} onBack={() => {}} />)
291
+
292
+ expect(await screen.findByText('已完成修改')).toBeTruthy()
293
+ expect(loadHistoryMock).toHaveBeenCalledTimes(1)
294
+ expect(loadHistoryMock.mock.calls[0]?.[0]).toBe('s-1')
295
+ expect(loadHistoryMock.mock.calls[0]?.[1]).toBeUndefined()
296
+ expect(loadHistoryMock.mock.calls[0]?.[2]).toBeInstanceOf(AbortSignal)
297
+ expect(capturedSignal).toBeInstanceOf(AbortSignal)
298
+ expect(capturedSignal?.aborted).toBe(false)
299
+
300
+ view.unmount()
301
+ expect(capturedSignal?.aborted).toBe(true)
302
+ })
303
+ })
304
+
190
305
  describe('ChatView model sheet', () => {
191
306
  it('labels the toolbar chip with the current model and selects a new one', async () => {
192
307
  loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
@@ -241,6 +356,73 @@ describe('ChatView model sheet', () => {
241
356
  })
242
357
  })
243
358
 
359
+ describe('ChatView composer', () => {
360
+ const inputBox = (): HTMLTextAreaElement => screen.getByRole('textbox') as HTMLTextAreaElement
361
+
362
+ /** Dispatch one keydown through the React tree and return the real event. */
363
+ const pressEnter = (input: HTMLTextAreaElement, shiftKey = false): KeyboardEvent => {
364
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, shiftKey })
365
+ input.dispatchEvent(event)
366
+ return event
367
+ }
368
+
369
+ it('sends on Enter by default and keeps Shift+Enter inserting a newline', async () => {
370
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
371
+ render(<ChatView session={session} onBack={() => {}} />)
372
+ await screen.findByText('已完成修改')
373
+
374
+ const input = inputBox()
375
+ expect(input.getAttribute('enterKeyHint')).toBe('send')
376
+ expect(input.getAttribute('placeholder')).toContain('Enter 发送')
377
+
378
+ fireEvent.change(input, { target: { value: '第一行' } })
379
+ const enter = pressEnter(input)
380
+ expect(enter.defaultPrevented).toBe(true)
381
+ await waitFor(() => {
382
+ expect(promptMock).toHaveBeenCalledWith('s-1', '第一行')
383
+ })
384
+
385
+ // Shift+Enter stays a newline gesture and never sends.
386
+ promptMock.mockClear()
387
+ const shifted = pressEnter(input, true)
388
+ expect(shifted.defaultPrevented).toBe(false)
389
+ expect(promptMock).not.toHaveBeenCalled()
390
+ })
391
+
392
+ it('inserts a newline on Enter and sends only from the button when the preference is false', async () => {
393
+ fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: false })
394
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
395
+ render(<ChatView session={session} onBack={() => {}} />)
396
+ await screen.findByText('已完成修改')
397
+
398
+ const input = inputBox()
399
+ await waitFor(() => { expect(input.getAttribute('enterKeyHint')).toBe('enter') })
400
+ expect(input.getAttribute('placeholder')).not.toContain('Enter 发送')
401
+
402
+ // The handler no longer prevents Enter, so the browser's default inserts
403
+ // a newline (emulated here through the controlled value) and no send fires.
404
+ fireEvent.change(input, { target: { value: '第一行' } })
405
+ const enter = pressEnter(input)
406
+ expect(enter.defaultPrevented).toBe(false)
407
+ fireEvent.change(input, { target: { value: '第一行\n' } })
408
+ expect(input.value).toBe('第一行\n')
409
+ expect(promptMock).not.toHaveBeenCalled()
410
+
411
+ // The send button still sends the full multi-line draft.
412
+ fireEvent.change(input, { target: { value: '第一行\n第二行' } })
413
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
414
+ await waitFor(() => {
415
+ expect(promptMock).toHaveBeenCalledWith('s-1', '第一行\n第二行')
416
+ })
417
+
418
+ // Shift+Enter keeps inserting a newline in either mode.
419
+ promptMock.mockClear()
420
+ const shifted = pressEnter(input, true)
421
+ expect(shifted.defaultPrevented).toBe(false)
422
+ expect(promptMock).not.toHaveBeenCalled()
423
+ })
424
+ })
425
+
244
426
  describe('ChatView scrolling', () => {
245
427
  // Controllable scrollHeight + a write log for the chat-scroll element. The
246
428
  // accessors live on Element.prototype, so patching them here lets every
@@ -16,7 +16,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
16
16
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
17
  import { loadHistory, prompt, type SessionView } from './App.tsx'
18
18
  import { errorText, formatTime, staleHostHint } from './App.tsx'
19
- import { models, selectModel, sendCommand } from '../api.ts'
19
+ import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
20
20
  import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
21
21
  import { MuxClient } from '../mux.ts'
22
22
  import { ThemeToggle } from '../theme-toggle.tsx'
@@ -29,6 +29,13 @@ export interface ChatViewProps {
29
29
  onBack(): void
30
30
  }
31
31
 
32
+ /**
33
+ * Hard cap on live events buffered while the initial history tail page is in
34
+ * flight. Beyond this the oldest buffered event is dropped and a follow-up
35
+ * history tail re-pull closes the seam.
36
+ */
37
+ export const MAX_TAIL_BUFFER_EVENTS = 500
38
+
32
39
  /** Extract the raw event from one history entry (the fold consumes events only). */
33
40
  function eventOf(entry: { event: WireEvent }): WireEvent {
34
41
  return entry.event
@@ -101,6 +108,18 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
101
108
  const [sending, setSending] = useState(false)
102
109
  const scrollRef = useRef<HTMLDivElement | undefined>(undefined)
103
110
  const pendingRef = useRef(false)
111
+ /**
112
+ * True while the initial tail page is in flight. Live events arriving in
113
+ * that window go to {@link liveBufferRef} instead of the message list: the
114
+ * tail load replaces the list wholesale, so a directly folded event would
115
+ * flash once, be discarded by the snapshot, and then be skipped forever by
116
+ * the seq watermark.
117
+ */
118
+ const tailLoadingRef = useRef(true)
119
+ /** Live session events buffered while the initial tail page loads. */
120
+ const liveBufferRef = useRef<WireEvent[]>([])
121
+ /** True once the live buffer hit its cap (oldest events were dropped). */
122
+ const liveBufferOverflowRef = useRef(false)
104
123
 
105
124
  /** The session's permission select (absent = capability not composed). */
106
125
  const [permissions, setPermissions] = useState<PermissionSelectValue | undefined>(undefined)
@@ -108,17 +127,49 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
108
127
  const [currentModel, setCurrentModel] = useState<{ provider: string; model: string; reasoningEffort?: string } | undefined>(undefined)
109
128
  /** Which bottom sheet is open. */
110
129
  const [sheet, setSheet] = useState<'model' | 'permission' | null>(null)
130
+ /**
131
+ * Composer preference from the plugin's host settings (default true keeps
132
+ * the legacy Enter-to-send behavior until the preference loads).
133
+ */
134
+ const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
135
+
136
+ // Read-only mobile display preferences ride the plugin's local
137
+ // `/m/api` method; a failure keeps the default (Enter sends).
138
+ useEffect(() => {
139
+ let cancelled = false
140
+ void fetchMobilePreferences().then(
141
+ (preferences) => {
142
+ if (!cancelled) setMobileEnterToSend(preferences.mobileEnterToSend !== false)
143
+ },
144
+ () => { /* keep the default */ },
145
+ )
146
+ return () => { cancelled = true }
147
+ }, [])
111
148
 
112
149
  // Tail page on open (content loads only when the session is opened).
113
150
  useEffect(() => {
114
151
  let cancelled = false
152
+ const controller = new AbortController()
153
+ // A stuck history load must not keep the chat empty (or the live buffer
154
+ // growing) forever: abort it and surface the transport error.
155
+ const timeout = setTimeout(() => {
156
+ controller.abort(new DOMException('history load timed out', 'TimeoutError'))
157
+ }, 15_000)
158
+ tailLoadingRef.current = true
159
+ liveBufferRef.current = []
160
+ liveBufferOverflowRef.current = false
115
161
  setLoading(true)
116
162
  setError(undefined)
117
163
  setMessages([])
118
- void loadHistory(session.sessionId).then(
164
+ void loadHistory(session.sessionId, undefined, controller.signal).then(
119
165
  (page) => {
120
166
  if (cancelled) return
121
- setMessages(foldEvents(page.events.map(eventOf)))
167
+ // Buffered live events re-fold on top of the snapshot; the watermark
168
+ // drops any the snapshot already includes, so nothing is lost or doubled.
169
+ const buffered = liveBufferRef.current
170
+ liveBufferRef.current = []
171
+ tailLoadingRef.current = false
172
+ setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))))
122
173
  setHasOlder(page.hasMore)
123
174
  setLoading(false)
124
175
  // The history-tail projection baseline seeds the permission picker.
@@ -126,9 +177,31 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
126
177
  // plugin (augmentation), so the base SDK map is indexed loosely.
127
178
  const projections = page.projections?.values as Record<string, unknown> | undefined
128
179
  setPermissions(parsePermissionSelect(projections?.['permissions']))
180
+ // The buffer overflowed while waiting (oldest events were dropped), so
181
+ // re-pull the freshest history page to close the gap on top of what is
182
+ // already rendered. Best-effort: a failure here only logs, it must not
183
+ // replace the loaded state with an error.
184
+ if (liveBufferOverflowRef.current) {
185
+ void loadHistory(session.sessionId, undefined, controller.signal).then(
186
+ (fresh) => {
187
+ if (cancelled) return
188
+ setMessages(previous => foldEvents(fresh.events.map(eventOf), previous))
189
+ liveBufferOverflowRef.current = false
190
+ },
191
+ (reason: unknown) => {
192
+ if (cancelled) return
193
+ console.warn('history tail re-pull after live buffer overflow failed', reason)
194
+ },
195
+ )
196
+ }
129
197
  },
130
198
  (reason: unknown) => {
131
199
  if (cancelled) return
200
+ // Load failed: flush the buffer so the live stream still renders.
201
+ const buffered = liveBufferRef.current
202
+ liveBufferRef.current = []
203
+ tailLoadingRef.current = false
204
+ if (buffered.length > 0) setMessages(foldEvents(buffered))
132
205
  setError(errorText(reason))
133
206
  setLoading(false)
134
207
  },
@@ -141,7 +214,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
141
214
  },
142
215
  () => { /* chip falls back to a plain label */ },
143
216
  )
144
- return () => { cancelled = true }
217
+ return () => {
218
+ cancelled = true
219
+ clearTimeout(timeout)
220
+ controller.abort()
221
+ }
145
222
  }, [session.sessionId])
146
223
 
147
224
  // Live frames: fold session events for this session in as they arrive.
@@ -150,7 +227,24 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
150
227
  return mux.onFrame((frame: MuxFrame) => {
151
228
  if (frame.type === 'session/event') {
152
229
  if (frame.sessionId !== session.sessionId) return
153
- setMessages(previous => foldEvents([frame.event as WireEvent], previous))
230
+ const event = frame.event as WireEvent
231
+ if (tailLoadingRef.current) {
232
+ if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
233
+ // Bound the tail-load window: drop the oldest buffered event and
234
+ // remember that a follow-up history re-pull is needed. Warn once
235
+ // per load, not for every subsequent overflow.
236
+ liveBufferRef.current.shift()
237
+ if (!liveBufferOverflowRef.current) {
238
+ console.warn(
239
+ `history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`,
240
+ )
241
+ liveBufferOverflowRef.current = true
242
+ }
243
+ }
244
+ liveBufferRef.current.push(event)
245
+ return
246
+ }
247
+ setMessages(previous => foldEvents([event], previous))
154
248
  return
155
249
  }
156
250
  // Live projection pushes keep the permission picker current.
@@ -277,11 +371,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
277
371
  className="chat-input"
278
372
  rows={1}
279
373
  value={input}
280
- placeholder="输入消息,Enter 发送…"
281
- enterKeyHint="send"
374
+ placeholder={mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…'}
375
+ enterKeyHint={mobileEnterToSend ? 'send' : 'enter'}
282
376
  onChange={(event) => { setInput(event.target.value) }}
283
377
  onKeyDown={(event) => {
284
- if (event.key === 'Enter' && !event.shiftKey) {
378
+ if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
285
379
  event.preventDefault()
286
380
  void send()
287
381
  }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ /** Mobile workspace landing: roster rendering and QR deep-link selection. */
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
5
+ import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
6
+ import { mobileWorkspaceTarget } from './App.tsx'
7
+ import { WorkspaceView } from './WorkspaceView.tsx'
8
+
9
+ vi.mock('../api.ts', () => ({
10
+ listWorkspaces: vi.fn(),
11
+ }))
12
+ import { listWorkspaces } from '../api.ts'
13
+
14
+ const listWorkspacesMock = vi.mocked(listWorkspaces)
15
+ const workspaces: WorkspaceRow[] = [
16
+ {
17
+ workspaceId: 'ws-1' as never,
18
+ path: '/tmp/first',
19
+ title: 'First',
20
+ sessionIds: [] as never,
21
+ createdAt: '2026-01-01T00:00:00.000Z',
22
+ updatedAt: '2026-01-01T00:00:00.000Z',
23
+ },
24
+ {
25
+ workspaceId: 'ws-2' as never,
26
+ path: '/tmp/second',
27
+ title: 'Second',
28
+ sessionIds: [] as never,
29
+ createdAt: '2026-01-01T00:00:00.000Z',
30
+ updatedAt: '2026-01-01T00:00:00.000Z',
31
+ },
32
+ ]
33
+
34
+ afterEach(() => {
35
+ cleanup()
36
+ vi.clearAllMocks()
37
+ })
38
+
39
+ describe('mobile workspace deep link', () => {
40
+ it('reads a non-empty workspace target from the query', () => {
41
+ expect(mobileWorkspaceTarget('?workspace=ws-2')).toBe('ws-2')
42
+ expect(mobileWorkspaceTarget('?workspace=')).toBeUndefined()
43
+ expect(mobileWorkspaceTarget('')).toBeUndefined()
44
+ })
45
+
46
+ it('opens the targeted workspace as soon as the roster loads', async () => {
47
+ listWorkspacesMock.mockResolvedValue(workspaces)
48
+ const onPick = vi.fn()
49
+
50
+ render(<WorkspaceView initialWorkspaceId="ws-2" onPick={onPick} />)
51
+
52
+ await waitFor(() => expect(onPick).toHaveBeenCalledWith(workspaces[1]))
53
+ expect(onPick).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('falls back to the roster when the target no longer exists', async () => {
57
+ listWorkspacesMock.mockResolvedValue(workspaces)
58
+ const onPick = vi.fn()
59
+
60
+ render(<WorkspaceView initialWorkspaceId="missing" onPick={onPick} />)
61
+
62
+ expect(await screen.findByText('First')).toBeTruthy()
63
+ expect(await screen.findByText('Second')).toBeTruthy()
64
+ expect(onPick).not.toHaveBeenCalled()
65
+ })
66
+ })
@@ -13,6 +13,8 @@ import { ThemeToggle } from '../theme-toggle.tsx'
13
13
 
14
14
  /** Props for the workspace roster. */
15
15
  export interface WorkspaceViewProps {
16
+ /** Workspace carried by the pairing link; opened after the roster loads. */
17
+ initialWorkspaceId?: string
16
18
  /** Open one workspace's session list. */
17
19
  onPick(workspace: WorkspaceRow): void
18
20
  }
@@ -22,7 +24,7 @@ export interface WorkspaceViewProps {
22
24
  * @param props - the pick action.
23
25
  * @returns the roster.
24
26
  */
25
- export function WorkspaceView({ onPick }: WorkspaceViewProps) {
27
+ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
26
28
  const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
27
29
  const [error, setError] = useState<string | undefined>(undefined)
28
30
 
@@ -31,6 +33,13 @@ export function WorkspaceView({ onPick }: WorkspaceViewProps) {
31
33
  void listWorkspaces().then(
32
34
  (rows) => {
33
35
  if (cancelled) return
36
+ const target = initialWorkspaceId === undefined
37
+ ? undefined
38
+ : rows.find(workspace => workspace.workspaceId === initialWorkspaceId)
39
+ if (target !== undefined) {
40
+ onPick(target)
41
+ return
42
+ }
34
43
  setItems(rows)
35
44
  },
36
45
  (reason: unknown) => {
@@ -39,7 +48,7 @@ export function WorkspaceView({ onPick }: WorkspaceViewProps) {
39
48
  },
40
49
  )
41
50
  return () => { cancelled = true }
42
- }, [])
51
+ }, [initialWorkspaceId, onPick])
43
52
 
44
53
  if (error !== undefined) {
45
54
  return (
package/src/mobile-api.ts CHANGED
@@ -40,6 +40,13 @@ const MOBILE_ALLOWLIST = new Set([
40
40
  'session.rename',
41
41
  ])
42
42
 
43
+ /**
44
+ * Locally answered display-preference method (the phone's read-only
45
+ * surface preferences; never proxied to the host ApiProxy and never a
46
+ * settings-domain write).
47
+ */
48
+ const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
49
+
43
50
  /** One session.list page (thin phones load incrementally). */
44
51
  const SESSION_PAGE_SIZE = 20
45
52
 
@@ -69,6 +76,8 @@ export interface MobileApiDeps {
69
76
  service: PairingService
70
77
  /** The host ApiProxy service (injected by the plugin). */
71
78
  apiProxy: ApiProxy
79
+ /** The resolved mobile composer preference (live per request). */
80
+ mobileEnterToSend: () => boolean
72
81
  }
73
82
 
74
83
  /** Mobile API route paths. */
@@ -87,7 +96,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
87
96
  * @returns the routes to register on webServer.
88
97
  */
89
98
  export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
90
- const { service, apiProxy } = deps
99
+ const { service, apiProxy, mobileEnterToSend } = deps
91
100
 
92
101
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
93
102
  const gateOk = (req: IncomingMessage): boolean => {
@@ -116,7 +125,8 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
116
125
  return
117
126
  }
118
127
  const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length)
119
- if (!MOBILE_ALLOWLIST.has(method)) {
128
+ const local = method === MOBILE_PREFERENCES_METHOD
129
+ if (!MOBILE_ALLOWLIST.has(method) && !local) {
120
130
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
121
131
  return
122
132
  }
@@ -133,6 +143,14 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
133
143
  writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing rpcId' } })
134
144
  return
135
145
  }
146
+ if (local) {
147
+ writeJson(res, 200, {
148
+ type: 'server-response',
149
+ rpcId,
150
+ result: { ok: true, value: { mobileEnterToSend: mobileEnterToSend() } },
151
+ })
152
+ return
153
+ }
136
154
  try {
137
155
  const response = await dispatch(apiProxy, method, parsed?.payload, rpcId)
138
156
  writeJson(res, 200, response)