@linxin666/dsh-remote-web-ui 0.1.12 → 0.1.14
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.
- package/README.i18n.yaml +6 -0
- package/README.md +17 -3
- package/README.zh.md +171 -0
- package/lib/client.js +171 -100
- package/lib/client.js.map +1 -1
- package/lib/index.js +338 -31
- package/lib/mobile.js +109 -33
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +10 -2
- package/lib/types/client/deep-link.js +10 -5
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +10 -2
- package/lib/types/client/update-api.d.ts +10 -0
- package/lib/types/client/update-api.d.ts.map +1 -1
- package/lib/types/client/update-api.js +22 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +7 -1
- package/lib/types/mobile/api.d.ts +9 -2
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +7 -3
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +3 -0
- package/lib/types/mobile/views/App.d.ts +3 -1
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +18 -3
- package/lib/types/mobile/views/ChatView.d.ts +6 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +95 -7
- package/lib/types/mobile/views/ChatView.test.js +161 -3
- package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +9 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
- package/lib/types/mobile-api.d.ts +2 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +17 -2
- package/lib/types/update.d.ts +5 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +122 -26
- package/package.json +1 -11
- package/src/client/PluginSettingsCard.tsx +30 -5
- package/src/client/RemoteSettingsCard.tsx +18 -0
- package/src/client/UpdateEntry.tsx +10 -2
- package/src/client/deep-link.ts +10 -4
- package/src/client/locales.ts +10 -2
- package/src/client/settings-card.module.css +139 -144
- package/src/client/update-api.ts +22 -2
- package/src/index.ts +14 -1
- package/src/mobile/api.ts +14 -2
- package/src/mobile/mobile-styles.ts +3 -0
- package/src/mobile/views/App.tsx +22 -3
- package/src/mobile/views/ChatView.test.tsx +185 -3
- package/src/mobile/views/ChatView.tsx +102 -8
- package/src/mobile/views/WorkspaceView.test.tsx +66 -0
- package/src/mobile/views/WorkspaceView.tsx +11 -2
- package/src/mobile-api.ts +20 -2
- package/src/update.ts +116 -26
|
@@ -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
|
-
|
|
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 () => {
|
|
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
|
-
|
|
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=
|
|
281
|
-
enterKeyHint=
|
|
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
|
-
|
|
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)
|