dsh-zen-remote 1.1.2 → 1.1.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAIlD,6DAA6D;AAC7D,eAAO,MAAM,YAAY,4BAA4B,CAAA;AAErD;;kEAEkE;AAClE,eAAO,MAAM,mBAAmB,mCAAmC,CAAA;AAEnE,+FAA+F;AAC/F,eAAO,MAAM,UAAU,iBAAiB,CAAA;AAExC,6DAA6D;AAC7D,eAAO,MAAM,wBAAwB,QAAmB,CAAA;AAQxD,wBAAwB;AACxB,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;mEAE+D;IAC/D,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AAwCD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAa5D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBlD;AA6HD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,eAAoB,GAAG,IAAI,CAyBtE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAIlD,6DAA6D;AAC7D,eAAO,MAAM,YAAY,4BAA4B,CAAA;AAErD;;kEAEkE;AAClE,eAAO,MAAM,mBAAmB,mCAAmC,CAAA;AAEnE,+FAA+F;AAC/F,eAAO,MAAM,UAAU,iBAAiB,CAAA;AAExC,6DAA6D;AAC7D,eAAO,MAAM,wBAAwB,QAAmB,CAAA;AAQxD,wBAAwB;AACxB,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;mEAE+D;IAC/D,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB;;;;;;;gCAO4B;IAC5B,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;yCACqC;IACrC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAGhH;AAwCD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAa5D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBlD;AA6HD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,eAAoB,GAAG,IAAI,CA8BtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -81,16 +81,16 @@
81
81
  },
82
82
  "peerDependencies": {
83
83
  "@deepseek-ai/cordis": "^4.0.1",
84
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
85
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
86
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7",
87
- "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.7",
88
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
89
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
90
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
91
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
92
- "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
93
- "@deepseek-ai/dsh-session-log-export": "^0.1.0-rc.7",
84
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
85
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
86
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
87
+ "@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
88
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
89
+ "@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
90
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
91
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
92
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
93
+ "@deepseek-ai/dsh-session-log-export": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
94
94
  "react": "^18.2.0",
95
95
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
96
96
  },
@@ -5,6 +5,8 @@
5
5
  import assert from 'node:assert/strict'
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { composerLift, estimatedLift, keyboardLift as lift, safetyPad } from '../src/client/effects/keyboard-avoid.ts'
8
+ import { KEYBOARD_DEFAULTS, parseClientConfig } from '../src/client/client-config.ts'
9
+ import { clamped } from '../src/index.ts'
8
10
 
9
11
  // The reported class of bug: keyboard shrinks the visual viewport, browser
10
12
  // does not pan it — the whole shrink is an occluded band at the bottom.
@@ -88,4 +90,41 @@ assert.match(
88
90
  'keyboard-avoid transform must hit the box-generating _root child',
89
91
  )
90
92
 
