@linxin666/dsh-remote-web-ui 0.1.12 → 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 (64) 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 +169 -98
  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/locales.d.ts +8 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/locales.js +8 -0
  20. package/lib/types/client/update-api.d.ts +10 -0
  21. package/lib/types/client/update-api.d.ts.map +1 -1
  22. package/lib/types/client/update-api.js +22 -2
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/index.js +7 -1
  26. package/lib/types/mobile/api.d.ts +9 -2
  27. package/lib/types/mobile/api.d.ts.map +1 -1
  28. package/lib/types/mobile/api.js +7 -3
  29. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  30. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  31. package/lib/types/mobile/mobile-styles.js +3 -0
  32. package/lib/types/mobile/views/App.d.ts +3 -1
  33. package/lib/types/mobile/views/App.d.ts.map +1 -1
  34. package/lib/types/mobile/views/App.js +18 -3
  35. package/lib/types/mobile/views/ChatView.d.ts +6 -0
  36. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  37. package/lib/types/mobile/views/ChatView.js +95 -7
  38. package/lib/types/mobile/views/ChatView.test.js +161 -3
  39. package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
  40. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  41. package/lib/types/mobile/views/WorkspaceView.js +9 -2
  42. package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
  43. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
  44. package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
  45. package/lib/types/mobile-api.d.ts +2 -0
  46. package/lib/types/mobile-api.d.ts.map +1 -1
  47. package/lib/types/mobile-api.js +17 -2
  48. package/package.json +1 -11
  49. package/src/client/PluginSettingsCard.tsx +30 -5
  50. package/src/client/RemoteSettingsCard.tsx +18 -0
  51. package/src/client/UpdateEntry.tsx +10 -2
  52. package/src/client/deep-link.ts +10 -4
  53. package/src/client/locales.ts +8 -0
  54. package/src/client/settings-card.module.css +139 -144
  55. package/src/client/update-api.ts +22 -2
  56. package/src/index.ts +14 -1
  57. package/src/mobile/api.ts +14 -2
  58. package/src/mobile/mobile-styles.ts +3 -0
  59. package/src/mobile/views/App.tsx +22 -3
  60. package/src/mobile/views/ChatView.test.tsx +185 -3
  61. package/src/mobile/views/ChatView.tsx +102 -8
  62. package/src/mobile/views/WorkspaceView.test.tsx +66 -0
  63. package/src/mobile/views/WorkspaceView.tsx +11 -2
  64. package/src/mobile-api.ts +20 -2
@@ -6,14 +6,34 @@
6
6
 
7
7
  import type { UpdateRunResult, UpdateStatus } from '../update.ts'
8
8
 
9
+ /**
10
+ * Error thrown when the update status probe fails: carries the HTTP status
11
+ * so the panel can tell "the update route is not mounted" (404 — the host
12
+ * process runs an older plugin build) apart from real network failures.
13
+ */
14
+ export class UpdateStatusError extends Error {
15
+ /** HTTP status of the failed response (0 when the fetch never returned). */
16
+ readonly status: number
17
+ constructor(status: number) {
18
+ super('update status unavailable (HTTP ' + String(status) + ')')
19
+ this.status = status
20
+ }
21
+ }
22
+
9
23
  /**
10
24
  * Probe the update status: install mode, owning profile, and the
11
25
  * current-vs-latest comparison for every family package.
12
26
  * @returns the status snapshot.
13
27
  */
14
28
  export async function fetchUpdateStatus(): Promise<UpdateStatus> {
15
- const response = await fetch('/api/update/status')
16
- if (!response.ok) throw new Error('update status unavailable')
29
+ let response: Response
30
+ try {
31
+ response = await fetch('/api/update/status')
32
+ } catch {
33
+ // Network-level failure: nothing came back at all.
34
+ throw new UpdateStatusError(0)
35
+ }
36
+ if (!response.ok) throw new UpdateStatusError(response.status)
17
37
  return await response.json() as UpdateStatus
18
38
  }
