@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.1

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
4
+ import { RpcTransportError } from '../rpc.ts'
5
+
6
+ const api = vi.hoisted(() => ({
7
+ fetchMobilePreferences: vi.fn(),
8
+ history: vi.fn(),
9
+ listSessions: vi.fn(),
10
+ listWorkspaces: vi.fn(),
11
+ prompt: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('../api.ts', () => api)
15
+ vi.mock('../mux.ts', () => ({
16
+ MuxClient: class {
17
+ start(): void {}
18
+ stop(): void {}
19
+ observe(): void {}
20
+ },
21
+ }))
22
+ vi.mock('./WorkspaceView.tsx', () => ({ WorkspaceView: () => <div>workspace-ready</div> }))
23
+ vi.mock('./SessionListView.tsx', () => ({ SessionListView: () => <div>sessions-ready</div> }))
24
+ vi.mock('./ChatView.tsx', () => ({ ChatView: () => <div>chat-ready</div> }))
25
+
26
+ import { App, mobilePairStateForError } from './App.tsx'
27
+
28
+ afterEach(() => {
29
+ cleanup()
30
+ vi.clearAllMocks()
31
+ })
32
+
33
+ describe('mobile paired-device gate', () => {
34
+ it('distinguishes a missing paired cookie from a transport outage', () => {
35
+ expect(mobilePairStateForError(new RpcTransportError('HTTP 403'))).toBe('unpaired')
36
+ expect(mobilePairStateForError(new RpcTransportError('HTTP 503'))).toBe('unavailable')
37
+ expect(mobilePairStateForError(new Error('offline'))).toBe('unavailable')
38
+ })
39
+
40
+ it('keeps a valid paired context usable after a failed QR token', async () => {
41
+ api.fetchMobilePreferences.mockResolvedValue({ mobileEnterToSend: true })
42
+ render(<App initialPairError="配对链接已被使用。" />)
43
+
44
+ await waitFor(() => expect(screen.getByText('workspace-ready')).toBeDefined())
45
+ expect(screen.queryByRole('alert')).toBeNull()
46
+ })
47
+
48
+ it('shows the pairing screen only when the mobile gateway rejects the device', async () => {
49
+ api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 403'))
50
+ render(<App initialPairError="配对链接已被使用。" />)
51
+
52
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。'))
53
+ })
54
+
55
+ it('shows a retryable connection failure rather than mounting remote views on outage', async () => {
56
+ api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 503'))
57
+ render(<App />)
58
+
59
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('无法连接到运行中的 DSH host。'))
60
+ expect(screen.queryByText('workspace-ready')).toBeNull()
61
+ })
62
+ })
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { useCallback, useEffect, useRef, useState } from 'react'
9
9
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
- import { history as fetchHistory, listSessions, listWorkspaces, prompt } from '../api.ts'
10
+ import { fetchMobilePreferences, history as fetchHistory, listSessions, listWorkspaces, prompt } from '../api.ts'
11
11
  import { MuxClient } from '../mux.ts'
12
12
  import { RpcCallError, RpcTransportError } from '../rpc.ts'
13
13
  import { ChatView } from './ChatView.tsx'
14
14
  import { SessionListView } from './SessionListView.tsx'
15
15
  import { WorkspaceView as WorkspaceRoster } from './WorkspaceView.tsx'
16
+ import { PairRequiredView } from '../PairRequiredView.tsx'
16
17
 
17
18
  /** One navigation level. */
18
19
  type Route =
@@ -71,11 +72,57 @@ export function formatTime(epochMs: number): string {
71
72
  return `${String(date.getMonth() + 1)}月${String(date.getDate())}日 ${clock}`
72
73
  }
73
74
 
74
- /**
75
- * The surface root.
76
- * @returns the app tree.
77
- */
78
- export function App() {
75
+ /** Props accepted by the mobile root before it begins paired-device RPC calls. */
76
+ export interface AppProps {
77
+ initialPairError?: string
78
+ }
79
+
80
+ /** Whether the mobile gateway rejected this browser for lack of a paired cookie. */
81
+ export function isUnpairedMobileError(error: unknown): boolean {
82
+ return error instanceof RpcTransportError && error.message === 'HTTP 403'
83
+ }
84
+
85
+ /** The result of probing the paired-device-only mobile preference endpoint. */
86
+ export type MobilePairState = 'checking' | 'paired' | 'unpaired' | 'unavailable'
87
+
88
+ /** Classify an initial mobile preference failure without treating outage as authorization. */
89
+ export function mobilePairStateForError(error: unknown): Extract<MobilePairState, 'unpaired' | 'unavailable'> {
90
+ return isUnpairedMobileError(error) ? 'unpaired' : 'unavailable'
91
+ }
92
+
93
+ /** Gate the independent mobile bundle until its own browser context is paired. */
94
+ export function App({ initialPairError }: AppProps) {
95
+ const [pairState, setPairState] = useState<MobilePairState>('checking')
96
+
97
+ useEffect(() => {
98
+ let current = true
99
+ void fetchMobilePreferences().then(
100
+ () => { if (current) setPairState('paired') },
101
+ (error: unknown) => { if (current) setPairState(mobilePairStateForError(error)) },
102
+ )
103
+ return () => { current = false }
104
+ }, [])
105
+
106
+ if (pairState === 'checking') {
107
+ return <main className="mobile mobile-empty"><p className="mobile-muted">正在连接...</p></main>
108
+ }
109
+ if (pairState === 'unpaired') {
110
+ return <PairRequiredView initialError={initialPairError} onPaired={(path) => { window.location.replace(path) }} />
111
+ }
112
+ if (pairState === 'unavailable') {
113
+ return (
114
+ <main className="mobile mobile-empty">
115
+ <p className="mobile-error" role="alert">无法连接到运行中的 DSH host。</p>
116
+ <button className="mobile-new" type="button" onClick={() => { window.location.reload() }}>重试</button>
117
+ </main>
118
+ )
119
+ }
120
+
121
+ return <PairedApp />
122
+ }
123
+
124
+ /** The existing remote mobile surface, mounted only after device pairing succeeds. */
125
+ function PairedApp() {
79
126
  const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
80
127
  const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
81
128
  () => mobileWorkspaceTarget(window.location.search),
@@ -11,13 +11,13 @@
11
11
  * permission pickers, both as bottom sheets.
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
14
+ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
15
15
  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
19
  import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
20
- import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
20
+ import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
21
21
  import { renderMarkdown } from '../markdown.ts'
22
22
  import { MuxClient } from '../mux.ts'
23
23
  import { ThemeToggle } from '../theme-toggle.tsx'
@@ -144,6 +144,8 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
144
144
  const tailLoadingRef = useRef(true)
145
145
  /** Live session events buffered while the initial tail page loads. */
146
146
  const liveBufferRef = useRef<WireEvent[]>([])
147
+ /** Incremental folder for this session's stream (indexes stay hot across events). */
148
+ const folderRef = useRef<EventFolder | undefined>(undefined)
147
149
  /** True once the live buffer hit its cap (oldest events were dropped). */
148
150
  const liveBufferOverflowRef = useRef(false)
149
151
 
@@ -188,6 +190,7 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
188
190
  tailLoadingRef.current = true
189
191
  liveBufferRef.current = []
190
192
  liveBufferOverflowRef.current = false
193
+ folderRef.current = undefined
191
194
  setLoading(true)
192
195
  setError(undefined)
193
196
  setMessages([])
@@ -199,7 +202,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
199
202
  const buffered = liveBufferRef.current
200
203
  liveBufferRef.current = []
201
204
  tailLoadingRef.current = false
202
- setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))))
205
+ const folder = new EventFolder(foldEvents(page.events.map(eventOf)))
206
+ folderRef.current = folder
207
+ setMessages(folder.fold(buffered))
203
208
  setHasOlder(page.hasMore)