93
+ // --- the tuning knobs (2026-08-22) -----------------------------------------
94
+ // The two estimates above are guesses about someone else's hardware, so the
95
+ // plugin row can retune them. What must hold: an install that sets nothing
96
+ // behaves EXACTLY as it did before the knobs existed, and a set value cannot
97
+ // push the composer somewhere there is no way back from.
98
+
99
+ // Shipped defaults are the values the assertions above were written against.
100
+ assert.deepEqual(KEYBOARD_DEFAULTS, { liftRatio: 0.42, liftMaxPx: 400, safetyPadPx: 15 })
101
+ assert.equal(estimatedLift(858, KEYBOARD_DEFAULTS), estimatedLift(858), 'defaults are the no-config path')
102
+ assert.equal(safetyPad(ANDROID, KEYBOARD_DEFAULTS.safetyPadPx), safetyPad(ANDROID))
103
+
104
+ // A retuned row moves both halves of the estimate, and the pad.
105
+ assert.equal(estimatedLift(700, { liftRatio: 0.5, liftMaxPx: 400, safetyPadPx: 15 }), 350)
106
+ assert.equal(estimatedLift(858, { liftRatio: 0.5, liftMaxPx: 500, safetyPadPx: 15 }), 429)
107
+ assert.equal(estimatedLift(858, { liftRatio: 0.5, liftMaxPx: 380, safetyPadPx: 15 }), 380, 'cap still wins')
108
+ assert.equal(safetyPad(ANDROID, 40), 40)
109
+ assert.equal(safetyPad(IPHONE, 40), 0, 'a configured pad stays Android-only')
110
+
111
+ // An empty / failed / garbage route body leaves every default in place — the
112
+ // client half is where the defaults live, so this is the whole contract.
113
+ for (const body of [undefined, null, {}, 'not json', { keyboardLiftRatio: 'big' }, { keyboardLiftRatio: NaN }]) {
114
+ assert.deepEqual(parseClientConfig(body).keyboard, KEYBOARD_DEFAULTS, `body ${JSON.stringify(body) ?? 'undefined'} keeps defaults`)
115
+ }
116
+ assert.equal(parseClientConfig({ keyboardLiftRatio: 0.5 }).keyboard.liftRatio, 0.5)
117
+ assert.equal(parseClientConfig({ keyboardSafetyPadPx: 0 }).keyboard.safetyPadPx, 0, 'zero is a real value, not "unset"')
118
+ assert.equal(parseClientConfig({ turnFoldDesktop: true }).turnFoldDesktop, true)
119
+
120
+ // The host clamps before publishing: a typo in the YAML must not be able to
121
+ // push the composer off-screen with no way back except editing the YAML.
122
+ assert.deepEqual(clamped('keyboardLiftRatio', 5, 0, 1), { keyboardLiftRatio: 1 })
123
+ assert.deepEqual(clamped('keyboardLiftRatio', -2, 0, 1), { keyboardLiftRatio: 0 })
124
+ assert.deepEqual(clamped('keyboardLiftMaxPx', 9000, 0, 2000), { keyboardLiftMaxPx: 2000 })
125
+ assert.deepEqual(clamped('keyboardLiftRatio', undefined, 0, 1), {}, 'unset stays absent so the client default wins')
126
+ for (const bad of [Number.NaN, Infinity, '0.5', null]) {
127
+ assert.deepEqual(clamped('keyboardLiftRatio', bad, 0, 1), {}, `${String(bad)} is not a number`)
128
+ }
129
+
91
130
  console.log('KEYBOARD AVOID CHECK OK')
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The plugin row's client-facing knobs, fetched once per page.
3
+ *
4
+ * The client bundle ships statically and never sees the row config, so the
5
+ * host republishes the client-relevant subset at {@link CLIENT_CONFIG_ROUTE}
6
+ * (src/index.ts). Two effects read it — turn-fold's desktop switch and
7
+ * keyboard-avoid's fallback tuning — so the request is memoized here rather
8
+ * than fired once per reader.
9
+ *
10
+ * Defaults live in THIS file, not in the host: the route omits a knob the
11
+ * row never set, so there is exactly one place each default is written and
12
+ * no way for the two halves to drift apart on the value. The host's job is
13
+ * only to validate and clamp what a user actually typed.
14
+ *
15
+ * Every read is total — a failed request, a non-JSON body, or a garbage
16
+ * value all fall back to the defaults rather than throwing into an effect.
17
+ */
18
+
19
+ /** Mirror of src/index.ts CLIENT_CONFIG_ROUTE (client tsconfig cannot reach it). */
20
+ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
21
+
22
+ /**
23
+ * Tuning for the estimated-lift path in effects/keyboard-avoid.ts — the
24
+ * fallback for a keyboard the browser cannot see at all. There is nothing to
25
+ * measure on those devices, so these are estimates, and estimates about
26
+ * hardware need a knob: IME toolbars, foldables, and PWA shells all sit at
27
+ * different heights, and the person holding the phone is the only one who
28
+ * can see whether the composer cleared the keys.
29
+ */
30
+ export interface KeyboardTuning {
31
+ /** Share of the layout viewport the estimated lift starts from. */
32
+ liftRatio: number
33
+ /** Ceiling on that estimate, so a tall viewport cannot strand the composer mid-screen. */
34
+ liftMaxPx: number
35
+ /** Extra clearance above a keyboard the browser DID react to (Android only). */
36
+ safetyPadPx: number
37
+ }
38
+
39
+ /** Shipped tuning: the values measured on the one reported device (issue #1,
40
+ * WeType ~315 CSS px on an 858px viewport) plus the Android IME under-report
41
+ * pad. Changing these changes the default for every install — a per-device
42
+ * fix belongs in that install's plugin row instead. */
43
+ export const KEYBOARD_DEFAULTS: KeyboardTuning = {
44
+ liftRatio: 0.42,
45
+ liftMaxPx: 400,
46
+ safetyPadPx: 15,
47
+ }
48
+
49
+ /** Everything the client half reads off the plugin row. */
50
+ export interface ClientConfig {
51
+ /** Fold each turn's process at every width, not just below the phone breakpoint. */
52
+ turnFoldDesktop: boolean
53
+ keyboard: KeyboardTuning
54
+ }
55
+
56
+ /** One published number, or the shipped default when the row left it unset
57
+ * (the route omits the key) or the body is not what it claims to be. */
58
+ function num(value: unknown, fallback: number): number {
59
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
60
+ }
61
+
62
+ /** Parse one route body. Exported for scripts/check-client-config.mjs. */
63
+ export function parseClientConfig(body: unknown): ClientConfig {
64
+ const row: Record<string, unknown> = typeof body === 'object' && body !== null
65
+ ? body as Record<string, unknown>
66
+ : {}
67
+ return {
68
+ turnFoldDesktop: row['turnFoldDesktop'] === true,
69
+ keyboard: {
70
+ liftRatio: num(row['keyboardLiftRatio'], KEYBOARD_DEFAULTS.liftRatio),
71
+ liftMaxPx: num(row['keyboardLiftMaxPx'], KEYBOARD_DEFAULTS.liftMaxPx),
72
+ safetyPadPx: num(row['keyboardSafetyPadPx'], KEYBOARD_DEFAULTS.safetyPadPx),
73
+ },
74
+ }
75
+ }
76
+
77
+ let cached: Promise<ClientConfig> | undefined
78
+
79
+ /** The row config, fetched at most once per page load. Never rejects. */
80
+ export function clientConfig(): Promise<ClientConfig> {
81
+ cached ??= fetch(CLIENT_CONFIG_ROUTE, { cache: 'no-store' })
82
+ .then((res) => (res.ok ? res.json() as Promise<unknown> : undefined))
83
+ .catch(() => undefined)
84
+ .then(parseClientConfig)
85
+ return cached
86
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { clientConfig, KEYBOARD_DEFAULTS, type KeyboardTuning } from '../client-config.ts'
2
3
 
3
4
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
5
  const PHONE_QUERY = '(max-width: 767px)'
@@ -27,14 +28,19 @@ const MAX_SCALE = 1.01
27
28
  *
28
29
  * ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
29
30
  * 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
31
+ * so padding there would be a visible gap bought for nothing.
32
+ *
33
+ * The 15px default is what shipped; a device whose IME hides more of the
34
+ * composer than that can raise it through the plugin row's
35
+ * `config.keyboardSafetyPadPx` ({@link KeyboardTuning}). */
36
+ const SAFETY_PAD_PX = KEYBOARD_DEFAULTS.safetyPadPx
32
37
 
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
+ /** The safety pad this platform gets: the configured clearance, or nothing
39
+ * off Android. UA sniffing is the right tool here — the target is a platform
40
+ * defect, not a feature that could be detected.
41
+ * @param padPx - the row's clearance; defaults to the shipped 15px. */
42
+ export function safetyPad(userAgent: string, padPx: number = SAFETY_PAD_PX): number {
43
+ return /Android/iu.test(userAgent) ? padPx : 0
38
44
  }
39
45
 
40
46
  /** A viewport that lost at least this much against its no-keyboard baseline
@@ -66,11 +72,16 @@ const PROBE_EPSILON_PX = 8
66
72
  * There is no signal to measure, so this is an estimate: the reporter's
67
73
  * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
68
74
  * 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.
75
+ *
76
+ * Both numbers are a guess about someone else's hardware, so both are knobs:
77
+ * `config.keyboardLiftRatio` / `config.keyboardLiftMaxPx` on the plugin row
78
+ * ({@link KeyboardTuning}). The shipped pair stays the default — an install
79
+ * that never touches them behaves exactly as before.
80
+ *
81
+ * @param tuning - the row's tuning; defaults to the shipped estimate.
71
82
  */
72
- export function estimatedLift(innerHeight: number): number {
73
- return Math.min(Math.round(innerHeight * 0.42), 400)
83
+ export function estimatedLift(innerHeight: number, tuning: KeyboardTuning = KEYBOARD_DEFAULTS): number {
84
+ return Math.min(Math.round(innerHeight * tuning.liftRatio), tuning.liftMaxPx)
74
85
  }
75
86
 
76
87
  /** One visualViewport reading, in CSS pixels. */
@@ -188,7 +199,16 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
188
199
  * it. Re-read on every sync that finds the composer unfocused, so a
189
200
  * rotation or a URL-bar change rebases it without extra listeners. */
190
201
  let baseline = 0
191
- const pad = safetyPad(navigator.userAgent)
202
+ /** Row tuning for the estimate path. Starts on the shipped defaults and
203
+ * is replaced once the config request lands (a few ms into the page, and
204
+ * always long before a touch focus can raise a keyboard) — no re-sync is
205
+ * forced, the next focus or viewport event picks the new values up. */
206
+ let tuning = KEYBOARD_DEFAULTS
207
+ let pad = safetyPad(navigator.userAgent)
208
+ void clientConfig().then((loaded) => {
209
+ tuning = loaded.keyboard
210
+ pad = safetyPad(navigator.userAgent, tuning.safetyPadPx)
211
+ })
192
212
 
193
213
  const composerTextarea = (node: unknown): boolean =>
194
214
  node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
@@ -265,7 +285,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
265
285
  // the probe below still runs and revokes the verdict if the viewport
266
286
  // turns out to react after all.
267
287
  if (localStorage.getItem(DUMB_KEY) === '1') {
268
- estimate = estimatedLift(window.innerHeight)
288
+ estimate = estimatedLift(window.innerHeight, tuning)
269
289
  sync()
270
290
  }
271
291
  const h0 = viewport.height
@@ -297,7 +317,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
297
317
  // remember the verdict so the next focus skips the wait.
298
318
  if (!composerTextarea(document.activeElement)) return
299
319
  localStorage.setItem(DUMB_KEY, '1')
300
- estimate = estimatedLift(window.innerHeight)
320
+ estimate = estimatedLift(window.innerHeight, tuning)
301
321
  sync()
302
322
  }
303
323
  probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
@@ -1,4 +1,5 @@
1
1
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { clientConfig } from '../client-config.ts'
2
3
  import { NS } from '../locales.ts'
3
4
 
4
5
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
@@ -15,8 +16,6 @@ const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop'
15
16
  const DESKTOP_PARAM = 'mobile-nav-turn-fold'
16
17
  /** Root attribute the stylesheet keys the width-independent rules on. */
17
18
  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
19
 
21
20
  /** Read (and, when the URL param is present, persist) the per-browser opt-in. */
22
21
  function desktopOptIn(): boolean {
@@ -26,17 +25,10 @@ function desktopOptIn(): boolean {
26
25
  return localStorage.getItem(DESKTOP_KEY) === '1'
27
26
  }
28
27
 
29
- /** True when the plugin row asks for desktop fold; false on any failure. */
28
+ /** True when the plugin row asks for desktop fold; false on any failure —
29
+ * the shared reader in client-config.ts already swallows those. */
30
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
- }
31
+ return (await clientConfig()).turnFoldDesktop
40
32
  }
