dsh-zen-remote 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+
3
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
+ const PHONE_QUERY = '(max-width: 767px)'
5
+
6
+ /** Root CSS variable composer.css.ts turns into a composer lift. */
7
+ const LIFT_VAR = '--mnav-kb-lift'
8
+
9
+ /** Root attribute gating the transform rule. Present only while lifting:
10
+ * a transform — even translateY(0) — makes the bar the containing block for
11
+ * any fixed-position descendant, so the rule must not exist at rest. */
12
+ const LIFT_ATTR = 'data-mnav-kb'
13
+
14
+ /** Ignore sub-pixel / rounding noise so the composer never jitters. */
15
+ const MIN_LIFT_PX = 2
16
+
17
+ /** Above this visualViewport scale the geometry is pinch-zoom, not keyboard. */
18
+ const MAX_SCALE = 1.01
19
+
20
+ /** Extra clearance whenever the keyboard is up AND the browser reacted to it.
21
+ * Third-party IMEs routinely under-report their height — the toolbar strip
22
+ * above the keys is left out of what they hand the system, so the viewport
23
+ * shrinks by less than the keyboard actually covers and the composer lands
24
+ * slightly under it (搜狗 ~30px is a documented case; one reporter saw the
25
+ * same with 微信输入法 after the viewport DID shrink). Deliberately small:
26
+ * it only has to clear a toolbar strip, not a keyboard.
27
+ *
28
+ * ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
29
+ * behaviour; iOS has one system keyboard whose height WebKit reports exactly,
30
+ * so padding there would be a visible gap bought for nothing. */
31
+ const SAFETY_PAD_PX = 15
32
+
33
+ /** The safety pad this platform gets: the clearance above, or nothing off
34
+ * Android. UA sniffing is the right tool here — the target is a platform
35
+ * defect, not a feature that could be detected. */
36
+ export function safetyPad(userAgent: string): number {
37
+ return /Android/iu.test(userAgent) ? SAFETY_PAD_PX : 0
38
+ }
39
+
40
+ /** A viewport that lost at least this much against its no-keyboard baseline
41
+ * has a keyboard up. Comfortably above the ~56px a collapsing URL bar moves,
42
+ * comfortably below any real keyboard. */
43
+ const KEYBOARD_MIN_SHRINK_PX = 100
44
+
45
+ /** The official composer slot wrapper (same marker keyboard-guard reads). */
46
+ const COMPOSER = '[data-slot="conversation.composer.bar"]'
47
+
48
+ /** Dumb-keyboard probe: after a touch-granted focus, the viewport gets this
49
+ * long to move before the keyboard is declared invisible to the browser. */
50
+ const PROBE_TOTAL_MS = 1200
51
+ const PROBE_INTERVAL_MS = 120
52
+
53
+ /** Per-browser cache of the probe's verdict. Once a keyboard has proven
54
+ * invisible, later focuses lift IMMEDIATELY instead of sitting through the
55
+ * probe again — the probe still runs in the background on every focus, and
56
+ * any viewport movement it sees revokes the cache (IME switched, engine
57
+ * fixed), returning the browser to the pure geometric path. */
58
+ const DUMB_KEY = 'dsh-mobile-nav.kb-dumb'
59
+ /** Any viewport movement beyond this during the probe means the browser can
60
+ * see the keyboard (or is panning) — the geometric path owns the job then. */
61
+ const PROBE_EPSILON_PX = 8
62
+
63
+ /**
64
+ * Fallback lift for a keyboard the browser cannot see (issue #1 确诊根因:
65
+ * 微信输入法在该设备上不向系统 insets 上报键盘高度, Chrome 键盘高度恒 0).
66
+ * There is no signal to measure, so this is an estimate: the reporter's
67
+ * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
68
+ * 400px covers taller IME toolbars without stranding the composer mid-screen.
69
+ * ponytail: single heuristic constant; per-IME calibration only if reports
70
+ * show it misses.
71
+ */
72
+ export function estimatedLift(innerHeight: number): number {
73
+ return Math.min(Math.round(innerHeight * 0.42), 400)
74
+ }
75
+
76
+ /** One visualViewport reading, in CSS pixels. */
77
+ export interface ViewportReading {
78
+ /** Layout viewport height (window.innerHeight). */
79
+ innerHeight: number
80
+ /** visualViewport.height. */
81
+ vvHeight: number
82
+ /** visualViewport.offsetTop. */
83
+ offsetTop: number
84
+ /** visualViewport.scale. */
85
+ scale: number
86
+ }
87
+
88
+ /**
89
+ * How far the composer must rise so its bottom edge sits on the visual
90
+ * viewport's bottom edge (issue #1, 方案 2 of
91
+ * docs/research-ime-keyboard-occlusion.md).
92
+ *
93
+ * The composer is sticky at the LAYOUT viewport's bottom; the keyboard
94
+ * shrinks only the VISUAL viewport (Chrome 108+ resizes-visual). When the
95
+ * browser also pans the visual viewport down to reveal the focused field —
96
+ * iOS, and Android when its auto-scroll works — the occluded band is zero
97
+ * and this stays a no-op. When it shrinks without panning (the reported
98
+ * class of bug), the difference is exactly the hidden band.
99
+ *
100
+ * Pinch-zoom shrinks vvHeight too; the scale guard keeps zooming from
101
+ * flinging the composer around.
102
+ */
103
+ export function keyboardLift(reading: ViewportReading): number {
104
+ if (reading.scale > MAX_SCALE) return 0
105
+ const occluded = reading.innerHeight - reading.vvHeight - reading.offsetTop
106
+ return occluded >= MIN_LIFT_PX ? Math.round(occluded) : 0
107
+ }
108
+
109
+ /**
110
+ * The lift the composer actually gets, from the three sources in priority
111
+ * order.
112
+ * @param geometric - {@link keyboardLift} of the current reading.
113
+ * @param estimate - the dumb-keyboard estimate, 0 when not in that mode.
114
+ * @param keyboardShrunk - the viewport lost {@link KEYBOARD_MIN_SHRINK_PX}
115
+ * or more against its no-keyboard baseline, i.e. the browser reacted.
116
+ * @param pad - this platform's safety clearance, from {@link safetyPad}.
117
+ * @returns pixels to translate the composer up by.
118
+ */
119
+ export function composerLift(
120
+ geometric: number,
121
+ estimate: number,
122
+ keyboardShrunk: boolean,
123
+ pad: number,
124
+ ): number {
125
+ // Measured occlusion, plus clearance for the strip the IME did not declare.
126
+ if (geometric > 0) return geometric + pad
127
+ // The estimate is a generous fraction already; padding it would strand the
128
+ // composer mid-screen.
129
+ if (estimate > 0) return estimate
130
+ // Nothing occluded on paper, but a keyboard IS up and the browser handled
131
+ // it: the only thing that can still cover the composer is an under-reported
132
+ // toolbar, so clear exactly that.
133
+ return keyboardShrunk ? pad : 0
134
+ }
135
+
136
+ /**
137
+ * S10 — keep the composer above the software keyboard (< 768px).
138
+ *
139
+ * The shell deliberately relies on the browser's own focus-reveal behaviour
140
+ * (home.css.ts: plain overflow:hidden so iOS pans the visual viewport, no
141
+ * visualViewport JS). Issue #1 (小米 + 微信输入法) showed one environment
142
+ * where that chain can break while the viewport still shrinks. This effect
143
+ * is the increment that covers it: mirror the occluded band into a root CSS
144
+ * variable, and let the stylesheet translate the composer up by it. In every
145
+ * environment where the browser already handles the keyboard the band
146
+ * computes to zero and nothing changes.
147
+ *
148
+ * `scroll` is listened to as well as `resize`: panning the visual viewport
149
+ * changes offsetTop without a resize (CSSOM View §13.2), and both sides of
150
+ * the subtraction must stay fresh.
151
+ *
152
+ * Second layer (2026-08-21, after on-device confirmation): the reporter's
153
+ * 小米 + 微信输入法 keyboard is INVISIBLE to Chrome — vv.height stayed 859/858
154
+ * with the keyboard open, so no event ever fires and the geometry above is
155
+ * honestly zero. For that class only, a touch-granted composer focus starts a
156
+ * short probe; if the viewport has not moved at all by the end, the composer
157
+ * gets an ESTIMATED lift until blur. Guards against false positives:
158
+ * - probe only after a recent touch pointerdown (a hardware-keyboard focus
159
+ * never lifts anything);
160
+ * - any viewport movement ≥ {@link PROBE_EPSILON_PX} cancels the probe — a
161
+ * browser that shows any reaction owns the reveal itself (iOS pans,
162
+ * working Android resizes);
163
+ * - a non-zero geometric lift always wins over the estimate.
164
+ *
165
+ * Third layer: an IME that under-reports its height (toolbar strip left out
166
+ * of what it declares) makes the browser shrink the viewport by less than the
167
+ * keyboard covers — every number the page can read is self-consistent, so no
168
+ * occlusion is computable. Whenever a keyboard is up and the browser DID
169
+ * react, the composer therefore also gets {@link safetyPad} of clearance —
170
+ * Android only, where the under-reporting happens.
171
+ * See {@link composerLift} for how the three sources compose.
172
+ */
173
+ export function installKeyboardAvoid(ctx: ClientContext): void {
174
+ ctx.effect(() => {
175
+ const vv = window.visualViewport
176
+ if (vv === null || vv === undefined) return () => {}
177
+ const viewport = vv
178
+ const narrow = window.matchMedia(PHONE_QUERY)
179
+ const root = document.documentElement
180
+ let frame = 0
181
+ let applied = 0
182
+ let attached = false
183
+ /** Estimated lift while a browser-invisible keyboard is up; 0 otherwise. */
184
+ let estimate = 0
185
+ let probeTimer = 0
186
+ let lastTouch = 0
187
+ /** Viewport height with no keyboard up, the shrink is measured against
188
+ * it. Re-read on every sync that finds the composer unfocused, so a
189
+ * rotation or a URL-bar change rebases it without extra listeners. */
190
+ let baseline = 0
191
+ const pad = safetyPad(navigator.userAgent)
192
+
193
+ const composerTextarea = (node: unknown): boolean =>
194
+ node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
195
+
196
+ const sync = (): void => {
197
+ const focused = composerTextarea(document.activeElement)
198
+ if (!focused) baseline = viewport.height
199
+ const geometric = keyboardLift({
200
+ innerHeight: window.innerHeight,
201
+ vvHeight: viewport.height,
202
+ offsetTop: viewport.offsetTop,
203
+ scale: viewport.scale,
204
+ })
205
+ const shrunk = focused && baseline - viewport.height >= KEYBOARD_MIN_SHRINK_PX
206
+ const lift = composerLift(geometric, estimate, shrunk, pad)
207
+ if (lift === applied) return
208
+ applied = lift
209
+ if (lift === 0) {
210
+ root.style.removeProperty(LIFT_VAR)
211
+ root.removeAttribute(LIFT_ATTR)
212
+ } else {
213
+ root.style.setProperty(LIFT_VAR, `${lift}px`)
214
+ root.setAttribute(LIFT_ATTR, '')
215
+ }
216
+ }
217
+ const schedule = (): void => {
218
+ if (frame !== 0) return
219
+ frame = requestAnimationFrame(() => {
220
+ frame = 0
221
+ sync()
222
+ })
223
+ }
224
+
225
+ const stopProbe = (): void => {
226
+ if (probeTimer !== 0) window.clearTimeout(probeTimer)
227
+ probeTimer = 0
228
+ }
229
+ /** End the estimated lift AND close the keyboard (blur), keeping the two
230
+ * in one coherent state. Only ever called while an estimate is active, so
231
+ * healthy environments never feel it. */
232
+ const retract = (): void => {
233
+ stopProbe()
234
+ if (estimate !== 0) {
235
+ estimate = 0
236
+ sync()
237
+ }
238
+ const el = document.activeElement
239
+ if (composerTextarea(el) && el instanceof HTMLElement) el.blur()
240
+ }
241
+ /** True when a touch outside the composer means "the reader moved on". */
242
+ const outside = (target: EventTarget | null): boolean =>
243
+ estimate !== 0 && target instanceof Element && target.closest(COMPOSER) === null
244
+ const onPointerDown = (event: PointerEvent): void => {
245
+ if (event.pointerType === 'touch') lastTouch = Date.now()
246
+ // An invisible keyboard also closes invisibly (system back / the IME's
247
+ // own collapse chevron keep the textarea focused), so blur alone cannot
248
+ // end the estimate. A tap OR a scroll outside the composer is the
249
+ // reader moving on — retract and dismiss the keyboard together, the
250
+ // same gesture mainstream chat apps use.
251
+ // ponytail: collapse-then-just-read keeps the lift until the next
252
+ // touch; a real close signal does not exist in this class.
253
+ if (outside(event.target)) retract()
254
+ }
255
+ const onTouchMove = (event: TouchEvent): void => {
256
+ if (outside(event.target)) retract()
257
+ }
258
+ const onFocusIn = (event: FocusEvent): void => {
259
+ if (!composerTextarea(event.target)) return
260
+ // Focus not born from a touch (hardware keyboard, programmatic) raises
261
+ // no on-screen keyboard — never estimate for it.
262
+ if (Date.now() - lastTouch > 1000) return
263
+ stopProbe()
264
+ // A browser already convicted by an earlier probe lifts right away —
265
+ // the probe below still runs and revokes the verdict if the viewport
266
+ // turns out to react after all.
267
+ if (localStorage.getItem(DUMB_KEY) === '1') {
268
+ estimate = estimatedLift(window.innerHeight)
269
+ sync()
270
+ }
271
+ const h0 = viewport.height
272
+ const t0 = viewport.offsetTop
273
+ const i0 = window.innerHeight
274
+ const deadline = Date.now() + PROBE_TOTAL_MS
275
+ const moved = (): boolean =>
276
+ Math.abs(viewport.height - h0) >= PROBE_EPSILON_PX
277
+ || Math.abs(viewport.offsetTop - t0) >= PROBE_EPSILON_PX
278
+ || Math.abs(window.innerHeight - i0) >= PROBE_EPSILON_PX
279
+ const step = (): void => {
280
+ probeTimer = 0
281
+ if (moved()) {
282
+ // The browser can see this keyboard: the geometric path owns the
283
+ // reveal. Drop any stale verdict and estimated lift.
284
+ localStorage.removeItem(DUMB_KEY)
285
+ if (estimate !== 0) {
286
+ estimate = 0
287
+ sync()
288
+ }
289
+ return
290
+ }
291
+ if (Date.now() < deadline) {
292
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
293
+ return
294
+ }
295
+ // Keyboard is up (touch focus on a phone) yet the viewport never
296
+ // reacted: the browser cannot see it. Estimate until blur, and
297
+ // remember the verdict so the next focus skips the wait.
298
+ if (!composerTextarea(document.activeElement)) return
299
+ localStorage.setItem(DUMB_KEY, '1')
300
+ estimate = estimatedLift(window.innerHeight)
301
+ sync()
302
+ }
303
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
304
+ }
305
+ const onFocusOut = (event: FocusEvent): void => {
306
+ if (!composerTextarea(event.target)) return
307
+ stopProbe()
308
+ if (estimate === 0) return
309
+ estimate = 0
310
+ sync()
311
+ }
312
+
313
+ const attach = (): void => {
314
+ if (attached) return
315
+ attached = true
316
+ viewport.addEventListener('resize', schedule)
317
+ viewport.addEventListener('scroll', schedule)
318
+ document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true })
319
+ document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true })
320
+ document.addEventListener('focusin', onFocusIn, true)
321
+ document.addEventListener('focusout', onFocusOut, true)
322
+ sync()
323
+ }
324
+ const detach = (): void => {
325
+ if (!attached) return
326
+ attached = false
327
+ viewport.removeEventListener('resize', schedule)
328
+ viewport.removeEventListener('scroll', schedule)
329
+ document.removeEventListener('pointerdown', onPointerDown, true)
330
+ document.removeEventListener('touchmove', onTouchMove, true)
331
+ document.removeEventListener('focusin', onFocusIn, true)
332
+ document.removeEventListener('focusout', onFocusOut, true)
333
+ stopProbe()
334
+ if (frame !== 0) cancelAnimationFrame(frame)
335
+ frame = 0
336
+ applied = 0
337
+ estimate = 0
338
+ baseline = 0
339
+ root.style.removeProperty(LIFT_VAR)
340
+ root.removeAttribute(LIFT_ATTR)
341
+ }
342
+
343
+ if (narrow.matches) attach()
344
+ const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
345
+ narrow.addEventListener('change', onChange)
346
+ return () => {
347
+ narrow.removeEventListener('change', onChange)
348
+ detach()
349
+ }
350
+ }, 'dsh-mobile-nav: composer keyboard avoid')
351
+ }
@@ -4,6 +4,41 @@ import { NS } from '../locales.ts'
4
4
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
5
5
  const PHONE_QUERY = '(max-width: 767px)'