19
39
 
package/src/index.ts CHANGED
@@ -96,6 +96,13 @@ export interface Config {
96
96
  * tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
97
97
  */
98
98
  autoTunnel?: boolean
99
+ /**
100
+ * Mobile composer behavior: when true (default), a plain Enter in the
101
+ * phone chat textarea sends the prompt and Shift+Enter inserts a newline.
102
+ * When false, plain Enter inserts a newline and only the send button
103
+ * sends (Shift+Enter keeps inserting a newline).
104
+ */
105
+ mobileEnterToSend?: boolean
99
106
  /** Master switch for the plugin (browser half + host pairing surfaces). */
100
107
  enabled?: boolean
101
108
  }
@@ -108,6 +115,7 @@ export const Config: z<Config> = z.object({
108
115
  requirePairingForLan: z.boolean().default(true),
109
116
  publicBaseUrl: z.string(),
110
117
  autoTunnel: z.boolean().default(false),
118
+ mobileEnterToSend: z.boolean().default(true),
111
119
  enabled: z.boolean().default(true),
112
120
  })
113
121
 
@@ -130,6 +138,7 @@ const DEFAULTS: ResolvedConfig = {
130
138
  requirePairingForLan: true,
131
139
  publicBaseUrl: undefined,
132
140
  autoTunnel: false,
141
+ mobileEnterToSend: true,
133
142
  enabled: true,
134
143
  }
135
144
 
@@ -147,6 +156,7 @@ export function apply(ctx: Context, config?: Config): void {
147
156
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
148
157
  publicBaseUrl: config?.publicBaseUrl,
149
158
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
159
+ mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
150
160
  enabled: config?.enabled ?? DEFAULTS.enabled,
151
161
  }
152
162
  // The live source the pairing service and the gate read: the settings
@@ -163,6 +173,7 @@ export function apply(ctx: Context, config?: Config): void {
163
173
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
164
174
  publicBaseUrl: value.publicBaseUrl,
165
175
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
176
+ mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
166
177
  enabled: value.enabled ?? DEFAULTS.enabled,
167
178
  }
168
179
  }