41
33
 
42
34
  /** ChatView's flow column (dsh-client-ui-conversation lib/client.js:5512).
@@ -56,19 +48,30 @@ const CHIP_SELECTOR = '[data-mobile-nav="turn-fold"]'
56
48
  * per-block below), `turn-tail` (the footer with its actions), and the
57
49
  * error notices `turn-error` / `turn-max-tokens` / `model-retry`, which are
58
50
  * exactly what a reader must not have to hunt for. */
59
- const PROCESS_KINDS = new Set(['context', 'tool-call', 'command'])
51
+ export const PROCESS_KINDS = ['context', 'tool-call', 'command'] as const
52
+ const PROCESS_KIND_SET: ReadonlySet<string> = new Set(PROCESS_KINDS)
60
53
 
61
54
  /** Node kinds that open a new turn group. */
62
55
  const TURN_HEAD_KINDS = new Set(['user', 'steering'])
63
56
 
64
57
  /** ReasoningRow's own marker (lib/client.js:8966) — the Think disclosure
65
58
  * rendered inside an assistant-step row, beside that step's prose. */
66
- const THINK = '[data-variant="think"]'
59
+ export const THINK = '[data-variant="think"]'
67
60
 
68
- /** Attribute the phone stylesheet turns into `display: none`. */
61
+ /** Attribute the phone stylesheet turns into `display: none`. Still written
62
+ * per element for the one case the stylesheet cannot express on its own: an
63
+ * assistant-step row whose entire body is reasoning, which has to go as a
64
+ * whole row (see {@link foldsWholeRow}). */
69
65
  const FOLD = 'data-mnav-fold'
