@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,84 @@
1
+ // @vitest-environment jsdom
2
+ /** mobile-theme: light default, persisted dark toggle, document wiring. */
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ getMobileTheme, initMobileTheme, setMobileTheme, subscribeMobileTheme, toggleMobileTheme,
6
+ } from './mobile-theme.ts'
7
+
8
+ const STORAGE_KEY = 'dsh.remote.theme'
9
+
10
+ /** jsdom in this setup ships a bare localStorage object; install a real fake. */
11
+ function makeStorage(): Storage {
12
+ const map = new Map<string, string>()
13
+ return {
14
+ getItem: (key: string) => map.get(key) ?? null,
15
+ setItem: (key: string, value: string) => { map.set(key, value) },
16
+ removeItem: (key: string) => { map.delete(key) },
17
+ clear: () => { map.clear() },
18
+ key: (index: number) => [...map.keys()][index] ?? null,
19
+ get length() { return map.size },
20
+ } as Storage
21
+ }
22
+
23
+ let storage: Storage
24
+
25
+ beforeEach(() => {
26
+ storage = makeStorage()
27
+ vi.stubGlobal('localStorage', storage)
28
+ setMobileTheme('light')
29
+ document.documentElement.removeAttribute('data-theme')
30
+ })
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals()
34
+ })
35
+
36
+ describe('mobile-theme', () => {
37
+ it('defaults to light with nothing stored', () => {
38
+ expect(getMobileTheme()).toBe('light')
39
+ })
40
+
41
+ it('toggles to dark, persists it, and wires the document', () => {
42
+ expect(toggleMobileTheme()).toBe('dark')
43
+ expect(getMobileTheme()).toBe('dark')
44
+ expect(document.documentElement.dataset.theme).toBe('dark')
45
+ expect(storage.getItem(STORAGE_KEY)).toBe('dark')
46
+ })
47
+
48
+ it('toggles back to light', () => {
49
+ toggleMobileTheme()
50
+ expect(toggleMobileTheme()).toBe('light')
51
+ expect(document.documentElement.dataset.theme).toBe('light')
52
+ expect(storage.getItem(STORAGE_KEY)).toBe('light')
53
+ })
54
+
55
+ it('updates the browser chrome color along with the theme', () => {
56
+ const meta = document.createElement('meta')
57
+ meta.name = 'theme-color'
58
+ document.head.appendChild(meta)
59
+ setMobileTheme('dark')
60
+ expect(meta.content).toBe('#111418')
61
+ setMobileTheme('light')
62
+ expect(meta.content).toBe('#f3f5f9')
63
+ meta.remove()
64
+ })
65
+
66
+ it('boot reads the stored theme and initMobileTheme applies it', async () => {
67
+ storage.setItem(STORAGE_KEY, 'dark')
68
+ vi.resetModules()
69
+ const fresh = await import('./mobile-theme.ts')
70
+ expect(fresh.getMobileTheme()).toBe('dark')
71
+ fresh.initMobileTheme()
72
+ expect(document.documentElement.dataset.theme).toBe('dark')
73
+ })
74
+
75
+ it('notifies subscribers on change', () => {
76
+ const seen: string[] = []
77
+ const unsubscribe = subscribeMobileTheme(() => { seen.push(getMobileTheme()) })
78
+ setMobileTheme('dark')
79
+ setMobileTheme('light')
80
+ unsubscribe()
81
+ setMobileTheme('dark')
82
+ expect(seen).toEqual(['dark', 'light'])
83
+ })
84
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Mobile-surface theme: light by default, dark via an explicit persisted
3
+ * toggle. The standalone page boots without the shell, so there is no theme
4
+ * system to inherit — the choice is stored in localStorage and applied as a
5
+ * `data-theme` attribute on <html>; the stylesheet defines both palettes
6
+ * under `:root` (light) / `:root[data-theme='dark']` (dark).
7
+ *
8
+ * A tiny module store (subscribe/get) keeps the toggle button and the boot
9
+ * path in sync without threading props through the three view levels.
10
+ */
11
+
12
+ export type MobileTheme = 'light' | 'dark'
13
+
14
+ const STORAGE_KEY = 'dsh.remote.theme'
15
+ const THEME_COLOR_META_NAME = 'theme-color'
16
+ const LIGHT_THEME_COLOR = '#f3f5f9'
17
+ const DARK_THEME_COLOR = '#111418'
18
+
19
+ let current: MobileTheme = readStored() ?? 'light'
20
+ const listeners = new Set<() => void>()
21
+
22
+ function readStored(): MobileTheme | undefined {
23
+ try {
24
+ const value = localStorage.getItem(STORAGE_KEY)
25
+ return value === 'dark' || value === 'light' ? value : undefined
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ function persist(theme: MobileTheme): void {
32
+ try {
33
+ localStorage.setItem(STORAGE_KEY, theme)
34
+ } catch {
35
+ // Private-mode storage failures are non-fatal; the session keeps the theme.
36
+ }
37
+ }
38
+
39
+ /** Apply the theme to the document: data-theme attribute + browser chrome color. */
40
+ function applyToDocument(theme: MobileTheme): void {
41
+ if (typeof document === 'undefined') return
42
+ document.documentElement.dataset.theme = theme
43
+ const meta = document.querySelector<HTMLMetaElement>(`meta[name="${THEME_COLOR_META_NAME}"]`)
44
+ if (meta !== null) meta.content = theme === 'dark' ? DARK_THEME_COLOR : LIGHT_THEME_COLOR
45
+ }
46
+
47
+ /** Current theme (light unless the user explicitly toggled to dark). */
48
+ export function getMobileTheme(): MobileTheme {
49
+ return current
50
+ }
51
+
52
+ /** Subscribe to theme changes; returns the unsubscribe function. */
53
+ export function subscribeMobileTheme(listener: () => void): () => void {
54
+ listeners.add(listener)
55
+ return () => { listeners.delete(listener) }
56
+ }
57
+
58
+ /** Set the theme explicitly (persisted + applied to the document). */
59
+ export function setMobileTheme(theme: MobileTheme): void {
60
+ if (theme === current) return
61
+ current = theme
62
+ persist(theme)
63
+ applyToDocument(theme)
64
+ for (const listener of [...listeners]) listener()
65
+ }
66
+
67
+ /** Flip light/dark and return the new theme. */
68
+ export function toggleMobileTheme(): MobileTheme {
69
+ setMobileTheme(current === 'light' ? 'dark' : 'light')
70
+ return current
71
+ }
72
+
73
+ /** Apply the persisted (or default) theme once at boot, before first paint. */
74
+ export function initMobileTheme(): void {
75
+ applyToDocument(current)
76
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Mobile-surface live-event client: the plugin's `/m/api/events.mux` SSE
3
+ * channel (Server-Sent Events — the host bridges the mux stream onto it, so
4
+ * no WebSocket handshake or framing is needed on this side). The host
5
+ * pushes mux frames (subscribed baselines, session events, approvals,
6
+ * questions, queue snapshots, tasks, projections) as soon as the stream
7
+ * opens — no subscription handshake is needed. Frames arrive as
8
+ * server-request envelopes whose payload is the mux frame; unknown frame
9
+ * types are dropped so a newer host never breaks this client.
10
+ * EventSource reconnects automatically.
11
+ */
12
+
13
+ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
14
+ import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
15
+ import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
16
+
17
+ /** Injectable seams for tests. */
18
+ export interface MuxClientOptions {
19
+ /** EventSource factory (defaults to the browser EventSource). */
20
+ sourceFactory?: (url: string) => EventSourceLike
21
+ }
22
+
23
+ /** The EventSource subset this client uses (browser EventSource fits). */
24
+ export interface EventSourceLike {
25
+ onmessage: ((event: { data: string }) => void) | null
26
+ onerror: ((event: unknown) => void) | null
27
+ close(): void
28
+ }
29
+
30
+ /** Browser default source factory. */
31
+ function browserSource(url: string): EventSourceLike {
32
+ // The DOM EventSource is structurally compatible; the `this`-typed handler
33
+ // signatures differ, so the narrow face takes it through an adapter cast.
34
+ return new EventSource(url) as unknown as EventSourceLike
35
+ }
36
+
37
+ /**
38
+ * Keep one SSE subscription open, fanning validated frames out to
39
+ * subscribers. EventSource owns reconnection (with its own backoff); this
40
+ * class only manages the subscription lifecycle.
41
+ */
42
+ export class MuxClient {
43
+ private readonly sourceFactory: (url: string) => EventSourceLike
44
+ private readonly listeners = new Set<(frame: MuxFrame) => void>()
45
+ private source: EventSourceLike | undefined
46
+ private stopped = false
47
+ private readonly url: string
48
+
49
+ /**
50
+ * @param url - the mobile events endpoint (browser-relative).
51
+ * @param options - seams.
52
+ */
53
+ constructor(url = '/m/api/events.mux', options: MuxClientOptions = {}) {
54
+ this.url = url
55
+ this.sourceFactory = options.sourceFactory ?? browserSource
56
+ }
57
+
58
+ /** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
59
+ start(): void {
60
+ this.stopped = false
61
+ if (this.source !== undefined) return
62
+ this.connect()
63
+ }
64
+
65
+ /** Close for good. */
66
+ stop(): void {
67
+ this.stopped = true
68
+ this.closeSource()
69
+ }
70
+
71
+ /** Subscribe to validated frames; returns an unsubscribe function. */
72
+ onFrame(listener: (frame: MuxFrame) => void): () => void {
73
+ this.listeners.add(listener)
74
+ return () => { this.listeners.delete(listener) }
75
+ }
76
+
77
+ private connect(): void {
78
+ const source = this.sourceFactory(this.url)
79
+ this.source = source
80
+ source.onmessage = (event) => {
81
+ this.handleMessage(event.data)
82
+ }
83
+ source.onerror = () => {
84
+ // EventSource reconnects by itself; when we are closing, detach first
85
+ // so the native reconnect cannot outlive stop().
86
+ if (this.stopped && this.source === source) this.closeSource()
87
+ }
88
+ }
89
+
90
+ private handleMessage(data: string): void {
91
+ if (typeof data !== 'string' || data === '') return
92
+ let parsed: unknown
93
+ try {
94
+ parsed = JSON.parse(data)
95
+ } catch {
96
+ return
97
+ }
98
+ // The SSE channel carries server-request envelopes whose payload is the
99
+ // mux frame (same wire shape as the desktop mux channel).
100
+ const envelope = serverRequestSchema.safeParse(parsed)
101
+ if (!envelope.success) return
102
+ const frame = muxFrameSchema.safeParse(envelope.data.payload)
103
+ if (!frame.success) return
104
+ for (const listener of this.listeners) {
105
+ try {
106
+ listener(frame.data)
107
+ } catch {
108
+ // A throwing subscriber must not break the emit loop.
109
+ }
110
+ }
111
+ }
112
+
113
+ private closeSource(): void {
114
+ const source = this.source
115
+ this.source = undefined
116
+ if (source !== undefined) {
117
+ source.onmessage = null
118
+ source.onerror = null
119
+ try {
120
+ source.close()
121
+ } catch {
122
+ // Already closed.
123
+ }
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Mobile-surface unary RPC over the shared /api transport: the four-quadrant
3
+ * envelope (client-request → server-response), minted rpcIds, and typed
4
+ * error mapping. This is a thin, self-contained slice of the harness
5
+ * apiproxy fetch carrier — the mobile page is an independent bundle and must
6
+ * not depend on the main UI's module loader, so the wire contract is
7
+ * reimplemented here over plain fetch.
8
+ */
9
+
10
+ /** Transport-level failure (network, HTTP status, malformed envelope). */
11
+ export class RpcTransportError extends Error {
12
+ constructor(message: string) {
13
+ super(message)
14
+ this.name = 'RpcTransportError'
15
+ }
16
+ }
17
+
18
+ /** A business error the host answered with (200 + err result). */
19
+ export class RpcCallError extends Error {
20
+ /** The wire error (code + message + details). */
21
+ readonly error: { code: string; message: string }
22
+
23
+ constructor(error: { code: string; message: string }) {
24
+ super(error.message)
25
+ this.name = 'RpcCallError'
26
+ this.error = error
27
+ }
28
+ }
29
+
30
+ let rpcCounter = 0
31
+
32
+ /** Mint one process-unique rpcId (stable under crypto.randomUUID absence). */
33
+ export function mintRpcId(): string {
34
+ const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
35
+ ? crypto.randomUUID()
36
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
37
+ rpcCounter += 1
38
+ return `${random}-${rpcCounter.toString(36)}`
39
+ }
40
+
41
+ /**
42
+ * One unary call: POST /m/api/<method> (the plugin's own mobile channel —
43
+ * NOT the connection plugin's /api prefix, so the tunneled Host never needs
44
+ * to enter the transport trust fence) with the client-request envelope,
45
+ * resolve the server-response value, reject with the mapped error classes.
46
+ * @param method - the dotted RPC method, e.g. `session.list`.
47
+ * @param payload - the business payload.
48
+ * @param signal - optional abort.
49
+ * @returns the response value.
50
+ */
51
+ export async function callUnary<T>(
52
+ method: string,
53
+ payload: unknown,
54
+ signal?: AbortSignal,
55
+ ): Promise<T> {
56
+ const rpcId = mintRpcId()
57
+ let response: Response
58
+ try {
59
+ response = await fetch(`/m/api/${method}`, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'application/json' },
62
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
63
+ ...(signal !== undefined ? { signal } : {}),
64
+ })
65
+ } catch (error) {
66
+ const reason = error instanceof DOMException && error.name === 'AbortError'
67
+ ? 'aborted'
68
+ : error instanceof Error ? error.message : String(error)
69
+ throw new RpcTransportError(`transport failed: ${reason}`)
70
+ }
71
+ if (!response.ok) {
72
+ throw new RpcTransportError(`HTTP ${String(response.status)}`)
73
+ }
74
+ let envelope: unknown
75
+ try {
76
+ envelope = await response.json()
77
+ } catch {
78
+ throw new RpcTransportError('malformed response body')
79
+ }
80
+ const parsed = envelope as { type?: unknown; rpcId?: unknown; result?: unknown }
81
+ if (parsed?.type !== 'server-response' || parsed.rpcId !== rpcId) {
82
+ throw new RpcTransportError('response envelope mismatch')
83
+ }
84
+ const result = parsed.result as { ok?: boolean; value?: unknown; error?: { code: string; message: string } }
85
+ if (result?.ok === true) return result.value as T
86
+ if (result?.ok === false && result.error !== undefined) {
87
+ throw new RpcCallError(result.error)
88
+ }
89
+ throw new RpcTransportError('malformed result envelope')
90
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Theme toggle button: sits at the right end of every view header and flips
3
+ * the mobile surface between the light (default) and dark palettes. The
4
+ * glyph shows the theme you will switch TO (moon in light mode, sun in dark
5
+ * mode); the choice persists across visits.
6
+ */
7
+
8
+ import { useSyncExternalStore } from 'react'
9
+ import { getMobileTheme, subscribeMobileTheme, toggleMobileTheme } from './mobile-theme.ts'
10
+
11
+ /** Sun glyph: circle + eight rays (inline SVG, no emoji). */
12
+ function SunIcon() {
13
+ return (
14
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
15
+ <circle cx="12" cy="12" r="4.2" />
16
+ <path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5 5l1.7 1.7M17.3 17.3 19 19M19 5l-1.7 1.7M6.7 17.3 5 19" />
17
+ </svg>
18
+ )
19
+ }
20
+
21
+ /** Moon glyph: crescent path (inline SVG, no emoji). */
22
+ function MoonIcon() {
23
+ return (
24
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
25
+ <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />
26
+ </svg>
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Render the header theme toggle.
32
+ * @returns the toggle button.
33
+ */
34
+ export function ThemeToggle() {
35
+ const theme = useSyncExternalStore(subscribeMobileTheme, getMobileTheme)
36
+ const dark = theme === 'dark'
37
+ return (
38
+ <button
39
+ type="button"
40
+ className="mobile-theme-toggle"
41
+ aria-label={dark ? '切换到浅色' : '切换到深色'}
42
+ onClick={() => { toggleMobileTheme() }}
43
+ >
44
+ {dark ? <SunIcon /> : <MoonIcon />}
45
+ </button>
46
+ )
47
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Mobile surface root: the view state machine (workspaces → sessions →
3
+ * chat) and the top-level data flows. Deliberately plain React state — no
4
+ * router, no state library: the surface is three fixed levels with a back
5
+ * affordance, and every piece of data is fetched on demand.
6
+ */
7
+
8
+ import { useCallback, useEffect, useRef, useState } from 'react'
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'
11
+ import { MuxClient } from '../mux.ts'
12
+ import { RpcCallError, RpcTransportError } from '../rpc.ts'
13
+ import { ChatView } from './ChatView.tsx'
14
+ import { SessionListView } from './SessionListView.tsx'
15
+ import { WorkspaceView as WorkspaceRoster } from './WorkspaceView.tsx'
16
+
17
+ /** One navigation level. */
18
+ type Route =
19
+ | { kind: 'workspaces' }
20
+ | { kind: 'sessions'; workspace: WorkspaceRow }
21
+ | { kind: 'chat'; session: SessionView; workspace: WorkspaceRow }
22
+
23
+ /** The session-list row model (list + chat share it). */
24
+ export interface SessionView {
25
+ sessionId: string
26
+ title: string
27
+ cwd?: string
28
+ updatedAt: number
29
+ running: boolean
30
+ blank: boolean
31
+ }
32
+
33
+ /** One rendered chat message (produced by the fold). */
34
+ export interface RenderMessage {
35
+ id: string
36
+ kind: 'user' | 'assistant'
37
+ text: string
38
+ seq: number
39
+ time: number
40
+ pending?: boolean
41
+ failed?: boolean
42
+ toolSummary?: string
43
+ }
44
+
45
+ /** Map a list row to the surface model; the title comes from projections when present. */
46
+ export function toSessionView(item: {
47
+ sessionId: string
48
+ updatedAt: number
49
+ running: boolean
50
+ blank: boolean
51
+ cwd?: string
52
+ projections?: { values?: Record<string, unknown> }
53
+ }): SessionView {
54
+ const titleValue = item.projections?.values?.title
55
+ const title = typeof titleValue === 'string' && titleValue !== ''
56
+ ? titleValue
57
+ : item.cwd !== undefined ? item.cwd.split('/').filter(Boolean).at(-1) ?? item.cwd : '新会话'
58
+ return {
59
+ sessionId: item.sessionId,
60
+ title,
61
+ ...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
62
+ updatedAt: item.updatedAt,
63
+ running: item.running,
64
+ blank: item.blank,
65
+ }
66
+ }
67
+
68
+ /** Human clock, e.g. "14:05" or "昨天 20:31". */
69
+ export function formatTime(epochMs: number): string {
70
+ const date = new Date(epochMs)
71
+ const clock = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
72
+ const today = new Date()
73
+ if (date.toDateString() === today.toDateString()) return clock
74
+ const yesterday = new Date(today)
75
+ yesterday.setDate(today.getDate() - 1)
76
+ if (date.toDateString() === yesterday.toDateString()) return `昨天 ${clock}`
77
+ return `${String(date.getMonth() + 1)}月${String(date.getDate())}日 ${clock}`
78
+ }
79
+
80
+ /**
81
+ * The surface root.
82
+ * @returns the app tree.
83
+ */
84
+ export function App() {
85
+ const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
86
+ const muxRef = useRef<MuxClient | undefined>(undefined)
87
+
88
+ // The mux stream lives for the page lifetime: session events keep the
89
+ // open chat live, and reconnect is automatic.
90
+ useEffect(() => {
91
+ const mux = new MuxClient()
92
+ muxRef.current = mux
93
+ mux.start()
94
+ return () => { mux.stop() }
95
+ }, [])
96
+
97
+ const back = useCallback(() => {
98
+ setRoute(previous => {
99
+ if (previous.kind === 'chat') return { kind: 'sessions', workspace: previous.workspace }
100
+ if (previous.kind === 'sessions') return { kind: 'workspaces' }
101
+ return previous
102
+ })
103
+ }, [])
104
+
105
+ const openChat = useCallback((session: SessionView, workspace: WorkspaceRow) => {
106
+ setRoute({ kind: 'chat', session, workspace })
107
+ }, [])
108
+
109
+ return (
110
+ <div className="mobile">
111
+ {route.kind === 'workspaces'
112
+ ? <WorkspaceRoster onPick={(workspace) => { setRoute({ kind: 'sessions', workspace }) }} />
113
+ : route.kind === 'sessions'
114
+ ? (
115
+ <SessionListView
116
+ workspace={route.workspace}
117
+ onBack={back}
118
+ onPick={(session) => { openChat(session, route.workspace) }}
119
+ />
120
+ )
121
+ : <ChatView session={route.session} mux={muxRef.current} onBack={back} />}
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /** Shared error text for the surface's small failure affordances. */
127
+ export function errorText(error: unknown): string {
128
+ if (error instanceof RpcCallError) return error.error.message
129
+ if (error instanceof RpcTransportError) return error.message
130
+ if (error instanceof Error) return error.message
131
+ return String(error)
132
+ }
133
+
134
+ /**
135
+ * Actionable hint for transport-level 403s on host-gated channels (model
136
+ * picker, session creation): the phone's UI bundle is served fresh from disk
137
+ * per request, while the host-side allowlist lives in the long-running
138
+ * process — a rebuild without a restart shows the new surface against the
139
+ * old allowlist (HTTP 403 forbidden).
140
+ */
141
+ export function staleHostHint(message: string): string | undefined {
142
+ return /^HTTP 403/.test(message)
143
+ ? '宿主端插件可能仍在运行旧版本:请重启 dsh web 后再试。'
144
+ : undefined
145
+ }
146
+
147
+ /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
148
+ export function loadHistory(sessionId: string, beforeSeq?: number) {
149
+ return fetchHistory(sessionId, beforeSeq)
150
+ }
151
+
152
+ export { listSessions, listWorkspaces, prompt }