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,265 @@
|
|
|
1
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
2
|
+
import { GO_HOME_EVENT } from '../nav-store.ts'
|
|
3
|
+
|
|
4
|
+
/** Phone breakpoint — same query every phone-only effect in this plugin uses. */
|
|
5
|
+
const PHONE_QUERY = '(max-width: 767px)'
|
|
6
|
+
|
|
7
|
+
/** This plugin's two dismissible sheets (session-info, home workspace/chips
|
|
8
|
+
* picker — MobileHome.tsx's home-sheet hosts both the workspace switcher and
|
|
9
|
+
* the S5 chip-customize list under the same marker). Official popupSelect
|
|
10
|
+
* menus (permission/model) are deliberately excluded — the design spec
|
|
11
|
+
* leaves those mask-tap-only, and they are the suite's own DOM, not ours to
|
|
12
|
+
* add gesture handling to. */
|
|
13
|
+
const SHEET_SELECTOR = '[data-mobile-nav="info-sheet"], [data-mobile-nav="home-sheet"]'
|
|
14
|
+
|
|
15
|
+
const CLOSE_DISTANCE = 80
|
|
16
|
+
/** px/ms — a fast flick closes the sheet even short of CLOSE_DISTANCE. */
|
|
17
|
+
const CLOSE_VELOCITY = 0.5
|
|
18
|
+
/** Below this the drag is a tap/jitter, not yet a pull — avoids hijacking
|
|
19
|
+
* the very first pixels of an upward scroll inside the sheet. */
|
|
20
|
+
const DRAG_COMMIT_PX = 4
|
|
21
|
+
|
|
22
|
+
function prefersReducedMotion(): boolean {
|
|
23
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The sheet's own mask sibling — both this plugin's sheets name theirs
|
|
27
|
+
* with a "-mask" suffix (info-mask, home-sheet-mask) and already wire a
|
|
28
|
+
* click handler that calls the sheet's close(). Reusing that handler (a
|
|
29
|
+
* synthetic click) needs no access to the sheet's React state at all.
|
|
30
|
+
* Shared with the S6.1 edge-swipe-back priority chain below — both "drag
|
|
31
|
+
* down to dismiss" and "swipe back from the edge" close a sheet the exact
|
|
32
|
+
* same way. */
|
|
33
|
+
function closeSheet(sheet: HTMLElement): void {
|
|
34
|
+
sheet.parentElement?.querySelector<HTMLElement>('[data-mobile-nav$="-mask"]')?.click()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* S6.2 — sheet drag-to-close: this plugin's two own bottom sheets (session
|
|
39
|
+
* info, home workspace picker) support "drag down to dismiss", mirroring
|
|
40
|
+
* the mask-tap close they already have. Official popupSelect menus
|
|
41
|
+
* (permission/model) are excluded on purpose — the design spec leaves them
|
|
42
|
+
* mask-tap-only, and they are the suite's own DOM, not ours to add gesture
|
|
43
|
+
* handling to.
|
|
44
|
+
*
|
|
45
|
+
* touchmove is registered non-passive (unlike the edge-swipe-back gesture
|
|
46
|
+
* below) because this gesture needs to preventDefault once it takes over, so
|
|
47
|
+
* the sheet's own translateY tracks the finger instead of fighting the
|
|
48
|
+
* scroller's native rubber-band. Before that handoff — while the sheet's
|
|
49
|
+
* inner content isn't scrolled to top — nothing is intercepted at all, so
|
|
50
|
+
* a pull down first scrolls the content as normal (spec requirement).
|
|
51
|
+
*/
|
|
52
|
+
function installSheetDragClose(ctx: ClientContext): void {
|
|
53
|
+
ctx.effect(() => {
|
|
54
|
+
const narrow = window.matchMedia(PHONE_QUERY)
|
|
55
|
+
interface Drag { sheet: HTMLElement; startY: number; lastY: number; startTime: number; dragging: boolean }
|
|
56
|
+
let drag: Drag | null = null
|
|
57
|
+
|
|
58
|
+
const settle = (sheet: HTMLElement, close: boolean): void => {
|
|
59
|
+
if (close) {
|
|
60
|
+
sheet.style.transform = ''
|
|
61
|
+
sheet.style.transition = ''
|
|
62
|
+
closeSheet(sheet)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (prefersReducedMotion()) {
|
|
66
|
+
sheet.style.transition = 'none'
|
|
67
|
+
sheet.style.transform = ''
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
sheet.style.transition = 'transform .22s var(--ds-ease-out, ease-in-out)'
|
|
71
|
+
sheet.style.transform = ''
|
|
72
|
+
const clear = (): void => {
|
|
73
|
+
sheet.style.transition = ''
|
|
74
|
+
sheet.removeEventListener('transitionend', clear)
|
|
75
|
+
}
|
|
76
|
+
sheet.addEventListener('transitionend', clear)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onTouchStart = (event: TouchEvent): void => {
|
|
80
|
+
if (event.touches.length !== 1) {
|
|
81
|
+
drag = null
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const touch = event.touches[0]
|
|
85
|
+
const target = event.target
|
|
86
|
+
if (touch === undefined || !(target instanceof Element)) {
|
|
87
|
+
drag = null
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const sheet = target.closest<HTMLElement>(SHEET_SELECTOR)
|
|
91
|
+
if (sheet === null) {
|
|
92
|
+
drag = null
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
drag = { sheet, startY: touch.clientY, lastY: touch.clientY, startTime: Date.now(), dragging: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const onTouchMove = (event: TouchEvent): void => {
|
|
99
|
+
if (drag === null) return
|
|
100
|
+
const touch = event.touches[0]
|
|
101
|
+
if (touch === undefined) return
|
|
102
|
+
drag.lastY = touch.clientY
|
|
103
|
+
const dy = touch.clientY - drag.startY
|
|
104
|
+
if (!drag.dragging) {
|
|
105
|
+
if (drag.sheet.scrollTop > 0) return
|
|
106
|
+
if (dy <= DRAG_COMMIT_PX) return
|
|
107
|
+
drag.dragging = true
|
|
108
|
+
drag.sheet.style.transition = 'none'
|
|
109
|
+
}
|
|
110
|
+
event.preventDefault()
|
|
111
|
+
drag.sheet.style.transform = `translateY(${Math.max(0, dy)}px)`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const finish = (): void => {
|
|
115
|
+
if (drag === null) return
|
|
116
|
+
const { sheet, dragging, startY, lastY, startTime } = drag
|
|
117
|
+
drag = null
|
|
118
|
+
if (!dragging) return
|
|
119
|
+
const dy = Math.max(0, lastY - startY)
|
|
120
|
+
const elapsed = Math.max(1, Date.now() - startTime)
|
|
121
|
+
const velocity = dy / elapsed
|
|
122
|
+
settle(sheet, dy > CLOSE_DISTANCE || velocity > CLOSE_VELOCITY)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const attach = (): void => {
|
|
126
|
+
document.addEventListener('touchstart', onTouchStart, { passive: true })
|
|
127
|
+
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
|
128
|
+
document.addEventListener('touchend', finish, { passive: true })
|
|
129
|
+
document.addEventListener('touchcancel', finish, { passive: true })
|
|
130
|
+
}
|
|
131
|
+
const detach = (): void => {
|
|
132
|
+
document.removeEventListener('touchstart', onTouchStart)
|
|
133
|
+
document.removeEventListener('touchmove', onTouchMove)
|
|
134
|
+
document.removeEventListener('touchend', finish)
|
|
135
|
+
document.removeEventListener('touchcancel', finish)
|
|
136
|
+
drag = null
|
|
137
|
+
}
|
|
138
|
+
if (narrow.matches) attach()
|
|
139
|
+
const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
|
|
140
|
+
narrow.addEventListener('change', onChange)
|
|
141
|
+
return () => {
|
|
142
|
+
narrow.removeEventListener('change', onChange)
|
|
143
|
+
detach()
|
|
144
|
+
}
|
|
145
|
+
}, 'dsh-mobile-nav: sheet drag-to-close')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Left-edge start zone for S6.1's swipe-back, in CSS px from the viewport's
|
|
149
|
+
* left edge. This used to be the gateway half's own edge-swipe-back hot zone
|
|
150
|
+
* (history.back, useless against an SPA) — that gesture is removed in the
|
|
151
|
+
* same revision that adds this one, so the 24px strip changes owner instead
|
|
152
|
+
* of staying carved out (design doc "手势" row, 2026-08-17 fourth revision). */
|
|
153
|
+
const EDGE_ZONE_PX = 24
|
|
154
|
+
const EDGE_SWIPE_MIN_DX = 90
|
|
155
|
+
const EDGE_SWIPE_RATIO = 1.6
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* dsh-better-sidebar's panel-open read (AGENTS.md pitfall "第三方浮层的开合
|
|
159
|
+
* 态可以纯 CSS `:has()` 读"): the panel's class ends in "_panel" only while
|
|
160
|
+
* open — "_panelHidden" is appended once closed, so the string no longer
|
|
161
|
+
* ends in "_panel" and this selector stops matching. Same anchor
|
|
162
|
+
* MobileSessionHeader.tsx's workbench button and its own close button use.
|
|
163
|
+
*/
|
|
164
|
+
function betterSidebarPanelOpen(): boolean {
|
|
165
|
+
return document.querySelector('[data-dsh-better-sidebar] [class$="_panel"]') !== null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* S6.1 — left-edge swipe-back priority chain (design doc "手势" row, fourth
|
|
170
|
+
* revision): a swipe starting in the left {@link EDGE_ZONE_PX} always closes
|
|
171
|
+
* the TOPMOST dismissible surface rather than always navigating, so a user
|
|
172
|
+
* mid-workflow (info card open, workbench panel open) gets "close that"
|
|
173
|
+
* instead of "leave the session" from the same gesture. Checked in the
|
|
174
|
+
* design doc's own order:
|
|
175
|
+
* 1. this plugin's session-info sheet, if open
|
|
176
|
+
* 2. this plugin's home sheet (workspace switcher or S5 chip-customize),
|
|
177
|
+
* if open
|
|
178
|
+
* 3. dsh-better-sidebar's workbench panel, if open (代点 its own toggle —
|
|
179
|
+
* same anchor MobileSessionHeader.tsx's workbench/close buttons use)
|
|
180
|
+
* 4. otherwise, GO_HOME_EVENT — but only from the session view; the design
|
|
181
|
+
* doc calls out "在 home 层→无动作" explicitly, and the phone page
|
|
182
|
+
* stack's current level is read straight off MobileHome's own
|
|
183
|
+
* `data-view` attribute (no store handle needed here — effects/*.ts is
|
|
184
|
+
* already all DOM reads, matching every sibling effect in this file).
|
|
185
|
+
*/
|
|
186
|
+
function handleEdgeSwipeBack(): void {
|
|
187
|
+
const sheet = document.querySelector<HTMLElement>(SHEET_SELECTOR)
|
|
188
|
+
if (sheet !== null) {
|
|
189
|
+
closeSheet(sheet)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (betterSidebarPanelOpen()) {
|
|
193
|
+
document.querySelector<HTMLButtonElement>('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click()
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (document.querySelector('[data-mobile-nav="home"]')?.getAttribute('data-view') === 'session') {
|
|
197
|
+
window.dispatchEvent(new CustomEvent(GO_HOME_EVENT))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* S6.1 — left-edge swipe-back gesture install. Passive throughout (never
|
|
203
|
+
* calls preventDefault) and decided purely from the touchstart/touchend
|
|
204
|
+
* endpoints, exactly like the S6.1-predecessor content-area swipe this
|
|
205
|
+
* revision removes — so it never fights vertical (or, at the 24px strip,
|
|
206
|
+
* horizontal chip-row) scrolling, and needs no mid-drag tracking: the design
|
|
207
|
+
* doc explicitly allows "一步到位" over a followed-finger animation.
|
|
208
|
+
*/
|
|
209
|
+
function installEdgeSwipeBack(ctx: ClientContext): void {
|
|
210
|
+
ctx.effect(() => {
|
|
211
|
+
const narrow = window.matchMedia(PHONE_QUERY)
|
|
212
|
+
let start: { x: number; y: number; eligible: boolean } | null = null
|
|
213
|
+
|
|
214
|
+
const onTouchStart = (event: TouchEvent): void => {
|
|
215
|
+
if (event.touches.length !== 1) {
|
|
216
|
+
start = null
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const touch = event.touches[0]
|
|
220
|
+
if (touch === undefined) {
|
|
221
|
+
start = null
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
start = { x: touch.clientX, y: touch.clientY, eligible: touch.clientX <= EDGE_ZONE_PX }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const onTouchEnd = (event: TouchEvent): void => {
|
|
228
|
+
const state = start
|
|
229
|
+
start = null
|
|
230
|
+
if (state === null || !state.eligible) return
|
|
231
|
+
const touch = event.changedTouches[0]
|
|
232
|
+
if (touch === undefined) return
|
|
233
|
+
const dx = touch.clientX - state.x
|
|
234
|
+
const dy = touch.clientY - state.y
|
|
235
|
+
if (dx <= EDGE_SWIPE_MIN_DX || Math.abs(dx) <= EDGE_SWIPE_RATIO * Math.abs(dy)) return
|
|
236
|
+
handleEdgeSwipeBack()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const attach = (): void => {
|
|
240
|
+
document.addEventListener('touchstart', onTouchStart, { passive: true })
|
|
241
|
+
document.addEventListener('touchend', onTouchEnd, { passive: true })
|
|
242
|
+
}
|
|
243
|
+
const detach = (): void => {
|
|
244
|
+
document.removeEventListener('touchstart', onTouchStart)
|
|
245
|
+
document.removeEventListener('touchend', onTouchEnd)
|
|
246
|
+
start = null
|
|
247
|
+
}
|
|
248
|
+
if (narrow.matches) attach()
|
|
249
|
+
const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
|
|
250
|
+
narrow.addEventListener('change', onChange)
|
|
251
|
+
return () => {
|
|
252
|
+
narrow.removeEventListener('change', onChange)
|
|
253
|
+
detach()
|
|
254
|
+
}
|
|
255
|
+
}, 'dsh-mobile-nav: left-edge swipe-back')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** S6: the two-gesture set — left-edge swipe-back and sheet drag-to-close.
|
|
259
|
+
* Both install/uninstall their own document listeners on the phone
|
|
260
|
+
* breakpoint's matchMedia change, so at >= 768px this is a true no-op (no
|
|
261
|
+
* listeners attached at all), matching every other effect in this file. */
|
|
262
|
+
export function installGestures(ctx: ClientContext): void {
|
|
263
|
+
installEdgeSwipeBack(ctx)
|
|
264
|
+
installSheetDragClose(ctx)
|
|
265
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
2
|
+
import { dotState } from '../session-dot.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session header running-status dot (S2). No official element exists to
|
|
6
|
+
* reposition (ConversationSessionHeader renders only the crumb title, the
|
|
7
|
+
* actions/utilities slots, and the tablist — dsh-client-ui-conversation
|
|
8
|
+
* lib/client.js:6949-7009), so this reads `ctx.sessions.list` directly
|
|
9
|
+
* (the same feed `useSessions` wraps) and stamps a data attribute the
|
|
10
|
+
* mobile stylesheet turns into a `::after` dot on the title crumb —
|
|
11
|
+
* "read data, self-draw" is the plan's documented fallback for this piece.
|
|
12
|
+
* Kept outside React: the dot must track the CURRENT session regardless of
|
|
13
|
+
* which component the header happens to mount, and a plain attribute +
|
|
14
|
+
* CSS avoids reaching into the official crumb's own DOM subtree.
|
|
15
|
+
*/
|
|
16
|
+
export function installHeaderStatusDot(ctx: ClientContext): void {
|
|
17
|
+
ctx.effect(() => {
|
|
18
|
+
const apply = (): void => {
|
|
19
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
20
|
+
if (frame === null) return
|
|
21
|
+
const { current, byId } = ctx.sessions.list.getSnapshot()
|
|
22
|
+
const row = current === undefined ? undefined : byId[current]
|
|
23
|
+
const state = row === undefined ? undefined : dotState(row)
|
|
24
|
+
if (state === undefined) frame.removeAttribute('data-mobile-nav-dot')
|
|
25
|
+
else frame.setAttribute('data-mobile-nav-dot', state)
|
|
26
|
+
}
|
|
27
|
+
apply()
|
|
28
|
+
return ctx.sessions.list.subscribe(apply)
|
|
29
|
+
}, 'dsh-mobile-nav: header status dot')
|
|
30
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phone chrome: KEEP the system status bar (no fullscreen) and make it
|
|
5
|
+
* blend into the page. On narrow screens:
|
|
6
|
+
* - The viewport meta gains viewport-fit=cover, so env(safe-area-inset-top)
|
|
7
|
+
* is the real status-bar / notch height and the stylesheet can push every
|
|
8
|
+
* surface below it (off notched phones, or in a browser tab where the
|
|
9
|
+
* layout viewport already sits below the status bar, the inset is 0 and
|
|
10
|
+
* nothing shifts).
|
|
11
|
+
* - A theme-color meta tracks the shell background (the official theme is
|
|
12
|
+
* toggled by body[data-ds-dark-theme], which flips --dsw-alias-bg-base):
|
|
13
|
+
* Android then paints the status bar / URL bar with the page's own base
|
|
14
|
+
* color, so the status bar reads as part of the UI instead of a foreign
|
|
15
|
+
* strip. The drawer paints the same strip on iOS / notch displays.
|
|
16
|
+
* - gesturestart is suppressed as the legacy-iOS fallback for double-tap
|
|
17
|
+
* zoom; modern browsers are covered by the stylesheet's
|
|
18
|
+
* touch-action: manipulation (which keeps pan and pinch zoom).
|
|
19
|
+
*/
|
|
20
|
+
export function installPhoneChrome(ctx: ClientContext): void {
|
|
21
|
+
ctx.effect(() => {
|
|
22
|
+
const narrow = window.matchMedia('(max-width: 1023px)')
|
|
23
|
+
const viewport = document.querySelector<HTMLMetaElement>('meta[name="viewport"]')
|
|
24
|
+
const originalViewport = viewport?.content ?? ''
|
|
25
|
+
const themeMeta = document.createElement('meta')
|
|
26
|
+
themeMeta.name = 'theme-color'
|
|
27
|
+
const bodyBg = (): string => getComputedStyle(document.body).backgroundColor
|
|
28
|
+
|
|
29
|
+
const sync = (): void => {
|
|
30
|
+
if (viewport !== null) viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover'
|
|
31
|
+
themeMeta.content = bodyBg()
|
|
32
|
+
if (themeMeta.parentElement === null) document.head.appendChild(themeMeta)
|
|
33
|
+
}
|
|
34
|
+
const restore = (): void => {
|
|
35
|
+
if (viewport !== null) viewport.content = originalViewport
|
|
36
|
+
themeMeta.remove()
|
|
37
|
+
}
|
|
38
|
+
const onGestureStart = (event: Event) => event.preventDefault()
|
|
39
|
+
if (narrow.matches) sync()
|
|
40
|
+
const onChange = (event: MediaQueryListEvent) => (event.matches ? sync() : restore())
|
|
41
|
+
narrow.addEventListener('change', onChange)
|
|
42
|
+
const observer = new MutationObserver(() => {
|
|
43
|
+
if (narrow.matches) themeMeta.content = bodyBg()
|
|
44
|
+
})
|
|
45
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
|
46
|
+
document.addEventListener('gesturestart', onGestureStart)
|
|
47
|
+
return () => {
|
|
48
|
+
narrow.removeEventListener('change', onChange)
|
|
49
|
+
observer.disconnect()
|
|
50
|
+
document.removeEventListener('gesturestart', onGestureStart)
|
|
51
|
+
restore()
|
|
52
|
+
}
|
|
53
|
+
}, 'dsh-mobile-nav: status bar theme + viewport + zoom guard')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Does the layout viewport sit shorter than the screen by at least the top
|
|
58
|
+
* inset env() still claims? (S1.2 predicate, wired in S1.3.)
|
|
59
|
+
*
|
|
60
|
+
* Device numbers (iPhone, iOS 26.5, standalone PWA, 393x852): innerHeight
|
|
61
|
+
* 793, env top 59 (= 852 - 793), env bottom 34, frame bottom 793 flush.
|
|
62
|
+
* The same device in a Safari TAB reads innerHeight 695 with every surface
|
|
63
|
+
* flush and every design value correct, which is what rules our own CSS out.
|
|
64
|
+
*
|
|
65
|
+
* What the true-branch means, settled by S1.2 + S1.3 device evidence: the
|
|
66
|
+
* viewport is anchored at screen y=0 and merely cut 59px short at the BOTTOM
|
|
67
|
+
* — not pushed down below the status bar. So the top inset is paid exactly
|
|
68
|
+
* once and must be left alone, while the bottom inset is a lie: the home
|
|
69
|
+
* indicator lives in the dead strip below the page. installSunkInset() acts
|
|
70
|
+
* on that, and only on --mnav-sab.
|
|
71
|
+
*
|
|
72
|
+
* The `envTop > 0` guard is what keeps every other standalone install out:
|
|
73
|
+
* landscape iPhone (top inset 0, the notch moves to left/right), iPad, and
|
|
74
|
+
* Android — where standalone also loses status-bar height off innerHeight but
|
|
75
|
+
* reports env top 0 — all fall through to false. Kept pure so the boundaries
|
|
76
|
+
* are checkable off-device: scripts/check-sunk-viewport.mjs.
|
|
77
|
+
*/
|
|
78
|
+
export function isViewportSunkBelowStatusBar(input: {
|
|
79
|
+
standalone: boolean
|
|
80
|
+
screenHeight: number
|
|
81
|
+
innerHeight: number
|
|
82
|
+
envTop: number
|
|
83
|
+
}): boolean {
|
|
84
|
+
return input.standalone
|
|
85
|
+
&& input.envTop > 0
|
|
86
|
+
&& input.screenHeight - input.innerHeight >= input.envTop
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* iOS standalone-PWA keyboard shrink (S1.2, 2026-08-17) — the ~60px white
|
|
91
|
+
* band under the composer AND under the session list.
|
|
92
|
+
*
|
|
93
|
+
* Documented WebKit defect: the first time the software keyboard opens inside
|
|
94
|
+
* a home-screen (standalone) PWA, the layout viewport permanently loses the
|
|
95
|
+
* status-bar height for the rest of the app session. innerHeight,
|
|
96
|
+
* visualViewport.height and 100dvh all report the shrunken value together, so
|
|
97
|
+
* nothing inside the page can see anything wrong — the page simply ends early
|
|
98
|
+
* and the strip below it is system background that no CSS can reach. Reported
|
|
99
|
+
* as 932 -> 873 on an iPhone Pro Max; the user's device reads 852 -> 793.
|
|
100
|
+
* Both are exactly one status bar. See
|
|
101
|
+
* https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d
|
|
102
|
+
*
|
|
103
|
+
* This is why the reported symptom is asymmetric and why the earlier
|
|
104
|
+
* "double-counted top inset" reading was wrong: the top edge is genuinely
|
|
105
|
+
* correct (env top 59 is paid once, the viewport starts at screen y=0), the
|
|
106
|
+
* bottom is simply 59px short. It also explains a chat client being hit
|
|
107
|
+
* hardest — the trigger is typing, which happens on the first message.
|
|
108
|
+
*
|
|
109
|
+
* The only known cure is to make WebKit re-measure: drop a full-viewport
|
|
110
|
+
* element out of the box tree and force a synchronous reflow. Two departures
|
|
111
|
+
* from the published recipe:
|
|
112
|
+
* - scrollTop is saved and restored around the toggle. Un-boxing an ancestor
|
|
113
|
+
* resets every descendant scroller to 0, which in a chat client means the
|
|
114
|
+
* conversation jumps to its first message. Both happen inside one task, so
|
|
115
|
+
* no frame is ever painted in between and nothing flickers.
|
|
116
|
+
* - the baseline is the tallest innerHeight this session has actually seen,
|
|
117
|
+
* not the screen height. On any device where the viewport is legitimately
|
|
118
|
+
* short, the baseline equals the current height and this never fires.
|
|
119
|
+
*
|
|
120
|
+
* Standalone-gated, so a browser tab (and every desktop, CDP included) is a
|
|
121
|
+
* strict no-op — which is also why the fix cannot be regression-tested here
|
|
122
|
+
* and has to be confirmed on the device.
|
|
123
|
+
*/
|
|
124
|
+
export function installViewportHeal(ctx: ClientContext): void {
|
|
125
|
+
ctx.effect(() => {
|
|
126
|
+
const standalone = window.matchMedia('(display-mode: standalone)').matches
|
|
127
|
+
|| (navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
128
|
+
/* iOS-only on purpose. The ceiling below leans on screen.height being the
|
|
129
|
+
height the viewport SHOULD have, which holds for an iPhone standalone
|
|
130
|
+
app under viewport-fit=cover and does not hold on Android, where the
|
|
131
|
+
navigation bar makes screen.height permanently larger than any viewport
|
|
132
|
+
and every keyboard dismiss would trigger a pointless reflow. */
|
|
133
|
+
if (!standalone || !/iPad|iPhone|iPod/.test(navigator.userAgent)) return () => {}
|
|
134
|
+
|
|
135
|
+
/* Ceiling = the tallest this session has seen, floored at the screen —
|
|
136
|
+
the screen half matters because the shrink outlives a page reload, so a
|
|
137
|
+
PWA reopened into the broken state would otherwise measure 793 as
|
|
138
|
+
"normal" and never heal itself. */
|
|
139
|
+
let tallest = window.innerHeight
|
|
140
|
+
const ceiling = (): number => Math.max(tallest, window.screen.height)
|
|
141
|
+
const onResize = (): void => {
|
|
142
|
+
if (window.innerHeight > tallest) tallest = window.innerHeight
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Give up after three ineffective attempts per trigger: on a device whose
|
|
146
|
+
viewport is short for a reason a reflow cannot fix (the iOS 26.x
|
|
147
|
+
standalone regression family) retrying forever buys nothing. The two
|
|
148
|
+
budgets are separate on purpose — the cold-start attempts would
|
|
149
|
+
otherwise spend the whole allowance before the user ever types, and the
|
|
150
|
+
keyboard shrink IS reflow-curable even on a device whose cold start is
|
|
151
|
+
not. */
|
|
152
|
+
const strikes = { boot: 0, blur: 0 }
|
|
153
|
+
const heal = (kind: 'boot' | 'blur'): void => {
|
|
154
|
+
if (strikes[kind] >= 3 || ceiling() - window.innerHeight <= 4) return
|
|
155
|
+
const frame = document.querySelector<HTMLElement>('[data-mobile-nav="frame"]')
|
|
156
|
+
if (frame === null) return
|
|
157
|
+
const scroller = document.querySelector<HTMLElement>('[class$="_scrollBody"]')
|
|
158
|
+
const scrollTop = scroller?.scrollTop ?? 0
|
|
159
|
+
const previous = frame.style.display
|
|
160
|
+
frame.style.display = 'none'
|
|
161
|
+
void frame.offsetHeight /* forces the synchronous reflow that re-measures */
|
|
162
|
+
frame.style.display = previous
|
|
163
|
+
if (scroller !== null) scroller.scrollTop = scrollTop
|
|
164
|
+
strikes[kind] = ceiling() - window.innerHeight > 4 ? strikes[kind] + 1 : 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Blur is when the keyboard starts closing; the viewport settles a beat
|
|
168
|
+
later. Two attempts because the dismiss animation length is not
|
|
169
|
+
specified anywhere, and a heal that runs too early is a silent no-op. */
|
|
170
|
+
const timers: number[] = []
|
|
171
|
+
const onFocusOut = (event: FocusEvent): void => {
|
|
172
|
+
if (!(event.target instanceof HTMLElement)) return
|
|
173
|
+
if (event.target.matches('textarea, input') !== true) return
|
|
174
|
+
timers.push(window.setTimeout(() => heal('blur'), 300), window.setTimeout(() => heal('blur'), 900))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Cold start (S1.3): the user's iOS 26.5 device is already 59px short on
|
|
178
|
+
a freshly killed-and-reopened app that never saw the keyboard, so the
|
|
179
|
+
keyboard trigger alone is not the whole story. Same reflow, tried once
|
|
180
|
+
the shell has mounted and again after it has settled, plus every return
|
|
181
|
+
to the foreground — iOS re-lays a backgrounded PWA out and that is
|
|
182
|
+
another moment the height can come back wrong. Whether a reflow can win
|
|
183
|
+
against a system-level regression is unknowable off-device; the strike
|
|
184
|
+
budget is what keeps a losing attempt cheap. */
|
|
185
|
+
timers.push(window.setTimeout(() => heal('boot'), 1000), window.setTimeout(() => heal('boot'), 3000))
|
|
186
|
+
const onVisible = (): void => {
|
|
187
|
+
if (document.visibilityState !== 'visible') return
|
|
188
|
+
timers.push(window.setTimeout(() => heal('boot'), 300))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener('resize', onResize)
|
|
192
|
+
document.addEventListener('focusout', onFocusOut, true)
|
|
193
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
194
|
+
return () => {
|
|
195
|
+
window.removeEventListener('resize', onResize)
|
|
196
|
+
document.removeEventListener('focusout', onFocusOut, true)
|
|
197
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
198
|
+
for (const t of timers) clearTimeout(t)
|
|
199
|
+
}
|
|
200
|
+
}, 'dsh-mobile-nav: iOS viewport-shrink heal (boot / foreground / keyboard)')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sunk-viewport compensation (S1.3, 2026-08-17) — zeroes --mnav-sab ONLY.
|
|
205
|
+
*
|
|
206
|
+
* When isViewportSunkBelowStatusBar() holds, the layout viewport is 59px
|
|
207
|
+
* shorter than the screen while env() still reports the full pair of insets.
|
|
208
|
+
* The bottom one is then a lie with a cost: the home indicator sits in the
|
|
209
|
+
* dead strip BELOW the page, so the 34px we reserve for it is 34px of blank
|
|
210
|
+
* page stacked on top of an already-blank system band. Zeroing it does not
|
|
211
|
+
* fix the band (nothing in the page can) but stops us widening it.
|
|
212
|
+
*
|
|
213
|
+
* --mnav-sat is deliberately left alone: the top edge is measured correct on
|
|
214
|
+
* the device (header sits right under the notch), and the S1.2 round already
|
|
215
|
+
* burned a cycle on the theory that it was double-counted.
|
|
216
|
+
*
|
|
217
|
+
* Reversible by design. The predicate is re-evaluated on every resize, so a
|
|
218
|
+
* viewport that comes back to full height — the heal above winning, or Apple
|
|
219
|
+
* shipping the fix — drops the override and the normal env() compensation
|
|
220
|
+
* returns without a reload. `data-mnav-sunk` on <html> is what the debug
|
|
221
|
+
* badge reads, so the badge reports the state that is actually in force
|
|
222
|
+
* rather than re-deriving it.
|
|
223
|
+
*/
|
|
224
|
+
export function installSunkInset(ctx: ClientContext): void {
|
|
225
|
+
ctx.effect(() => {
|
|
226
|
+
/* ?mobile-nav-inset outranks this: both write the same inline property on
|
|
227
|
+
<html>, and the debug param exists precisely to fake insets that this
|
|
228
|
+
would then erase. Param present = stay out entirely, attribute included,
|
|
229
|
+
so the badge shows "n/a" instead of a state nobody is enforcing. */
|
|
230
|
+
if (new URLSearchParams(location.search).has('mobile-nav-inset')) return () => {}
|
|
231
|
+
const root = document.documentElement
|
|
232
|
+
/* env() is only readable through a real element's computed style. */
|
|
233
|
+
const probe = document.createElement('div')
|
|
234
|
+
probe.style.cssText =
|
|
235
|
+
'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' +
|
|
236
|
+
'padding-top:env(safe-area-inset-top,0px)'
|
|
237
|
+
document.body.appendChild(probe)
|
|
238
|
+
|
|
239
|
+
const sync = (): void => {
|
|
240
|
+
const sunk = isViewportSunkBelowStatusBar({
|
|
241
|
+
standalone: window.matchMedia('(display-mode: standalone)').matches
|
|
242
|
+
|| (navigator as Navigator & { standalone?: boolean }).standalone === true,
|
|
243
|
+
screenHeight: window.screen.height,
|
|
244
|
+
innerHeight: window.innerHeight,
|
|
245
|
+
envTop: Number.parseFloat(getComputedStyle(probe).paddingTop) || 0,
|
|
246
|
+
})
|
|
247
|
+
root.dataset.mnavSunk = sunk ? '1' : '0'
|
|
248
|
+
if (sunk) root.style.setProperty('--mnav-sab', '0px')
|
|
249
|
+
else root.style.removeProperty('--mnav-sab')
|
|
250
|
+
}
|
|
251
|
+
sync()
|
|
252
|
+
/* resize covers rotation and any height the heal above wins back.
|
|
253
|
+
Deliberately not visualViewport: it fires on every keyboard show/hide
|
|
254
|
+
while innerHeight, the value read here, does not move on iOS. */
|
|
255
|
+
window.addEventListener('resize', sync)
|
|
256
|
+
window.addEventListener('orientationchange', sync)
|
|
257
|
+
return () => {
|
|
258
|
+
window.removeEventListener('resize', sync)
|
|
259
|
+
window.removeEventListener('orientationchange', sync)
|
|
260
|
+
probe.remove()
|
|
261
|
+
root.style.removeProperty('--mnav-sab')
|
|
262
|
+
delete root.dataset.mnavSunk
|
|
263
|
+
}
|
|
264
|
+
}, 'dsh-mobile-nav: sunk-viewport bottom-inset override')
|
|
265
|
+
}
|