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.
- package/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- 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
|
+
}
|