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,279 @@
1
+ import { useEffect, useLayoutEffect, useState } from 'react'
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
+ import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import { NS } from './locales.ts'
5
+
6
+ /** Full props for the shell overlay entry. */
7
+ export interface MobileNavOverlayProps extends PropsRuntime<'shell.overlay'>, PropsLocale<typeof NS> {
8
+ /** Bound ctx.layout.toggleSidebar(). */
9
+ toggleSidebar: () => void
10
+ }
11
+
12
+ /** Same breakpoint as the shell's SIDEBAR_AUTO_COLLAPSE (viewport < 1024). */
13
+ const MOBILE_QUERY = '(max-width: 1023px)'
14
+
15
+ /**
16
+ * The tablet range, where the sidebar is still a drawer. Below 768px the
17
+ * phone app shell (MobileHome) replaced the drawer entirely — the sidebar is
18
+ * display:none there, so every drawer affordance below (backdrop, floating
19
+ * opener, Escape, close-on-navigate) would operate an invisible panel.
20
+ */
21
+ const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023px)'
22
+
23
+ /**
24
+ * Live matchMedia hook.
25
+ * @param query - the media query to follow.
26
+ * @returns whether it currently matches.
27
+ */
28
+ function useMedia(query: string): boolean {
29
+ const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
30
+ useEffect(() => {
31
+ const list = window.matchMedia(query)
32
+ const onChange = (event: MediaQueryListEvent) => setMatches(event.matches)
33
+ setMatches(list.matches)
34
+ list.addEventListener('change', onChange)
35
+ return () => list.removeEventListener('change', onChange)
36
+ }, [query])
37
+ return matches
38
+ }
39
+
40
+ /** The AppFrame element: direct parent of the shell overlay layer. */
41
+ function findFrame(): HTMLElement | null {
42
+ return document.querySelector('[data-shell-overlay]')?.parentElement ?? null
43
+ }
44
+
45
+ /**
46
+ * Mobile shell overlay: owns the `data-mobile-nav` marker on the AppFrame
47
+ * element (the CSS restructure keys off it), mirrors the frame's collapsed
48
+ * state into React state, and renders the dimmed backdrop plus a floating
49
+ * directory button for the hero/blank phases that have no session header.
50
+ */
51
+ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
52
+ const mobile = useMedia(MOBILE_QUERY)
53
+ const tablet = useMedia(TABLET_QUERY)
54
+ const [open, setOpen] = useState(false)
55
+ const [fabVisible, setFabVisible] = useState(false)
56
+
57
+ // Frame ownership + open-state mirror. On wide screens this effect is inert:
58
+ // the marker is never set, so the layout is untouched.
59
+ useLayoutEffect(() => {
60
+ if (!mobile) {
61
+ setOpen(false)
62
+ return
63
+ }
64
+ const frame = findFrame()
65
+ if (frame === null) return
66
+ frame.setAttribute('data-mobile-nav', 'frame')
67
+ const sync = () => setOpen(!frame.hasAttribute('data-sidebar-collapsed'))
68
+ sync()
69
+ const observer = new MutationObserver(sync)
70
+ observer.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] })
71
+ return () => {
72
+ observer.disconnect()
73
+ frame.removeAttribute('data-mobile-nav')
74
+ frame.removeAttribute('data-mobile-preview-full')
75
+ }
76
+ }, [mobile])
77
+
78
+ // The floating button is a fallback for surfaces without a session header:
79
+ // phase "active" means the header (and its toggle) is rendered already.
80
+ // Tablet only — on a phone the home screen owns navigation.
81
+ useEffect(() => {
82
+ if (!tablet) {
83
+ setFabVisible(false)
84
+ return
85
+ }
86
+ const sync = () => setFabVisible(document.querySelector('[data-phase="active"]') === null)
87
+ sync()
88
+ const observer = new MutationObserver(sync)
89
+ // childList: the conversation root can be replaced wholesale on session
90
+ // switches, so attribute-only observation would miss the new phase.
91
+ observer.observe(document.documentElement, {
92
+ subtree: true,
93
+ childList: true,
94
+ attributes: true,
95
+ attributeFilter: ['data-phase'],
96
+ })
97
+ return () => observer.disconnect()
98
+ }, [tablet])
99
+
100
+ // Escape closes the drawer — but yields to an open modal dialog (e.g. the
101
+ // settings panel), which owns its own Escape handling.
102
+ useEffect(() => {
103
+ if (!tablet || !open) return
104
+ const onKeyDown = (event: KeyboardEvent) => {
105
+ if (event.key === 'Escape' && document.querySelector('[aria-modal="true"]') === null) toggleSidebar()
106
+ }
107
+ // Capture phase: run before the settings panel's own document-bubble Escape
108
+ // handler, so the modal is still present when we yield to it.
109
+ document.addEventListener('keydown', onKeyDown, true)
110
+ return () => document.removeEventListener('keydown', onKeyDown, true)
111
+ }, [tablet, open, toggleSidebar])
112
+
113
+ // Navigation inside the drawer closes it: tapping a session row or a
114
+ // plugin takeover entry (task board / ssh) must hand the screen to the
115
+ // content it just opened. Capture phase — the drawer closes before the
116
+ // shell or a plugin processes the click, so takeover panels never render
117
+ // under the open drawer.
118
+ //
119
+ // Deliberately NOT closed by this rule:
120
+ // - Settings / Session log: their dialogs render INSIDE the drawer DOM
121
+ // (portaled into the sidebar); closing the drawer would slide the dialog
122
+ // off-screen with it.
123
+ // - Workspace folder chevrons, the logo: pure UI toggles, not navigation.
124
+ // - Anything while a modal dialog is open: the dialog owns the screen.
125
+ useEffect(() => {
126
+ if (!tablet || !open) return
127
+ const onDrawerClick = (event: MouseEvent) => {
128
+ if (document.querySelector('[aria-modal="true"]') !== null) return
129
+ const target = event.target as HTMLElement | null
130
+ if (target === null) return
131
+ const drawer = document.querySelector<HTMLElement>('[data-mobile-nav="frame"] > :first-child')
132
+ if (drawer === null || !drawer.contains(target)) return
133
+ // A session row's own action buttons — the "Session actions" kebab
134
+ // (delete / rename), revealed on hover / long-press — open an edit
135
+ // menu. Tapping one must NOT count as tapping the row, or the drawer
136
+ // would close and take the just-opened menu with it.
137
+ if (target.closest('[class*="sessionRow"] button') !== null) return
138
+ const navigates = target.closest(
139
+ 'button[data-dsh-taskboard-entry], button[data-dsh-ssh-entry], [class*="newSession"], [class*="sessionRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"]',
140
+ )
141
+ if (navigates !== null) toggleSidebar()
142
+ }
143
+ document.addEventListener('click', onDrawerClick, true)
144
+ return () => document.removeEventListener('click', onDrawerClick, true)
145
+ }, [tablet, open, toggleSidebar])
146
+
147
+ // Fullscreen toggle for the aionui preview sheet. The button is appended
148
+ // INTO the preview column (position: absolute against it), so it rides
149
+ // the sheet's own motion — open animation, geometry transition — locked
150
+ // by construction instead of matching transition curves, and it hides
151
+ // with the sheet automatically. The suite's React re-renders the column
152
+ // content, so a MutationObserver re-appends the button whenever it is
153
+ // wiped. (The sheet is z-index 56, above the overlay layer's z-20
154
+ // stacking context, so a button inside the sheet is never covered.)
155
+ // Clicking toggles the frame's `data-mobile-preview-full` marker; the
156
+ // two SVG icons swap via CSS.
157
+ useEffect(() => {
158
+ if (!mobile) return
159
+ let button: HTMLButtonElement | null = null
160
+ let observer: MutationObserver | null = null
161
+ const onClick = (): void => {
162
+ findFrame()?.toggleAttribute('data-mobile-preview-full')
163
+ }
164
+ const ensure = (): void => {
165
+ const col = document.querySelector('[data-aionui-preview-col]')
166
+ if (col === null) return
167
+ if (button === null) {
168
+ button = document.createElement('button')
169
+ button.type = 'button'
170
+ button.dataset.mobileNav = 'preview-full-toggle'
171
+ button.setAttribute('aria-label', t('previewFullscreen'))
172
+ button.title = t('previewFullscreen')
173
+ button.innerHTML = [
174
+ '<svg class="dsh-mobile-nav-full-in" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
175
+ '<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
176
+ '</svg>',
177
+ '<svg class="dsh-mobile-nav-full-out" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
178
+ '<path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
179
+ '</svg>',
180
+ ].join('')
181
+ button.addEventListener('click', onClick)
182
+ }
183
+ if (button.parentElement !== col) col.appendChild(button)
184
+ }
185
+ ensure()
186
+ observer = new MutationObserver(ensure)
187
+ observer.observe(document.body, { childList: true, subtree: true })
188
+ return () => {
189
+ observer.disconnect()
190
+ button?.remove()
191
+ }
192
+ }, [mobile, t])
193
+
194
+ // Move the git branch chip (conversation.input.dock) INTO the composer
195
+ // card on mobile: it reads as a stray capsule floating between the dock
196
+ // rows and the input card. Reparenting into the card lets CSS pin it to
197
+ // the card's top-left (the card is position: relative) and give the card
198
+ // a chip row via padding-top. The dock's React re-render restores the
199
+ // chip to the dock, so a MutationObserver re-appends idempotently (same
200
+ // pattern as the preview fullscreen toggle above). When the viewport
201
+ // widens, cleanup moves the chip back to the dock — the desktop layout
202
+ // is untouched.
203
+ useEffect(() => {
204
+ if (!mobile) return
205
+ let observer: MutationObserver | null = null
206
+ const ensure = (): void => {
207
+ const chip = document.querySelector(
208
+ '[data-slot="conversation.input.dock"] [data-gitgraph-chip-anchor]',
209
+ )
210
+ if (chip === null) return
211
+ const card = document.querySelector('textarea')?.closest('[class$="_card"]')
212
+ if (card == null) return
213
+ if (chip.parentElement !== card) card.insertBefore(chip, card.firstChild)
214
+ }
215
+ ensure()
216
+ observer = new MutationObserver(ensure)
217
+ observer.observe(document.body, { childList: true, subtree: true })
218
+ return () => {
219
+ observer?.disconnect()
220
+ const chip = document.querySelector(
221
+ '[data-slot="conversation.input.dock"] [data-gitgraph-chip-anchor]',
222
+ )
223
+ const dock = document.querySelector('[data-slot="conversation.input.dock"]')
224
+ if (chip !== null && dock !== null && chip.parentElement !== dock) dock.appendChild(chip)
225
+ }
226
+ }, [mobile])
227
+
228
+ // Settings dialog: move the toolbar (Open configuration file + close)
229
+ // INTO the nav row so it shares ONE line with the category tabs — the
230
+ // official layout gives the toolbar its own row under the tabs, which on
231
+ // a phone leaves a full-width dead gap and pushes the options area down
232
+ // (user feedback 2026-08-16). The toolbar is React-owned, so a
233
+ // MutationObserver re-appends idempotently (same pattern as the chip
234
+ // above). Desktop untouched: this effect only runs while the frame
235
+ // marker is active. The toolbar is anchored by its class suffix — the
236
+ // export dialog (header + description + body) has no nav row, so
237
+ // querying the nav first makes the move a no-op there.
238
+ useEffect(() => {
239
+ if (!mobile) return
240
+ let observer: MutationObserver | null = null
241
+ const ensure = (): void => {
242
+ const dialog = document.querySelector('[aria-modal="true"]')
243
+ if (dialog === null) return
244
+ const nav = dialog.querySelector(':scope > [class$="_nav"]')
245
+ const header = dialog.querySelector('[class$="_header"]')
246
+ if (nav === null || header === null) return
247
+ if (header.parentElement !== nav) nav.appendChild(header)
248
+ }
249
+ ensure()
250
+ observer = new MutationObserver(ensure)
251
+ observer.observe(document.body, { childList: true, subtree: true })
252
+ return () => observer?.disconnect()
253
+ }, [mobile])
254
+
255
+ if (!tablet) return null
256
+ return (
257
+ <>
258
+ {open && (
259
+ <div
260
+ data-mobile-nav="backdrop"
261
+ role="button"
262
+ aria-label={t('backdrop')}
263
+ onClick={() => toggleSidebar()}
264
+ />
265
+ )}
266
+ {fabVisible && !open && (
267
+ <button
268
+ type="button"
269
+ data-mobile-nav="fab"
270
+ aria-label={t('open')}
271
+ title={t('open')}
272
+ onClick={() => toggleSidebar()}
273
+ >
274
+ <IconPanelLeftOutline16 size={18} />
275
+ </button>
276
+ )}
277
+ </>
278
+ )
279
+ }
@@ -0,0 +1,52 @@
1
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
2
+ import { IconFolderOpenOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { NS } from './locales.ts'
4
+
5
+ /** Full props for the session-header directory toggle. */
6
+ export interface MobileNavToggleProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
7
+ /** Bound ctx.layout.toggleSidebar(). */
8
+ toggleSidebar: () => void
9
+ }
10
+
11
+ /**
12
+ * Mobile-only icon buttons next to the session title:
13
+ * - toggle: opens the directory drawer on narrow screens.
14
+ * - files: toggles the dsh-web-ui explorer sheet directly — one tap opens,
15
+ * a second tap closes it, no drawer round-trip. (The drawer footer keeps
16
+ * a Files entry for the hero/blank phases where this header does not
17
+ * exist.)
18
+ * Hidden entirely on wide screens (CSS media query).
19
+ */
20
+ export function MobileNavToggle({ toggleSidebar, t }: MobileNavToggleProps) {
21
+ const toggleExplorer = (): void => {
22
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
23
+ if (frame === null) return
24
+ if (frame.hasAttribute('data-aionui-explorer-open')) {
25
+ frame.removeAttribute('data-aionui-explorer-open')
26
+ } else {
27
+ frame.setAttribute('data-aionui-explorer-open', '')
28
+ }
29
+ }
30
+ return (
31
+ <>
32
+ <button
33
+ type="button"
34
+ data-mobile-nav="toggle"
35
+ aria-label={t('open')}
36
+ title={t('open')}
37
+ onClick={() => toggleSidebar()}
38
+ >
39
+ <IconPanelLeftOutline16 size={16} />
40
+ </button>
41
+ <button
42
+ type="button"
43
+ data-mobile-nav="files"
44
+ aria-label={t('files')}
45
+ title={t('files')}
46
+ onClick={toggleExplorer}
47
+ >
48
+ <IconFolderOpenOutline16 size={16} />
49
+ </button>
50
+ </>
51
+ )
52
+ }
@@ -0,0 +1,242 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
+ import { IconChevronLeftOutline14, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import { NS } from './locales.ts'
5
+ import { GO_HOME_EVENT, SESSION_INFO_EVENT } from './nav-store.ts'
6
+
7
+ /**
8
+ * ic_ds_info_outline_16 — @deepseek-ai/dsh-client-ui-primitives has no
9
+ * info-circle icon (grepped lib/types/icons/index.d.ts, 2026-08-17: 71
10
+ * icons, nearest is IconQuestionOutline14, wrong glyph AND wrong size).
11
+ * Hand-built to the same 16x16 box the rest of the header icon family
12
+ * uses, so the ⓘ button in MobileHeaderUtilities below reads as one
13
+ * family with the workbench button's mirrored IconPanelLeftOutline16
14
+ * (real-device round 2 feedback: "same size (16), same stroke weight").
15
+ */
16
+ function IconInfoOutline16({ size = 16 }: { size?: number }) {
17
+ return (
18
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
19
+ <circle cx="8" cy="8" r="6.7" stroke="currentColor" strokeWidth="1.3" />
20
+ <circle cx="8" cy="4.7" r="0.95" fill="currentColor" />
21
+ <rect x="7.25" y="6.9" width="1.5" height="4.7" rx="0.75" fill="currentColor" />
22
+ </svg>
23
+ )
24
+ }
25
+
26
+ /** Full props for the session header's back button + view-switch row. */
27
+ export type MobileHeaderActionsProps =
28
+ & PropsRuntime<'conversation.session.header.actions'>
29
+ & PropsLocale<typeof NS>
30
+
31
+ /** One tab read off the official (now visually hidden) Chat/Trajectory tablist. */
32
+ export interface ViewTabInfo {
33
+ label: string
34
+ active: boolean
35
+ el: HTMLButtonElement
36
+ }
37
+
38
+ /**
39
+ * Reads the official session-header tablist by role/aria only (no hashed
40
+ * classes) — the plan's one sanctioned official-DOM read: ChatStore's view
41
+ * selection has no public setter (design doc Appendix C), so switching
42
+ * views means clicking the official tab button ourselves.
43
+ *
44
+ * Exported: effects/gestures.ts (S6) reuses this exact read for the
45
+ * content-area swipe gesture instead of re-querying the tablist a second
46
+ * way — it runs outside React (a document-level touch listener), so it
47
+ * needs the plain function, not the {@link useViewTabs} hook below.
48
+ */
49
+ export function readViewTabs(): ViewTabInfo[] {
50
+ const list = document.querySelector('header [role="tablist"]')
51
+ if (list === null) return []
52
+ return [...list.querySelectorAll<HTMLButtonElement>('[role="tab"]')].map((el) => ({
53
+ label: el.textContent ?? '',
54
+ active: el.getAttribute('aria-selected') === 'true',
55
+ el,
56
+ }))
57
+ }
58
+
59
+ /**
60
+ * Live view-tab mirror. The tablist mounts/unmounts with the session header
61
+ * and its `aria-selected` flips on every view switch (ours or the suite's
62
+ * own), so a MutationObserver — not a one-time read — keeps the mirror
63
+ * current. Scoped to `document.body` like the existing aionui-compat
64
+ * effects (styles/aionui-compat.ts): the tablist itself may not exist yet
65
+ * at mount time.
66
+ *
67
+ * Exported: MobileSessionInfo.tsx (S4) reuses this exact hook for the info
68
+ * sheet's Chat/Trajectory segmented control instead of re-reading the
69
+ * tablist a second way.
70
+ */
71
+ export function useViewTabs(): ViewTabInfo[] {
72
+ const [tabs, setTabs] = useState<ViewTabInfo[]>(() => [])
73
+ useEffect(() => {
74
+ const sync = (): void => setTabs(readViewTabs())
75
+ sync()
76
+ const observer = new MutationObserver(sync)
77
+ observer.observe(document.body, {
78
+ subtree: true,
79
+ attributes: true,
80
+ attributeFilter: ['aria-selected'],
81
+ childList: true,
82
+ })
83
+ return () => observer.disconnect()
84
+ }, [])
85
+ return tabs
86
+ }
87
+
88
+ /**
89
+ * Session header, left lane: the back button (returns the phone page stack
90
+ * to the session list) plus the "current view + dots" row that mirrors the
91
+ * hidden official tablist. Both render unconditionally; CSS
92
+ * (styles/header.css.ts) keeps them hidden at >= 768px so the tablet drawer
93
+ * and the desktop layout stay exactly as they were.
94
+ */
95
+ export function MobileHeaderActions({ t }: MobileHeaderActionsProps) {
96
+ const tabs = useViewTabs()
97
+ const active = tabs.find((tab) => tab.active) ?? tabs[0]
98
+ return (
99
+ <>
100
+ <button
101
+ type="button"
102
+ data-mobile-nav="header-back"
103
+ aria-label={t('backToList')}
104
+ title={t('backToList')}
105
+ onClick={() => window.dispatchEvent(new CustomEvent(GO_HOME_EVENT))}
106
+ >
107
+ <IconChevronLeftOutline14 size={20} />
108
+ </button>
109
+ {tabs.length > 1 && active !== undefined && (
110
+ <button
111
+ type="button"
112
+ data-mobile-nav="header-viewrow"
113
+ aria-label={t('switchView')}
114
+ onClick={() => tabs.find((tab) => !tab.active)?.el.click()}
115
+ >
116
+ <span data-mobile-nav="header-viewrow-label">{active.label}</span>
117
+ <span data-mobile-nav="header-viewrow-dots" aria-hidden="true">
118
+ {tabs.map((tab, index) => (
119
+ <i key={index} data-active={tab.active ? '' : undefined} />
120
+ ))}
121
+ </span>
122
+ </button>
123
+ )}
124
+ </>
125
+ )
126
+ }
127
+
128
+ /** Full props for the session header's right-edge utility buttons. */
129
+ export type MobileHeaderUtilitiesProps =
130
+ & PropsRuntime<'conversation.session.header.utilities'>
131
+ & PropsLocale<typeof NS>
132
+
133
+ /**
134
+ * Session header, right lane: the session-info entry (S4 owns the actual
135
+ * sheet — this fires a hook event for it to pick up) and the workbench
136
+ * entry, which triggers dsh-better-sidebar's own toggle. There is no public
137
+ * API for "open the panel" (BetterSidebarService.openTab only auto-expands
138
+ * for a content open, not a bare type-only open), so this clicks the
139
+ * plugin's real toggle button through a stable, non-hashed anchor: its root
140
+ * mount marker `[data-dsh-better-sidebar]` plus the `_toggleButton` class
141
+ * suffix (verified live: 2026-08-17). Safe no-op when the plugin, or any
142
+ * other workbench-style plugin sharing that convention, is not installed.
143
+ */
144
+ export function MobileHeaderUtilities({ t }: MobileHeaderUtilitiesProps) {
145
+ // Better-sidebar phone close button (S3.1 follow-up, 2026-08-17): the
146
+ // panel's own top-right toggle cluster is hidden below 768px
147
+ // (styles/compat.css.ts) because it duplicates the workbench button
148
+ // below — but that cluster is also the panel's ONLY close control, so
149
+ // hiding it blindly leaves an open panel with no way out. This button is
150
+ // appended straight to document.body, mirroring the existing
151
+ // preview-full-toggle pattern in MobileNavOverlay.tsx (raw DOM, not a
152
+ // React portal — react-dom is not among this plugin's platform-module
153
+ // imports, see AGENTS.md "client import purity"): never inside the
154
+ // panel's own subtree (the third party's React re-renders would wipe
155
+ // it) and never under any transformed/backdrop-filter ancestor (the S4
156
+ // info-card WebKit lesson in AGENTS.md — position:fixed would re-anchor
157
+ // to that ancestor instead of the viewport). It clicks the SAME hidden
158
+ // official toggle the workbench button below uses. Visibility is pure
159
+ // CSS (styles/compat.css.ts: `body:has([data-dsh-better-sidebar]
160
+ // [class$="_panel"])` — the panel's class ends in "_panel" only while
161
+ // open, "_panelHidden" is appended once closed), so this effect only
162
+ // has to guarantee the node exists — no MutationObserver needed to
163
+ // track open/closed state. Icon paths copied verbatim from
164
+ // IconCloseOutline16 (primitives) for the same reason IconInfoOutline16
165
+ // above is hand-built: this button lives outside the React tree, so it
166
+ // cannot render a primitives component directly.
167
+ useEffect(() => {
168
+ const button = document.createElement('button')
169
+ button.type = 'button'
170
+ button.dataset.mobileNav = 'better-sidebar-close'
171
+ button.setAttribute('aria-label', t('workbenchClose'))
172
+ button.title = t('workbenchClose')
173
+ // Bottom-center labeled pill (real-device follow-up, 2026-08-17): the
174
+ // icon markup is a static trusted string (safe as innerHTML), but the
175
+ // locale label is untrusted-shaped text — built as a real text node via
176
+ // textContent, not string-concatenated into the same innerHTML, so a
177
+ // translation can never be parsed as markup.
178
+ button.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">'
179
+ + '<path d="M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z" fill="currentColor"/>'
180
+ + '<path d="M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z" fill="currentColor"/>'
181
+ + '</svg>'
182
+ const label = document.createElement('span')
183
+ label.textContent = t('workbenchClose')
184
+ button.appendChild(label)
185
+ const onClick = (): void => {
186
+ document.querySelector<HTMLButtonElement>('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click()
187
+ }
188
+ button.addEventListener('click', onClick)
189
+ document.body.appendChild(button)
190
+ return () => {
191
+ button.removeEventListener('click', onClick)
192
+ button.remove()
193
+ }
194
+ }, [t])
195
+
196
+ // Presence gate (2026-08-17 user question): without dsh-better-sidebar
197
+ // installed the workbench button was a dead control — rendered, clickable,
198
+ // did nothing. Same live-probe pattern as the home chips: observe until the
199
+ // plugin's root marker exists, hide the button while it doesn't. The close
200
+ // pill never had this problem (its CSS :has() gate needs the panel node).
201
+ const [workbenchPresent, setWorkbenchPresent] = useState(
202
+ () => document.querySelector('[data-dsh-better-sidebar]') !== null,
203
+ )
204
+ useEffect(() => {
205
+ const check = () =>
206
+ setWorkbenchPresent(document.querySelector('[data-dsh-better-sidebar]') !== null)
207
+ const observer = new MutationObserver(check)
208
+ observer.observe(document.body, { childList: true, subtree: true })
209
+ check()
210
+ return () => observer.disconnect()
211
+ }, [])
212
+
213
+ return (
214
+ <>
215
+ <button
216
+ type="button"
217
+ data-mobile-nav="header-info"
218
+ aria-label={t('sessionInfo')}
219
+ title={t('sessionInfo')}
220
+ onClick={() => window.dispatchEvent(new CustomEvent(SESSION_INFO_EVENT))}
221
+ >
222
+ <IconInfoOutline16 size={20} />
223
+ </button>
224
+ {workbenchPresent && <button
225
+ type="button"
226
+ data-mobile-nav="header-workbench"
227
+ aria-label={t('workbench')}
228
+ title={t('workbench')}
229
+ onClick={() => {
230
+ document.querySelector<HTMLButtonElement>('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click()
231
+ }}
232
+ >
233
+ {/* No IconPanelRightOutline16 in primitives (grepped lib/types/
234
+ icons/index.d.ts, 2026-08-17) — mirrored via CSS (styles/
235
+ header.css.ts) instead of hand-drawing a new glyph. The panel
236
+ icon's "left column" reads as "right column" flipped, which is
237
+ exactly the workbench's own right-side-panel semantics. */}
238
+ <IconPanelLeftOutline16 size={20} />
239
+ </button>}
240
+ </>
241
+ )
242
+ }