@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,207 +0,0 @@
1
- // @vitest-environment jsdom
2
- /** SessionListView: owned-row filtering, incremental pages, session creation. */
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
- import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
6
- import { SessionListView, type SessionListViewProps } from './SessionListView.tsx'
7
- import { type SessionView } from './App.tsx'
8
-
9
- // The api module is fully mocked; the view's App.tsx helpers stay real.
10
- vi.mock('../api.ts', () => ({
11
- listSessions: vi.fn(),
12
- listWorkspaces: vi.fn(),
13
- listAgentPresets: vi.fn(),
14
- createSession: vi.fn(),
15
- }))
16
- import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
17
-
18
- const listSessionsMock = vi.mocked(listSessions)
19
- const listWorkspacesMock = vi.mocked(listWorkspaces)
20
- const listAgentPresetsMock = vi.mocked(listAgentPresets)
21
- const createSessionMock = vi.mocked(createSession)
22
-
23
- const workspace: WorkspaceRow = {
24
- workspaceId: 'w-1' as never,
25
- path: '/tmp/demo',
26
- title: '演示项目',
27
- sessionIds: ['s-1'] as never,
28
- createdAt: '2026-01-01T00:00:00.000Z',
29
- updatedAt: '2026-01-01T00:00:00.000Z',
30
- }
31
-
32
- /** A session.list summary row. */
33
- function summary(sessionId: string, updatedAt: number, extra: Record<string, unknown> = {}): never {
34
- return { sessionId, updatedAt, running: false, blank: false, ...extra } as never
35
- }
36
-
37
- let picked: SessionView | undefined
38
-
39
- function renderList(props: Partial<SessionListViewProps> = {}): void {
40
- picked = undefined
41
- render(
42
- <SessionListView
43
- workspace={workspace}
44
- onBack={() => {}}
45
- onPick={(session) => { picked = session }}
46
- {...props}
47
- />,
48
- )
49
- }
50
-
51
- beforeEach(() => {
52
- listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
53
- listWorkspacesMock.mockResolvedValue([workspace])
54
- listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
55
- createSessionMock.mockResolvedValue({ sessionId: 's-new' })
56
- })
57
-
58
- afterEach(() => {
59
- cleanup()
60
- vi.clearAllMocks()
61
- })
62
-
63
- describe('SessionListView roster', () => {
64
- it('renders the sessions owned by the workspace', async () => {
65
- listSessionsMock.mockResolvedValue({
66
- items: [
67
- summary('s-1', 1_700_000_000_000, { projections: { values: { title: '改造移动端' } } }),
68
- summary('s-other', 1_600_000_000_000, { cwd: '/tmp/foreign' }),
69
- ],
70
- hasMore: false,
71
- })
72
- renderList()
73
-
74
- expect(await screen.findByText('改造移动端')).toBeTruthy()
75
- // The foreign session (not in workspace.sessionIds) stays hidden.
76
- expect(screen.queryByText(/foreign/)).toBeNull()
77
- })
78
-
79
- it('shows the empty state when the workspace owns no sessions', async () => {
80
- renderList()
81
- expect(await screen.findByText(/还没有会话/)).toBeTruthy()
82
- })
83
-
84
- it('refreshes the owned-id set from workspace.list on mount', async () => {
85
- listSessionsMock.mockResolvedValue({
86
- items: [summary('s-2', 1_700_000_000_000, { cwd: '/tmp/other' })],
87
- hasMore: false,
88
- })
89
- listWorkspacesMock.mockResolvedValue([
90
- { ...workspace, sessionIds: ['s-2'] as never },
91
- ])
92
- renderList()
93
- // s-2 is absent from the stale row but present in the fresh roster.
94
- expect(await screen.findByText(/other/)).toBeTruthy()
95
- })
96
- })
97
-
98
- describe('SessionListView creation', () => {
99
- it('creates a blank session in the workspace and opens it immediately', async () => {
100
- renderList()
101
- await screen.findByText(/还没有会话/)
102
-
103
- fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
104
-
105
- await waitFor(() => {
106
- expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
107
- })
108
- await waitFor(() => {
109
- expect(picked).toBeDefined()
110
- })
111
- expect(picked?.sessionId).toBe('s-new')
112
- expect(picked?.blank).toBe(true)
113
- // The fresh row is prepended without waiting for a refetch.
114
- expect(await screen.findByText('新会话')).toBeTruthy()
115
- })
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
-
182
- it('keeps the button disabled while a creation is in flight', async () => {
183
- let resolveCreate: (value: { sessionId: string }) => void = () => {}
184
- createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
185
- renderList()
186
- await screen.findByText(/还没有会话/)
187
-
188
- fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
189
- const button = await screen.findByRole('button', { name: '创建中…' })
190
- expect(button.hasAttribute('disabled')).toBe(true)
191
-
192
- resolveCreate({ sessionId: 's-new' })
193
- await waitFor(() => { expect(picked).toBeDefined() })
194
- })
195
-
196
- it('shows the stale-host hint when creation is refused with HTTP 403', async () => {
197
- createSessionMock.mockRejectedValue(new Error('HTTP 403'))
198
- renderList()
199
- await screen.findByText(/还没有会话/)
200
-
201
- fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
202
-
203
- expect(await screen.findByText(/HTTP 403/)).toBeTruthy()
204
- expect(await screen.findByText(/重启 dsh web/)).toBeTruthy()
205
- expect(picked).toBeUndefined()
206
- })
207
- })
@@ -1,244 +0,0 @@
1
- /**
2
- * Sessions level: one workspace's sessions, loaded incrementally — the
3
- * first page fetches session.list with a cursor, scrolling appends further
4
- * pages (never the whole list at once). Rows are filtered to the opened
5
- * workspace by its owned session ids; extra pages are pulled on demand so
6
- * a workspace with many sessions converges without a full transfer.
7
- *
8
- * Creating a session is this level's other action: the workspace's id is
9
- * sent to session.create (the host attaches the new session to it), the
10
- * fresh row is prepended optimistically, and the user lands straight in
11
- * the new chat — the same "new session opens" flow as the desktop UI.
12
- */
13
-
14
- import { useCallback, useEffect, useRef, useState } from 'react'
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'
17
- import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
18
- import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
19
- import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
20
- import { ThemeToggle } from '../theme-toggle.tsx'
21
-
22
- /** Props for the session list. */
23
- export interface SessionListViewProps {
24
- workspace: WorkspaceRow
25
- onBack(): void
26
- onPick(session: SessionView): void
27
- }
28
-
29
- /** Rows that belong to the opened workspace (its owned session id set). */
30
- function ownedItems(page: SessionSummary[], workspace: WorkspaceRow): SessionView[] {
31
- const owned = new Set(workspace.sessionIds)
32
- return page
33
- .filter(item => owned.has(item.sessionId as never))
34
- .map(item => toSessionView(item))
35
- }
36
-
37
- /**
38
- * Render one workspace's paged session list.
39
- * @param props - the workspace, back action, and pick action.
40
- * @returns the session list.
41
- */
42
- export function SessionListView({ workspace, onBack, onPick }: SessionListViewProps) {
43
- const [rows, setRows] = useState<SessionView[]>([])
44
- const [hasMore, setHasMore] = useState(false)
45
- const [loading, setLoading] = useState(true)
46
- const [error, setError] = useState<string | undefined>(undefined)
47
- const [creating, setCreating] = useState(false)
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)
52
- const cursorRef = useRef<string | undefined>(undefined)
53
- const busyRef = useRef(false)
54
- // The freshest owned-id set: the mount effect re-reads the workspace roster
55
- // so a session created since this row was captured still shows; loadMore
56
- // filters later pages through this ref instead of the stale prop.
57
- const workspaceRef = useRef(workspace)
58
-
59
- // First page on mount. The workspace roster is re-read alongside so the
60
- // owned-id set is fresh: a session created (or attached) since this
61
- // workspace row was captured must not vanish from the filter.
62
- useEffect(() => {
63
- let cancelled = false
64
- setLoading(true)
65
- setError(undefined)
66
- void Promise.all([listSessions(), listWorkspaces()]).then(
67
- ([page, workspaces]) => {
68
- if (cancelled) return
69
- const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId)
70
- const current = fresh ?? workspace
71
- workspaceRef.current = current
72
- setRows(ownedItems(page.items, current))
73
- cursorRef.current = page.nextCursor
74
- setHasMore(page.hasMore)
75
- setLoading(false)
76
- },
77
- (reason: unknown) => {
78
- if (cancelled) return
79
- setError(errorText(reason))
80
- setLoading(false)
81
- },
82
- )
83
- return () => { cancelled = true }
84
- }, [workspace])
85
-
86
- useEffect(() => {
87
- let cancelled = false
88
- setPresets([])
89
- setSelectedPreset(undefined)
90
- setPresetsLoading(true)
91
- void listAgentPresets().then(
92
- (roster) => {
93
- if (cancelled) return
94
- const usable = roster.presets.filter(preset => preset.broken === undefined)
95
- setPresets(roster.presets)
96
- setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
97
- setPresetsLoading(false)
98
- },
99
- () => {
100
- if (cancelled) return
101
- setPresetsLoading(false)
102
- },
103
- )
104
- return () => { cancelled = true }
105
- }, [workspace])
106
-
107
- /** Pull the next page and append the rows that belong to this workspace. */
108
- const loadMore = useCallback(() => {
109
- if (busyRef.current) return
110
- const cursor = cursorRef.current
111
- if (cursor === undefined) return
112
- busyRef.current = true
113
- setLoading(true)
114
- void listSessions(cursor).then(
115
- (page) => {
116
- busyRef.current = false
117
- setLoading(false)
118
- cursorRef.current = page.nextCursor
119
- setHasMore(page.hasMore)
120
- setRows(previous => [...previous, ...ownedItems(page.items, workspaceRef.current)])
121
- },
122
- (reason: unknown) => {
123
- busyRef.current = false
124
- setLoading(false)
125
- setError(errorText(reason))
126
- },
127
- )
128
- }, [workspace])
129
-
130
- /** Create a blank session in this workspace and open it immediately. */
131
- const handleCreate = useCallback(() => {
132
- if (creating) return
133
- setCreating(true)
134
- setCreateError(undefined)
135
- void createSession({
136
- workspaceId: workspace.workspaceId,
137
- ...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
138
- }).then(
139
- (created) => {
140
- setCreating(false)
141
- const view: SessionView = {
142
- sessionId: created.sessionId,
143
- title: '新会话',
144
- updatedAt: Date.now(),
145
- running: false,
146
- blank: true,
147
- }
148
- setRows(previous => [view, ...previous])
149
- onPick(view)
150
- },
151
- (reason: unknown) => {
152
- setCreating(false)
153
- setCreateError(errorText(reason))
154
- },
155
- )
156
- }, [creating, workspace, onPick, selectedPreset])
157
-
158
- const createHint = createError !== undefined ? staleHostHint(createError) : undefined
159
- const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
160
-
161
- return (
162
- <div className="mobile">
163
- <header className="mobile-header">
164
- <button type="button" className="mobile-back" aria-label="返回" onClick={onBack}>‹</button>
165
- <h1 className="mobile-title mobile-titleInline">{workspace.title}</h1>
166
- <ThemeToggle />
167
- </header>
168
- {error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
169
- <div className="mobile-create mobile-pad">
170
- {presets.length > 0 && (
171
- <label className="mobile-preset">
172
- <span className="mobile-presetLabel">Agent 模式</span>
173
- <select
174
- className="mobile-presetSelect"
175
- value={selectedPreset ?? ''}
176
- disabled={selectedPreset === undefined}
177
- onChange={(event) => { setSelectedPreset(event.target.value) }}
178
- >
179
- {selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
180
- {presets.map(preset => (
181
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
182
- {preset.name ?? preset.id}
183
- {preset.isDefault ? '(默认)' : ''}
184
- {preset.trust === 'user' ? '(本地)' : ''}
185
- {preset.broken !== undefined ? '(不可用)' : ''}
186
- </option>
187
- ))}
188
- </select>
189
- </label>
190
- )}
191
- {selectedPresetEntry?.description !== undefined && (
192
- <p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
193
- )}
194
- <button
195
- type="button"
196
- className="mobile-new"
197
- disabled={creating || presetsLoading}
198
- onClick={() => { void handleCreate() }}
199
- >
200
- {creating ? '创建中…' : '+ 新建会话'}
201
- </button>
202
- </div>
203
- {createError !== undefined && (
204
- <p className="mobile-error mobile-pad">
205
- {createError}
206
- {createHint !== undefined && <span className="mobile-hint">{createHint}</span>}
207
- </p>
208
- )}
209
- <ul className="mobile-list">
210
- {rows.map(row => (
211
- <li key={row.sessionId}>
212
- <button type="button" className="mobile-row" onClick={() => { onPick(row) }}>
213
- <span className="mobile-rowMain">
214
- <span className="mobile-rowTitle">
215
- {row.blank ? '新会话' : row.title}
216
- {row.running ? <span className="mobile-live">●</span> : null}
217
- </span>
218
- <span className="mobile-rowMeta">{formatTime(row.updatedAt)}</span>
219
- </span>
220
- <span className="mobile-chevron">›</span>
221
- </button>
222
- </li>
223
- ))}
224
- </ul>
225
- {hasMore && (
226
- <div className="mobile-pad">
227
- <button
228
- type="button"
229
- className="mobile-button mobile-block"
230
- disabled={loading}
231
- onClick={() => { void loadMore() }}
232
- >
233
- {loading ? '加载中…' : '加载更多会话'}
234
- </button>
235
- </div>
236
- )}
237
- {!hasMore && rows.length === 0 && !loading && (
238
- <div className="mobile-empty">
239
- <p className="mobile-muted">该工作区还没有会话,点上方按钮新建一个</p>
240
- </div>
241
- )}
242
- </div>
243
- )
244
- }
@@ -1,227 +0,0 @@
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, fireEvent } 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
- listDirectory: vi.fn(),
12
- createWorkspace: vi.fn(),
13
- }))
14
- import { listWorkspaces, listDirectory, createWorkspace } from '../api.ts'
15
-
16
- const listWorkspacesMock = vi.mocked(listWorkspaces)
17
- const listDirectoryMock = vi.mocked(listDirectory)
18
- const createWorkspaceMock = vi.mocked(createWorkspace)
19
-
20
- const workspaces: WorkspaceRow[] = [
21
- {
22
- workspaceId: 'ws-1' as never,
23
- path: '/tmp/first',
24
- title: 'First',
25
- sessionIds: [] as never,
26
- createdAt: '2026-01-01T00:00:00.000Z',
27
- updatedAt: '2026-01-01T00:00:00.000Z',
28
- },
29
- {
30
- workspaceId: 'ws-2' as never,
31
- path: '/tmp/second',
32
- title: 'Second',
33
- sessionIds: [] as never,
34
- createdAt: '2026-01-01T00:00:00.000Z',
35
- updatedAt: '2026-01-01T00:00:00.000Z',
36
- },
37
- ]
38
-
39
- afterEach(() => {
40
- cleanup()
41
- vi.clearAllMocks()
42
- })
43
-
44
- describe('mobile workspace deep link', () => {
45
- it('reads a non-empty workspace target from the query', () => {
46
- expect(mobileWorkspaceTarget('?workspace=ws-2')).toBe('ws-2')
47
- expect(mobileWorkspaceTarget('?workspace=')).toBeUndefined()
48
- expect(mobileWorkspaceTarget('')).toBeUndefined()
49
- })
50
-
51
- it('opens the targeted workspace as soon as the roster loads', async () => {
52
- listWorkspacesMock.mockResolvedValue(workspaces)
53
- const onPick = vi.fn()
54
-
55
- render(<WorkspaceView initialWorkspaceId="ws-2" onPick={onPick} />)
56
-
57
- await waitFor(() => expect(onPick).toHaveBeenCalledWith(workspaces[1]))
58
- expect(onPick).toHaveBeenCalledTimes(1)
59
- })
60
-
61
- it('falls back to the roster when the target no longer exists', async () => {
62
- listWorkspacesMock.mockResolvedValue(workspaces)
63
- const onPick = vi.fn()
64
-
65
- render(<WorkspaceView initialWorkspaceId="missing" onPick={onPick} />)
66
-
67
- expect(await screen.findByText('First')).toBeTruthy()
68
- expect(await screen.findByText('Second')).toBeTruthy()
69
- expect(onPick).not.toHaveBeenCalled()
70
- })
71
- })
72
-
73
- describe('mobile workspace creation', () => {
74
- it('shows new workspace button and navigates to directory browser', async () => {
75
- listWorkspacesMock.mockResolvedValue(workspaces)
76
- listDirectoryMock.mockResolvedValue({
77
- path: '/home/user',
78
- home: '/home/user',
79
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
80
- entries: [
81
- { name: 'projects', path: '/home/user/projects', hidden: false },
82
- { name: '.config', path: '/home/user/.config', hidden: true }
83
- ],
84
- truncated: false
85
- })
86
-
87
- const onPick = vi.fn()
88
- render(<WorkspaceView onPick={onPick} />)
89
-
90
- // Wait for workspaces to load
91
- const createBtn = await screen.findByText('+ 新建工作区')
92
-
93
- // Click create button
94
- fireEvent.click(createBtn)
95
-
96
- // Verify directory browser renders
97
- expect(await screen.findByText('选择目录')).toBeTruthy()
98
-
99
- // Verify crumbs
100
- expect(await screen.findByText('user')).toBeTruthy()
101
-
102
- // Verify entries
103
- expect(await screen.findByText('projects')).toBeTruthy()
104
- expect(await screen.findByText('.config')).toBeTruthy()
105
- })
106
-
107
- it('navigates into directory and allows breadcrumb navigation', async () => {
108
- listWorkspacesMock.mockResolvedValue(workspaces)
109
-
110
- // Initial dir
111
- listDirectoryMock.mockResolvedValueOnce({
112
- path: '/home/user',
113
- home: '/home/user',
114
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
115
- entries: [{ name: 'projects', path: '/home/user/projects', hidden: false }],
116
- truncated: false
117
- })
118
-
119
- const onPick = vi.fn()
120
- render(<WorkspaceView onPick={onPick} />)
121
-
122
- // Open dir browser
123
- fireEvent.click(await screen.findByText('+ 新建工作区'))
124
-
125
- // Wait for first dir listing
126
- const projBtn = await screen.findByText('projects')
127
-
128
- // Prepare next listing
129
- listDirectoryMock.mockResolvedValueOnce({
130
- path: '/home/user/projects',
131
- home: '/home/user',
132
- crumbs: [
133
- { name: '', path: '/', hidden: false },
134
- { name: 'home', path: '/home', hidden: false },
135
- { name: 'user', path: '/home/user', hidden: false },
136
- { name: 'projects', path: '/home/user/projects', hidden: false }
137
- ],
138
- entries: [{ name: 'foo', path: '/home/user/projects/foo', hidden: false }],
139
- truncated: false
140
- })
141
-
142
- // Click into folder
143
- fireEvent.click(projBtn)
144
-
145
- // Verify new contents load
146
- expect(await screen.findByText('foo')).toBeTruthy()
147
-
148
- // Check breadcrumb
149
- const homeCrumb = screen.getByText('home')
150
- expect(homeCrumb).toBeTruthy()
151
-
152
- // Click breadcrumb
153
- listDirectoryMock.mockResolvedValueOnce({
154
- path: '/home',
155
- home: '/home/user',
156
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }],
157
- entries: [{ name: 'user', path: '/home/user', hidden: false }],
158
- truncated: false
159
- })
160
-
161
- fireEvent.click(homeCrumb)
162
- expect(await screen.findByText('user')).toBeTruthy()
163
- })
164
-
165
- it('creates workspace successfully', async () => {
166
- listWorkspacesMock.mockResolvedValue(workspaces)
167
- listDirectoryMock.mockResolvedValue({
168
- path: '/home/user/projects/foo',
169
- home: '/home/user',
170
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'foo', path: '/home/user/projects/foo', hidden: false }],
171
- entries: [],
172
- truncated: false
173
- })
174
-
175
- const newWorkspace = { ...workspaces[0], workspaceId: 'ws-new' as never, title: 'foo' }
176
- createWorkspaceMock.mockResolvedValue({
177
- workspace: newWorkspace,
178
- created: true
179
- })
180
-
181
- const onPick = vi.fn()
182
- render(<WorkspaceView onPick={onPick} />)
183
-
184
- fireEvent.click(await screen.findByText('+ 新建工作区'))
185
-
186
- const selectBtn = await screen.findByText('选择此目录')
187
- fireEvent.click(selectBtn)
188
-
189
- expect(await screen.findByText('创建中…')).toBeTruthy()
190
-
191
- await waitFor(() => {
192
- expect(createWorkspaceMock).toHaveBeenCalledWith('/home/user/projects/foo')
193
- expect(onPick).toHaveBeenCalledWith(newWorkspace)
194
- })
195
- })
196
-
197
- it('handles errors from directory listing and workspace creation', async () => {
198
- listWorkspacesMock.mockResolvedValue(workspaces)
199
- listDirectoryMock.mockRejectedValue(new Error('Permission denied'))
200
-
201
- const onPick = vi.fn()
202
- render(<WorkspaceView onPick={onPick} />)
203
-
204
- fireEvent.click(await screen.findByText('+ 新建工作区'))
205
-
206
- // Verify error from listing
207
- expect(await screen.findByText('Permission denied')).toBeTruthy()
208
-
209
- // Retry with success
210
- listDirectoryMock.mockResolvedValue({
211
- path: '/home',
212
- home: '/home/user',
213
- crumbs: [],
214
- entries: [],
215
- truncated: false
216
- })
217
- fireEvent.click(screen.getByText('重试'))
218
-
219
- const selectBtn = await screen.findByText('选择此目录')
220
-
221
- // Fail creation
222
- createWorkspaceMock.mockRejectedValue(new Error('Already exists'))
223
- fireEvent.click(selectBtn)
224
-
225
- expect(await screen.findByText('Already exists')).toBeTruthy()
226
- })
227
- })