@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,31 @@
1
+ import { MOBILE_ICON_FALLBACK, isRenderableIcon } from './icon-compat.mjs'
2
+
3
+ /** Props for the version-tolerant icon slot. */
4
+ export interface MobileIconProps {
5
+ /** Icon component resolved from the host primitives namespace (null when absent). */
6
+ icon: unknown
7
+ /** Which fallback glyph to draw when the running DSH has no such export. */
8
+ name: 'panelLeft' | 'download' | 'folderOpen'
9
+ /** Square edge in px, forwarded to the host icon. */
10
+ size: number
11
+ }
12
+
13
+ /**
14
+ * Renders the resolved host icon, or a text glyph when this DSH build no longer
15
+ * exports that name. It must never hand `undefined` to createElement: that is
16
+ * exactly what crashed the whole slot entry (React #130) and left the mobile
17
+ * drawer dead on DSH 0.1.7-rc.2+ (see icon-compat.mjs for the version matrix).
18
+ */
19
+ export function MobileIcon({ icon, name, size }: MobileIconProps) {
20
+ if (!isRenderableIcon(icon)) {
21
+ return (
22
+ <span data-mobile-nav="icon-fallback" aria-hidden="true" style={{ fontSize: size, lineHeight: 1 }}>
23
+ {MOBILE_ICON_FALLBACK[name]}
24
+ </span>
25
+ )
26
+ }
27
+ // The component type differs per DSH version, so it is only known at runtime;
28
+ // `never` as the return type keeps JSX typing happy without an `any` cast.
29
+ const Icon = icon as (props: { size?: number }) => never
30
+ return <Icon size={size} />
31
+ }
@@ -0,0 +1,299 @@
1
+ import { useEffect, useLayoutEffect, useState } from 'react'
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import { NS } from './locales.ts'
5
+ import { MobileIcon } from './MobileIcon.tsx'
6
+ import { resolveMobileIcons } from './icon-compat.mjs'
7
+ import {
8
+ findDrawer,
9
+ hasNativeDrawer,
10
+ TOGGLE_SELECTOR,
11
+ isOverlayTap,
12
+ navTargetFor,
13
+ } from './nav-targets.mjs'
14
+
15
+ /** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */
16
+ const icons = resolveMobileIcons(primitives)
17
+
18
+ /** Full props for the shell overlay entry. */
19
+ export interface MobileNavOverlayProps extends PropsRuntime<'shell.overlay'>, PropsLocale<typeof NS> {
20
+ /** Bound ctx.layout.toggleSidebar(). */
21
+ toggleSidebar: () => void
22
+ }
23
+
24
+ /** Same breakpoint as the shell's SIDEBAR_AUTO_COLLAPSE (viewport < 1024). */
25
+ const MOBILE_QUERY = '(max-width: 1023px)'
26
+
27
+ /** Live matchMedia hook for the narrow breakpoint. */
28
+ function useMobile(): boolean {
29
+ const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches)
30
+ useEffect(() => {
31
+ const query = window.matchMedia(MOBILE_QUERY)
32
+ const onChange = (event: MediaQueryListEvent) => setMobile(event.matches)
33
+ query.addEventListener('change', onChange)
34
+ return () => query.removeEventListener('change', onChange)
35
+ }, [])
36
+ return mobile
37
+ }
38
+
39
+ /** The AppFrame element: direct parent of the shell overlay layer. */
40
+ function findFrame(): HTMLElement | null {
41
+ return document.querySelector('[data-shell-overlay]')?.parentElement ?? null
42
+ }
43
+
44
+ /**
45
+ * Mobile shell overlay: owns the `data-mobile-nav` marker on the AppFrame
46
+ * element (the CSS restructure keys off it), mirrors the frame's collapsed
47
+ * state into React state, and renders the dimmed backdrop plus a floating
48
+ * directory button for the hero/blank phases that have no session header.
49
+ */
50
+ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
51
+ const mobile = useMobile()
52
+ const [open, setOpen] = useState(false)
53
+ const [fabVisible, setFabVisible] = useState(false)
54
+
55
+ // Frame ownership + open-state mirror. On wide screens this effect is inert:
56
+ // the marker is never set, so the layout is untouched.
57
+ useLayoutEffect(() => {
58
+ if (!mobile) {
59
+ setOpen(false)
60
+ return
61
+ }
62
+ const frame = findFrame()
63
+ if (frame === null) return
64
+ frame.setAttribute('data-mobile-nav', 'frame')
65
+ const sync = () => setOpen(!frame.hasAttribute('data-sidebar-collapsed'))
66
+ sync()
67
+ const observer = new MutationObserver(sync)
68
+ observer.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] })
69
+ return () => {
70
+ observer.disconnect()
71
+ frame.removeAttribute('data-mobile-nav')
72
+ }
73
+ }, [mobile])
74
+
75
+ // The floating button is a fallback for surfaces without a session header:
76
+ // phase "active" means the header (and its toggle) is rendered already.
77
+ useEffect(() => {
78
+ if (!mobile) {
79
+ setFabVisible(false)
80
+ return
81
+ }
82
+ const sync = () => setFabVisible(document.querySelector('[data-phase="active"]') === null)
83
+ sync()
84
+ const observer = new MutationObserver(sync)
85
+ // childList: the conversation root can be replaced wholesale on session
86
+ // switches, so attribute-only observation would miss the new phase.
87
+ observer.observe(document.documentElement, {
88
+ subtree: true,
89
+ childList: true,
90
+ attributes: true,
91
+ attributeFilter: ['data-phase'],
92
+ })
93
+ return () => observer.disconnect()
94
+ }, [mobile])
95
+
96
+ // Escape closes the drawer — but yields to an open modal dialog (e.g. the
97
+ // settings panel), which owns its own Escape handling.
98
+ useEffect(() => {
99
+ if (!mobile || !open) return
100
+ const onKeyDown = (event: KeyboardEvent) => {
101
+ if (event.key === 'Escape' && document.querySelector('[aria-modal="true"]') === null) toggleSidebar()
102
+ }
103
+ // Capture phase: run before the settings panel's own document-bubble Escape
104
+ // handler, so the modal is still present when we yield to it.
105
+ document.addEventListener('keydown', onKeyDown, true)
106
+ return () => document.removeEventListener('keydown', onKeyDown, true)
107
+ }, [mobile, open, toggleSidebar])
108
+
109
+ // Navigation inside the drawer closes it: tapping a session row or a
110
+ // plugin takeover entry (task board / ssh) must hand the screen to the
111
+ // content it just opened. Mouse clicks keep the direct close path. Touch
112
+ // session rows close only after aria-selected changes, so live session
113
+ // updates cannot strand a delayed click on a detached DOM target.
114
+ //
115
+ // Deliberately NOT closed by this rule:
116
+ // - Settings / Session log: their dialogs render INSIDE the drawer DOM
117
+ // (portaled into the sidebar); closing the drawer would slide the dialog
118
+ // off-screen with it.
119
+ // - Workspace folder rows (YDXeBa_projectRow), the logo: pure UI toggles,
120
+ // not navigation — see NAV_TARGETS in nav-targets.mjs.
121
+ // - Anything while a modal dialog is open: the dialog owns the screen.
122
+ useEffect(() => {
123
+ if (!mobile || !open) return
124
+ let lastTouchNavAt = 0
125
+ let lastTouchX = 0
126
+ let lastTouchY = 0
127
+ let suppressTouchClickUntil = 0
128
+ let pendingTouchRow: Element | null = null
129
+ let selectedRowAtArm: Element | null = null
130
+ let navClickArrived = false
131
+ let navObserver: MutationObserver | null = null
132
+ let navTimer: number | null = null
133
+
134
+ const drawerRoot = (): HTMLElement | null => findDrawer() as HTMLElement | null
135
+
136
+ const disarmNav = (): void => {
137
+ navObserver?.disconnect()
138
+ navObserver = null
139
+ if (navTimer !== null) window.clearTimeout(navTimer)
140
+ navTimer = null
141
+ pendingTouchRow = null
142
+ selectedRowAtArm = null
143
+ navClickArrived = false
144
+ }
145
+
146
+ const armNav = (row: Element): void => {
147
+ disarmNav()
148
+ pendingTouchRow = row
149
+ const drawer = drawerRoot()
150
+ selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
151
+ if (drawer === null) return
152
+ navObserver = new MutationObserver(() => {
153
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
154
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) {
155
+ disarmNav()
156
+ return
157
+ }
158
+ const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
159
+ if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
160
+ disarmNav()
161
+ toggleSidebar()
162
+ }
163
+ })
164
+ navObserver.observe(drawer, {
165
+ childList: true,
166
+ subtree: true,
167
+ attributes: true,
168
+ attributeFilter: ['aria-selected'],
169
+ })
170
+ navTimer = window.setTimeout(disarmNav, 2000)
171
+ }
172
+
173
+ const navigationTarget = (target: EventTarget | null): Element | null => {
174
+ if (document.querySelector('[aria-modal="true"]') !== null) return null
175
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
176
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return null
177
+ if (!(target instanceof Element)) return null
178
+ const drawer = drawerRoot()
179
+ if (drawer === null || !drawer.contains(target)) return null
180
+ return navTargetFor(target)
181
+ }
182
+
183
+ const isPendingTouchClick = (event: MouseEvent): boolean => {
184
+ const capabilities = (event as MouseEvent & {
185
+ sourceCapabilities?: { firesTouchEvents?: boolean }
186
+ }).sourceCapabilities
187
+ if (capabilities?.firesTouchEvents === true) return true
188
+ return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24
189
+ }
190
+
191
+ const onDrawerClick = (event: MouseEvent): void => {
192
+ // A touch row tap owns the close through the selection observer. Let the
193
+ // browser click reach React without a capture-phase close racing it.
194
+ if (performance.now() < suppressTouchClickUntil) return
195
+ if (
196
+ pendingTouchRow !== null
197
+ && performance.now() - lastTouchNavAt < 500
198
+ && isPendingTouchClick(event)
199
+ ) {
200
+ const target = navigationTarget(event.target)
201
+ const row = target?.closest('[role="treeitem"]')
202
+ if (row !== null && row !== undefined) {
203
+ pendingTouchRow = row
204
+ navClickArrived = true
205
+ return
206
+ }
207
+ }
208
+ if (navigationTarget(event.target) !== null) toggleSidebar()
209
+ }
210
+
211
+ const onDrawerPointerUp = (event: PointerEvent): void => {
212
+ if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
213
+ const target = navigationTarget(event.target)
214
+ if (target === null) return
215
+
216
+ const row = target.closest('[role="treeitem"]')
217
+ if (row !== null) {
218
+ if (row.getAttribute('aria-selected') === 'true') {
219
+ // Already-selected rows will not navigate; closing now is safe.
220
+ suppressTouchClickUntil = performance.now() + 500
221
+ toggleSidebar()
222
+ } else {
223
+ // Let React navigate first, then close on the selection mutation.
224
+ lastTouchNavAt = performance.now()
225
+ lastTouchX = event.clientX
226
+ lastTouchY = event.clientY
227
+ armNav(row)
228
+ }
229
+ return
230
+ }
231
+
232
+ // Non-row targets keep their existing click path; only host
233
+ // session/search rows participate in the selected-state hand-off.
234
+ }
235
+
236
+ document.addEventListener('click', onDrawerClick, true)
237
+ document.addEventListener('pointerup', onDrawerPointerUp, true)
238
+ return () => {
239
+ disarmNav()
240
+ document.removeEventListener('click', onDrawerClick, true)
241
+ document.removeEventListener('pointerup', onDrawerPointerUp, true)
242
+ }
243
+ }, [mobile, open, toggleSidebar])
244
+
245
+ // Tap-outside closes the drawer (issue #38). The backdrop is now
246
+ // pointer-events: none (pure dimming layer that never steals clicks), so
247
+ // "tap the dimmed area to close" moves here: any click outside the drawer
248
+ // (and outside the header toggle) closes it, keeping the standard
249
+ // interaction while letting drawer contents receive clicks normally.
250
+ // Capture phase: the close happens before the content processes the tap
251
+ // (same first-tap-closes behaviour as before).
252
+ useEffect(() => {
253
+ if (!mobile || !open) return
254
+ const onOutsideClick = (event: MouseEvent) => {
255
+ if (document.querySelector('[aria-modal="true"]') !== null) return
256
+ const target = event.target as HTMLElement | null
257
+ if (target === null) return
258
+ // 宿主自带抽屉(DSH 0.2.2 起)时,点遮罩关闭是它自己 drawerMask 的
259
+ // onClick;这里再 toggle 一次会把刚关上的抽屉又推开,手机上看就是
260
+ // 「点了没反应」。自带抽屉一律交给宿主。
261
+ if (hasNativeDrawer(document.querySelector('[data-mobile-nav="frame"]'))) return
262
+ if (target.closest(TOGGLE_SELECTOR) !== null) return
263
+ // Portaled overlays (issue #72): the workspace section's "视图选项" /
264
+ // "添加工作区" and the session row kebab all open menus that live on
265
+ // document.body — outside the drawer DOM. Without this exemption the
266
+ // first tap on a menu item is eaten: the drawer slides away, the menu
267
+ // unmounts with the sidebar, and the item's onClick never runs, so
268
+ // every workspace control reads as "点了没反应" on a phone.
269
+ if (isOverlayTap(target)) return
270
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
271
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
272
+ const drawer = findDrawer()
273
+ if (drawer !== null && drawer.contains(target)) return
274
+ toggleSidebar()
275
+ }
276
+ document.addEventListener('click', onOutsideClick, true)
277
+ return () => document.removeEventListener('click', onOutsideClick, true)
278
+ }, [mobile, open, toggleSidebar])
279
+
280
+ if (!mobile) return null
281
+ return (
282
+ <>
283
+ {open && (
284
+ <div data-mobile-nav="backdrop" />
285
+ )}
286
+ {fabVisible && !open && (
287
+ <button
288
+ type="button"
289
+ data-mobile-nav="fab"
290
+ aria-label={t('open')}
291
+ title={t('open')}
292
+ onClick={() => toggleSidebar()}
293
+ >
294
+ <MobileIcon icon={icons.panelLeft} name="panelLeft" size={18} />
295
+ </button>
296
+ )}
297
+ </>
298
+ )
299
+ }
@@ -0,0 +1,57 @@
1
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
2
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { NS } from './locales.ts'
4
+ import { MobileIcon } from './MobileIcon.tsx'
5
+ import { resolveMobileIcons } from './icon-compat.mjs'
6
+
7
+ /** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */
8
+ const icons = resolveMobileIcons(primitives)
9
+
10
+ /** Full props for the session-header directory toggle. */
11
+ export interface MobileNavToggleProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
12
+ /** Bound ctx.layout.toggleSidebar(). */
13
+ toggleSidebar: () => void
14
+ }
15
+
16
+ /**
17
+ * Mobile-only icon buttons next to the session title:
18
+ * - toggle: opens the directory drawer on narrow screens.
19
+ * - files: toggles the dsh-web-ui explorer sheet directly — one tap opens,
20
+ * a second tap closes it, no drawer round-trip. (The drawer footer keeps
21
+ * a Files entry for the hero/blank phases where this header does not
22
+ * exist.)
23
+ * Hidden entirely on wide screens (CSS media query).
24
+ */
25
+ export function MobileNavToggle({ toggleSidebar, t }: MobileNavToggleProps) {
26
+ const toggleExplorer = (): void => {
27
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
28
+ if (frame === null) return
29
+ if (frame.hasAttribute('data-aionui-explorer-open')) {
30
+ frame.removeAttribute('data-aionui-explorer-open')
31
+ } else {
32
+ frame.setAttribute('data-aionui-explorer-open', '')
33
+ }
34
+ }
35
+ return (
36
+ <>
37
+ <button
38
+ type="button"
39
+ data-mobile-nav="toggle"
40
+ aria-label={t('open')}
41
+ title={t('open')}
42
+ onClick={() => toggleSidebar()}
43
+ >
44
+ <MobileIcon icon={icons.panelLeft} name="panelLeft" size={16} />
45
+ </button>
46
+ <button
47
+ type="button"
48
+ data-mobile-nav="files"
49
+ aria-label={t('files')}
50
+ title={t('files')}
51
+ onClick={toggleExplorer}
52
+ >
53
+ <MobileIcon icon={icons.folderOpen} name="folderOpen" size={16} />
54
+ </button>
55
+ </>
56
+ )
57
+ }
@@ -0,0 +1,200 @@
1
+ // 移动端文件链接增强(issue #17,2026-10 修订):新版 DSH 点聊天里的文件链接会
2
+ // 在右侧栏打开文件预览(sidebarRight.openResource('dsh-resource://file/...'),手机
3
+ // 也能看),不再调用桌面端 workspaces.openPath(open <path>)。因此这里**不再拦截
4
+ // 点击**(旧守卫会吞掉现在有效的预览点击),只保留两件事:
5
+ // 1) 在「文案就是文件路径」的链接旁注入一个「复制」按钮,点它经主机 RPC 读文件
6
+ // 正文再写剪贴板(按钮自带内联样式,不依赖 Pocket 旧 mobile.css 是否生效);
7
+ // 2) 隐藏「添加工作区」入口(手机上配工作区无意义)。
8
+ // 移植自 dsh-web-mobile(MIT)。
9
+ //
10
+ // 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
11
+ // <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
12
+
13
+ /** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
14
+ const WS_LABELS = ['添加工作区', '添加工作区…', 'Add workspace', 'Add workspace…']
15
+ /** 复制按钮文案。 */
16
+ const COPY_LABEL = '复制'
17
+
18
+ /** 主机 fileRead 回调返回结构(与 client/api.js 的 FileReadResult 对齐)。 */
19
+ interface ReadFileResponse {
20
+ ok: boolean;
21
+ value?: { content: string; path: string; size: number };
22
+ error?: { message: string };
23
+ }
24
+
25
+ /** 文本是否像文件路径:只认「整段文案就是一条路径」,避免状态行/命令行等长文本误命中。 */
26
+ function looksLikeFilePath(text: string | null): boolean {
27
+ const t = (text ?? '').trim()
28
+ if (t.length < 3 || t.length > 320) return false
29
+ // 真实文件链接的文案就是纯路径:带空格或中文的(过程状态行、命令输出等)一律不算。
30
+ if (/\s/.test(t) || /[\u3400-\u9fff]/.test(t)) return false
31
+ if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true
32
+ if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true
33
+ if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true
34
+ return false
35
+ }
36
+
37
+ /** 写剪贴板:优先 navigator.clipboard,非安全上下文(局域网 http)回退 execCommand。 */
38
+ async function copyText(text: string): Promise<boolean> {
39
+ try {
40
+ if (navigator.clipboard?.writeText) {
41
+ await navigator.clipboard.writeText(text)
42
+ return true
43
+ }
44
+ } catch { /* 回退 */ }
45
+ try {
46
+ const ta = document.createElement('textarea')
47
+ ta.value = text
48
+ ta.style.position = 'fixed'
49
+ ta.style.top = '-9999px'
50
+ ta.style.opacity = '0'
51
+ document.body.appendChild(ta)
52
+ ta.focus()
53
+ ta.select()
54
+ const okCopy = document.execCommand('copy')
55
+ ta.remove()
56
+ return okCopy
57
+ } catch {
58
+ return false
59
+ }
60
+ }
61
+
62
+ export function startFileGuard(
63
+ readFile: (path: string) => Promise<ReadFileResponse>,
64
+ ): () => void {
65
+ // 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
66
+ let toastEl: HTMLElement | null = null
67
+ let toastTimer: number | null = null
68
+ const showToast = (text: string): void => {
69
+ if (toastEl === null) {
70
+ toastEl = document.createElement('div')
71
+ toastEl.setAttribute('data-mobile-nav', 'file-guard-toast')
72
+ Object.assign(toastEl.style, {
73
+ position: 'fixed',
74
+ left: '50%',
75
+ bottom: '64px',
76
+ transform: 'translateX(-50%)',
77
+ maxWidth: '84vw',
78
+ zIndex: '9999',
79
+ padding: '10px 14px',
80
+ borderRadius: '10px',
81
+ background: 'rgba(20,22,28,.92)',
82
+ color: '#fff',
83
+ fontSize: '13px',
84
+ lineHeight: '1.4',
85
+ textAlign: 'center',
86
+ fontFamily: 'inherit',
87
+ boxShadow: '0 4px 16px rgba(0,0,0,.28)',
88
+ pointerEvents: 'none',
89
+ opacity: '0',
90
+ transition: 'opacity .18s ease',
91
+ } as CSSStyleDeclaration)
92
+ document.body.appendChild(toastEl)
93
+ }
94
+ toastEl.textContent = text
95
+ requestAnimationFrame(() => {
96
+ if (toastEl !== null) toastEl.style.opacity = '1'
97
+ })
98
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
99
+ toastTimer = window.setTimeout(() => {
100
+ if (toastEl !== null) toastEl.style.opacity = '0'
101
+ }, 2600)
102
+ }
103
+
104
+ // 在文件链接旁注入「复制」按钮:点它经主机 RPC 读文件正文再写剪贴板。
105
+ // 用 data-mobile-nav-copy 标记已处理的链接,避免重复注入;React 重渲染会
106
+ // 产生新元素(无标记),MutationObserver 重新补上按钮。
107
+ const injectCopyButtons = (): void => {
108
+ const links = document.querySelectorAll('button, a')
109
+ links.forEach((el) => {
110
+ if (el.getAttribute('data-mobile-nav-copy') === '1') return
111
+ const txt = (el.textContent ?? '').trim()
112
+ if (!looksLikeFilePath(txt)) return
113
+ el.setAttribute('data-mobile-nav-copy', '1')
114
+ const btn = document.createElement('button')
115
+ btn.type = 'button'
116
+ btn.setAttribute('data-mobile-nav', 'copy-file')
117
+ btn.textContent = COPY_LABEL
118
+ // 自带内联样式:不依赖 Pocket 旧 mobile.css 是否生效(那个整表可能被别的
119
+ // 手机布局插件停用),否则深色主题下会退化成浏览器默认的灰色大按钮。
120
+ btn.style.height = '22px'
121
+ btn.style.padding = '0 8px'
122
+ btn.style.marginLeft = '6px'
123
+ btn.style.border = '1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .14))'
124
+ btn.style.borderRadius = '6px'
125
+ btn.style.background = 'var(--dsw-alias-bg-layer-1, #2b2b2e)'
126
+ btn.style.color = 'var(--dsw-alias-label-primary, inherit)'
127
+ btn.style.fontFamily = 'inherit'
128
+ btn.style.fontSize = '11px'
129
+ btn.style.lineHeight = '1'
130
+ btn.style.cursor = 'pointer'
131
+ btn.style.verticalAlign = 'baseline'
132
+ btn.addEventListener('click', async (e) => {
133
+ e.preventDefault()
134
+ e.stopPropagation()
135
+ const filePath = (el.textContent ?? '').trim()
136
+ btn.disabled = true
137
+ btn.textContent = '…'
138
+ try {
139
+ const res = await readFile(filePath)
140
+ if (!res?.ok) {
141
+ showToast(res?.error?.message ?? '复制失败')
142
+ return
143
+ }
144
+ const content = res.value?.content ?? ''
145
+ const copied = await copyText(content)
146
+ if (copied) {
147
+ const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024))
148
+ showToast(`已复制文件内容(${kb} KB)`)
149
+ } else {
150
+ showToast('复制失败,请手动选择')
151
+ }
152
+ } catch (err) {
153
+ showToast(err instanceof Error ? err.message : '复制失败')
154
+ } finally {
155
+ btn.disabled = false
156
+ btn.textContent = COPY_LABEL
157
+ }
158
+ })
159
+ el.parentElement?.insertBefore(btn, el.nextSibling)
160
+ })
161
+ }
162
+ injectCopyButtons()
163
+ const copyObserver = new MutationObserver(() => injectCopyButtons())
164
+ copyObserver.observe(document.body, { childList: true, subtree: true })
165
+
166
+ // 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
167
+ // 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
168
+ const hideWsEntries = (): (() => void) => {
169
+ const checkOne = (node: Node): void => {
170
+ if (node.nodeType !== 1) return
171
+ const el = node as HTMLElement
172
+ const txt = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
173
+ if (WS_LABELS.includes(txt)) {
174
+ el.style.display = 'none'
175
+ el.setAttribute('data-mobile-nav-hide', 'add-workspace')
176
+ }
177
+ }
178
+ const sel = '[role="menuitem"],[role="option"],li,button,a'
179
+ document.querySelectorAll(sel).forEach(checkOne)
180
+ const observer = new MutationObserver((mutations) => {
181
+ for (const m of mutations) {
182
+ m.addedNodes.forEach((n) => {
183
+ if (n.nodeType !== 1) return
184
+ checkOne(n)
185
+ ;(n as HTMLElement).querySelectorAll?.(sel).forEach(checkOne)
186
+ })
187
+ }
188
+ })
189
+ observer.observe(document.body, { childList: true, subtree: true })
190
+ return () => observer.disconnect()
191
+ }
192
+ const disconnectWs = hideWsEntries()
193
+
194
+ return () => {
195
+ copyObserver.disconnect()
196
+ disconnectWs()
197
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
198
+ toastEl?.remove()
199
+ }
200
+ }
@@ -0,0 +1,90 @@
1
+ // DSH primitives 图标导出的跨版本兼容层。
2
+ //
3
+ // 背景(2026-09 实测,dsh 0.1.7-rc.2 + dsh-pocket 2.10.6):
4
+ // 移动端组件原来直接具名导入
5
+ // IconPanelLeftOutline16 / IconDownloadOutline16 / IconFolderOpenOutline16,
6
+ // 而 @deepseek-ai/dsh-client-ui-primitives 在 0.1.7-rc 之后把图标导出改成了
7
+ // Icon<Name>OutlineRegular / Icon<Name>OutlineMedium(`16` 尺寸后缀被移除)。
8
+ // 于是这些导入在运行期变成 undefined —— 组件一渲染就抛 React #130
9
+ // ("Element type is invalid ... got: undefined"),slot 条目整条崩掉:
10
+ // 移动样式表照样注入,但 `data-mobile-nav="frame"` 标记永远挂不上,抽屉彻底失效。
11
+ // 手机上的表现:56px 侧栏图标条一直占位、正文被挤成一列(用户可见症状)。
12
+ //
13
+ // 这里做三件事:
14
+ // 1. 不再按具体名字静态导入 —— 新版名字在旧版不存在、旧版名字在新版不存在;
15
+ // 对真正的 ESM 来说,具名导入不存在的导出是**链接期**错误,比 undefined 更难查;
16
+ // 2. 用候选名列表在运行时解析,先后覆盖新旧两代命名;
17
+ // 3. 全都解析不到时返回 null(绝不返回 undefined),调用方用文字字形兜底 ——
18
+ // 在任何一代 DSH 上都不会再把整条 slot 打崩。
19
+ //
20
+ // 纯数据 + 纯函数、无 DOM 依赖,便于 `node --test` 直接引(test/icon-compat.test.js)。
21
+
22
+ /** 每个图标按「新名优先、旧名兜底」的候选顺序。 */
23
+ export const MOBILE_ICON_CANDIDATES = {
24
+ panelLeft: [
25
+ 'IconPanelLeftOutlineRegular',
26
+ 'IconPanelLeftOutlineMedium',
27
+ 'IconPanelLeftOutline16',
28
+ 'IconPanelLeftOutline',
29
+ ],
30
+ download: [
31
+ 'IconDownloadOutlineRegular',
32
+ 'IconDownloadOutlineMedium',
33
+ 'IconDownloadOutline16',
34
+ 'IconDownloadOutline',
35
+ ],
36
+ folderOpen: [
37
+ 'IconFolderOpenOutlineRegular',
38
+ 'IconFolderOpenOutlineMedium',
39
+ 'IconFolderOpenOutline16',
40
+ 'IconFolderOpenOutline',
41
+ 'IconFolderOpen',
42
+ ],
43
+ }
44
+
45
+ /** 解析不到图标时用的文字字形(保证按钮仍然可见、可点、可读)。 */
46
+ export const MOBILE_ICON_FALLBACK = {
47
+ panelLeft: '☰',
48
+ download: '⤓',
49
+ folderOpen: '▤',
50
+ }
51
+
52
+ /**
53
+ * 注册表里的某个导出能不能当 React 组件渲染。
54
+ * 组件可能是函数,也可能是对象(memo / forwardRef / class 组件),所以两者都收;
55
+ * 数字、字符串、null、undefined 一律不算 —— 它们正是打崩 slot 的元凶。
56
+ * @param value - 待判定的导出值。
57
+ * @returns 可渲染时为 true。
58
+ */
59
+ export function isRenderableIcon(value) {
60
+ if (typeof value === 'function') return true
61
+ return typeof value === 'object' && value !== null
62
+ }
63
+
64
+ /**
65
+ * 按候选顺序取第一个可渲染的导出。
66
+ * @param registry - 宿主 primitives 命名空间(可能为 null/不是对象)。
67
+ * @param names - 候选导出名,按优先级排列。
68
+ * @returns 命中的组件;一个都没有时返回 null(**绝不返回 undefined**)。
69
+ */
70
+ export function pickIcon(registry, names) {
71
+ if (registry === null || typeof registry !== 'object') return null
72
+ for (const name of names) {
73
+ const candidate = registry[name]
74
+ if (isRenderableIcon(candidate)) return candidate
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * 把宿主 primitives 命名空间解析成移动端要用的三个图标。
81
+ * @param registry - 宿主 primitives 命名空间。
82
+ * @returns `{ panelLeft, download, folderOpen }`,缺的为 null(不是 undefined)。
83
+ */
84
+ export function resolveMobileIcons(registry) {
85
+ const resolved = {}
86
+ for (const [key, names] of Object.entries(MOBILE_ICON_CANDIDATES)) {
87
+ resolved[key] = pickIcon(registry, names)
88
+ }
89
+ return resolved
90
+ }