70
66
  /** Per-item override written while its turn is expanded. */
71
- const OPEN = 'data-mnav-fold-open'
67
+ export const OPEN = 'data-mnav-fold-open'
68
+ /** Root attribute written for as long as this effect is attached — i.e. for
69
+ * exactly the widths where the fold is live. The stylesheet keys its
70
+ * born-folded rules on it, which is what makes a newly inserted tool call
71
+ * hidden at its FIRST paint instead of after the next rescan (the "row
72
+ * flashes in, then folds away" report, 2026-08-22); it also keeps the
73
+ * stylesheet a no-op — content visible — if this effect never runs. */
74
+ const ACTIVE_ATTR = 'data-mnav-fold-on'
72
75
 
73
76
  /**
74
77
  * True when the reasoning rows are the ONLY thing this row renders, so the
@@ -98,7 +101,7 @@ function foldsWholeRow(row: Element, thinks: readonly Element[]): boolean {
98
101
  function processItems(row: Element): readonly Element[] {
99
102
  const kind = row.getAttribute('data-chat-flow-kind')
100
103
  if (kind === null) return []
101
- if (PROCESS_KINDS.has(kind)) return [row]
104
+ if (PROCESS_KIND_SET.has(kind)) return [row]
102
105
  if (kind !== 'assistant-step') return []
103
106
  const thinks = [...row.querySelectorAll(THINK)]
104
107
  if (thinks.length === 0) return []
@@ -280,12 +283,14 @@ export function installTurnFold(ctx: ClientContext): void {
280
283
 
281
284
  const attach = (): void => {
282
285
  if (observer !== null) return
286
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
283
287
  observer = new MutationObserver(schedule)
284
288
  observer.observe(document.body, { childList: true, subtree: true })
285
289
  document.addEventListener('click', onClick)
286
290
  scan()
287
291
  }
288
292
  const detach = (): void => {
293
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
289
294
  observer?.disconnect()
290
295
  observer = null
291
296
  document.removeEventListener('click', onClick)
@@ -1,6 +1,8 @@
1
1
  // compat — split from src/client/mobile.css.ts (2026-08-16), order preserved.
2
2
  // Do not reorder: styles/index.ts concatenates in this exact order.
3
3
 
4
+ import { UPLOAD_DIR } from '../attach-upload.ts'
5
+
4
6
  export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
5
7
  The linxin666 plugin suite extends the shell frame directly:
6
8
  - aionui-panel appends two trailing grid columns (explorer / preview)
@@ -736,4 +738,35 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
736
738
  display: none !important;
737
739
  }
738
740
  }
741
+
742
+ /* ---------- dsh-at-file compatibility ----------
743
+ dsh-at-file registers its own \`conversation.input.dock\` entry
744
+ ([data-at-file-dock]) and renders one row per \`@path\` token in the
745
+ draft. That is exactly the same source this plugin's attachment chips
746
+ read, so on a phone a single uploaded image produced TWO entries on the
747
+ composer rail: our 48px thumbnail tile AND at-file's file-name row for
748
+ the same token (reported 2026-08-22).
749
+
750
+ Drop at-file's row for the upload directory only. Everything else the
751
+ user @-mentions by hand is at-file's own job and stays — this is not a
752
+ blanket hide. The path is readable from the row's own title attribute
753
+ (FilesDock passes \`title={mention.relative}\`), which is a rendered
754
+ contract rather than a hashed class name.
755
+
756
+ Phone only, and deliberately so: at \u003e= 768px our chips are hidden by
757
+ composer.css.ts, which leaves at-file's row as the ONLY thing on screen
758
+ representing the attachment. Hiding it there would make an uploaded file
759
+ invisible.
760
+
761
+ When every row was an upload the container itself is left empty, and an
762
+ empty flex item still claims the rail's 6px gap — so the dock goes too
763
+ unless at least one non-upload row survives. */
764
+ @media (max-width: 767px) {
765
+ [data-at-file-dock] [data-at-file-row]:has([title^="${UPLOAD_DIR}/"]) {
766
+ display: none !important;
767
+ }
768
+ [data-at-file-dock]:not(:has([data-at-file-row] [title]:not([title^="${UPLOAD_DIR}/"]))) {
769
+ display: none !important;
770
+ }
771
+ }
739
772
  `
@@ -315,9 +315,18 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
315
315
  /* --- 7b. attachment chips (S7.1) ---
316
316
  Our own dock entry opts OUT of the 26px pill cage above: an image chip is
317
317
  a 48px tile. The row sits tight under the card's top edge, so the chips
318
- read as part of the composer rather than as a floating strip. */
318
+ read as part of the composer rather than as a floating strip.
319
+
320
+ It also takes the LAST line of its own (line policy in 7c): a 48px tile
321
+ beside the to-do chip on one nowrap line squeezed that chip down to an
322
+ unreadable stub, with a long file name pushing the rest off-screen
323
+ (reported 2026-08-22). Its own chips wrap rather than overflow, so
324
+ several attachments stack instead of scrolling out of reach. */
319
325
  [data-slot="conversation.input.dock"] > [data-mobile-nav="attach-chips"] {
320
326
  display: flex !important;
327
+ flex: 1 0 100% !important;
328
+ flex-wrap: wrap !important;
329
+ order: 2 !important;
321
330
  align-items: center !important;
322
331
  gap: 6px !important;
323
332
  max-width: none !important;
@@ -327,6 +336,57 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
327
336
  overflow: visible !important;
328
337
  margin-bottom: -2px;
329
338
  }
339
+
340
+ /* --- 7c. the queue strip opts OUT of the cage, on a line of its own ---
341
+ Third entry that is not a pill. conversation.input.dock is a \`list\` slot
342
+ and DSH's own QueueDock registers into it at order 20 (id \`queue\`,
343
+ dsh-client-ui-conversation) — a 36px row per queued message, or a
344
+ collapsible "N 条排队消息" header once there is more than one. The 26px
345
+ cage above clipped it to a sliver: on a phone a queued message showed as
346
+ a grey stub poking out from behind the input card, unreadable and with
347
+ its edit / delete / send buttons cut off (reported 2026-08-22, same
348
+ failure the to-do card hit in 7a).
349
+
350
+ Two things are restored. Geometry: the official row height back, and the
351
+ dock's own composer-column sizing dropped — its width/max-width are
352
+ calc()s over the composer card variables and its bottom margin is
353
+ negative by design (upstream tucks the strip UNDER the card), none of
354
+ which survives being a flex item of our chip rail.
355
+
356
+ Layout — the line policy for the whole rail, now that three things want
357
+ room on it. The pills (the to-do card, a branch chip, whatever else
358
+ registers) keep order 0 and share the top line, still scrolling
359
+ horizontally. The queue takes the next line (order 1). The attachment
360
+ chips take the last one (order 2, 7b), so what the user is about to send
361
+ sits closest to the input it will be sent from. The rail only wraps
362
+ while one of the two full-width entries exists, so a session with
363
+ neither keeps the original single scrolling chip line. */
364
+ [data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"]) {
365
+ flex-wrap: wrap !important;
366
+ }
367
+ [data-slot="conversation.input.dock"] > [data-queue-dock] {
368
+ flex: 1 0 100% !important;
369
+ order: 1 !important;
370
+ max-width: none !important;
371
+ min-height: 0 !important;
372
+ max-height: none !important;
373
+ margin: 0 !important;
374
+ padding: 0 !important;
375
+ border-radius: 0 !important;
376
+ overflow: visible !important;
377
+ font-size: 13px !important;
378
+ line-height: normal !important;
379
+ }
380
+ /* The panel is the dock's only child; upstream rounds its top corners only
381
+ and drops the bottom border, because there it is half-hidden behind the
382
+ card. On its own line it is a free-standing strip, so close it up. */
383
+ [data-slot="conversation.input.dock"] > [data-queue-dock] > * {
384
+ border-radius: 12px !important;
385
+ }
386
+ [data-slot="conversation.input.dock"] > [data-queue-dock] > *::after {
387
+ border-bottom: 1px solid var(--dsw-alias-border-l1) !important;
388
+ }
389
+
330
390
  [data-mobile-nav="attach-chip"] {
331
391
  position: relative;
332
392
  display: inline-flex;
@@ -1,16 +1,53 @@
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) 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,
5
- // data-mobile-nav="turn-fold" on the injected summary row) are only ever
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.
2
+ // Two blocks, both inert unless the fold is actually running:
3
+ // * BORN_FOLDED, keyed on html[data-mnav-fold-on] — the root attribute
4
+ // effects/turn-fold.ts holds for as long as it is attached, so the block
5
+ // is live at exactly the widths the fold is. It hides process rows by
6
+ // the HOST's own markers, which means a row is folded at its first paint
7
+ // rather than a frame after the effect notices it (2026-08-22).
8
+ // * the `rules()` block below, scoped to (max-width: 767px) OR
9
+ // html[data-mnav-desktop-fold] (the per-browser desktop opt-in, issue
10
+ // #2), reading markers this plugin writes: data-mnav-fold on a process
11
+ // element, data-mobile-nav="turn-fold" on the injected summary row.
12
+ // effects/turn-fold.ts wipes both when it detaches, so a non-opted-in
13
+ // tablet/desktop is a no-op twice over, by attribute and by scope. It is
14
+ // authored once and emitted twice so the two scopes can never drift.
10
15
  // Appended last in styles/index.ts; it shares no selector with any other
11
16
  // file, the position just keeps the "phone files come after the shared
12
17
  // <=1023px block" ordering intact.
13
18
 
19
+ import { OPEN, PROCESS_KINDS, THINK } from '../effects/turn-fold.ts'
20
+
21
+ /** Mirror of the root attribute effects/turn-fold.ts holds while attached. */
22
+ const ACTIVE = 'data-mnav-fold-on'
23
+
24
+ /**
25
+ * Born folded — the reason a new tool call no longer blinks into view.
26
+ *
27
+ * These selectors read the HOST's own markers (ChatNodeSeat's
28
+ * `data-chat-flow-kind`, ReasoningRow's `data-variant`), which React sets in
29
+ * the same commit that inserts the row, so the row is already
30
+ * `display: none` at its first paint. effects/turn-fold.ts still rescans on
31
+ * the next animation frame, but by then it is only writing the per-turn
32
+ * {@link OPEN} override and the summary chip — it is no longer what hides
33
+ * anything, which is what the reader used to see as a one-frame flash
34
+ * (2026-08-22).
35
+ *
36
+ * Keyed on the root attribute the effect holds while it is attached, so this
37
+ * block is live for exactly the widths the fold is, and hides nothing at all
38
+ * when the effect never ran.
39
+ *
40
+ * One case stays with the effect: an assistant-step row whose whole body is
41
+ * reasoning has to be hidden as a row (an emptied-out flex item still claims
42
+ * the flow column's 16px gap), and "this row has nothing but Think in it" is
43
+ * not a selector. Its rows therefore still fold a frame late — as blank
44
+ * space, not as content, since the Think rows below are hidden here.
45
+ */
46
+ const BORN_FOLDED = [
47
+ ...PROCESS_KINDS.map((kind) => `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="${kind}"]:not([${OPEN}])`),
48
+ `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"] ${THINK}:not([${OPEN}])`,
49
+ ].join(',\n')
50
+
14
51
  /** The fold + chip rules, with every selector prefixed by `scope`. */
