@linxin666/dsh-remote-web-ui 0.1.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 (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
@@ -0,0 +1,139 @@
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
+ createSession: vi.fn(),
14
+ }))
15
+ import { createSession, listSessions, listWorkspaces } from '../api.ts'
16
+
17
+ const listSessionsMock = vi.mocked(listSessions)
18
+ const listWorkspacesMock = vi.mocked(listWorkspaces)
19
+ const createSessionMock = vi.mocked(createSession)
20
+
21
+ const workspace: WorkspaceRow = {
22
+ workspaceId: 'w-1' as never,
23
+ path: '/tmp/demo',
24
+ title: '演示项目',
25
+ sessionIds: ['s-1'] as never,
26
+ createdAt: '2026-01-01T00:00:00.000Z',
27
+ updatedAt: '2026-01-01T00:00:00.000Z',
28
+ }
29
+
30
+ /** A session.list summary row. */
31
+ function summary(sessionId: string, updatedAt: number, extra: Record<string, unknown> = {}): never {
32
+ return { sessionId, updatedAt, running: false, blank: false, ...extra } as never
33
+ }
34
+
35
+ let picked: SessionView | undefined
36
+
37
+ function renderList(props: Partial<SessionListViewProps> = {}): void {
38
+ picked = undefined
39
+ render(
40
+ <SessionListView
41
+ workspace={workspace}
42
+ onBack={() => {}}
43
+ onPick={(session) => { picked = session }}
44
+ {...props}
45
+ />,
46
+ )
47
+ }
48
+
49
+ beforeEach(() => {
50
+ listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
51
+ listWorkspacesMock.mockResolvedValue([workspace])
52
+ createSessionMock.mockResolvedValue({ sessionId: 's-new' })
53
+ })
54
+
55
+ afterEach(() => {
56
+ cleanup()
57
+ vi.clearAllMocks()
58
+ })
59
+
60
+ describe('SessionListView roster', () => {
61
+ it('renders the sessions owned by the workspace', async () => {
62
+ listSessionsMock.mockResolvedValue({
63
+ items: [
64
+ summary('s-1', 1_700_000_000_000, { projections: { values: { title: '改造移动端' } } }),
65
+ summary('s-other', 1_600_000_000_000, { cwd: '/tmp/foreign' }),
66
+ ],
67
+ hasMore: false,
68
+ })
69
+ renderList()
70
+
71
+ expect(await screen.findByText('改造移动端')).toBeTruthy()
72
+ // The foreign session (not in workspace.sessionIds) stays hidden.
73
+ expect(screen.queryByText(/foreign/)).toBeNull()
74
+ })
75
+
76
+ it('shows the empty state when the workspace owns no sessions', async () => {
77
+ renderList()
78
+ expect(await screen.findByText(/还没有会话/)).toBeTruthy()
79
+ })
80
+
81
+ it('refreshes the owned-id set from workspace.list on mount', async () => {
82
+ listSessionsMock.mockResolvedValue({
83
+ items: [summary('s-2', 1_700_000_000_000, { cwd: '/tmp/other' })],
84
+ hasMore: false,
85
+ })
86
+ listWorkspacesMock.mockResolvedValue([
87
+ { ...workspace, sessionIds: ['s-2'] as never },
88
+ ])
89
+ renderList()
90
+ // s-2 is absent from the stale row but present in the fresh roster.
91
+ expect(await screen.findByText(/other/)).toBeTruthy()
92
+ })
93
+ })
94
+
95
+ describe('SessionListView creation', () => {
96
+ it('creates a blank session in the workspace and opens it immediately', async () => {
97
+ renderList()
98
+ await screen.findByText(/还没有会话/)
99
+
100
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
101
+
102
+ await waitFor(() => {
103
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
104
+ })
105
+ await waitFor(() => {
106
+ expect(picked).toBeDefined()
107
+ })
108
+ expect(picked?.sessionId).toBe('s-new')
109
+ expect(picked?.blank).toBe(true)
110
+ // The fresh row is prepended without waiting for a refetch.
111
+ expect(await screen.findByText('新会话')).toBeTruthy()
112
+ })
113
+
114
+ it('keeps the button disabled while a creation is in flight', async () => {
115
+ let resolveCreate: (value: { sessionId: string }) => void = () => {}
116
+ createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
117
+ renderList()
118
+ await screen.findByText(/还没有会话/)
119
+
120
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
121
+ const button = await screen.findByRole('button', { name: '创建中…' })
122
+ expect(button.hasAttribute('disabled')).toBe(true)
123
+
124
+ resolveCreate({ sessionId: 's-new' })
125
+ await waitFor(() => { expect(picked).toBeDefined() })
126
+ })
127
+
128
+ it('shows the stale-host hint when creation is refused with HTTP 403', async () => {
129
+ createSessionMock.mockRejectedValue(new Error('HTTP 403'))
130
+ renderList()
131
+ await screen.findByText(/还没有会话/)
132
+
133
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
134
+
135
+ expect(await screen.findByText(/HTTP 403/)).toBeTruthy()
136
+ expect(await screen.findByText(/重启 dsh web/)).toBeTruthy()
137
+ expect(picked).toBeUndefined()
138
+ })
139
+ })
@@ -0,0 +1,186 @@
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 { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
+ import { createSession, listSessions, listWorkspaces } from '../api.ts'
18
+ import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
19
+ import { ThemeToggle } from '../theme-toggle.tsx'
20
+
21
+ /** Props for the session list. */
22
+ export interface SessionListViewProps {
23
+ workspace: WorkspaceRow
24
+ onBack(): void
25
+ onPick(session: SessionView): void
26
+ }
27
+
28
+ /** Rows that belong to the opened workspace (its owned session id set). */
29
+ function ownedItems(page: SessionSummary[], workspace: WorkspaceRow): SessionView[] {
30
+ const owned = new Set(workspace.sessionIds)
31
+ return page
32
+ .filter(item => owned.has(item.sessionId as never))
33
+ .map(item => toSessionView(item))
34
+ }
35
+
36
+ /**
37
+ * Render one workspace's paged session list.
38
+ * @param props - the workspace, back action, and pick action.
39
+ * @returns the session list.
40
+ */
41
+ export function SessionListView({ workspace, onBack, onPick }: SessionListViewProps) {
42
+ const [rows, setRows] = useState<SessionView[]>([])
43
+ const [hasMore, setHasMore] = useState(false)
44
+ const [loading, setLoading] = useState(true)
45
+ const [error, setError] = useState<string | undefined>(undefined)
46
+ const [creating, setCreating] = useState(false)
47
+ const [createError, setCreateError] = useState<string | undefined>(undefined)
48
+ const cursorRef = useRef<string | undefined>(undefined)
49
+ const busyRef = useRef(false)
50
+
51
+ // First page on mount. The workspace roster is re-read alongside so the
52
+ // owned-id set is fresh: a session created (or attached) since this
53
+ // workspace row was captured must not vanish from the filter.
54
+ useEffect(() => {
55
+ let cancelled = false
56
+ setLoading(true)
57
+ setError(undefined)
58
+ void Promise.all([listSessions(), listWorkspaces()]).then(
59
+ ([page, workspaces]) => {
60
+ if (cancelled) return
61
+ const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId)
62
+ const current = fresh ?? workspace
63
+ setRows(ownedItems(page.items, current))
64
+ cursorRef.current = page.nextCursor
65
+ setHasMore(page.hasMore)
66
+ setLoading(false)
67
+ },
68
+ (reason: unknown) => {
69
+ if (cancelled) return
70
+ setError(errorText(reason))
71
+ setLoading(false)
72
+ },
73
+ )
74
+ return () => { cancelled = true }
75
+ }, [workspace])
76
+
77
+ /** Pull the next page and append the rows that belong to this workspace. */
78
+ const loadMore = useCallback(() => {
79
+ if (busyRef.current) return
80
+ const cursor = cursorRef.current
81
+ if (cursor === undefined) return
82
+ busyRef.current = true
83
+ setLoading(true)
84
+ void listSessions(cursor).then(
85
+ (page) => {
86
+ busyRef.current = false
87
+ setLoading(false)
88
+ cursorRef.current = page.nextCursor
89
+ setHasMore(page.hasMore)
90
+ setRows(previous => [...previous, ...ownedItems(page.items, workspace)])
91
+ },
92
+ (reason: unknown) => {
93
+ busyRef.current = false
94
+ setLoading(false)
95
+ setError(errorText(reason))
96
+ },
97
+ )
98
+ }, [workspace])
99
+
100
+ /** Create a blank session in this workspace and open it immediately. */
101
+ const handleCreate = useCallback(() => {
102
+ if (creating) return
103
+ setCreating(true)
104
+ setCreateError(undefined)
105
+ void createSession({ workspaceId: workspace.workspaceId }).then(
106
+ (created) => {
107
+ setCreating(false)
108
+ const view: SessionView = {
109
+ sessionId: created.sessionId,
110
+ title: '新会话',
111
+ updatedAt: Date.now(),
112
+ running: false,
113
+ blank: true,
114
+ }
115
+ setRows(previous => [view, ...previous])
116
+ onPick(view)
117
+ },
118
+ (reason: unknown) => {
119
+ setCreating(false)
120
+ setCreateError(errorText(reason))
121
+ },
122
+ )
123
+ }, [creating, workspace, onPick])
124
+
125
+ const createHint = createError !== undefined ? staleHostHint(createError) : undefined
126
+
127
+ return (
128
+ <div className="mobile">
129
+ <header className="mobile-header">
130
+ <button type="button" className="mobile-back" aria-label="返回" onClick={onBack}>‹</button>
131
+ <h1 className="mobile-title mobile-titleInline">{workspace.title}</h1>
132
+ <ThemeToggle />
133
+ </header>
134
+ {error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
135
+ <div className="mobile-pad">
136
+ <button
137
+ type="button"
138
+ className="mobile-new"
139
+ disabled={creating}
140
+ onClick={() => { void handleCreate() }}
141
+ >
142
+ {creating ? '创建中…' : '+ 新建会话'}
143
+ </button>
144
+ </div>
145
+ {createError !== undefined && (
146
+ <p className="mobile-error mobile-pad">
147
+ {createError}
148
+ {createHint !== undefined && <span className="mobile-hint">{createHint}</span>}
149
+ </p>
150
+ )}
151
+ <ul className="mobile-list">
152
+ {rows.map(row => (
153
+ <li key={row.sessionId}>
154
+ <button type="button" className="mobile-row" onClick={() => { onPick(row) }}>
155
+ <span className="mobile-rowMain">
156
+ <span className="mobile-rowTitle">
157
+ {row.blank ? '新会话' : row.title}
158
+ {row.running ? <span className="mobile-live">●</span> : null}
159
+ </span>
160
+ <span className="mobile-rowMeta">{formatTime(row.updatedAt)}</span>
161
+ </span>
162
+ <span className="mobile-chevron">›</span>
163
+ </button>
164
+ </li>
165
+ ))}
166
+ </ul>
167
+ {hasMore && (
168
+ <div className="mobile-pad">
169
+ <button
170
+ type="button"
171
+ className="mobile-button mobile-block"
172
+ disabled={loading}
173
+ onClick={() => { void loadMore() }}
174
+ >
175
+ {loading ? '加载中…' : '加载更多会话'}
176
+ </button>
177
+ </div>
178
+ )}
179
+ {!hasMore && rows.length === 0 && !loading && (
180
+ <div className="mobile-empty">
181
+ <p className="mobile-muted">该工作区还没有会话,点上方按钮新建一个</p>
182
+ </div>
183
+ )}
184
+ </div>
185
+ )
186
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Landing level: the workspace roster. The mobile surface opens straight
3
+ * here — no new-session homepage — and every workspace row is a thin
4
+ * fetch from workspace.list (the roster is small; sessions are not loaded
5
+ * until a workspace is opened).
6
+ */
7
+
8
+ import { useEffect, useState } from 'react'
9
+ import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
+ import { listWorkspaces } from '../api.ts'
11
+ import { errorText } from './App.tsx'
12
+ import { ThemeToggle } from '../theme-toggle.tsx'
13
+
14
+ /** Props for the workspace roster. */
15
+ export interface WorkspaceViewProps {
16
+ /** Open one workspace's session list. */
17
+ onPick(workspace: WorkspaceRow): void
18
+ }
19
+
20
+ /**
21
+ * Render the workspace roster.
22
+ * @param props - the pick action.
23
+ * @returns the roster.
24
+ */
25
+ export function WorkspaceView({ onPick }: WorkspaceViewProps) {
26
+ const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
27
+ const [error, setError] = useState<string | undefined>(undefined)
28
+
29
+ useEffect(() => {
30
+ let cancelled = false
31
+ void listWorkspaces().then(
32
+ (rows) => {
33
+ if (cancelled) return
34
+ setItems(rows)
35
+ },
36
+ (reason: unknown) => {
37
+ if (cancelled) return
38
+ setError(errorText(reason))
39
+ },
40
+ )
41
+ return () => { cancelled = true }
42
+ }, [])
43
+
44
+ if (error !== undefined) {
45
+ return (
46
+ <div className="mobile">
47
+ <header className="mobile-header">
48
+ <h1 className="mobile-title">工作区</h1>
49
+ <ThemeToggle />
50
+ </header>
51
+ <div className="mobile-empty">
52
+ <p className="mobile-error">加载失败:{error}</p>
53
+ <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined) }}>
54
+ 重试
55
+ </button>
56
+ </div>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ if (items === undefined) {
62
+ return (
63
+ <div className="mobile">
64
+ <header className="mobile-header">
65
+ <h1 className="mobile-title">工作区</h1>
66
+ <ThemeToggle />
67
+ </header>
68
+ <div className="mobile-empty">
69
+ <p className="mobile-muted">加载中…</p>
70
+ </div>
71
+ </div>
72
+ )
73
+ }
74
+
75
+ if (items.length === 0) {
76
+ return (
77
+ <div className="mobile">
78
+ <header className="mobile-header">
79
+ <h1 className="mobile-title">工作区</h1>
80
+ <ThemeToggle />
81
+ </header>
82
+ <div className="mobile-empty">
83
+ <p className="mobile-muted">暂无工作区</p>
84
+ </div>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ return (
90
+ <div className="mobile">
91
+ <header className="mobile-header">
92
+ <h1 className="mobile-title">工作区</h1>
93
+ <ThemeToggle />
94
+ </header>
95
+ <ul className="mobile-list">
96
+ {items.map(workspace => (
97
+ <li key={workspace.workspaceId}>
98
+ <button type="button" className="mobile-row" onClick={() => { onPick(workspace) }}>
99
+ <span className="mobile-rowTitle">{workspace.title}</span>
100
+ <span className="mobile-rowMeta">{workspace.path}</span>
101
+ <span className="mobile-chevron">›</span>
102
+ </button>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ </div>
107
+ )
108
+ }