dsh-zen-remote 2.2.7 → 2.2.8

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.
@@ -3,163 +3,285 @@ import type { ClientContext } from '../compat/types.ts'
3
3
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
4
  const PHONE_QUERY = '(max-width: 767px)'
5
5
 
6
- /** The model pill itself. Absent in a subagent session — see `sync`. */
7
- const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]'
8
-
9
- /** The model pill's own popup, which section 4 of composer.css.ts turns into
10
- * a bottom sheet — inline under the pill on ≤ 0.1.2, portaled to body on
11
- * 0.1.5+ (same two spellings as composer.css.ts's MODEL_MENU; the body form
12
- * is the only body-level `role=menu` with an `-menu` id, measured on
13
- * 0.1.5-rc.2). Appending into the portal root is no different from the
14
- * inline case: React leaves nodes it did not create alone, and the sheet
15
- * still unmounts on close, which is what the detached-menu path handles. */
16
- const MENU_SELECTOR = '[data-slot="conversation.input.model"] [class$="_menu"], body > [id$="-menu"][role="menu"]'
6
+ /** The composer bar slot: the scan root, and the anchor the synthetic dock
7
+ * rail hangs beside (hero stage). */
8
+ const BAR_SELECTOR = '[data-slot="conversation.composer.bar"]'
17
9
 
18
10
  /**
19
- * The third-party composer controls this effect relocates. Both register into
20
- * `conversation.input.right`; moving the slot container rather than its
21
- * children keeps them together and leaves exactly one node to put back.
11
+ * Where a secondary control can legally sit in the composer, as ONE selector
12
+ * list scanned inside the bar. Slots first (input.right / input.left), then
13
+ * the official flex groups, then the row itself — a third party may inject
14
+ * at any of the six (subscriptions and vision-router register into
15
+ * input.right; dsh-workbuddy's credits and dsh-skills-ui's chips hang off
16
+ * _row directly). Every position excludes `[data-mobile-nav]` so this
17
+ * plugin's own nodes (the strip itself, and anything else we mount) are
18
+ * never collected, and the official controls by name, so the whitelist
19
+ * never fights the host. opt-in by marker, never `:not()`-hide unclaimed
20
+ * nodes — the failure direction is "extra control stays visible", never
21
+ * "control hidden from both places".
22
22
  */
23
- const EXTRAS_SELECTOR = '[data-slot="conversation.input.right"]'
23
+ const EXTRAS_SELECTOR = [
24
+ '[data-slot="conversation.input.right"] > *:not([data-mobile-nav])',
25
+ '[data-slot="conversation.input.left"] > *:not([data-mobile-nav])',
26
+ '[class$="_tools"] > *:not(button[class$="_add"]):not(input[type="file"]):not([class$="_modes"]):not([data-slot="conversation.input.left"]):not([data-mobile-nav])',
27
+ '[class$="_standardControls"] > *:not([data-slot="conversation.input.model"]):not([data-slot="conversation.input.right"]):not([data-mobile-nav])',
28
+ '[class$="_trailing"] > *:not([class$="_standardControls"]):not([class$="_activity"]):not([class$="_primary"]):not([data-mobile-nav])',
29
+ '[class$="_row"] > *:not([class$="_tools"]):not([class$="_trailing"]):not([data-mobile-nav])',
30
+ ].join(', ')
24
31
 
25
32
  /**
26
- * dsh-vision-router's own mount marker. Used here only to answer "is there
27
- * anything in this slot BESIDES the vision toggle" — see `worthMoving`.
33
+ * dsh-vision-router's own mount marker. Used only to answer "is the single
34
+ * collected control the vision toggle" — see `worthMoving`.
28
35
  */
29
36
  const VISION_SELECTOR = '[data-vision-router-mode-toggle]'
30
37
 
31
38
  /**
32
- * Set on the container while this effect manages it, in the row and in the
33
- * sheet alike. It is the styling hook for both halves in composer.css.ts, and
34
- * it is deliberately what the row's `display: none` keys off: without the
35
- * marker the controls stay exactly where the host put them, so a version of
36
- * this file that declines to act — or never runs at all — cannot leave them
37
- * hidden in the row and absent from the sheet, i.e. unreachable.
39
+ * On the input.right CONTAINER while the effect manages any of its children,
40
+ * and on the strip itself. It is the styling hook for both halves in
41
+ * composer.css.ts (the emptied container hides without leaving a flex gap;
42
+ * an inline `[role="menu"]` opened by a parked chip escapes the strip's
43
+ * overflow clip through it). It is deliberately what the row's
44
+ * `display: none` keys off: without the marker the controls stay exactly
45
+ * where the host put them, so a version of this file that declines to act —
46
+ * or never runs at all — cannot leave them hidden in the row and absent from
47
+ * the strip, i.e. unreachable.
38
48
  */