204
209
  setLoading(false)
205
210
  // The history-tail projection baseline seeds the permission picker.
@@ -215,7 +220,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
215
220
  void loadHistory(session.sessionId, undefined, controller.signal).then(
216
221
  (fresh) => {
217
222
  if (cancelled) return
218
- setMessages(previous => foldEvents(fresh.events.map(eventOf), previous))
223
+ const folder = folderRef.current
224
+ const freshEvents = fresh.events.map(eventOf)
225
+ setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents))
219
226
  liveBufferOverflowRef.current = false
220
227
  },
221
228
  (reason: unknown) => {
@@ -231,7 +238,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
231
238
  const buffered = liveBufferRef.current
232
239
  liveBufferRef.current = []
233
240
  tailLoadingRef.current = false
234
- if (buffered.length > 0) setMessages(foldEvents(buffered))
241
+ if (buffered.length > 0) {
242
+ const folder = folderRef.current
243
+ setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered))
244
+ }
235
245
  setError(errorText(reason))
236
246
  setLoading(false)
237
247
  },
@@ -274,7 +284,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
274
284
  liveBufferRef.current.push(event)
275
285
  return
276
286
  }
277
- setMessages(previous => foldEvents([event], previous))
287
+ setMessages(previous => {
288
+ const folder = folderRef.current
289
+ return folder === undefined ? foldEvents([event], previous) : folder.fold([event])
290
+ })
278
291
  return
