dsh-zen-remote 2.2.6 → 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.
package/lib/client.js CHANGED
@@ -7236,63 +7236,6 @@ function releaseThumbnailsExcept(keep) {
7236
7236
  }
7237
7237
  }
7238
7238
  };
7239
- __modules["client/host-attach.js"] = function (require, module, exports) {
7240
- "use strict";
7241
- Object.defineProperty(exports, "__esModule", { value: true });
7242
- exports.HOST_ATTACH_INPUT = void 0;
7243
- exports.hostAttachPresent = hostAttachPresent;
7244
- exports.useOwnAttachUi = useOwnAttachUi;
7245
- const react_1 = require("react");
7246
- /**
7247
- * The host composer's own hidden file picker, when the running DSH ships one.
7248
- *
7249
- * DSH 0.1.5 grew a native attachment flow (dsh-attachment / dsh-client-file-
7250
- * upload): the official tool row carries a second `_add` paperclip whose click
7251
- * drives an `<input type="file" hidden>` that is a DIRECT sibling of the
7252
- * buttons inside `_tools`. That input is the one structural, locale-free
7253
- * proof that the host implements composer attachment upload — the button
7254
- * itself can only be told apart from the "+" command menu by its localized
7255
- * aria-label, and the capability prop (`addFiles`) lives on the composer
7256
- * bar's own inject face, which never reaches `conversation.input.left`
7257
- * registrants. Scoped to `_tools` children so a third-party plugin parking
7258
- * its own file input somewhere else in the composer cannot be misread as
7259
- * the official feature; `:not([data-mobile-nav])` keeps OUR picker
7260
- * (`attach-picker`) out of the match.
7261
- */
7262
- exports.HOST_ATTACH_INPUT = '[data-slot="conversation.composer.bar"] [class$="_tools"] > input[type="file"]:not([data-mobile-nav])';
7263
- /** Synchronous read: does this host's composer ship its own attach picker? */
7264
- function hostAttachPresent() {
7265
- return document.querySelector(exports.HOST_ATTACH_INPUT) !== null;
7266
- }
7267
- /**
7268
- * True while OUR attachment UI should mount (the host has none of its own).
7269
- *
7270
- * The rule this encodes (2026-09-11): where the host implements attachment
7271
- * upload, the phone composer shows exactly ONE attach control — the host's —
7272
- * and this plugin's S7 button and chips row stand down; on a host without
7273
- * one (DSH ≤ 0.1.2), both load as they always have.
7274
- *
7275
- * `useLayoutEffect`, not `useEffect`: on the composer's very first mount our
7276
- * slot child renders in the same React pass as the host's tool row, so the
7277
- * state initializer below reads the DOM BEFORE either exists — the layout
7278
- * effect re-checks after the commit and before the browser paints, so the
7279
- * demotion to hidden (or, on an old host, the decision to stay) never
7280
- * reaches the screen as a flash. The MutationObserver covers the lifetime
7281
- * after that: the picker's existence only really changes with the page, but
7282
- * a dynamic host plugin or a reload must not strand a stale verdict.
7283
- */
7284
- function useOwnAttachUi() {
7285
- const [own, setOwn] = (0, react_1.useState)(() => !hostAttachPresent());
7286
- (0, react_1.useLayoutEffect)(() => {
7287
- const check = () => setOwn(!hostAttachPresent());
7288
- const observer = new MutationObserver(check);
7289
- observer.observe(document.body, { childList: true, subtree: true });
7290
- check();
7291
- return () => observer.disconnect();
7292
- }, []);
7293
- return own;
7294
- }
7295
- };
7296
7239
  __modules["client/MobileAttachButton.js"] = function (require, module, exports) {
7297
7240
  "use strict";
7298
7241
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -7301,7 +7244,6 @@ const jsx_runtime_1 = require("react/jsx-runtime");
7301
7244
  const react_1 = require("react");
7302
7245
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
7303
7246
  const attach_upload_ts_1 = require("./client/attach-upload.js");
7304
- const host_attach_ts_1 = require("./client/host-attach.js");
7305
7247
  /** Post one file to the host route and return the workspace-relative path it landed on. */
7306
7248
  async function uploadFile(sessionId, file) {
7307
7249
  const response = await fetch((0, attach_upload_ts_1.uploadUrl)(sessionId, file.name), {
@@ -7327,12 +7269,20 @@ async function uploadFile(sessionId, file) {
7327
7269
  * the CLIENT's picker — no `accept` attribute on purpose, so iOS offers the
7328
7270
  * full 相册 / 拍照 / 选取文件 sheet rather than one of them.
7329
7271
  *
7330
- * DSH 0.1.5 gave the official tool row its own paperclip (the host attachment
7331
- * flow), which made TWO identical controls on a phone. This button now stands
7332
- * down entirely while the host ships its own picker (`host-attach.ts` probes
7333
- * for it) — the official flow uploads into `~/.dsh/attachments` and renders
7334
- * its own rail, so the composer keeps exactly one attach affordance. On a
7335
- * host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
7272
+ * DSH 0.1.5 gave the official tool row its own paperclip, and this button
7273
+ * used to stand down whenever that flow's hidden `<input type="file">` was
7274
+ * probed in the composer (that probe file is deleted with #31), keeping one
7275
+ * attach control per composer. DSH 0.2.x then removed the standalone
7276
+ * paperclip (the file action moved into the `+` command menu) while KEEPING
7277
+ * the hidden input, so the probe matched on every host and the phone composer
7278
+ * was left with no attach affordance at all. The phone shell now always
7279
+ * renders this button — its picker is the CLIENT's (相册 / 拍照 / 选取文件),
7280
+ * and every attachment takes the same `@.dsh-uploads/` draft-token path on
7281
+ * every DSH version. On DSH ≤ 0.1.5, which still ships the second official
7282
+ * paperclip, composer.css.ts hides it below 768px so the row keeps exactly
7283
+ * one attach control. Subagent sessions stay buttonless, mirroring the host:
7284
+ * the official InputBar disables its own file input while
7285
+ * `subagent !== null`.
7336
7286
  *
7337
7287
  * Every picked file takes the SAME path: upload to the node half, then append
7338
7288
  * `@.dsh-uploads/name` to the draft through the official write path — no DOM
@@ -7347,21 +7297,25 @@ async function uploadFile(sessionId, file) {
7347
7297
  * Files upload one at a time: a phone uplink gains nothing from parallelism
7348
7298
  * and a serial loop keeps the mentions in pick order.
7349
7299
  */
7350
- function MobileAttachButton({ t, sessionId, useInput, inputActions }) {
7300
+ function MobileAttachButton({ t, sessionId, useInput, inputActions, useSessions }) {
7351
7301
  const picker = (0, react_1.useRef)(null);
7352
7302
  const [busy, setBusy] = (0, react_1.useState)(false);
7353
7303
  const [error, setError] = (0, react_1.useState)(null);
7354
7304
  const draft = useInput((state) => state.draft);
7355
- const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
7305
+ // Same session-row read the share icon/menu use (RemoteHeaderIcon.tsx):
7306
+ // `origin: 'subagent'` is the only subagent signal that reaches slot
7307
+ // registrants — the InputBar's own `subagent` prop does not.
7308
+ const row = useSessions((sessions) => sessions.byId[sessionId]);
7356
7309
  // Uploads are awaited, so the `draft` captured at pick time goes stale — the
7357
7310
  // user can keep typing while a file is in flight. The ref is refreshed on
7358
7311
  // every render (useInput re-renders us on each draft change), so the loop
7359
7312
  // below can rebase onto whatever the composer holds right now.
7360
7313
  const liveDraft = (0, react_1.useRef)(draft);
7361
7314
  liveDraft.current = draft;
7362
- // The host's own attach button owns this seat now — render nothing (the
7363
- // hidden picker input goes with it). All hooks stay above this line.
7364
- if (!ownUi)
7315
+ // Subagent sessions get no attach affordance, mirroring the host (its own
7316
+ // file input is disabled while a subagent session is open). All hooks stay
7317
+ // above this line.
7318
+ if (row?.origin === 'subagent')
7365
7319
  return null;
7366
7320
  const pick = async (event) => {
7367
7321
  const files = Array.from(event.target.files ?? []);
@@ -7410,7 +7364,6 @@ const jsx_runtime_1 = require("react/jsx-runtime");
7410
7364
  const react_1 = require("react");
7411
7365
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
7412
7366
  const attach_upload_ts_1 = require("./client/attach-upload.js");
7413
- const host_attach_ts_1 = require("./client/host-attach.js");
7414
7367
  /**
7415
7368
  * Attachment preview row (S7.1), in `conversation.input.dock` — the official
7416
7369
  * full-width row above the composer card, which composer.css.ts already lays
@@ -7439,16 +7392,9 @@ const host_attach_ts_1 = require("./client/host-attach.js");
7439
7392
  */
7440
7393
  function MobileAttachChips({ t, useInput, inputActions }) {
7441
7394
  const draft = useInput((state) => state.draft);
7442
- const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
7443
7395
  (0, react_1.useEffect)(() => {
7444
7396
  (0, attach_upload_ts_1.releaseThumbnailsExcept)((0, attach_upload_ts_1.mentionsIn)(draft));
7445
7397
  }, [draft]);
7446
- // Same gate as MobileAttachButton (host-attach.ts): where the host ships
7447
- // its own attachment flow, our chips row stands down too — the official
7448
- // rail owns the preview seat. The effect above still runs, so object URLs
7449
- // from OUR flow get revoked even after the switch.
7450
- if (!ownUi)
7451
- return null;
7452
7398
  const attachments = (0, attach_upload_ts_1.mentionsIn)(draft);
7453
7399
  if (attachments.length === 0)
7454
7400
  return null;
@@ -9581,18 +9527,33 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
9581
9527
  flex: none;
9582
9528
  border-radius: 6px;
9583
9529
  object-fit: contain;
9584
- }
9585
- /* Dark theme (real-device report, 2026-08-17): the runtime favicon is a
9586
- dark-on-transparent whale mark, illegible on the dark page. Force a
9587
- white silhouette regardless of the source image's own colors —
9588
- brightness(0) collapses every opaque pixel to black first, invert(1)
9589
- then flips that to white; transparent pixels stay transparent either
9590
- way. Same body[data-ds-dark-theme] marker as the home-row hairline
9591
- above (AGENTS.md: prefers-color-scheme never fires, the app switches
9592
- themes via this attribute with color-scheme hardcoded to light). */
9593
- body[data-ds-dark-theme] [data-mobile-nav="home-logo"] {
9530
+ /* rc.3 fix (2026-10-25, Safari real-device report "logo 不显示"): the
9531
+ host now ships TWO media-scoped favicons and the first in DOM order
9532
+ is the DARK one — a WHITE whale (fill #fff). useSiteIconHref picks
9533
+ the first link unconditionally, so on a light page the logo arrived
9534
+ white-on-light and vanished. brightness(0) collapses ANY source (the
9535
+ old single black favicon or the new white one) to a black silhouette,
9536
+ making light mode correct for both hosts; the dark-theme rules below
9537
+ re-invert it to white. */
9538
+ filter: brightness(0);
9539
+ }
9540
+ /* Dark theme (real-device report, 2026-08-17): force a white silhouette
9541
+ regardless of the source image's own colors — brightness(0) collapses
9542
+ every opaque pixel to black first, invert(1) then flips that to white;
9543
+ transparent pixels stay transparent either way. Three dark signals,
9544
+ deliberately redundant: the pre-rc.3 body[data-ds-dark-theme] marker,
9545
+ and rc.3's resolved <html data-ds-theme-source> ("dark" pinned, or
9546
+ "system" under a dark OS — rc.3 stamps the resolved scheme on
9547
+ html[style] with no body attribute until the first theme flip). */
9548
+ body[data-ds-dark-theme] [data-mobile-nav="home-logo"],
9549
+ html[data-ds-theme-source="dark"] [data-mobile-nav="home-logo"] {
9594
9550
  filter: brightness(0) invert(1);
9595
9551
  }
9552
+ @media (prefers-color-scheme: dark) {
9553
+ html[data-ds-theme-source="system"] [data-mobile-nav="home-logo"] {
9554
+ filter: brightness(0) invert(1);
9555
+ }
9556
+ }
9596
9557
  [data-mobile-nav="ws-switch"] {
9597
9558
  display: inline-flex;
9598
9559
  align-items: center;
@@ -10598,11 +10559,16 @@ __modules["client/styles/composer.css.js"] = function (require, module, exports)
10598
10559
  //
10599
10560
  // Official structure this file reshapes (verified live at 390px, see the S3
10600
10561
  // report): InputBar renders
10601
- // _card > _row > [_tools > (_add, _modes > PermissionSelect, input.left)]
10562
+ // _card > _row > [_tools > (_add, input[type=file] hidden, _modes >
10563
+ // PermissionSelect, input.left)]
10602
10564
  // [_trailing > (_standardControls > (input.right, input.model), _activity, _primary)]
10603
10565
  // with `_tools` / `_trailing` / `_standardControls` as inner flex groups
10604
10566
  // (0.1.7 added `_standardControls` and moved the ContextMeter out of the row
10605
- // into the dock under the card). Flattening all three to
10567
+ // into the dock under the card). That hidden file input is the stand-down
10568
+ // probe host-attach.ts USED to key on — since #31 the phone's own attach
10569
+ // button is a permanent resident instead, and DSH 0.1.5's second `_add`
10570
+ // button (the standalone paperclip 0.2.x removed) is hidden by section 2.
10571
+ // Flattening all three to
10606
10572
  // `display: contents` turns every control into a direct flex item of `_row`,
10607
10573
  // which is what makes a pure `order` reflow possible without touching a
10608
10574
  // single official component.
@@ -10616,8 +10582,21 @@ const ROOT = '[data-slot="conversation.composer.bar"] > [class$="_root"]';
10616
10582
  const RING_W = '30px';
10617
10583
  /** DSH 0.1.7's wrapper around input.right + input.model inside `_trailing`. */
10618
10584
  const STD = `${ROW} > [class$="_trailing"] > [class$="_standardControls"]`;
10619
- /** The model seat's own root inside the trailing group (long form so it beats the <=1023px pill rules in layout.css.ts). */
10620
- const MODEL = `${STD} > [data-slot="conversation.input.model"] > [class$="_root"]`;
10585
+ /**
10586
+ * The model seat's content, whichever plugin owns it: officially ModelSelect's
10587
+ * `_root`, but a third party can replace the whole `conversation.input.model`
10588
+ * slot (…codex-ui mounts its own selector there), and a subagent session has
10589
+ * nothing matching `_root` at all. Spelled `*:not(…)` rather than bare `> *`
10590
+ * on purpose: `:not([data-mobile-nav])` carries the same (0,1,0) the old
10591
+ * `[class$="_root"]` did, keeping every rule below a full attribute CLASS
10592
+ * ahead of the ≤1023px pill rules in layout.css.ts — their `[data-phase] …
10593
+ * :has(…)` forms measure (0,6,1) on the trigger and (0,7,1) on the label
10594
+ * with `!important`, so a bare `> *` would leave the phone winning by
10595
+ * exactly one class on every MODEL-derived rule, one upstream attr away
10596
+ * from losing. The :not() also keeps this plugin's own nodes from ever
10597
+ * reading as the model seat, should one ever be mounted there.
10598
+ */
10599
+ const MODEL = `${STD} > [data-slot="conversation.input.model"] > *:not([data-mobile-nav])`;
10621
10600
  /** The permission select's trigger button (structure only — the PermissionSelect hashed classes are never named). */
10622
10601
  const PERM = `${ROW} > [class$="_tools"] > [class$="_modes"]`;
10623
10602
  /**
@@ -10652,6 +10631,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10652
10631
  [data-mobile-nav="attach-picker"] {
10653
10632
  display: none !important;
10654
10633
  }
10634
+ /* The composer-chips strip (effects/model-sheet-extras.ts) — same
10635
+ every-width default as the picker above: the phone block re-shows it
10636
+ inside the dock rail; the desktop shell keeps none. */
10637
+ [data-mobile-nav="composer-chips"] {
10638
+ display: none !important;
10639
+ }
10655
10640
 
10656
10641
  @media (max-width: 767px) {
10657
10642
  /* --- 1. flatten the two official groups ---
@@ -10675,8 +10660,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10675
10660
  }
10676
10661
 
10677
10662
  /* --- 2. the running order ---
10678
- The attachment button (S3 placeholder, S7 wires it up) sits leftmost;
10679
- the official "+" command menu keeps its seat right next to it, where
10663
+ The attachment button (S3 placeholder, S7 wires it up) sits leftmost and
10664
+ is a PERMANENT resident now (#31): on DSH 0.2.x the standalone paperclip
10665
+ moved into the "+" command menu while its hidden <input type="file">
10666
+ stayed in _tools, so the old host-attach stand-down probe matched on
10667
+ every host and the phone composer was left with no attach affordance at
10668
+ all. The official "+" command menu keeps its seat right next to it, where
10680
10669
  the two "insert something" affordances read as one cluster. */
10681
10670
  ${ROW} [data-mobile-nav="attach"] {
10682
10671
  order: 1 !important;
@@ -10684,6 +10673,14 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10684
10673
  ${ROW} > [class$="_tools"] > [class$="_add"] {
10685
10674
  order: 2 !important;
10686
10675
  }
10676
+ /* DSH ≤ 0.1.5 still renders that second _add button (the standalone
10677
+ paperclip) as a direct _tools child; hide it below 768px so the row
10678
+ keeps exactly one attach control — ours, which uploads into the session
10679
+ workspace the phone can actually reach. On 0.2.x the only button in
10680
+ _tools is the first of its type, so this never matches. */
10681
+ ${ROW} > [class$="_tools"] > button[class$="_add"]:nth-of-type(2) {
10682
+ display: none !important;
10683
+ }
10687
10684
  ${ROW} > [class$="_tools"] > [class$="_modes"] {
10688
10685
  order: 3 !important;
10689
10686
  /* Rigid on purpose. This seat holds the permission chip, whose own
@@ -10835,7 +10832,8 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10835
10832
  caps the pill so the row's elastic gap survives extreme model ids;
10836
10833
  the label rules below override the <=1023px grow rules in
10837
10834
  layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */
10838
- ${MODEL} > [class$="_trigger"] {
10835
+ ${MODEL} > [class$="_trigger"],
10836
+ ${MODEL} > button {
10839
10837
  background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
10840
10838
  height: 30px !important;
10841
10839
  width: auto !important;
@@ -10861,6 +10859,18 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10861
10859
  flex: none !important;
10862
10860
  white-space: nowrap !important;
10863
10861
  }
10862
+ /* Third-party model selectors (…codex-ui) render a bare <button> with no
10863
+ _trigger classes and text straight inside it; give that button the same
10864
+ shrink+truncate contract the official label gets, so a long model id
10865
+ ellipsizes instead of stretching the row. The official trigger also
10866
+ matches this (harmless: it already ellipsizes its own label). */
10867
+ ${MODEL} > button {
10868
+ max-width: 100% !important;
10869
+ min-width: 0 !important;
10870
+ overflow: hidden !important;
10871
+ text-overflow: ellipsis !important;
10872
+ white-space: nowrap !important;
10873
+ }
10864
10874
  /* PermissionSelect wraps its trigger in the Menu primitive's root span,
10865
10875
  which must shrink to the icon button's fixed width. */
10866
10876
  ${PERM} > span:has(> button[class$="_trigger"]) {
@@ -10928,90 +10938,97 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10928
10938
  border-radius: 12px !important;
10929
10939
  font-size: 15px !important;
10930
10940
  }
10931
- /* --- 4a. third-party composer entries live in the model sheet ---
10932
- effects/model-sheet-extras.ts parks the \`conversation.input.right\`
10933
- container inside the model menu while that menu is open and puts it back
10934
- when it closes; these rules are the two halves of that trip.
10941
+ /* --- 4a. third-party composer entries live in the dock's chip strip ---
10942
+ effects/model-sheet-extras.ts collects the composer's secondary controls
10943
+ (input.right, input.left, and anything a third party parks directly into
10944
+ _tools / _standardControls / _trailing / _row) and moves the REAL nodes
10945
+ into [data-mobile-nav="composer-chips"] — a 26px horizontally
10946
+ scrollable strip the effect mounts at the head of the dock rail above
10947
+ the card. Always visible, one tap, no sheet in between.
10935
10948
 
10936
- In the row: hidden, but ONLY once the effect has marked the container as
10937
- one it manages. Keying the hide off the marker rather than off "not
10938
- currently parked" is what makes the failure direction safe — if the
10939
- effect declines to act (nothing in the slot but the vision toggle, so
10940
- nothing worth moving) or never runs at all, the marker is absent, this
10941
- rule does not match, and the controls stay exactly where the host put
10942
- them. The inverse spelling hid them in the row whenever they were not in
10943
- the sheet, which in those same cases left them reachable from neither
10944
- place (caught in review, 2026-09-06).
10945
- Hiding by ancestor rather than by a display rule on the container itself
10946
- is what lets the parked copy style itself freely — same element, two
10947
- homes, two rule sets. \`!important\` because the slot container carries an
10948
- inline \`display: contents\`. */
10949
- ${STD} > [data-slot="conversation.input.right"][data-zen-sheet-extras] {
10949
+ In the row: hidden, but ONLY once the effect has marked them. Two opt-in
10950
+ markers, both written by the effect: the input.right CONTAINER gets
10951
+ data-zen-sheet-extras (so an emptied wrapper leaves no flex gap), and
10952
+ every collected node gets data-zen-composer-extra. Visibility comes back
10953
+ the moment a node is parked (="open") or the effect releases it (marker
10954
+ removed) — a version of the effect that declines to act, or never runs
10955
+ at all, leaves every control exactly where the host put it; nothing can
10956
+ end up hidden from both places at once (the 2026-09-06 review's rule). */
10957
+ [data-slot="conversation.input.right"][data-zen-sheet-extras],
10958
+ [data-zen-composer-extra]:not([data-zen-composer-extra="open"]) {
10950
10959
  display: none !important;
10951
10960
  }
10952
- /* Parked in the sheet: a column of full-width rows in the sheet's own
10953
- language, so a relocated control reads as a sibling of 模型 and 推理等级
10954
- rather than a chip that wandered in. */
10955
- ${MODEL_MENU} > [data-zen-sheet-extras] {
10961
+ /* The strip itself: one 26px line at the rail's head (order 0, document
10962
+ order first via the effect's prepend), scrolling sideways when the
10963
+ collected controls overflow. Scoped to the dock CHILD — the generic
10964
+ marker also sits on the emptied input.right container in the row, where
10965
+ nothing but the opt-in hide above may touch it. This specificity (0,2,0)
10966
+ is what beats the section 7 \`> *\` cage wherever they overlap (overflow,
10967
+ max-width, radius are all !important there too). */
10968
+ [data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"] {
10956
10969
  display: flex !important;
10957
- flex-direction: column !important;
10958
- gap: 2px !important;
10959
- width: 100% !important;
10960
- }
10961
- /* Undo the row's compaction on every parked control: back to full width and
10962
- the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
10963
- naming either plugin — subscriptions wraps its trigger in a positioning
10964
- div, vision-router puts its button straight in the slot. */
10965
- ${MODEL_MENU} > [data-zen-sheet-extras] > *,
10966
- ${MODEL_MENU} > [data-zen-sheet-extras] > * > button {
10967
- width: 100% !important;
10968
- max-width: none !important;
10970
+ flex-flow: row nowrap !important;
10971
+ align-items: center !important;
10972
+ gap: 6px !important;
10973
+ order: 0 !important;
10974
+ flex: 0 1 auto !important;
10969
10975
  min-width: 0 !important;
10970
- justify-content: flex-start !important;
10976
+ max-width: 100% !important;
10977
+ min-height: 26px !important;
10978
+ max-height: 26px !important;
10979
+ border-radius: 0 !important;
10980
+ overflow-x: auto !important;
10981
+ overflow-y: hidden !important;
10982
+ scrollbar-width: none !important;
10971
10983
  }
10972
- ${MODEL_MENU} > [data-zen-sheet-extras] button {
10973
- box-sizing: border-box !important;
10974
- min-height: 48px !important;
10975
- height: auto !important;
10976
- padding: 0 12px !important;
10977
- gap: 10px !important;
10978
- border-radius: 12px !important;
10979
- border: none !important;
10980
- background: transparent !important;
10981
- font-size: 15px !important;
10982
- /* Both plugins center their chip label, which is right for a pill in the
10983
- row and wrong for a full-width row: the sheet's own cells start their
10984
- label at the left edge. justify-content covers the flex triggers,
10985
- text-align the ones that are not flex containers. */
10986
- text-align: left !important;
10987
- justify-content: flex-start !important;
10984
+ [data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"]::-webkit-scrollbar {
10985
+ display: none !important;
10988
10986
  }
10989
- /* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it
10990
- parks beside the context ring), and that rule still matches in here on
10991
- ≤ 0.1.2 — the inline sheet is a DOM descendant of the composer bar even
10992
- though it paints as a fixed layer (the 0.1.5 body portal is not, where
10993
- this is a harmless no-op). Reset it so the sheet follows DOM order
10994
- instead of inheriting a decision that was about a different layout. */
10995
- ${MODEL_MENU} > [data-zen-sheet-extras] > * {
10987
+ /* Parked controls shrink from whatever the row allowed them to the rail's
10988
+ 26px pill scale: auto width again (no full-width rows here), tight
10989
+ height, and the chip font the rest of the rail uses. */
10990
+ [data-mobile-nav="composer-chips"] > * {
10991
+ flex: 0 0 auto !important;
10996
10992
  order: 0 !important;
10993
+ display: inline-flex !important;
10994
+ align-items: center !important;
10995
+ min-height: 26px !important;
10996
+ max-height: 26px !important;
10997
+ width: auto !important;
10998
+ max-width: none !important;
10997
10999
  }
10998
- /* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);
10999
- in the sheet it is a row, so its label comes back and the icon leads. */
11000
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {
11000
+ [data-mobile-nav="composer-chips"] button {
11001
+ box-sizing: border-box !important;
11002
+ height: 26px !important;
11003
+ min-height: 26px !important;
11004
+ max-height: 26px !important;
11005
+ width: auto !important;
11006
+ padding: 0 10px !important;
11007
+ gap: 5px !important;
11008
+ border-radius: 999px !important;
11009
+ font-size: 11.5px !important;
11010
+ line-height: 18px !important;
11011
+ white-space: nowrap !important;
11012
+ }
11013
+ /* The vision toggle is squeezed to a 28px icon-only button in the row
11014
+ (compat.css.ts); in the strip it is a 14px icon + text pill, so its
11015
+ label comes back. */
11016
+ [data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > span {
11001
11017
  display: inline !important;
11002
11018
  }
11003
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {
11004
- width: 18px !important;
11005
- height: 18px !important;
11019
+ [data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > svg:first-of-type {
11020
+ width: 14px !important;
11021
+ height: 14px !important;
11006
11022
  }
11007
11023
  /* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
11008
- trigger. Inside the sheet — which is \`overflow: hidden\` so its rounded
11009
- top clips its content — that menu would be cut off. Same escape section 4
11010
- uses for the host's two menus: position: fixed leaves the clip behind
11011
- (the sheet sets \`transform: none\`, so nothing between here and the
11012
- viewport captures fixed positioning) and it lands as its own sheet on top
11013
- of this one. */
11014
- ${MODEL_MENU} > [data-zen-sheet-extras] [role="menu"] {
11024
+ trigger. Inside the strip — which is \`overflow-x: auto\`, so its content
11025
+ clips at the strip's box — that menu would be cut off. Same escape
11026
+ section 4 uses for the host's two menus: position: fixed leaves the clip
11027
+ behind and it lands as its own bottom sheet over the composer. */
11028
+ [class$="_composerSeat"]:has([data-zen-sheet-extras] [role="menu"]) {
11029
+ z-index: 60 !important;
11030
+ }
11031
+ [data-zen-sheet-extras] [role="menu"] {
11015
11032
  position: fixed !important;
11016
11033
  left: 0 !important;
11017
11034
  right: 0 !important;
@@ -11026,13 +11043,13 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11026
11043
  border-radius: 16px 16px 0 0 !important;
11027
11044
  border-bottom: none !important;
11028
11045
  padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
11029
- /* Above the model sheet it sits on (60, section 4), below the session-info
11030
- sheet's 70 (info.css.ts) — it is a child of the model sheet, not a peer
11031
- of the page's own layers. */
11046
+ /* Above the page content behind the seat (the seat is raised to 60 by
11047
+ the rule above while a parked menu is open), below the session-info
11048
+ sheet's 70 (info.css.ts). */
11032
11049
  z-index: 61 !important;
11033
11050
  box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
11034
11051
  }
11035
- ${MODEL_MENU} > [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
11052
+ [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
11036
11053
  min-height: 48px !important;
11037
11054
  border-radius: 12px !important;
11038
11055
  font-size: 15px !important;
@@ -11060,7 +11077,10 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11060
11077
  body:has(${MODEL} > [class$="_menu"]) [data-chat-flow] + div,
11061
11078
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
11062
11079
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
11063
- body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div {
11080
+ body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
11081
+ /* T-issue30: an inline menu a parked chip opened (escaped to a fixed
11082
+ sheet by 4a). */
11083
+ body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
11064
11084
  display: none !important;
11065
11085
  }
11066
11086
  /* --- 5. input box: two lines minimum, five lines maximum ---
@@ -14194,164 +14214,288 @@ Object.defineProperty(exports, "__esModule", { value: true });
14194
14214
  exports.installModelSheetExtras = installModelSheetExtras;
14195
14215
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
14196
14216
  const PHONE_QUERY = '(max-width: 767px)';
14197
- /** The model pill itself. Absent in a subagent session — see `sync`. */
14198
- const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]';
14199
- /** The model pill's own popup, which section 4 of composer.css.ts turns into
14200
- * a bottom sheet — inline under the pill on ≤ 0.1.2, portaled to body on
14201
- * 0.1.5+ (same two spellings as composer.css.ts's MODEL_MENU; the body form
14202
- * is the only body-level `role=menu` with an `-menu` id, measured on
14203
- * 0.1.5-rc.2). Appending into the portal root is no different from the
14204
- * inline case: React leaves nodes it did not create alone, and the sheet
14205
- * still unmounts on close, which is what the detached-menu path handles. */
14206
- const MENU_SELECTOR = '[data-slot="conversation.input.model"] [class$="_menu"], body > [id$="-menu"][role="menu"]';
14217
+ /** The composer bar slot: the scan root, and the anchor the synthetic dock
14218
+ * rail hangs beside (hero stage). */
14219
+ const BAR_SELECTOR = '[data-slot="conversation.composer.bar"]';
14207
14220
  /**
14208
- * The third-party composer controls this effect relocates. Both register into
14209
- * `conversation.input.right`; moving the slot container rather than its
14210
- * children keeps them together and leaves exactly one node to put back.
14221
+ * Where a secondary control can legally sit in the composer, as ONE selector
14222
+ * list scanned inside the bar. Slots first (input.right / input.left), then
14223
+ * the official flex groups, then the row itself — a third party may inject
14224
+ * at any of the six (subscriptions and vision-router register into
14225
+ * input.right; dsh-workbuddy's credits and dsh-skills-ui's chips hang off
14226
+ * _row directly). Every position excludes `[data-mobile-nav]` so this
14227
+ * plugin's own nodes (the strip itself, and anything else we mount) are
14228
+ * never collected, and the official controls by name, so the whitelist
14229
+ * never fights the host. opt-in by marker, never `:not()`-hide unclaimed
14230
+ * nodes — the failure direction is "extra control stays visible", never
14231
+ * "control hidden from both places".
14211
14232
  */
14212
- const EXTRAS_SELECTOR = '[data-slot="conversation.input.right"]';
14233
+ const EXTRAS_SELECTOR = [
14234
+ '[data-slot="conversation.input.right"] > *:not([data-mobile-nav])',
14235
+ '[data-slot="conversation.input.left"] > *:not([data-mobile-nav])',
14236
+ '[class$="_tools"] > *:not(button[class$="_add"]):not(input[type="file"]):not([class$="_modes"]):not([data-slot="conversation.input.left"]):not([data-mobile-nav])',
14237
+ '[class$="_standardControls"] > *:not([data-slot="conversation.input.model"]):not([data-slot="conversation.input.right"]):not([data-mobile-nav])',
14238
+ '[class$="_trailing"] > *:not([class$="_standardControls"]):not([class$="_activity"]):not([class$="_primary"]):not([data-mobile-nav])',
14239
+ '[class$="_row"] > *:not([class$="_tools"]):not([class$="_trailing"]):not([data-mobile-nav])',
14240
+ ].join(', ');
14213
14241
  /**
14214
- * dsh-vision-router's own mount marker. Used here only to answer "is there
14215
- * anything in this slot BESIDES the vision toggle" — see `worthMoving`.
14242
+ * dsh-vision-router's own mount marker. Used only to answer "is the single
14243
+ * collected control the vision toggle" — see `worthMoving`.
14216
14244
  */
14217
14245
  const VISION_SELECTOR = '[data-vision-router-mode-toggle]';
14218
14246
  /**
14219
- * Set on the container while this effect manages it, in the row and in the
14220
- * sheet alike. It is the styling hook for both halves in composer.css.ts, and
14221
- * it is deliberately what the row's `display: none` keys off: without the
14222
- * marker the controls stay exactly where the host put them, so a version of
14223
- * this file that declines to act — or never runs at all — cannot leave them
14224
- * hidden in the row and absent from the sheet, i.e. unreachable.
14247
+ * On the input.right CONTAINER while the effect manages any of its children,
14248
+ * and on the strip itself. It is the styling hook for both halves in
14249
+ * composer.css.ts (the emptied container hides without leaving a flex gap;
14250
+ * an inline `[role="menu"]` opened by a parked chip escapes the strip's
14251
+ * overflow clip through it). It is deliberately what the row's
14252
+ * `display: none` keys off: without the marker the controls stay exactly
14253
+ * where the host put them, so a version of this file that declines to act —
14254
+ * or never runs at all — cannot leave them hidden in the row and absent from
14255
+ * the strip, i.e. unreachable.
14225
14256
  */
14226
14257
  const MANAGED = 'data-zen-sheet-extras';
14227
14258
  /**
14228
- * Is the slot holding anything that actually costs the row width?
14259
+ * On every collected NODE while it is parked in the strip. Purely opt-in:
14260
+ * the marker's absence never hides anything, and `releaseAll` removes it —
14261
+ * an unmarked control is the host's own business again.
14262
+ */
14263
+ const EXTRA = 'data-zen-composer-extra';
14264
+ /**
14265
+ * Is there anything collected that actually costs the row width?
14229
14266
  *
14230
14267
  * The row is one nowrap flex line whose only elastic item is the model pill
14231
14268
  * (composer.css.ts section 1), so entries here are paid for out of the model
14232
- * name. But that is only a problem once something WIDE lands in the slot —
14233
- * today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
14234
- * eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
14235
- * accommodates fine, and moving it on its own would cost a tap (it becomes
14236
- * two: open the sheet, then toggle) to buy width nobody needed.
14269
+ * name. But that is only a problem once something WIDE lands there — today
14270
+ * dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and eats
14271
+ * ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
14272
+ * accommodates fine, and moving it on its own would cost nothing visually
14273
+ * but would hide a one-tap control behind a scroll (user review, 2026-09-06).
14237
14274
  *
14238
- * So: relocate only when the slot holds an entry that is not the vision
14239
- * toggle. With no such entry — subscriptions not installed, or installed but
14240
- * not contributing on this model — nothing moves and the row keeps its one
14241
- * icon, which is the behaviour asked for (user review, 2026-09-06).
14275
+ * So: move only when the collection holds an entry that is not (or does not
14276
+ * contain) the vision toggle. With no such entry — subscriptions not
14277
+ * installed, or installed but not contributing on this model — nothing moves
14278
+ * and the row keeps its one icon. Why width rather than sniffing for
14279
+ * subscriptions by name: the slot system adds no per-registration marker, so
14280
+ * naming the plugin would anchor on a marker we'd have to add to our own
14281
+ * fork of it, and the feature would die the day the fork is dropped. The
14282
+ * width question is the real question anyway, and this asks it directly.
14242
14283
  *
14243
- * Why this rather than sniffing for subscriptions by name: the slot system
14244
- * adds no per-registration marker (measured — entries render straight into the
14245
- * container) and subscriptions marks nothing of its own, so naming it would
14246
- * mean adding a marker to our fork of that plugin and anchoring on it. That
14247
- * marker dies the day the fork is dropped, and the feature would go quiet
14248
- * with nothing to catch it. The width question is the real question anyway,
14249
- * and this asks it directly.
14284
+ * Unlike the pre-#30 design, NOTHING here depends on the official model
14285
+ * pill existing: replaced model slots (…codex-ui) and subagent sessions —
14286
+ * which have no pill at all — collect and relocate exactly like everyone
14287
+ * else.
14250
14288
  */
14251
- function worthMoving(extras) {
14252
- return [...extras.children].some(child => !child.matches(VISION_SELECTOR));
14289
+ function worthMoving(controls) {
14290
+ if (controls.length === 0)
14291
+ return false;
14292
+ if (controls.length === 1) {
14293
+ const child = controls[0];
14294
+ if (child === undefined)
14295
+ return false;
14296
+ // "is or contains" — some plugins wrap their toggle in a positioning node.
14297
+ if (child.matches(VISION_SELECTOR) || child.querySelector(VISION_SELECTOR) !== null)
14298
+ return false;
14299
+ }
14300
+ return true;
14253
14301
  }
14254
14302
  /**
14255
- * Phone: park the composer row's third-party controls inside the model sheet.
14303
+ * Phone: move the composer row's secondary third-party controls into a
14304
+ * 26px horizontally-scrollable strip inside the dock rail above the card —
14305
+ * always visible, one tap from anywhere, no sheet in between.
14256
14306
  *
14257
- * With the speed chip and the vision toggle both present on a GPT model the
14258
- * row runs out of width. Both are model-scoped settings, so the model sheet —
14259
- * which already holds 模型 and 推理等级 — is where they belong (user request,
14260
- * 2026-09-06).
14307
+ * Why move the real controls instead of drawing our own rows that drive
14308
+ * them: the same reason native-trigger-overlay.ts moves the real trigger. A
14309
+ * stand-in has to script the original, which is a road this plugin does not
14310
+ * go down — beyond the trusted-input wall that effect documents, our own
14311
+ * rows would have to mirror every piece of state (speed tier, disabled,
14312
+ * aria-pressed, whether a vision twin exists for the current model) and
14313
+ * would rot on the next upstream restyle. Moving the node keeps one source
14314
+ * of truth: their React roots go on owning and updating these elements, we
14315
+ * only change where they hang — never cloneNode, and the move is anchored
14316
+ * with a Comment at the original spot so `unparkAll` can put each node back
14317
+ * exactly where React left it.
14261
14318
  *
14262
- * Why move the real controls instead of drawing our own rows that drive them:
14263
- * the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
14264
- * has to script the original, which is a road this plugin does not go down —
14265
- * beyond the trusted-input wall that effect documents, our own rows would have
14266
- * to mirror every piece of state (speed tier, disabled, aria-pressed, whether
14267
- * a vision twin exists for the current model) and would rot on the next
14268
- * upstream restyle. Moving the node keeps one source of truth: their React
14269
- * roots go on owning and updating these elements, we only change where they
14270
- * hang.
14319
+ * Measured 2026-09-06 (DSH 0.1.2, 390px) before the original version of
14320
+ * this effect — the two things that would have killed the approach both
14321
+ * hold: a relocated control survives host re-renders (typing into the
14322
+ * composer re-renders the row and does not yank it back) and stays
14323
+ * hit-testable at its new position.
14271
14324
  *
14272
- * Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
14273
- * that would have killed the approach both hold: a relocated control survives
14274
- * host re-renders (typing into the composer re-renders the row and does not
14275
- * yank it back) and stays hit-testable at its new position.
14276
- *
14277
- * Restoring on close is not cosmetic. The sheet unmounts when it closes and
14278
- * anything still inside goes with it, so the controls would vanish from the
14279
- * page while their React roots kept updating detached nodes. The observer
14280
- * therefore watches the menu leaving too, and the parked node is held as a
14281
- * reference rather than looked up — once the menu is detached a document
14282
- * query can no longer find it.
14325
+ * Known hazard, inherent to relocating React-owned nodes: a control whose
14326
+ * REGISTRATION unmounts while parked (plugin disabled, slot re-keyed) is
14327
+ * removed by React through its ORIGINAL parent — `parent.removeChild(child)`
14328
+ * throws when the child moved, and the exception lands in React's commit.
14329
+ * Updates that keep the registration mounted (state-driven re-renders,
14330
+ * text/attribute changes, a control's own inner nodes unmounting) are safe:
14331
+ * those removals run inside the parked subtree, where every parent-child
14332
+ * link React recorded still holds. Parking is also fully reversible — any
14333
+ * decline path (desktop, nothing worth moving, teardown) hands every node
14334
+ * back before anything is removed.
14283
14335
  */
14284
14336
  function installModelSheetExtras(ctx) {
14285
14337
  ctx.effect(() => {
14286
14338
  const phone = window.matchMedia(PHONE_QUERY);
14287
- /** The container while parked in the sheet, plus where to put it back. */
14288
- let parked = null;
14289
- const park = (node, menu) => {
14290
- if (parked !== null || node.parentElement === null)
14291
- return;
14292
- parked = { node, parent: node.parentElement, next: node.nextSibling };
14293
- menu.appendChild(node);
14339
+ /** Parked nodes with the Comment anchoring each one's original spot. */
14340
+ let parked = [];
14341
+ let syntheticDock = null;
14342
+ /** Collected controls, document order; parked ones appended so the set
14343
+ * stays stable while they live in the strip. */
14344
+ const collect = (bar) => {
14345
+ const controls = Array.from(bar.querySelectorAll(EXTRAS_SELECTOR));
14346
+ for (const { node } of parked) {
14347
+ if (node.isConnected && !controls.includes(node))
14348
+ controls.push(node);
14349
+ }
14350
+ return controls;
14294
14351
  };
14295
- const unpark = () => {
14296
- if (parked === null)
14352
+ const park = (node, strip) => {
14353
+ if (node.parentElement === null)
14297
14354
  return;
14298
- const { node, parent, next } = parked;
14299
- parked = null;
14300
- // The menu may already be detached, having taken the container with it.
14301
- // The node itself is still live either way; re-home it only if its old
14302
- // parent is still on the page (when the whole composer went away, the
14303
- // owning React roots rebuild the slot on the next mount).
14304
- if (parent.isConnected)
14305
- parent.insertBefore(node, next);
14355
+ const placeholder = document.createComment('zen-composer-extra');
14356
+ node.parentElement.insertBefore(placeholder, node);
14357
+ node.setAttribute(EXTRA, 'open');
14358
+ strip.appendChild(node);
14359
+ parked.push({ node, placeholder });
14360
+ };
14361
+ const unparkAll = () => {
14362
+ for (const { node, placeholder } of parked) {
14363
+ // Re-home only while the anchor still marks the spot AND that home
14364
+ // is on the page — when the whole composer went away, the owning
14365
+ // React roots rebuild the slot on the next mount.
14366
+ if (placeholder.parentNode !== null && placeholder.parentNode.isConnected) {
14367
+ placeholder.parentNode.insertBefore(node, placeholder);
14368
+ }
14369
+ placeholder.remove();
14370
+ node.removeAttribute(EXTRA);
14371
+ }
14372
+ parked = [];
14306
14373
  };
14307
- /** Hand the container back to the host, marker and all. */
14308
- const release = (node) => {
14309
- unpark();
14310
- node?.removeAttribute(MANAGED);
14374
+ /** Everything the effect owns, back to host-placed defaults: parked
14375
+ * nodes re-homed, strip, synthetic rail and every marker gone.
14376
+ * Idempotent — teardown and every decline path go through here. */
14377
+ const releaseAll = () => {
14378
+ unparkAll();
14379
+ document.querySelector('[data-mobile-nav="composer-chips"]')?.remove();
14380
+ syntheticDock?.remove();
14381
+ syntheticDock = null;
14382
+ const bar = document.querySelector(BAR_SELECTOR);
14383
+ const scope = bar ?? document;
14384
+ for (const node of scope.querySelectorAll(`[${EXTRA}]`))
14385
+ node.removeAttribute(EXTRA);
14386
+ bar?.querySelector('[data-slot="conversation.input.right"]')?.removeAttribute(MANAGED);
14387
+ };
14388
+ /** The rail the strip lives in — the host's own dock, or a synthetic
14389
+ * stand-in while the hero composer has rendered none yet. */
14390
+ const adoptDock = (bar) => {
14391
+ // :not([data-zen-synthetic-dock]) is load-bearing: the synthetic rail
14392
+ // is bar's SIBLING carrying the same data-slot, so an unqualified
14393
+ // query would find it as the "real" dock, delete it as stale, recreate
14394
+ // it after — the observer would oscillate forever.
14395
+ const dock = bar.parentElement?.querySelector('[data-slot="conversation.input.dock"]:not([data-zen-synthetic-dock])') ?? null;
14396
+ if (dock !== null) {
14397
+ if (syntheticDock !== null) {
14398
+ // Real dock appeared: hand every parked node back before the
14399
+ // synthetic rail (and the strip inside it) is destroyed — removing
14400
+ // a rail full of React-owned controls would detach them from the
14401
+ // page. sync re-parks them into the new strip right after.
14402
+ unparkAll();
14403
+ syntheticDock.remove();
14404
+ syntheticDock = null;
14405
+ }
14406
+ return dock;
14407
+ }
14408
+ if (syntheticDock === null || !syntheticDock.isConnected) {
14409
+ syntheticDock = document.createElement('div');
14410
+ syntheticDock.setAttribute('data-slot', 'conversation.input.dock');
14411
+ syntheticDock.setAttribute('data-zen-synthetic-dock', '');
14412
+ bar.parentNode?.insertBefore(syntheticDock, bar);
14413
+ }
14414
+ return syntheticDock;
14415
+ };
14416
+ const ensureStrip = (home) => {
14417
+ let strip = home.querySelector('[data-mobile-nav="composer-chips"]');
14418
+ if (strip === null) {
14419
+ strip = document.createElement('div');
14420
+ strip.setAttribute('data-mobile-nav', 'composer-chips');
14421
+ strip.setAttribute(MANAGED, '');
14422
+ // Document-order first; CSS `order: 0` keeps it the rail's leftmost
14423
+ // pill line among the order-0 ties.
14424
+ home.prepend(strip);
14425
+ }
14426
+ return strip;
14311
14427
  };
14312
14428
  const sync = () => {
14313
- const extras = document.querySelector(EXTRAS_SELECTOR);
14314
- if (extras === null) {
14315
- unpark();
14429
+ const bar = document.querySelector(BAR_SELECTOR);
14430
+ if (bar === null) {
14431
+ releaseAll();
14316
14432
  return;
14317
14433
  }
14318
- // No model pill, no sheet to park into — a subagent session has none.
14319
- // Claiming the slot there would hide the controls from the row with
14320
- // nowhere to show them instead, which is the unreachable state the
14321
- // marker is meant to prevent.
14322
- const seat = document.querySelector(MODEL_SEAT_SELECTOR);
14323
- if (!phone.matches || seat === null || !worthMoving(extras)) {
14324
- release(extras);
14434
+ // A parked node its own React root unmounted is dead weight; drop it
14435
+ // and its anchor before collecting, so it neither blocks nor hides.
14436
+ parked = parked.filter(({ node, placeholder }) => {
14437
+ if (node.isConnected)
14438
+ return true;
14439
+ placeholder.remove();
14440
+ return false;
14441
+ });
14442
+ const controls = collect(bar);
14443
+ if (!phone.matches || !worthMoving(controls)) {
14444
+ releaseAll();
14325
14445
  return;
14326
14446
  }
14327
- extras.setAttribute(MANAGED, '');
14328
- const menu = document.querySelector(MENU_SELECTOR);
14329
- if (menu === null)
14330
- unpark();
14331
- else
14332
- park(extras, menu);
14447
+ const home = adoptDock(bar);
14448
+ const strip = ensureStrip(home);
14449
+ // Parked entries the set has outgrown (an uninstalled plugin, a
14450
+ // re-render that moved a control into a claimed position) go home and
14451
+ // lose their marker.
14452
+ parked = parked.filter(({ node, placeholder }) => {
14453
+ if (controls.includes(node) && node.isConnected)
14454
+ return true;
14455
+ if (node.isConnected && placeholder.parentNode !== null && placeholder.parentNode.isConnected) {
14456
+ placeholder.parentNode.insertBefore(node, placeholder);
14457
+ }
14458
+ placeholder.remove();
14459
+ node.removeAttribute(EXTRA);
14460
+ return false;
14461
+ });
14462
+ for (const node of controls) {
14463
+ if (!parked.some((entry) => entry.node === node))
14464
+ park(node, strip);
14465
+ }
14466
+ // Stale markers anywhere in the bar — parked ones are in `controls`,
14467
+ // so they survive this sweep.
14468
+ for (const node of bar.querySelectorAll(`[${EXTRA}]`)) {
14469
+ if (!controls.includes(node))
14470
+ node.removeAttribute(EXTRA);
14471
+ }
14472
+ const right = bar.querySelector('[data-slot="conversation.input.right"]');
14473
+ if (right !== null) {
14474
+ const rightManaged = right.children.length > 0 ||
14475
+ parked.some(({ placeholder }) => placeholder.parentNode === right);
14476
+ if (rightManaged)
14477
+ right.setAttribute(MANAGED, '');
14478
+ else
14479
+ right.removeAttribute(MANAGED);
14480
+ }
14333
14481
  };
14334
14482
  // Synchronous in the observer callback, never behind rAF: the callback is
14335
- // a microtask and runs before this frame paints, so the controls are in
14336
- // the sheet the first time it is drawn. A rAF would land a frame late and
14337
- // show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
14338
- // Body-level childList is the same shape modal-back.ts uses; aria-expanded
14339
- // is watched too so a close that reuses the node still re-syncs.
14483
+ // a microtask and runs before this frame paints, so the strip and the
14484
+ // parked nodes are in place the first time their frame is drawn. A rAF
14485
+ // would land a frame late (AGENTS.md's "晚一帧" rule). Body-level
14486
+ // childList is the same shape modal-back.ts uses. The observer also sees
14487
+ // this effect's own moves; sync is idempotent, so the extra pass is a
14488
+ // no-op.
14340
14489
  const observer = new MutationObserver(sync);
14341
- observer.observe(document.body, {
14342
- childList: true,
14343
- subtree: true,
14344
- attributes: true,
14345
- attributeFilter: ['aria-expanded'],
14346
- });
14490
+ observer.observe(document.body, { childList: true, subtree: true });
14347
14491
  phone.addEventListener('change', sync);
14348
14492
  sync();
14349
14493
  return () => {
14350
14494
  observer.disconnect();
14351
14495
  phone.removeEventListener('change', sync);
14352
- release(document.querySelector(EXTRAS_SELECTOR));
14496
+ releaseAll();
14353
14497
  };
14354
- }, 'dsh-mobile-nav: model sheet extras');
14498
+ }, 'dsh-mobile-nav: composer chips extras');
14355
14499
  }
14356
14500
  };
14357
14501
  __modules["client/effects/permission-sheet.js"] = function (require, module, exports) {