6
6
 
7
+ /** Desktop fold (issue #2) is switched on either way:
8
+ * - plugin row `config.turnFoldDesktop: true` — the host republishes it at
9
+ * {@link CLIENT_CONFIG_ROUTE} because the client bundle ships statically
10
+ * and never sees the row config;
11
+ * - per-browser override, same convention as the debug flag: a URL param
12
+ * writes localStorage (`?mobile-nav-turn-fold=1` opts this browser in,
13
+ * `=0` opts back out). */
14
+ const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop'
15
+ const DESKTOP_PARAM = 'mobile-nav-turn-fold'
16
+ /** Root attribute the stylesheet keys the width-independent rules on. */
17
+ const DESKTOP_ATTR = 'data-mnav-desktop-fold'
18
+ /** Mirror of src/index.ts CLIENT_CONFIG_ROUTE (client tsconfig cannot reach it). */
19
+ const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
20
+
21
+ /** Read (and, when the URL param is present, persist) the per-browser opt-in. */
22
+ function desktopOptIn(): boolean {
23
+ const param = new URLSearchParams(location.search).get(DESKTOP_PARAM)
24
+ if (param === '1') localStorage.setItem(DESKTOP_KEY, '1')
25
+ else if (param === '0') localStorage.removeItem(DESKTOP_KEY)
26
+ return localStorage.getItem(DESKTOP_KEY) === '1'
27
+ }
28
+
29
+ /** True when the plugin row asks for desktop fold; false on any failure. */
30
+ async function desktopConfigured(): Promise<boolean> {
31
+ try {
32
+ const res = await fetch(CLIENT_CONFIG_ROUTE, { cache: 'no-store' })
33
+ if (!res.ok) return false
34
+ const body: unknown = await res.json()
35
+ return typeof body === 'object' && body !== null
36
+ && (body as { turnFoldDesktop?: unknown }).turnFoldDesktop === true
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
7
42
  /** ChatView's flow column (dsh-client-ui-conversation lib/client.js:5512).
8
43
  * Trajectory and every other view render their own tree and never carry this
9
44
  * marker, so scoping here is what keeps this effect Chat-only. */
@@ -116,7 +151,9 @@ function groupsOf(flow: Element): readonly Group[] {
116
151
  }
117
152
 
118
153
  /**
119
- * S8 — collapse a turn's process into one summary row (< 768px only).
154
+ * S8 — collapse a turn's process into one summary row (< 768px by default;
155
+ * every width when the plugin row sets `config.turnFoldDesktop` or a browser
156
+ * opts itself in via `?mobile-nav-turn-fold=1`).
120
157
  *
121
158
  * Route taken: DOM marking, not the `conversation.chat.node` keyed slot.
122
159
  * That slot dispatches on node kind and a second registration at a key
@@ -258,12 +295,32 @@ export function installTurnFold(ctx: ClientContext): void {
258
295
  clear()
259
296
  }
260
297
 
261
- if (narrow.matches) attach()
262
298
  const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
299
+ let disposed = false
300
+ let desktop = false
301
+ /** Attach at every width and drop the breakpoint listener — the
302
+ * stylesheet's html[DESKTOP_ATTR] rules take over from the phone media
303
+ * query. Idempotent: the config answer and the local opt-in may both
304
+ * ask for it. */
305
+ const enableDesktop = (): void => {
306
+ if (desktop || disposed) return
307
+ desktop = true
308
+ document.documentElement.setAttribute(DESKTOP_ATTR, '')
309
+ narrow.removeEventListener('change', onChange)
310
+ attach()
311
+ }
312
+
313
+ if (narrow.matches) attach()
263
314
  narrow.addEventListener('change', onChange)
315
+ if (desktopOptIn()) enableDesktop()
316
+ // The row config arrives async; a late enable just attaches then. Not
317
+ // awaited before phone attach — folding must not wait on a round-trip.
318
+ else void desktopConfigured().then((on) => (on ? enableDesktop() : undefined))
264
319
  // A language switch changes the chip copy of every already-rendered turn.
265
320
  const stopLocale = ctx.locale.subscribe(schedule)
266
321
  return () => {
322
+ disposed = true
323
+ document.documentElement.removeAttribute(DESKTOP_ATTR)
267
324
  narrow.removeEventListener('change', onChange)
268
325
  stopLocale()
269
326
  detach()
@@ -18,6 +18,7 @@ import { installTurnFold } from './effects/turn-fold.ts'
18
18
  import { installModalBack } from './effects/modal-back.ts'
19
19
  import { installWelcomeNoticeOptOut } from './effects/welcome-notice.ts'
20
20
  import { installKeyboardGuard } from './effects/keyboard-guard.ts'
21
+ import { installKeyboardAvoid } from './effects/keyboard-avoid.ts'
21
22
  import { NS, en, zh } from './locales.ts'
22
23
  import type { MobileNavKey } from './locales.ts'
23
24
 
@@ -92,6 +93,11 @@ export function apply(ctx: ClientContext): void {
92
93
  // user tapped the composer (or typed) themselves.
93
94
  installKeyboardGuard(ctx)
94
95
 
96
+ // S10: when the keyboard shrinks the visual viewport but the browser fails
97
+ // to reveal the focused composer (issue #1 的「视口缩了页面没跟上」类环境),
98
+ // translate the composer up by the occluded band. Inert everywhere else.
99
+ installKeyboardAvoid(ctx)
100
+
95
101
  // Page-stack store (apply world) — created before any registration so
96
102
  // every slot below (the phone home screen, the session header's back
97
103
  // button) shares the exact same handle/instance.
@@ -513,6 +513,21 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
513
513
  [data-slot="conversation.composer.bar"] > [class$="_root"] {
514
514
  padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;
515
515
  }
516
+
517
+ /* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the
518
+ layout viewport hidden behind the software keyboard into --mnav-kb-lift
519
+ and stamps the html attribute while (and only while) it is non-zero —
520
+ a resting transform, even translateY(0), would silently become the
521
+ containing block for any fixed-position descendant of the bar. In every
522
+ environment where the browser's own focus-reveal works the attribute is
523
+ simply never set.
524
+ The target is the slot's _root CHILD, not the slot wrapper: the wrapper
525
+ is display:contents — it generates no box, so a transform on it is a
526
+ silent no-op (found live on-device via CDP, 2026-08-21; the variable and
527
+ attribute were set and nothing moved). */
528
+ html[data-mnav-kb] [data-slot="conversation.composer.bar"] > [class$="_root"] {
529
+ transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));
530
+ }
516
531
  }
517
532
 
518
533
  /* The attachment button and its preview row only exist for the phone shell.
@@ -1,26 +1,22 @@
1
1
  // turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
2
- // Everything that can hide content lives inside (max-width: 767px); the two
3
- // markers this file reads (data-mnav-fold on a process element,
2
+ // Everything that can hide content lives inside (max-width: 767px) OR behind
3
+ // html[data-mnav-desktop-fold] (the per-browser desktop opt-in, issue #2);
4
+ // the two markers this file reads (data-mnav-fold on a process element,
4
5
  // data-mobile-nav="turn-fold" on the injected summary row) are only ever
5
- // written by effects/turn-fold.ts, which detaches and wipes them at >= 768px
6
- // — so tablet and desktop are a no-op twice over, by attribute and by media
7
- // query. Appended last in styles/index.ts; it shares no selector with any
8
- // other file, the position just keeps the "phone files come after the shared
6
+ // written by effects/turn-fold.ts, which detaches and wipes them when it is
7
+ // not active — so a non-opted-in tablet/desktop is a no-op twice over, by
8
+ // attribute and by scope. The rule block is authored once and emitted twice
9
+ // (phone media query / desktop attribute scope) so the two can never drift.
10
+ // Appended last in styles/index.ts; it shares no selector with any other
11
+ // file, the position just keeps the "phone files come after the shared
9
12
  // <=1023px block" ordering intact.
10
13
 
11
- export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
12
-
13
- /* The summary row never paints outside the phone breakpoint, even if a
14
- media-query change raced the effect's own cleanup. */
15
- [data-mobile-nav="turn-fold"] {
16
- display: none;
17
- }
18
-
19
- @media (max-width: 767px) {
14
+ /** The fold + chip rules, with every selector prefixed by `scope`. */
15
+ const rules = (scope: string): string => `
20
16
  /* Folded process: tool-call / context / command rows and the Think
21
17
  disclosures inside an assistant step. !important because the official
22
18
  flow item and ReasoningRow both set their own display. */
23
- [data-mnav-fold]:not([data-mnav-fold-open]) {
19
+ ${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
24
20
  display: none !important;
25
21
  }
26
22
 
@@ -30,7 +26,7 @@ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) --------
30
26
  column with gap: 16px (ChatView.module.css) — align-self keeps the chip
31
27
  from stretching, and the negative block margin trims that generous gap
32
28
  back to something a 26px row can live in. */
33
- [data-chat-flow] > [data-mobile-nav="turn-fold"] {
29
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"] {
34
30
  display: inline-flex;
35
31
  align-self: flex-start;
36
32
  align-items: center;
@@ -49,14 +45,14 @@ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) --------
49
45
  cursor: pointer;
50
46
  -webkit-tap-highlight-color: transparent;
51
47
  }
52
- [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
48
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
53
49
  background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
54
50
  }
55
51
 
56
52
  /* Leading dot: idle turns get a quiet mark, a running turn gets the same
57
53
  business-primary colour the session header's own status dot uses
58
54
  (styles/header.css.ts) plus a breathing pulse. */
59
- [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
55
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
60
56
  content: '';
61
57
  flex: none;
62
58
  width: 6px;
@@ -65,7 +61,7 @@ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) --------
65
61
  background: currentColor;
66
62
  opacity: .45;
67
63
  }
68
- [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
64
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
69
65
  background: var(--dsw-alias-state-business-primary, #4f6ef7);
70
66
  opacity: 1;
71
67
  animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
@@ -73,7 +69,7 @@ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) --------
73
69
 
74
70
  /* Chevron drawn from two borders — no icon injection, no mask image, and
75
71
  it rotates to point up once the turn is open. */
76
- [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
72
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
77
73
  content: '';
78
74
  flex: none;
79
75
  width: 5px;
@@ -84,21 +80,36 @@ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) --------
84
80
  transform: rotate(45deg);
85
81
  transition: transform .16s var(--ds-ease-out, ease-in-out);
86
82
  }
87
- [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
83
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
88
84
  margin-top: 3px;
89
85
  transform: rotate(-135deg);
90
86
  }
91
87
 
92
88
  @media (prefers-reduced-motion: reduce) {
93
- [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
89
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
94
90
  animation: none;
95
91
  }
96
- [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
92
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
97
93
  transition: none;
98
94
  }
99
95
  }
96
+ `
97
+
98
+ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px, or desktop opt-in) ---------- */
99
+
100
+ /* The summary row never paints outside its active scope, even if a
101
+ media-query change raced the effect's own cleanup. */
102
+ [data-mobile-nav="turn-fold"] {
103
+ display: none;
100
104
  }
101
105
 
106
+ @media (max-width: 767px) {${rules('')}}
107
+
108
+ /* Desktop opt-in: the exact same block, keyed on the root attribute
109
+ effects/turn-fold.ts sets for a browser opted in via
110
+ ?mobile-nav-turn-fold=1 — active at every width. */
111
+ ${rules('html[data-mnav-desktop-fold]')}
112
+
102
113
  @keyframes dsh-mobile-nav-breathe {
103
114
  0%, 100% { opacity: 1; transform: scale(1); }
104
115
  50% { opacity: .4; transform: scale(.72); }
package/src/index.ts CHANGED
@@ -26,6 +26,11 @@ import type {} from '@deepseek-ai/dsh-session'
26
26
  /** Exact route the phone composer POSTs one file body to. */
27
27
  export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
28
28
 
29
+ /** Exact route the browser GETs the plugin row's client-facing knobs from.
30
+ * The client bundle ships statically and never sees the row config, so the
31
+ * host republishes the client-relevant subset here (issue #2). */
32
+ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
33
+
29
34
  /** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
30
35
  export const UPLOAD_DIR = '.dsh-uploads'
31
36
 
@@ -38,10 +43,14 @@ const MAX_NAME_BYTES = 180
38
43
  /** Distinct leaf names tried before a collision is given up on. */
39
44
  const MAX_COLLISION_TRIES = 100
40
45
 
41
- /** Host half config; the only knob is the body cap. */
46
+ /** Host half config. */
42
47
  export interface MobileNavConfig {
43
48
  /** Max upload body in bytes; larger bodies get 413. Default {@link DEFAULT_MAX_UPLOAD_BYTES}. */
44
49
  maxUploadBytes?: number
50
+ /** Fold each turn's process at every viewport width, not just below the
51
+ * phone breakpoint. Default false (phone-only). A browser can still opt
52
+ * itself in via `?mobile-nav-turn-fold=1` when this is off. */
53
+ turnFoldDesktop?: boolean
45
54
  }
46
55
 
47
56
  /**
@@ -340,4 +349,20 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
340
349
  handler: (req, res) => handleUpload(webCtx, maxBytes, req, res),
341
350
  }), 'dsh-mobile-nav: upload route')
342
351
  })
352
+ // Needs only the webServer: the client-config route must exist even in a
353
+ // composition without live sessions.
354
+ ctx.inject(['webServer'], (webCtx) => {
355
+ webCtx.effect(() => webCtx.webServer.register({
356
+ kind: 'exact',
357
+ path: CLIENT_CONFIG_ROUTE,
358
+ handler: (req, res) => {
359
+ if (req.method !== 'GET') {
360
+ res.setHeader('Allow', 'GET')
361
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
362
+ return
363
+ }
364
+ responseJson(res, 200, { turnFoldDesktop: config.turnFoldDesktop === true })
365
+ },
366
+ }), 'dsh-mobile-nav: client config route')
367
+ })
343
368
  }