279
292
  }
280
293
  // Live projection pushes keep the permission picker current.
@@ -330,7 +343,13 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
330
343
  pendingRef.current = false
331
344
  setLoading(false)
332
345
  const older = foldEvents(page.events.map(eventOf))
333
- setMessages(previous => [...older, ...previous])
346
+ const folder = folderRef.current
347
+ if (folder === undefined) {
348
+ setMessages(previous => [...older, ...previous])
349
+ } else {
350
+ folder.prepend(older)
351
+ setMessages(folder.snapshot())
352
+ }
334
353
  setHasOlder(page.hasMore)
335
354
  },
336
355
  (reason: unknown) => {
@@ -481,8 +500,12 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
481
500
 
482
501
  /* ── message rows ─────────────────────────────────────────────────────── */
483
502
 
484
- /** One rendered message row (user bubble or assistant bubble with folds). */
485
- function MessageRow({ message, showToolCalls, showSystemMessages }: {
503
+ /**
504
+ * One rendered message row (user bubble or assistant bubble with folds).
505
+ * Memoized: live streaming updates exactly one message object per frame, so
506
+ * unchanged rows skip re-rendering their markdown/sub-components.
507
+ */
508
+ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }: {
486
509
  message: RenderMessage
487
510
  showToolCalls: boolean
488
511
  showSystemMessages: boolean
@@ -510,7 +533,7 @@ function MessageRow({ message, showToolCalls, showSystemMessages }: {
510
533
  <span className="chat-msg-time">{formatTime(message.time)}</span>
511
534
  </div>
512
535
  )
513
- }
536
+ })
514
537
 
515
538
  /** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
516
539
  function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean }) {
@@ -10,12 +10,14 @@ import { type SessionView } from './App.tsx'
10
10
  vi.mock('../api.ts', () => ({
11
11
  listSessions: vi.fn(),
12
12
  listWorkspaces: vi.fn(),
13
+ listAgentPresets: vi.fn(),
13
14
  createSession: vi.fn(),
14
15
  }))
15
- import { createSession, listSessions, listWorkspaces } from '../api.ts'
16
+ import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
16
17
 
17
18
  const listSessionsMock = vi.mocked(listSessions)
18
19
  const listWorkspacesMock = vi.mocked(listWorkspaces)
20
+ const listAgentPresetsMock = vi.mocked(listAgentPresets)
19
21
  const createSessionMock = vi.mocked(createSession)
20
22
 
21
23
  const workspace: WorkspaceRow = {
@@ -49,6 +51,7 @@ function renderList(props: Partial<SessionListViewProps> = {}): void {
49
51
  beforeEach(() => {
50
52
  listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
51
53
  listWorkspacesMock.mockResolvedValue([workspace])
54
+ listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
52
55
  createSessionMock.mockResolvedValue({ sessionId: 's-new' })
53
56
  })
54
57
 
@@ -111,6 +114,71 @@ describe('SessionListView creation', () => {
111
114
  expect(await screen.findByText('新会话')).toBeTruthy()
112
115
  })
113
116
 
117
+ it('creates the session with the selected agent preset', async () => {
118
+ listAgentPresetsMock.mockResolvedValue({
119
+ presets: [
120
+ { id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
121
+ { id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
122
+ ],
123
+ authorable: false,
124
+ hasDocument: false,
125
+ })
126
+ renderList()
127
+
128
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' })
129
+ expect((picker as HTMLSelectElement).value).toBe('router-standard')
130
+ fireEvent.change(picker, { target: { value: 'router-spec' } })
131
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
132
+
133
+ await waitFor(() => {
134
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' })
135
+ })
136
+ expect(await screen.findByText('规格驱动模式')).toBeTruthy()
137
+ })
138
+
139
+ it('skips a broken default preset and disables it in the picker', async () => {
140
+ listAgentPresetsMock.mockResolvedValue({
141
+ presets: [
142
+ { id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
143
+ { id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
144
+ ],
145
+ authorable: false,
146
+ hasDocument: false,
147
+ })
148
+ renderList()
149
+
150
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' }) as HTMLSelectElement
151
+ expect(picker.value).toBe('local-router')
152
+ expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true)
153
+ expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy()
154
+ })
155
+
156
+ it('keeps default session creation available when the preset roster fails', async () => {
157
+ listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'))
158
+ renderList()
159
+ await screen.findByText(/还没有会话/)
160
+
161
+ const button = await screen.findByRole('button', { name: '+ 新建会话' })
162
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
163
+ fireEvent.click(button)
164
+
165
+ await waitFor(() => {
166
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
167
+ })
168
+ })
169
+
170
+ it('waits for the preset roster before enabling session creation', async () => {
171
+ let resolvePresets: (value: Awaited<ReturnType<typeof listAgentPresets>>) => void = () => {}
172
+ listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve }))
173
+ renderList()
174
+
175
+ const button = screen.getByRole('button', { name: '+ 新建会话' })
176
+ expect(button.hasAttribute('disabled')).toBe(true)
177
+
178
+ resolvePresets({ presets: [], authorable: false, hasDocument: false })
179
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
180
+ })
181
+
114
182
  it('keeps the button disabled while a creation is in flight', async () => {
115
183
  let resolveCreate: (value: { sessionId: string }) => void = () => {}
116
184
  createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
@@ -13,8 +13,9 @@
13
13
 
14
14
  import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
16
+ import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
16
17
  import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
- import { createSession, listSessions, listWorkspaces } from '../api.ts'
18
+ import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
18
19
  import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
19
20
  import { ThemeToggle } from '../theme-toggle.tsx'
20
21
 
@@ -45,6 +46,9 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
45
46
  const [error, setError] = useState<string | undefined>(undefined)
46
47
  const [creating, setCreating] = useState(false)
47
48
  const [createError, setCreateError] = useState<string | undefined>(undefined)
49
+ const [presets, setPresets] = useState<readonly AgentPresetEntry[]>([])
50
+ const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined)
51
+ const [presetsLoading, setPresetsLoading] = useState(true)
48
52
  const cursorRef = useRef<string | undefined>(undefined)
49
53
  const busyRef = useRef(false)
50
54
 
@@ -74,6 +78,27 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
74
78
  return () => { cancelled = true }
75
79
  }, [workspace])
76
80
 
81
+ useEffect(() => {
82
+ let cancelled = false
83
+ setPresets([])
84
+ setSelectedPreset(undefined)
85
+ setPresetsLoading(true)
86
+ void listAgentPresets().then(
87
+ (roster) => {
88
+ if (cancelled) return
89
+ const usable = roster.presets.filter(preset => preset.broken === undefined)
90
+ setPresets(roster.presets)
91
+ setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
92
+ setPresetsLoading(false)
93
+ },
94
+ () => {
95
+ if (cancelled) return
96
+ setPresetsLoading(false)
97
+ },
98
+ )
99
+ return () => { cancelled = true }
100
+ }, [workspace])
101
+
77
102
  /** Pull the next page and append the rows that belong to this workspace. */
78
103
  const loadMore = useCallback(() => {
79
104
  if (busyRef.current) return
@@ -102,7 +127,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
102
127
  if (creating) return
103
128
  setCreating(true)
104
129
  setCreateError(undefined)
105
- void createSession({ workspaceId: workspace.workspaceId }).then(
130
+ void createSession({
131
+ workspaceId: workspace.workspaceId,
132
+ ...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
133
+ }).then(
106
134
  (created) => {
107
135
  setCreating(false)
108
136
  const view: SessionView = {
@@ -120,9 +148,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
120
148
  setCreateError(errorText(reason))
121
149
  },
122
150
  )
123
- }, [creating, workspace, onPick])
151
+ }, [creating, workspace, onPick, selectedPreset])
124
152
 
125
153
  const createHint = createError !== undefined ? staleHostHint(createError) : undefined
154
+ const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
126
155
 
127
156
  return (
128
157
  <div className="mobile">
@@ -132,11 +161,35 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
132
161
  <ThemeToggle />
133
162
  </header>
134
163
  {error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
135
- <div className="mobile-pad">
164
+ <div className="mobile-create mobile-pad">
165
+ {presets.length > 0 && (
166
+ <label className="mobile-preset">
167
+ <span className="mobile-presetLabel">Agent 模式</span>
168
+ <select
169
+ className="mobile-presetSelect"
170
+ value={selectedPreset ?? ''}
171
+ disabled={selectedPreset === undefined}
172
+ onChange={(event) => { setSelectedPreset(event.target.value) }}
173
+ >
174
+ {selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
175
+ {presets.map(preset => (
176
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
177
+ {preset.name ?? preset.id}
178
+ {preset.isDefault ? '(默认)' : ''}
179
+ {preset.trust === 'user' ? '(本地)' : ''}
180
+ {preset.broken !== undefined ? '(不可用)' : ''}
181
+ </option>
182
+ ))}
183
+ </select>
184
+ </label>
185
+ )}
186
+ {selectedPresetEntry?.description !== undefined && (
187
+ <p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
188
+ )}
136
189
  <button
137
190
  type="button"
138
191
  className="mobile-new"
139
- disabled={creating}
192
+ disabled={creating || presetsLoading}
140
193
  onClick={() => { void handleCreate() }}
141
194
  >
142
195
  {creating ? '创建中…' : '+ 新建会话'}
@@ -27,6 +27,8 @@ export interface WorkspaceViewProps {
27
27
  export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
28
28
  const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
29
29
  const [error, setError] = useState<string | undefined>(undefined)
30
+ // Bumped by the retry button to re-run the roster fetch effect.
31
+ const [reload, setReload] = useState(0)
30
32
 
31
33
  useEffect(() => {
32
34
  let cancelled = false
@@ -48,7 +50,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
48
50
  },
49
51
  )
50
52
  return () => { cancelled = true }
51
- }, [initialWorkspaceId, onPick])
53
+ }, [initialWorkspaceId, onPick, reload])
52
54
 
53
55
  if (error !== undefined) {
54
56
  return (
@@ -59,7 +61,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
59
61
  </header>
60
62
  <div className="mobile-empty">
61
63
  <p className="mobile-error">加载失败:{error}</p>
62
- <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined) }}>
64
+ <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined); setReload(n => n + 1) }}>
63
65
  重试