39
49
  const MANAGED = 'data-zen-sheet-extras'
40
50
 
41
51
  /**
42
- * Is the slot holding anything that actually costs the row width?
52
+ * On every collected NODE while it is parked in the strip. Purely opt-in:
53
+ * the marker's absence never hides anything, and `releaseAll` removes it —
54
+ * an unmarked control is the host's own business again.
55
+ */
56
+ const EXTRA = 'data-zen-composer-extra'
57
+
58
+ /**
59
+ * Is there anything collected that actually costs the row width?
43
60
  *
44
61
  * The row is one nowrap flex line whose only elastic item is the model pill
45
62
  * (composer.css.ts section 1), so entries here are paid for out of the model
46
- * name. But that is only a problem once something WIDE lands in the slot —
47
- * today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
48
- * eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
49
- * accommodates fine, and moving it on its own would cost a tap (it becomes
50
- * two: open the sheet, then toggle) to buy width nobody needed.
63
+ * name. But that is only a problem once something WIDE lands there — today
64
+ * dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and eats
65
+ * ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
66
+ * accommodates fine, and moving it on its own would cost nothing visually
67
+ * but would hide a one-tap control behind a scroll (user review, 2026-09-06).
51
68
  *
52
- * So: relocate only when the slot holds an entry that is not the vision
53
- * toggle. With no such entry — subscriptions not installed, or installed but
54
- * not contributing on this model — nothing moves and the row keeps its one
55
- * icon, which is the behaviour asked for (user review, 2026-09-06).
69
+ * So: move only when the collection holds an entry that is not (or does not
70
+ * contain) the vision toggle. With no such entry — subscriptions not
71
+ * installed, or installed but not contributing on this model — nothing moves
72
+ * and the row keeps its one icon. Why width rather than sniffing for
73
+ * subscriptions by name: the slot system adds no per-registration marker, so
74
+ * naming the plugin would anchor on a marker we'd have to add to our own
75
+ * fork of it, and the feature would die the day the fork is dropped. The
76
+ * width question is the real question anyway, and this asks it directly.
56
77
  *
57
- * Why this rather than sniffing for subscriptions by name: the slot system
58
- * adds no per-registration marker (measured — entries render straight into the
59
- * container) and subscriptions marks nothing of its own, so naming it would
60
- * mean adding a marker to our fork of that plugin and anchoring on it. That
61
- * marker dies the day the fork is dropped, and the feature would go quiet
62
- * with nothing to catch it. The width question is the real question anyway,
63
- * and this asks it directly.
78
+ * Unlike the pre-#30 design, NOTHING here depends on the official model
79
+ * pill existing: replaced model slots (…codex-ui) and subagent sessions —
80
+ * which have no pill at all — collect and relocate exactly like everyone
81
+ * else.
64
82
  */
