@guowenzhang/dsh-pocket 0.0.0-stage → 2.12.2

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