64
66
  </button>
65
67
  </div>
package/src/mobile-api.ts CHANGED
@@ -28,9 +28,16 @@ import type { PairingService } from './pairing.ts'
28
28
  import { readBoundedJson, writeJson } from './http.ts'
29
29
  import { readCookie } from './gate.ts'
30
30
 
31
- /** Methods the phone surface may call. Everything else is refused. */
31
+ /**
32
+ * Methods the phone surface may call. Everything else is refused HERE — but
33
+ * note the paired-device cookie also passes the global api/gate for the full
34
+ * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
35
+ * the allowlist only constrains this /m/api proxy, not the cookie's reach.
36
+ * stop() revokes every device; there is no per-device revocation yet.
37
+ */
32
38
  const MOBILE_ALLOWLIST = new Set([
33
39
  'workspace.list',
40
+ 'agentPreset.list',
34
41
  'session.create',
35
42
  'session.list',
36
43
  'session.history',
@@ -292,6 +299,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
292
299
  result: response.result,
293
300
  })
294
301
  if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
302
+ if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
295
303
  if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
296
304
  if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
297
305
  if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
@@ -0,0 +1,19 @@
1
+ /** PWA registration for the standalone /m mobile surface. */
2
+ export const MOBILE_PWA_SCOPE = '/m/'
3
+ export const MOBILE_SERVICE_WORKER_URL = '/m/service-worker.js'
4
+
5
+ /** Register the mobile shell worker when the current browser supports it. */
6
+ export async function registerMobilePwa(
7
+ serviceWorker: Pick<ServiceWorkerContainer, 'register'> | undefined = typeof navigator === 'undefined' ? undefined : navigator.serviceWorker,
8
+ ): Promise<void> {
9
+ if (serviceWorker === undefined) return
10
+
11
+ try {
12
+ await serviceWorker.register(MOBILE_SERVICE_WORKER_URL, {
13
+ scope: MOBILE_PWA_SCOPE,
14
+ updateViaCache: 'none',
15
+ })
16
+ } catch {
17
+ // HTTP LAN origins cannot register workers; the online mobile UI still works.
18
+ }
19
+ }