dsh-zen-remote 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.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,426 @@
1
+ import { useEffect, useState } from 'react'
2
+ import {
3
+ IconChecklistOutline14,
4
+ IconCodeOutline16,
5
+ IconCordisPluginOutline14,
6
+ IconDataOutline16,
7
+ IconDownloadOutline16,
8
+ IconEllipsisOutline16,
9
+ IconPanelLeftOutline16,
10
+ } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
12
+ import type { MobileNavKey } from './locales.ts'
13
+ import { isChipEnabled, toggleChip, useChipsPrefs } from './chips-store.ts'
14
+
15
+ /** One icon component's minimal shared shape (every `@deepseek-ai/dsh-client-ui-primitives` icon accepts `size`, regardless of the fixed number in its own name — see e.g. IconDownloadOutline16 used at size 14 elsewhere in this codebase). */
16
+ type IconFC = (props: { size?: number }) => React.JSX.Element
17
+
18
+ /**
19
+ * One home-screen chip's static registration (S5). `selector` is the
20
+ * stable, non-hashed DOM anchor for the plugin's REAL entry button — the
21
+ * chip never reimplements the target feature, it just "代点" (synthetic
22
+ * `.click()`, bypasses hit-testing per the S2.1 precedent in AGENTS.md) the
23
+ * button the plugin itself already renders elsewhere in the tree, exactly
24
+ * like MobileSessionHeader's Chat/Trajectory tab click and the workbench
25
+ * button's `[data-dsh-better-sidebar] button[class$="_toggleButton"]`.
26
+ * `selector: null` marks the one chip (session log) whose availability and
27
+ * action come from injected props instead of a DOM probe — it is a
28
+ * first-party dsh-session-log-export service call, not a third-party
29
+ * plugin's button.
30
+ */
31
+ export interface ChipDef {
32
+ id: string
33
+ label: MobileNavKey
34
+ Icon: IconFC
35
+ selector: string | null
36
+ }
37
+
38
+ /**
39
+ * Task board / SSH entry buttons: not verified live in this environment (the
40
+ * "web" profile currently installs neither plugin — checked
41
+ * `~/.dsh/profiles/web/pnpm-lock.yaml`), but the exact selectors already
42
+ * appear in MobileNavOverlay.tsx's drawer-close-on-navigate matcher
43
+ * (`button[data-dsh-taskboard-entry]` / `button[data-dsh-ssh-entry]`), which
44
+ * an earlier slice already confirmed live against those plugins. Reused
45
+ * verbatim rather than re-derived. Both entries render inside the sidebar
46
+ * tree (same as the settings trigger below) but need no portal fix of their
47
+ * own: MobileNavOverlay's own comment classes them as "navigation" — they
48
+ * take over the MAIN content area rather than opening a dialog nested in
49
+ * the sidebar, so a plain `.click()` is enough once the sidebar's
50
+ * display:none stops mattering (only the SOURCE button needs to exist in
51
+ * the DOM for the synthetic click to dispatch, not be painted).
52
+ */
53
+ const TASKBOARD_SELECTOR = 'button[data-dsh-taskboard-entry]'
54
+ const SSH_SELECTOR = 'button[data-dsh-ssh-entry]'
55
+
56
+ /**
57
+ * Better-sidebar's own workbench toggle — the exact selector
58
+ * MobileSessionHeader.tsx's header workbench button already clicks. Its
59
+ * panel is better-sidebar's own top-level mount (`[data-dsh-better-sidebar]`),
60
+ * not nested in our sidebar tree, so it needs no portal fix either.
61
+ */
62
+ const FILES_SELECTOR = '[data-dsh-better-sidebar] button[class$="_toggleButton"]'
63
+
64
+ /**
65
+ * dsh-usage-stats' sidebar-footer badge (`sidebar.footer.action`, order 10 —
66
+ * AGENTS.md). Read straight from the installed plugin's own
67
+ * `~/.dsh/profiles/web/node_modules/dsh-usage-stats/lib/client.js`:
68
+ * `data-usage-stats-badge` on the trigger button, `data-usage-stats-panel`
69
+ * on its `position: fixed` result panel (no backdrop/mask, unlike settings).
70
+ * Both live inside the sidebar's `footerActions` row — display:none on the
71
+ * phone breakpoint's sidebar root (home.css.ts) hides them exactly like the
72
+ * settings dialog, so the badge's panel needs the SAME portal fix
73
+ * (styles/chips.css.ts, gated on `:has([data-usage-stats-panel])` alongside
74
+ * the settings gate on `:has([aria-modal="true"])`).
75
+ */
76
+ const USAGE_SELECTOR = 'button[data-usage-stats-badge]'
77
+
78
+ /** Static chip registry (S5). Order here is the default row order. */
79
+ export const CHIP_DEFS: readonly ChipDef[] = [
80
+ { id: 'taskboard', label: 'chipTaskboard', Icon: IconChecklistOutline14, selector: TASKBOARD_SELECTOR },
81
+ { id: 'ssh', label: 'chipSsh', Icon: IconCodeOutline16, selector: SSH_SELECTOR },
82
+ { id: 'files', label: 'files', Icon: IconPanelLeftOutline16, selector: FILES_SELECTOR },
83
+ { id: 'usage', label: 'chipUsage', Icon: IconDataOutline16, selector: USAGE_SELECTOR },
84
+ { id: 'sessionLog', label: 'sessionLog', Icon: IconDownloadOutline16, selector: null },
85
+ ]
86
+
87
+ /**
88
+ * Live presence of each selector-backed chip's target button, refreshed on
89
+ * every DOM mutation (plugins mount their sidebar-footer entries
90
+ * asynchronously, same as the FAB-visibility / aionui-compat effects
91
+ * elsewhere in this plugin). Not gated by `mobile`/viewport — cheap, and
92
+ * MobileHome itself already returns null above 768px, so this hook's
93
+ * subscription lives only as long as the phone home screen does.
94
+ */
95
+ function useDetectedIds(): ReadonlySet<string> {
96
+ const [detected, setDetected] = useState<ReadonlySet<string>>(() => new Set())
97
+ useEffect(() => {
98
+ const sync = (): void => {
99
+ const next = new Set<string>()
100
+ for (const def of CHIP_DEFS) {
101
+ if (def.selector !== null && document.querySelector(def.selector) !== null) next.add(def.id)
102
+ }
103
+ setDetected(next)
104
+ }
105
+ sync()
106
+ const observer = new MutationObserver(sync)
107
+ observer.observe(document.body, { childList: true, subtree: true })
108
+ return () => observer.disconnect()
109
+ }, [])
110
+ return detected
111
+ }
112
+
113
+ /**
114
+ * S5.1: the live DOM anchor every third-party plugin's sidebar-footer entry
115
+ * mounts under (verified 2026-08-17 against the running dev profile —
116
+ * `renderSlot` wraps the list slot's rendered children in a
117
+ * `display: contents` DIV carrying this exact attribute, one plugin's
118
+ * output per DIRECT child). Unlike every other selector in this file this
119
+ * one is not a specific plugin's button — it is the harvest root.
120
+ */
121
+ const FOOTER_ACTION_SLOT_SELECTOR = '[data-slot="sidebar.footer.action"]'
122
+
123
+ /**
124
+ * One auto-discovered ("harvested") home-screen chip: some OTHER plugin's
125
+ * own sidebar-footer-action button, picked up live from the DOM instead of
126
+ * being hand-registered in {@link CHIP_DEFS}. See AGENTS.md's "Chip
127
+ * harvest" note for the mechanism and its known limits (locale-keyed
128
+ * `id`).
129
+ */
130
+ interface HarvestedChip {
131
+ /** `harvest:` + the discovered name — the persisted-pref key AND the
132
+ * React list key. Not a slug: the name IS the stable-enough identity:
133
+ * switching the DSH UI language renames the source plugin's label, which
134
+ * mints a new id and orphans the old toggle preference (harmless, same
135
+ * as any other stale `ChipPrefs` key — chips-store.ts's `isChipEnabled`
136
+ * never errors on an unknown key). Accepted limitation, not fixed here. */
137
+ id: string
138
+ name: string
139
+ /** Sanitized cloned `<svg>…</svg>` markup, or `''` when the source
140
+ * button has no icon (falls back to {@link IconCordisPluginOutline14}). */
141
+ iconHtml: string
142
+ /** The plugin's OWN button/link — `.click()`ed directly, same "代点"
143
+ * precedent as every selector-backed {@link ChipDef}. */
144
+ el: HTMLElement
145
+ }
146
+
147
+ /**
148
+ * `name` extraction order (S5.1 plan): visible text first, then the two
149
+ * standard fallbacks for icon-only controls. Returns `''` (caller skips
150
+ * the entry) when none of the three yield anything — an unnamed chip
151
+ * would be unreadable and untoggleable.
152
+ */
153
+ function harvestName(el: HTMLElement): string {
154
+ const text = (el.textContent ?? '').trim()
155
+ if (text !== '') return text
156
+ const aria = el.getAttribute('aria-label')?.trim()
157
+ if (aria !== undefined && aria !== '') return aria
158
+ const title = el.getAttribute('title')?.trim()
159
+ if (title !== undefined && title !== '') return title
160
+ return ''
161
+ }
162
+
163
+ /**
164
+ * Deep-clones the source icon and strips every `id` (source and
165
+ * descendants) so a colliding `<clipPath id="a">`/`url(#a)` pair between
166
+ * two harvested plugins' icons can never cross-reference. Serialized to a
167
+ * string (not kept as a live node) so React can own it via
168
+ * `dangerouslySetInnerHTML` — the source `<svg>` stays exactly where the
169
+ * other plugin's own React tree put it.
170
+ */
171
+ function cloneIconHtml(svg: SVGElement): string {
172
+ const clone = svg.cloneNode(true) as SVGElement
173
+ clone.removeAttribute('id')
174
+ clone.querySelectorAll('[id]').forEach((node) => node.removeAttribute('id'))
175
+ const wrap = document.createElement('div')
176
+ wrap.appendChild(clone)
177
+ return wrap.innerHTML
178
+ }
179
+
180
+ /**
181
+ * True when `el` (or an ancestor up to and including `boundary`) looks like
182
+ * a transient dialog/overlay rather than a stable entry control: `role`
183
+ * `"dialog"`, `aria-modal="true"`, or a computed `position: fixed` (the
184
+ * shape every popup/panel in this codebase and its verified third-party
185
+ * plugins uses — settings, usage-stats' panel, scheduled-tasks' overlay).
186
+ * `getComputedStyle` resolves `position` from the cascade regardless of
187
+ * whether an ancestor is `display: none` (verified live 2026-08-17 against
188
+ * the hidden <768px sidebar), so this is reliable even though the subtree
189
+ * being scanned is not currently painted.
190
+ */
191
+ function hasDialogAncestor(el: HTMLElement, boundary: HTMLElement): boolean {
192
+ let node: HTMLElement | null = el
193
+ while (node !== null) {
194
+ if (node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true') return true
195
+ if (getComputedStyle(node).position === 'fixed') return true
196
+ if (node === boundary) break
197
+ node = node.parentElement
198
+ }
199
+ return false
200
+ }
201
+
202
+ /**
203
+ * The harvest scan (S5.1, stability fix S5.2 2026-08-17). Walks the DIRECT
204
+ * children of the footer-action slot root. The "one child = one plugin's
205
+ * entire output" assumption from S5.1 only holds when the plugin's
206
+ * component returns a single root element — it does NOT hold when a
207
+ * component returns a Fragment with multiple top-level nodes (e.g. a
208
+ * trigger button plus a conditionally-rendered dialog): the slot renderer
209
+ * has no per-entry wrapper, so a Fragment's second root node lands as its
210
+ * OWN direct child of the slot container the moment it mounts. Real-device
211
+ * report (2026-08-17): the "定时任务" (scheduled-tasks) plugin does exactly
212
+ * this — clicking its trigger inserts a NEW direct sibling child
213
+ * (`position: fixed` overlay + dialog) carrying several of its own buttons
214
+ * (close, "+ 新建任务", …), and the old querySelectorAll-everything scan
215
+ * harvested every one of them as a brand new chip on the next
216
+ * MutationObserver tick, while the dialog itself stayed invisible (nested
217
+ * under the <768px sidebar's `display: none` root — same class of bug
218
+ * chips.css.ts already portal-fixes for settings/usage-stats, see below).
219
+ *
220
+ * Three exclusions, in order:
221
+ * 1. **Self**: any child that IS or CONTAINS a `[data-mobile-nav]` node —
222
+ * every element this plugin itself renders carries that attribute
223
+ * (AGENTS.md Conventions), so this one check keeps the harvest from
224
+ * re-discovering our own Files/Session-log buttons as "new" chips.
225
+ * 2. **Dialog/overlay children**: a child that IS, or whose first clickable
226
+ * sits inside, a dialog-like element ({@link hasDialogAncestor}) is
227
+ * skipped ENTIRELY — such children are the transient expanded content of
228
+ * an entry this scan already harvested elsewhere (or will, from its own
229
+ * always-present trigger), not a new plugin's persistent entry point.
230
+ * 3. **Already precisely wired**: a harvested button that is the SAME DOM
231
+ * node a {@link CHIP_DEFS} selector already resolves to (dsh-usage-stats'
232
+ * badge is both — verified live) is dropped, so the precise entry (better
233
+ * icon/label) always wins and the row never shows the plugin twice.
234
+ *
235
+ * Identity is bound to the CHILD, not to individual buttons: at most one
236
+ * candidate — the first clickable in DOM order (`querySelector`, not
237
+ * `querySelectorAll`) — is taken per direct child. A child that later grows
238
+ * MORE buttons in its own subtree (an inline, non-dialog expansion) keeps
239
+ * resolving to the same first button on every rescan, so it never mints
240
+ * additional chips either.
241
+ */
242
+ function scanHarvest(): HarvestedChip[] {
243
+ const container = document.querySelector(FOOTER_ACTION_SLOT_SELECTOR)
244
+ if (container === null) return []
245
+ const result: HarvestedChip[] = []
246
+ for (const raw of Array.from(container.children)) {
247
+ const child = raw as HTMLElement
248
+ if (child.hasAttribute('data-mobile-nav') || child.querySelector('[data-mobile-nav]') !== null) continue
249
+ const el: HTMLElement | null = child.matches('button, a[href]') ? child : child.querySelector('button, a[href]')
250
+ if (el === null) continue
251
+ if (hasDialogAncestor(el, child)) continue
252
+ if (CHIP_DEFS.some((def) => def.selector !== null && document.querySelector(def.selector) === el)) continue
253
+ const name = harvestName(el)
254
+ if (name === '') continue
255
+ const svg = el.querySelector('svg')
256
+ result.push({ id: `harvest:${name}`, name, iconHtml: svg === null ? '' : cloneIconHtml(svg), el })
257
+ }
258
+ return result
259
+ }
260
+
261
+ /**
262
+ * Live harvested-chip list, refreshed on every DOM mutation — same
263
+ * MutationObserver shape as {@link useDetectedIds} right above (plugins
264
+ * mount their footer entries asynchronously; removing a plugin removes its
265
+ * entry the same way). Kept as a second, independent observer rather than
266
+ * folded into `useDetectedIds`: the two scans answer unrelated questions
267
+ * (is a KNOWN selector present vs. what's UNKNOWN in the slot) and this
268
+ * keeps each hook's diff small and easy to reason about on its own.
269
+ */
270
+ function useHarvestedChips(): readonly HarvestedChip[] {
271
+ const [harvested, setHarvested] = useState<readonly HarvestedChip[]>(() => [])
272
+ useEffect(() => {
273
+ const sync = (): void => setHarvested(scanHarvest())
274
+ sync()
275
+ const observer = new MutationObserver(sync)
276
+ observer.observe(document.body, { childList: true, subtree: true })
277
+ return () => observer.disconnect()
278
+ }, [])
279
+ return harvested
280
+ }
281
+
282
+ /** One harvested chip's icon: the plugin's own (sanitized, cloned) SVG, or the generic plugin glyph when it has none. */
283
+ function HarvestIcon({ html }: { html: string }) {
284
+ if (html === '') return <IconCordisPluginOutline14 size={16} />
285
+ return <span data-mobile-nav="chip-harvest-icon" dangerouslySetInnerHTML={{ __html: html }} />
286
+ }
287
+
288
+ /** Session-log's own availability/action, and everyone else's `.click()` target. */
289
+ function activate(def: ChipDef, sessionId: string | undefined, downloadSessionLog: (id: string) => void): void {
290
+ if (def.id === 'sessionLog') {
291
+ if (sessionId !== undefined) downloadSessionLog(sessionId)
292
+ return
293
+ }
294
+ if (def.selector !== null) document.querySelector<HTMLButtonElement>(def.selector)?.click()
295
+ }
296
+
297
+ /** Full props for the chip row. */
298
+ export interface MobileHomeChipsProps {
299
+ t: Translate<MobileNavKey>
300
+ sessionId: string | undefined
301
+ downloadSessionLog: (id: string) => void
302
+ onCustomize: () => void
303
+ }
304
+
305
+ /**
306
+ * Plugin-entry chips row (S5): a horizontally-scrolling line of 34px pills
307
+ * between the workspace title and the session list, plus a trailing "···"
308
+ * that opens the customize sheet (MobileHomeChipsSheetBody below, rendered
309
+ * by MobileHome inside its existing home-sheet chrome). Every chip renders
310
+ * only when both true: the user has not hidden it (useChipsPrefs) AND its
311
+ * target actually exists right now (useDetectedIds / sessionId) — an
312
+ * uninstalled plugin's chip never appears, matching the plan's "按用户实装
313
+ * 插件逐个接入口". Chips beyond {@link CHIP_DEFS} (S5.1: any OTHER plugin's
314
+ * own sidebar-footer-action entry) are auto-discovered by
315
+ * {@link useHarvestedChips} and appended after the hand-registered ones —
316
+ * "装了新插件、chips 行零代码长出对应入口".
317
+ */
318
+ export function MobileHomeChips({ t, sessionId, downloadSessionLog, onCustomize }: MobileHomeChipsProps) {
319
+ const detected = useDetectedIds()
320
+ const harvested = useHarvestedChips()
321
+ const prefs = useChipsPrefs()
322
+ const visible = CHIP_DEFS.filter((def) => {
323
+ if (!isChipEnabled(prefs, def.id)) return false
324
+ return def.id === 'sessionLog' ? sessionId !== undefined : detected.has(def.id)
325
+ })
326
+ const visibleHarvested = harvested.filter((h) => isChipEnabled(prefs, h.id))
327
+ return (
328
+ <div data-mobile-nav="chip-row">
329
+ {visible.map((def) => (
330
+ <button
331
+ key={def.id}
332
+ type="button"
333
+ data-mobile-nav="chip"
334
+ onClick={() => activate(def, sessionId, downloadSessionLog)}
335
+ >
336
+ <def.Icon size={16} />
337
+ <span>{t(def.label)}</span>
338
+ </button>
339
+ ))}
340
+ {visibleHarvested.map((h) => (
341
+ <button key={h.id} type="button" data-mobile-nav="chip" onClick={() => h.el.click()}>
342
+ <HarvestIcon html={h.iconHtml} />
343
+ <span>{h.name}</span>
344
+ </button>
345
+ ))}
346
+ <button
347
+ type="button"
348
+ data-mobile-nav="chip-more"
349
+ aria-label={t('chipCustomize')}
350
+ title={t('chipCustomize')}
351
+ onClick={onCustomize}
352
+ >
353
+ <IconEllipsisOutline16 size={16} />
354
+ </button>
355
+ </div>
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Customize-sheet body (S5, existence filter added 2026-08-17 real-device
361
+ * follow-up): one toggle row per chip whose target actually exists —
362
+ * `useDetectedIds()`, the SAME live probe `MobileHomeChips` uses for the row
363
+ * itself. Originally listed every `CHIP_DEFS` entry unconditionally, on the
364
+ * theory that a not-yet-installed plugin's toggle should still be
365
+ * pre-settable; real-device feedback was that this instead surfaces dead
366
+ * switches for plugins the user never installed (taskboard/ssh). Detection
367
+ * is a live MutationObserver subscription, so installing the plugin later
368
+ * still makes its toggle appear with no reload needed — "将来装了自动出现"
369
+ * survives the change, it just also stops showing before that. `sessionLog`
370
+ * (selector: null) is exempt: its existence is a runtime session-scope
371
+ * question, not a plugin-install question, so it always lists regardless of
372
+ * whether a session happens to be open right now. Stale prefs entries for a
373
+ * chip that no longer renders (e.g. an uninstalled plugin's old toggle
374
+ * value) are simply never read — `isChipEnabled`/`toggleChip`
375
+ * (chips-store.ts) key off `CHIP_DEFS` ids already, so leftover keys in the
376
+ * persisted object are inert, not an error. Rendered by MobileHome inside
377
+ * the SAME home-sheet-layer/mask/sheet chrome the workspace switcher uses
378
+ * (S6's drag-to-close and mask-click-close already bind generically to
379
+ * `[data-mobile-nav="home-sheet"]`, so this second use needs no new gesture
380
+ * wiring — see styles/chips.css.ts).
381
+ */
382
+ export function MobileHomeChipsSheetBody({ t }: { t: Translate<MobileNavKey> }) {
383
+ const prefs = useChipsPrefs()
384
+ const detected = useDetectedIds()
385
+ const harvested = useHarvestedChips()
386
+ const listed = CHIP_DEFS.filter((def) => def.selector === null || detected.has(def.id))
387
+ return (
388
+ <>
389
+ <div data-mobile-nav="home-sheet-title">{t('chipCustomize')}</div>
390
+ {listed.map((def) => {
391
+ const enabled = isChipEnabled(prefs, def.id)
392
+ return (
393
+ <div key={def.id} data-mobile-nav="chip-toggle-row">
394
+ <def.Icon size={16} />
395
+ <span data-mobile-nav="chip-toggle-label">{t(def.label)}</span>
396
+ <button
397
+ type="button"
398
+ role="switch"
399
+ aria-checked={enabled}
400
+ aria-label={t(def.label)}
401
+ data-mobile-nav="chip-toggle"
402
+ onClick={() => toggleChip(def.id)}
403
+ />
404
+ </div>
405
+ )
406
+ })}
407
+ {harvested.map((h) => {
408
+ const enabled = isChipEnabled(prefs, h.id)
409
+ return (
410
+ <div key={h.id} data-mobile-nav="chip-toggle-row">
411
+ <HarvestIcon html={h.iconHtml} />
412
+ <span data-mobile-nav="chip-toggle-label">{h.name}</span>
413
+ <button
414
+ type="button"
415
+ role="switch"
416
+ aria-checked={enabled}
417
+ aria-label={h.name}
418
+ data-mobile-nav="chip-toggle"
419
+ onClick={() => toggleChip(h.id)}
420
+ />
421
+ </div>
422
+ )
423
+ })}
424
+ </>
425
+ )
426
+ }