15
52
  const rules = (scope: string): string => `
16
53
  /* Folded process: tool-call / context / command rows and the Think
@@ -97,6 +134,12 @@ const rules = (scope: string): string => `
97
134
 
98
135
  export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px, or desktop opt-in) ---------- */
99
136
 
137
+ /* Process rows and Think disclosures start folded, before any script has
138
+ looked at them — see BORN_FOLDED above. */
139
+ ${BORN_FOLDED} {
140
+ display: none !important;
141
+ }
142
+
100
143
  /* The summary row never paints outside its active scope, even if a
101
144
  media-query change raced the effect's own cleanup. */
102
145
  [data-mobile-nav="turn-fold"] {
package/src/index.ts CHANGED
@@ -51,6 +51,35 @@ export interface MobileNavConfig {
51
51
  * phone breakpoint. Default false (phone-only). A browser can still opt
52
52
  * itself in via `?mobile-nav-turn-fold=1` when this is off. */
53
53
  turnFoldDesktop?: boolean
54
+ /** Calibration for the composer lift used when a phone's keyboard is
55
+ * invisible to the browser (src/client/effects/keyboard-avoid.ts). Leave
56
+ * every one of these unset to keep the shipped estimate — the route omits
57
+ * what the row never set, and the client half owns the defaults, so there
58
+ * is one place each default is written.
59
+ *
60
+ * Share of the layout viewport the estimated lift starts from (shipped
61
+ * 0.42). Clamped to 0-1. */
62
+ keyboardLiftRatio?: number
63
+ /** Ceiling on that estimate in CSS pixels (shipped 400). Clamped to 0-2000. */
64
+ keyboardLiftMaxPx?: number
65
+ /** Extra clearance above a keyboard the browser DID react to, Android only
66
+ * (shipped 15). Clamped to 0-200. */
67
+ keyboardSafetyPadPx?: number
68
+ }
69
+
70
+ /**
71
+ * One configured number on its way to the browser, clamped into a band that
72
+ * cannot break the composer — a ratio of 5 or a 9000px lift would push the
73
+ * input clean off the screen with no way back except editing the YAML again.
74
+ * Absent from the result when the row left it unset or wrote something that
75
+ * is not a finite number, which leaves the client on its shipped default.
76
+ *
77
+ * Exported for scripts/check-keyboard-avoid.mjs — this is the trust boundary
78
+ * between a hand-edited YAML row and the browser, so it is asserted directly.
79
+ */
80
+ export function clamped(key: string, value: number | undefined, min: number, max: number): Record<string, number> {
81
+ if (typeof value !== 'number' || !Number.isFinite(value)) return {}
82
+ return { [key]: Math.min(Math.max(value, min), max) }
54
83
  }
55
84
 
56
85
  /**
@@ -361,7 +390,12 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
361
390
  responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
362
391
  return
363
392
  }
364
- responseJson(res, 200, { turnFoldDesktop: config.turnFoldDesktop === true })
393
+ responseJson(res, 200, {
394
+ turnFoldDesktop: config.turnFoldDesktop === true,
395
+ ...clamped('keyboardLiftRatio', config.keyboardLiftRatio, 0, 1),
396
+ ...clamped('keyboardLiftMaxPx', config.keyboardLiftMaxPx, 0, 2000),
397
+ ...clamped('keyboardSafetyPadPx', config.keyboardSafetyPadPx, 0, 200),
398
+ })
365
399
  },
366
400
  }), 'dsh-mobile-nav: client config route')
367
401
  })