dsh-pocket-relay 1.0.0

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.
@@ -0,0 +1,378 @@
1
+ // dsh-web-mobile 移植(MIT,见 LICENSE.dsh-web-mobile):移动端适配的 client 侧实现。
2
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
3
+ import { MobileNavToggle } from './MobileNavToggle.tsx'
4
+ import { MobileNavOverlay } from './MobileNavOverlay.tsx'
5
+ import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
6
+ import { startFileGuard } from './fileGuard.ts'
7
+ import { MOBILE_CSS } from './mobile.css.ts'
8
+ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js'
9
+ import { NS, en, zh } from './locales.ts'
10
+ import type { MobileNavKey } from './locales.ts'
11
+ import { resolveLayout, persistLayoutFromUrl } from './layout-mode.mjs'
12
+
13
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
14
+ interface LocaleNamespaceMap {
15
+ /** Directory-drawer controls copy. */
16
+ 'mobileNav': MobileNavKey
17
+ }
18
+ }
19
+
20
+ /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
21
+
22
+ /**
23
+ * Mobile-adaptive shell, browser half: injects the mobile stylesheet, then
24
+ * contributes the directory toggle to the session header and the backdrop +
25
+ * floating button to the shell overlay.
26
+ * @param ctx - client root context.
27
+ */
28
+ export function mobileApply(ctx): void {
29
+ // 布局模式(issue #74):URL 参数 > localStorage > auto(=matchMedia)。
30
+ // desktop 模式(宽屏手机/平板强制电脑布局)下整段 mobile 效果都不挂——
31
+ // 不加 styles、不挂 slots、不跑 effects,直接走 DSH 原生桌面 UI。
32
+ const urlValue = new URL(window.location.href).searchParams.get('dsh-layout') ?? '';
33
+ const narrowMQ = window.matchMedia('(max-width: 1023px)');
34
+ const stored = persistLayoutFromUrl(urlValue);
35
+ const layout = resolveLayout({ urlValue, stored, narrowMatch: narrowMQ.matches });
36
+ document.body?.setAttribute('data-dsh-pocket-layout', layout);
37
+ if (layout === 'desktop') return;
38
+ // 强制 mobile:narrow 永远 true;宽度变化不再切换(用户已显式选 mobile)
39
+ // auto 模式:narrow 是真实的 matchMedia,宽度变化会触发 effect 挂载/卸载
40
+ let narrow: MediaQueryList = narrowMQ;
41
+ if (layout === 'mobile') {
42
+ narrow = { matches: true, addEventListener: () => {}, removeEventListener: () => {} } as MediaQueryList;
43
+ }
44
+
45
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-mobile-nav: dictionaries')
46
+
47
+ ctx.effect(() => {
48
+ const tag = document.createElement('style')
49
+ tag.dataset.plugin = '@dsh-external/dsh-mobile-nav'
50
+ tag.dataset.pluginCss = '@dsh-external/dsh-mobile-nav/mobile.css'
51
+ tag.textContent = MOBILE_CSS
52
+ document.head.appendChild(tag)
53
+ return () => {
54
+ tag.remove()
55
+ }
56
+ }, 'dsh-mobile-nav: styles')
57
+
58
+ // Phone chrome: KEEP the system status bar (no fullscreen) and make it
59
+ // blend into the page. On narrow screens:
60
+ // - The viewport meta gains viewport-fit=cover, so env(safe-area-inset-top)
61
+ // is the real status-bar / notch height and the stylesheet can push every
62
+ // surface below it (off notched phones, or in a browser tab where the
63
+ // layout viewport already sits below the status bar, the inset is 0 and
64
+ // nothing shifts).
65
+ // - A theme-color meta tracks the shell background (the official theme is
66
+ // toggled by body[data-ds-dark-theme], which flips --dsw-alias-bg-base):
67
+ // Android then paints the status bar / URL bar with the page's own base
68
+ // color, so the status bar reads as part of the UI instead of a foreign
69
+ // strip. The drawer paints the same strip on iOS / notch displays.
70
+ // - gesturestart is suppressed as the legacy-iOS fallback for double-tap
71
+ // zoom; modern browsers are covered by the stylesheet's
72
+ // touch-action: manipulation (which keeps pan and pinch zoom).
73
+ ctx.effect(() => {
74
+ const viewport = document.querySelector<HTMLMetaElement>('meta[name="viewport"]')
75
+ const originalViewport = viewport?.content ?? ''
76
+ const themeMeta = document.createElement('meta')
77
+ themeMeta.name = 'theme-color'
78
+ const bodyBg = (): string => getComputedStyle(document.body).backgroundColor
79
+
80
+ const sync = (): void => {
81
+ if (viewport !== null) viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover'
82
+ themeMeta.content = bodyBg()
83
+ if (themeMeta.parentElement === null) document.head.appendChild(themeMeta)
84
+ }
85
+ const restore = (): void => {
86
+ if (viewport !== null) viewport.content = originalViewport
87
+ themeMeta.remove()
88
+ }
89
+ const onGestureStart = (event: Event) => event.preventDefault()
90
+ if (narrow.matches) sync()
91
+ const onChange = (event: MediaQueryListEvent) => (event.matches ? sync() : restore())
92
+ narrow.addEventListener('change', onChange)
93
+ const observer = new MutationObserver(() => {
94
+ if (narrow.matches) themeMeta.content = bodyBg()
95
+ })
96
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
97
+ document.addEventListener('gesturestart', onGestureStart)
98
+ return () => {
99
+ narrow.removeEventListener('change', onChange)
100
+ observer.disconnect()
101
+ document.removeEventListener('gesturestart', onGestureStart)
102
+ restore()
103
+ }
104
+ }, 'dsh-mobile-nav: status bar theme + viewport + zoom guard')
105
+
106
+ // dsh-web-ui compatibility: the aionui explorer column would render as a
107
+ // sheet over the whole mobile UI whenever its (persisted) expanded state
108
+ // is active — including right after a reload, with no way out (the
109
+ // suite's floating expand button only exists while collapsed). Instead
110
+ // of fighting the suite's store timing, the mobile stylesheet keeps the
111
+ // explorer column hidden by default and the header's Files action (plus
112
+ // the drawer footer entry) opens it via the `data-aionui-explorer-open`
113
+ // marker on the frame. This effect just clears that marker when the
114
+ // sheet's own collapse chevron is tapped, so closing is symmetric with
115
+ // opening.
116
+ ctx.effect(() => {
117
+ if (!narrow.matches) return () => {}
118
+ const onChevronClick = (event: MouseEvent) => {
119
+ const target = event.target as HTMLElement | null
120
+ if (target === null || !target.closest('.aionui-collapse-chevron')) return
121
+ document.querySelector('[data-mobile-nav="frame"]')?.removeAttribute('data-aionui-explorer-open')
122
+ }
123
+ document.addEventListener('click', onChevronClick, true)
124
+ return () => document.removeEventListener('click', onChevronClick, true)
125
+ }, 'dsh-mobile-nav: aionui explorer close marker')
126
+
127
+ // The mobile "Files" entries (header icon + drawer footer) open the
128
+ // dsh-web-ui **aionui explorer column** — a host-side component that plain
129
+ // DeepSeek Harness does NOT ship (issue #48). Without it the entries are
130
+ // dead buttons (they only toggle a frame attribute; nothing ever renders).
131
+ // Detect whether the host provides the column and mark the frame with
132
+ // `data-mobile-nav-explorer="1|0"` so the stylesheet can hide the entries
133
+ // on hosts without it (dsh-web-ui installs keep the feature).
134
+ ctx.effect(() => {
135
+ if (!narrow.matches) return () => {}
136
+ const frame = (): HTMLElement | null => document.querySelector('[data-mobile-nav="frame"]')
137
+ const check = () => {
138
+ const has = document.querySelector('[data-aionui-explorer-col]') !== null
139
+ frame()?.setAttribute('data-mobile-nav-explorer', has ? '1' : '0')
140
+ }
141
+ check()
142
+ const timer = window.setTimeout(check, 1500) // 宿主懒渲染:稍后再查一次
143
+ const observer = new MutationObserver(check)
144
+ observer.observe(document.body, { childList: true, subtree: true })
145
+ return () => {
146
+ window.clearTimeout(timer)
147
+ observer.disconnect()
148
+ }
149
+ }, 'dsh-mobile-nav: explorer availability (issue #48)')
150
+
151
+ // dsh-web-ui compatibility: the aionui preview column persists its open
152
+ // tabs in localStorage and restores them on load, which would pop the
153
+ // preview sheet over the fresh UI after a reload. Gate it like the
154
+ // explorer: the stylesheet keeps the column hidden unless the frame
155
+ // carries `data-aionui-preview-open`; this effect sets that marker when
156
+ // the user actually taps a file row in the explorer sheet, and clears it
157
+ // whenever the suite hides the column again (collapse chevron / tab
158
+ // close), so a restored-but-unwanted sheet never appears.
159
+ ctx.effect(() => {
160
+ if (!narrow.matches) return () => {}
161
+ const frame = (): HTMLElement | null => document.querySelector('[data-mobile-nav="frame"]')
162
+ const onTap = (event: MouseEvent) => {
163
+ const target = event.target as HTMLElement | null
164
+ if (target === null) return
165
+ if (target.closest('[data-aionui-explorer-col] [class$="_treeRow"]') === null) return
166
+ frame()?.setAttribute('data-aionui-preview-open', '')
167
+ }
168
+ const sync = (): void => {
169
+ const pv = document.querySelector('[data-aionui-preview-col]')
170
+ if (pv === null) return
171
+ if (getComputedStyle(pv).visibility === 'hidden') frame()?.removeAttribute('data-aionui-preview-open')
172
+ }
173
+ document.addEventListener('click', onTap, true)
174
+ const observer = new MutationObserver(sync)
175
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style'] })
176
+ sync()
177
+ return () => {
178
+ document.removeEventListener('click', onTap, true)
179
+ observer.disconnect()
180
+ }
181
+ }, 'dsh-mobile-nav: preview sheet open marker')
182
+
183
+ // The official conversation status row (turns / steps / LLM time / TTFT /
184
+ // cache) has a hashed class, so the stylesheet cannot target it directly.
185
+ // Its stable boundary is the official conversation.composer.dock slot. Mark
186
+ // only the metrics root inside that slot; never scan every *_root under the
187
+ // composer because the editor itself is now contenteditable (not textarea)
188
+ // and its root also contains the dock text.
189
+ ctx.effect(() => {
190
+ if (!narrow.matches) return () => {}
191
+ // The composer root renders the TPS readout ("TPS 89.4 tok/s") as its
192
+ // own row BELOW the status strip; fold it into the strip so every
193
+ // metric scrolls together. The suite re-renders its own tree, so this
194
+ // must be idempotent and re-run on every mutation.
195
+ const moveTps = (stats: Element): void => {
196
+ if ([...stats.children].some((c) => /^TPS\s+\d/.test((c.textContent ?? '').trim()))) return
197
+ const stack = stats.closest('[class$="_composerStack"]')
198
+ if (stack === null) return
199
+ for (const el of stack.querySelectorAll('div')) {
200
+ const text = (el.textContent ?? '').trim()
201
+ if (!/^TPS\s+\d/.test(text)) continue
202
+ if (el.children.length > 0) continue
203
+ stats.appendChild(el)
204
+ return
205
+ }
206
+ }
207
+ const mark = (): void => {
208
+ const selector = '[data-phase] [data-slot="conversation.composer.dock"] [class$="_root"]'
209
+ for (const root of document.querySelectorAll(selector)) {
210
+ const text = root.textContent ?? ''
211
+ if (!/(turns|steps|\bLLM\b|轮|步)/.test(text)) continue
212
+ root.setAttribute('data-mobile-nav', 'stats')
213
+ moveTps(root)
214
+ return
215
+ }
216
+ }
217
+ const observer = new MutationObserver(mark)
218
+ observer.observe(document.body, { childList: true, subtree: true })
219
+ mark()
220
+ return () => {
221
+ observer.disconnect()
222
+ }
223
+ }, 'dsh-mobile-nav: stats line marker')
224
+
225
+ // The dsh-web-ui explorer / preview columns toggle via `visibility`
226
+ // (their inline style), which never restarts a CSS animation — so the
227
+ // sheets would only animate on first mount. Replay the rise animation
228
+ // with the Web Animations API each time a column turns visible, then
229
+ // leave the resting state to the stylesheet.
230
+ ctx.effect(() => {
231
+ if (!narrow.matches) return () => {}
232
+ const cols = ['[data-aionui-explorer-col]', '[data-aionui-preview-col]']
233
+ const seen = new Map<string, boolean>()
234
+ const play = (el: Element): void => {
235
+ el.animate(
236
+ [
237
+ { opacity: 0, transform: 'translateY(28px)' },
238
+ { opacity: 1, transform: 'none' },
239
+ ],
240
+ { duration: 280, easing: 'cubic-bezier(.16, 1, .3, 1)', fill: 'backwards' },
241
+ )
242
+ }
243
+ const check = (): void => {
244
+ for (const sel of cols) {
245
+ const el = document.querySelector(sel)
246
+ if (el === null) continue
247
+ const visible = getComputedStyle(el).visibility === 'visible'
248
+ const prev = seen.get(sel) ?? false
249
+ if (visible && !prev) play(el)
250
+ seen.set(sel, visible)
251
+ }
252
+ }
253
+ const observer = new MutationObserver(check)
254
+ // Visibility flips come through inline style mutations (suite) or the
255
+ // explorer-open marker on the frame; class changes are watched too.
256
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class', 'data-aionui-explorer-open'] })
257
+ check()
258
+ return () => {
259
+ observer.disconnect()
260
+ }
261
+ }, 'dsh-mobile-nav: sheet rise animation replay')
262
+
263
+ // 移动端文件守卫(issue #17 修正):手机上点 dsh-web 渲染的文件链接会触发桌面
264
+ // 端 workspaces.openPath(open ...) —— 既打不开(路径在电脑上),又会抛
265
+ // "path open failed"。这里在捕获阶段拦截这类点击 / 键盘激活,改为弹一个提示,
266
+ // 并隐藏「添加工作区」入口(手机上配工作区无意义);同时在文件链接旁注入
267
+ // 「复制」按钮,点它经主机 RPC 读取文件正文再写入剪贴板。只挂窄屏。
268
+ ctx.effect(() => {
269
+ if (!narrow.matches) return () => {}
270
+ // 尽量拿到当前工作区 cwd(文件链接文案是相对它的),传给主机 RPC 做精确解析;
271
+ // 拿不到就回退到主机 process.cwd()。dsh-web 的 workspaces 服务暴露当前工作区。
272
+ const getWorkspaceCwd = (): string => {
273
+ try {
274
+ const ws = (ctx as unknown as { get?: (k: string) => unknown }).get?.('workspaces')
275
+ ?? (ctx as unknown as { workspaces?: unknown }).workspaces
276
+ const list = (ws as { list?: unknown })?.list
277
+ const arr: unknown[] | null = Array.isArray(list)
278
+ ? list
279
+ : (list && typeof list === 'object' && 'value' in (list as object)
280
+ ? (list as { value: unknown[] }).value
281
+ : null)
282
+ if (Array.isArray(arr)) {
283
+ for (const w of arr) {
284
+ const c = (w as { cwd?: string; root?: string })?.cwd
285
+ ?? (w as { cwd?: string; root?: string })?.root
286
+ if (typeof c === 'string' && c) return c
287
+ }
288
+ }
289
+ } catch { /* 忽略,回退 process.cwd() */ }
290
+ return ''
291
+ }
292
+ // 手机侧读文件回调:走 dsh-pocket 的 RPC 通道,由主机侧 fileRead 端点处理。
293
+ const readFile = (filePath: string) =>
294
+ ctx.connection.rpc.call(
295
+ POCKET_RPC_CHANNEL,
296
+ POCKET_ENDPOINTS.fileRead,
297
+ { path: filePath, cwd: getWorkspaceCwd() },
298
+ ) as Promise<{ ok: boolean; value?: { content: string; path: string; size: number }; error?: { message: string } }>
299
+ return startFileGuard(readFile)
300
+ }, 'dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)')
301
+
302
+ // 手机上模型 / 提供方设置加载失败:上游 dsh-web 会渲染「加载提供方目录失败 /
303
+ // Settings are unavailable in this browser」。这条文案不是 dsh-pocket 的,但手机侧
304
+ // 本就不支持改模型设置,原报错只会吓到用户。窄屏下用 MutationObserver 就地把该报错
305
+ // 文本替换成「去电脑端修改」的引导提示(仅手机,桌面端不受影响)。
306
+ ctx.effect(() => {
307
+ if (!narrow.matches) return () => {}
308
+ const PHRASES = ['加载提供方目录失败', 'Settings are unavailable in this browser']
309
+ const NOTICE = '手机上不支持模型设置,请去电脑端修改设置'
310
+ // 取包含报错文案的最深节点,避免把外层大容器整块清掉。
311
+ const findDeepest = (el: Element): Element => {
312
+ let deepest = el
313
+ for (const child of el.querySelectorAll('*')) {
314
+ if (PHRASES.some((p) => (child.textContent ?? '').includes(p))) deepest = child
315
+ }
316
+ return deepest
317
+ }
318
+ const patch = (): void => {
319
+ for (const el of document.querySelectorAll('body *')) {
320
+ const t = el.textContent ?? ''
321
+ if (!PHRASES.some((p) => t.includes(p))) continue
322
+ if ((el as HTMLElement).dataset?.dshpModelNotice === '1') continue
323
+ const target = findDeepest(el)
324
+ target.textContent = NOTICE
325
+ ;(target as HTMLElement).dataset.dshpModelNotice = '1'
326
+ }
327
+ }
328
+ const observer = new MutationObserver(patch)
329
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
330
+ patch()
331
+ return () => observer.disconnect()
332
+ }, 'dsh-mobile-nav: replace model-settings load error with mobile hint')
333
+
334
+
335
+ ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
336
+ name: 'conversation.session.header.actions',
337
+ id: 'mobile-nav-toggle',
338
+ order: 10,
339
+ locale: NS,
340
+ inject: () => ({
341
+ toggleSidebar: () => ctx.layout.toggleSidebar(),
342
+ }),
343
+ }, MobileNavToggle))
344
+
345
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
346
+ name: 'shell.overlay',
347
+ id: 'mobile-nav-overlay',
348
+ order: 10,
349
+ locale: NS,
350
+ inject: () => ({
351
+ toggleSidebar: () => ctx.layout.toggleSidebar(),
352
+ }),
353
+ }, MobileNavOverlay))
354
+
355
+ // Session log download, relocated from the session header to the drawer
356
+ // footer on mobile (the header capsule is hidden by CSS); the drawer
357
+ // footer also hosts the Files action that opens the dsh-web-ui explorer
358
+ // sheet.
359
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
360
+ name: 'sidebar.footer.action',
361
+ id: 'mobile-nav-session-log',
362
+ order: 10,
363
+ locale: NS,
364
+ inject: () => ({
365
+ downloadSessionLog: (sessionId: string) => ctx.sessionLogDownload.download(sessionId),
366
+ toggleSidebar: () => ctx.layout.toggleSidebar(),
367
+ }),
368
+ }, MobileDrawerFooter))
369
+ }
370
+
371
+ // Type-only augmentation imports: pull the layout / conversation / sidebar
372
+ // SlotMap merges and the sessionLogDownload service typing into this program
373
+ // without any runtime import.
374
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
375
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
376
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
377
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
378
+ import type {} from '@deepseek-ai/dsh-session-log-export/client'