@@ -267,7 +278,9 @@ export function apply(ctx: Context, config?: Config): void {
267
278
  const routes = [
268
279
  ...makeRoutes({ service, lanAddresses }),
269
280
  ...makeMobileRoutes(),
270
- ...(apiProxy !== undefined ? makeMobileApiRoutes({ service, apiProxy }) : []),
281
+ ...(apiProxy !== undefined
282
+ ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
283
+ : []),
271
284
  ...updateRoutes,
272
285
  ]
273
286
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
package/src/mobile/api.ts CHANGED
@@ -35,12 +35,23 @@ export interface HistoryPage {
35
35
  projections?: SessionProjectionsBlock
36
36
  }
37
37
 
38
+ /** Read-only display preferences the plugin answers locally on `/m/api`. */
39
+ export interface MobilePreferences {
40
+ /** Plain Enter sends the drafted prompt (false: Enter inserts a newline). */
41
+ mobileEnterToSend: boolean
42
+ }
43
+
38
44
  /** The workspace roster (session ids come back per workspace). */
39
45
  export async function listWorkspaces(): Promise<WorkspaceView[]> {
40
46
  const { items } = await callUnary<{ items: WorkspaceView[] }>('workspace.list', {})
41
47
  return items
42
48
  }
43
49
 
50
+ /** Read-only mobile display preferences (answered by the plugin, not the host proxy). */
51
+ export async function fetchMobilePreferences(): Promise<MobilePreferences> {
52
+ return await callUnary<MobilePreferences>('mobile.preferences', {})
53
+ }
54
+
44
55
  /** One session.list page; omit the cursor for the first page. */
45
56
  export async function listSessions(cursor?: string): Promise<SessionPage> {
46
57
  return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
@@ -56,17 +67,18 @@ export async function createSession(
56
67
  return await callUnary<CreatedSession>('session.create', options)
57
68
  }
58
69
 
59
- /** One history window; omit beforeSeq for the tail page. */
70
+ /** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
60
71
  export async function history(
61
72
  sessionId: string,
62
73
  beforeSeq?: number,
63
74
  maxMessages = 30,
75
+ signal?: AbortSignal,
64
76
  ): Promise<HistoryPage> {
65
77
  return await callUnary<HistoryPage>('session.history', {
66
78
  sessionId,
67
79
  maxMessages,
68
80
  ...(beforeSeq !== undefined ? { beforeSeq } : {}),
69
- })
81
+ }, signal)
70
82
  }
71
83
 
72
84
  /** Send one text prompt (queued: the agent picks it up in order). */
@@ -765,6 +765,9 @@ body {
765
765
  min-width: 0;
766
766
  min-height: 40px;
767
767
  max-height: 120px;
768
+ /* Grow with the content (still bounded by min/max-height); browsers
769
+ without field-sizing support ignore this line and keep the old size. */
770
+ field-sizing: content;
768
771
  padding: 9px 12px;
769
772
  border: 1px solid var(--m-border);
770
773
  border-radius: 10px;
@@ -42,6 +42,12 @@ export interface RenderMessage {
42
42
  toolSummary?: string
43
43
  }
44
44
 
45
+ /** Read the optional workspace target carried from the pairing QR flow. */
46
+ export function mobileWorkspaceTarget(search: string): string | undefined {
47
+ const value = new URLSearchParams(search).get('workspace')
48
+ return value === null || value === '' ? undefined : value
49
+ }
50
+
45
51
  /** Map a list row to the surface model; the title comes from projections when present. */
46
52
  export function toSessionView(item: {
47
53
  sessionId: string
@@ -83,6 +89,9 @@ export function formatTime(epochMs: number): string {
83
89
  */
84
90
  export function App() {
85
91
  const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
92
+ const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
93
+ () => mobileWorkspaceTarget(window.location.search),
94
+ )
86
95
  const muxRef = useRef<MuxClient | undefined>(undefined)
87
96
 
88
97
  // The mux stream lives for the page lifetime: session events keep the
@@ -113,10 +122,20 @@ export function App() {
113
122
  setRoute({ kind: 'chat', session, workspace })
114
123
  }, [])
115
124
 
125
+ const openWorkspace = useCallback((workspace: WorkspaceRow) => {
126
+ if (initialWorkspaceId !== undefined) {
127
+ const url = new URL(window.location.href)
128
+ url.searchParams.delete('workspace')
129
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
130
+ setInitialWorkspaceId(undefined)
131
+ }
132
+ setRoute({ kind: 'sessions', workspace })
133
+ }, [initialWorkspaceId])
134
+
116
135
  return (
117
136
  <div className="mobile">
118
137
  {route.kind === 'workspaces'
119
- ? <WorkspaceRoster onPick={(workspace) => { setRoute({ kind: 'sessions', workspace }) }} />
138
+ ? <WorkspaceRoster initialWorkspaceId={initialWorkspaceId} onPick={openWorkspace} />
120
139
  : route.kind === 'sessions'
121
140
  ? (
122
141
  <SessionListView
@@ -152,8 +171,8 @@ export function staleHostHint(message: string): string | undefined {
152
171
  }
153
172
 
154
173
  /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
155
- export function loadHistory(sessionId: string, beforeSeq?: number) {
156
- return fetchHistory(sessionId, beforeSeq)
174
+ export function loadHistory(sessionId: string, beforeSeq?: number, signal?: AbortSignal) {
175
+ return fetchHistory(sessionId, beforeSeq, undefined, signal)
157
176
  }
158
177
 
159
178
  export { listSessions, listWorkspaces, prompt }
@@ -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
  }