65
- function worthMoving(extras: Element): boolean {
66
- return [...extras.children].some(child => !child.matches(VISION_SELECTOR))
83
+ function worthMoving(controls: readonly Element[]): boolean {
84
+ if (controls.length === 0) return false
85
+ if (controls.length === 1) {
86
+ const child = controls[0]
87
+ if (child === undefined) return false
88
+ // "is or contains" — some plugins wrap their toggle in a positioning node.
89
+ if (child.matches(VISION_SELECTOR) || child.querySelector(VISION_SELECTOR) !== null) return false
90
+ }
91
+ return true
67
92
  }
68
93
 
69
94
  /**
70
- * Phone: park the composer row's third-party controls inside the model sheet.
95
+ * Phone: move the composer row's secondary third-party controls into a
96
+ * 26px horizontally-scrollable strip inside the dock rail above the card —
97
+ * always visible, one tap from anywhere, no sheet in between.
71
98
  *
72
- * With the speed chip and the vision toggle both present on a GPT model the
73
- * row runs out of width. Both are model-scoped settings, so the model sheet —
74
- * which already holds 模型 and 推理等级 — is where they belong (user request,
75
- * 2026-09-06).
99
+ * Why move the real controls instead of drawing our own rows that drive
100
+ * them: the same reason native-trigger-overlay.ts moves the real trigger. A
101
+ * stand-in has to script the original, which is a road this plugin does not
102
+ * go down — beyond the trusted-input wall that effect documents, our own
103
+ * rows would have to mirror every piece of state (speed tier, disabled,
104
+ * aria-pressed, whether a vision twin exists for the current model) and
105
+ * would rot on the next upstream restyle. Moving the node keeps one source
106
+ * of truth: their React roots go on owning and updating these elements, we
107
+ * only change where they hang — never cloneNode, and the move is anchored
108
+ * with a Comment at the original spot so `unparkAll` can put each node back
109
+ * exactly where React left it.
76
110
  *
77
- * Why move the real controls instead of drawing our own rows that drive them:
78
- * the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
79
- * has to script the original, which is a road this plugin does not go down —
80
- * beyond the trusted-input wall that effect documents, our own rows would have
81
- * to mirror every piece of state (speed tier, disabled, aria-pressed, whether
82
- * a vision twin exists for the current model) and would rot on the next
83
- * upstream restyle. Moving the node keeps one source of truth: their React
84
- * roots go on owning and updating these elements, we only change where they
85
- * hang.
111
+ * Measured 2026-09-06 (DSH 0.1.2, 390px) before the original version of
112
+ * this effect — the two things that would have killed the approach both
113
+ * hold: a relocated control survives host re-renders (typing into the
114
+ * composer re-renders the row and does not yank it back) and stays
115
+ * hit-testable at its new position.
86
116
  *
87
- * Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
88
- * that would have killed the approach both hold: a relocated control survives
89
- * host re-renders (typing into the composer re-renders the row and does not
90
- * yank it back) and stays hit-testable at its new position.
91
- *
92
- * Restoring on close is not cosmetic. The sheet unmounts when it closes and
93
- * anything still inside goes with it, so the controls would vanish from the
94
- * page while their React roots kept updating detached nodes. The observer
95
- * therefore watches the menu leaving too, and the parked node is held as a
96
- * reference rather than looked up — once the menu is detached a document
97
- * query can no longer find it.
117
+ * Known hazard, inherent to relocating React-owned nodes: a control whose
118
+ * REGISTRATION unmounts while parked (plugin disabled, slot re-keyed) is
119
+ * removed by React through its ORIGINAL parent — `parent.removeChild(child)`
120
+ * throws when the child moved, and the exception lands in React's commit.
121
+ * Updates that keep the registration mounted (state-driven re-renders,
122
+ * text/attribute changes, a control's own inner nodes unmounting) are safe:
123
+ * those removals run inside the parked subtree, where every parent-child
124
+ * link React recorded still holds. Parking is also fully reversible — any
125
+ * decline path (desktop, nothing worth moving, teardown) hands every node
126
+ * back before anything is removed.
98
127
  */
99
128
  export function installModelSheetExtras(ctx: ClientContext): void {
100
129
  ctx.effect(() => {
101
130
  const phone = window.matchMedia(PHONE_QUERY)
102
- /** The container while parked in the sheet, plus where to put it back. */
103
- let parked: { node: Element, parent: Element, next: Node | null } | null = null
131
+ /** Parked nodes with the Comment anchoring each one's original spot. */
132
+ let parked: Array<{ node: Element, placeholder: Comment }> = []
133
+ let syntheticDock: HTMLElement | null = null
134
+
135
+ /** Collected controls, document order; parked ones appended so the set
136
+ * stays stable while they live in the strip. */
137
+ const collect = (bar: Element): Element[] => {
138
+ const controls = Array.from(bar.querySelectorAll(EXTRAS_SELECTOR))
139
+ for (const { node } of parked) {
140
+ if (node.isConnected && !controls.includes(node)) controls.push(node)
141
+ }
142
+ return controls
143
+ }
104
144
 
105
- const park = (node: Element, menu: Element): void => {
106
- if (parked !== null || node.parentElement === null) return
107
- parked = { node, parent: node.parentElement, next: node.nextSibling }
108
- menu.appendChild(node)
145
+ const park = (node: Element, strip: HTMLElement): void => {
146
+ if (node.parentElement === null) return
147
+ const placeholder = document.createComment('zen-composer-extra')
148
+ node.parentElement.insertBefore(placeholder, node)
149
+ node.setAttribute(EXTRA, 'open')
150
+ strip.appendChild(node)
151
+ parked.push({ node, placeholder })
109
152
  }
110
153
 
111
- const unpark = (): void => {
112
- if (parked === null) return
113
- const { node, parent, next } = parked
114
- parked = null
115
- // The menu may already be detached, having taken the container with it.
116
- // The node itself is still live either way; re-home it only if its old
117
- // parent is still on the page (when the whole composer went away, the
118
- // owning React roots rebuild the slot on the next mount).
119
- if (parent.isConnected) parent.insertBefore(node, next)
154
+ const unparkAll = (): void => {
155
+ for (const { node, placeholder } of parked) {
156
+ // Re-home only while the anchor still marks the spot AND that home
157
+ // is on the page — when the whole composer went away, the owning
158
+ // React roots rebuild the slot on the next mount.
159
+ if (placeholder.parentNode !== null && placeholder.parentNode.isConnected) {
160
+ placeholder.parentNode.insertBefore(node, placeholder)
161
+ }
162
+ placeholder.remove()
163
+ node.removeAttribute(EXTRA)
164
+ }
165
+ parked = []
120
166
  }
121
167
 
122
- /** Hand the container back to the host, marker and all. */
123
- const release = (node: Element | null): void => {
124
- unpark()
125
- node?.removeAttribute(MANAGED)
168
+ /** Everything the effect owns, back to host-placed defaults: parked
169
+ * nodes re-homed, strip, synthetic rail and every marker gone.
170
+ * Idempotent — teardown and every decline path go through here. */
171
+ const releaseAll = (): void => {
172
+ unparkAll()
173
+ document.querySelector('[data-mobile-nav="composer-chips"]')?.remove()
174
+ syntheticDock?.remove()
175
+ syntheticDock = null
176
+ const bar = document.querySelector(BAR_SELECTOR)
177
+ const scope: ParentNode = bar ?? document
178
+ for (const node of scope.querySelectorAll(`[${EXTRA}]`)) node.removeAttribute(EXTRA)
179
+ bar?.querySelector('[data-slot="conversation.input.right"]')?.removeAttribute(MANAGED)
180
+ }
181
+
182
+ /** The rail the strip lives in — the host's own dock, or a synthetic
183
+ * stand-in while the hero composer has rendered none yet. */
184
+ const adoptDock = (bar: Element): Element => {
185
+ // :not([data-zen-synthetic-dock]) is load-bearing: the synthetic rail
186
+ // is bar's SIBLING carrying the same data-slot, so an unqualified
187
+ // query would find it as the "real" dock, delete it as stale, recreate
188
+ // it after — the observer would oscillate forever.
189
+ const dock = bar.parentElement?.querySelector('[data-slot="conversation.input.dock"]:not([data-zen-synthetic-dock])') ?? null
190
+ if (dock !== null) {
191
+ if (syntheticDock !== null) {
192
+ // Real dock appeared: hand every parked node back before the
193
+ // synthetic rail (and the strip inside it) is destroyed — removing
194
+ // a rail full of React-owned controls would detach them from the
195
+ // page. sync re-parks them into the new strip right after.
196
+ unparkAll()
197
+ syntheticDock.remove()
198
+ syntheticDock = null
199
+ }
200
+ return dock
201
+ }
202
+ if (syntheticDock === null || !syntheticDock.isConnected) {
203
+ syntheticDock = document.createElement('div')
204
+ syntheticDock.setAttribute('data-slot', 'conversation.input.dock')
205
+ syntheticDock.setAttribute('data-zen-synthetic-dock', '')
206
+ bar.parentNode?.insertBefore(syntheticDock, bar)
207
+ }
208
+ return syntheticDock
209
+ }
210
+
211
+ const ensureStrip = (home: Element): HTMLElement => {
212
+ let strip = home.querySelector<HTMLElement>('[data-mobile-nav="composer-chips"]')
213
+ if (strip === null) {
214
+ strip = document.createElement('div')
215
+ strip.setAttribute('data-mobile-nav', 'composer-chips')
216
+ strip.setAttribute(MANAGED, '')
217
+ // Document-order first; CSS `order: 0` keeps it the rail's leftmost
218
+ // pill line among the order-0 ties.
219
+ home.prepend(strip)
220
+ }
221
+ return strip
126
222
  }
127
223
 
128
224
  const sync = (): void => {
129
- const extras = document.querySelector(EXTRAS_SELECTOR)
130
- if (extras === null) { unpark(); return }
131
- // No model pill, no sheet to park into — a subagent session has none.
132
- // Claiming the slot there would hide the controls from the row with
133
- // nowhere to show them instead, which is the unreachable state the
134
- // marker is meant to prevent.
135
- const seat = document.querySelector(MODEL_SEAT_SELECTOR)
136
- if (!phone.matches || seat === null || !worthMoving(extras)) { release(extras); return }
137
- extras.setAttribute(MANAGED, '')
138
- const menu = document.querySelector(MENU_SELECTOR)
139
- if (menu === null) unpark()
140
- else park(extras, menu)
225
+ const bar = document.querySelector(BAR_SELECTOR)
226
+ if (bar === null) { releaseAll(); return }
227
+ // A parked node its own React root unmounted is dead weight; drop it
228
+ // and its anchor before collecting, so it neither blocks nor hides.
229
+ parked = parked.filter(({ node, placeholder }) => {
230
+ if (node.isConnected) return true
231
+ placeholder.remove()
232
+ return false
233
+ })
234
+ const controls = collect(bar)
235
+ if (!phone.matches || !worthMoving(controls)) { releaseAll(); return }
236
+
237
+ const home = adoptDock(bar)
238
+ const strip = ensureStrip(home)
239
+
240
+ // Parked entries the set has outgrown (an uninstalled plugin, a
241
+ // re-render that moved a control into a claimed position) go home and
242
+ // lose their marker.
243
+ parked = parked.filter(({ node, placeholder }) => {
244
+ if (controls.includes(node) && node.isConnected) return true
245
+ if (node.isConnected && placeholder.parentNode !== null && placeholder.parentNode.isConnected) {
246
+ placeholder.parentNode.insertBefore(node, placeholder)
247
+ }
248
+ placeholder.remove()
249
+ node.removeAttribute(EXTRA)
250
+ return false
251
+ })
252
+ for (const node of controls) {
253
+ if (!parked.some((entry) => entry.node === node)) park(node, strip)
254
+ }
255
+ // Stale markers anywhere in the bar — parked ones are in `controls`,
256
+ // so they survive this sweep.
257
+ for (const node of bar.querySelectorAll(`[${EXTRA}]`)) {
258
+ if (!controls.includes(node)) node.removeAttribute(EXTRA)
259
+ }
260
+ const right = bar.querySelector('[data-slot="conversation.input.right"]')
261
+ if (right !== null) {
262
+ const rightManaged = right.children.length > 0 ||
263
+ parked.some(({ placeholder }) => placeholder.parentNode === right)
264
+ if (rightManaged) right.setAttribute(MANAGED, '')
265
+ else right.removeAttribute(MANAGED)
266
+ }
141
267
  }
142
268
 
143
269
  // Synchronous in the observer callback, never behind rAF: the callback is
144
- // a microtask and runs before this frame paints, so the controls are in
145
- // the sheet the first time it is drawn. A rAF would land a frame late and
146
- // show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
147
- // Body-level childList is the same shape modal-back.ts uses; aria-expanded
148
- // is watched too so a close that reuses the node still re-syncs.
270
+ // a microtask and runs before this frame paints, so the strip and the
271
+ // parked nodes are in place the first time their frame is drawn. A rAF
272
+ // would land a frame late (AGENTS.md's "晚一帧" rule). Body-level
273
+ // childList is the same shape modal-back.ts uses. The observer also sees
274
+ // this effect's own moves; sync is idempotent, so the extra pass is a
275
+ // no-op.
149
276
  const observer = new MutationObserver(sync)
150
- observer.observe(document.body, {
151
- childList: true,
152
- subtree: true,
153
- attributes: true,
154
- attributeFilter: ['aria-expanded'],
155
- })
277
+ observer.observe(document.body, { childList: true, subtree: true })
156
278
  phone.addEventListener('change', sync)
157
279
  sync()
158
280
 
159
281
  return () => {
160
282
  observer.disconnect()
161
283
  phone.removeEventListener('change', sync)
162
- release(document.querySelector(EXTRAS_SELECTOR))
284
+ releaseAll()
163
285
  }
164
- }, 'dsh-mobile-nav: model sheet extras')
286
+ }, 'dsh-mobile-nav: composer chips extras')
165
287
  }