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.
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;
@@ -10613,11 +10559,16 @@ __modules["client/styles/composer.css.js"] = function (require, module, exports)
10613
10559
  //
10614
10560
  // Official structure this file reshapes (verified live at 390px, see the S3
10615
10561
  // report): InputBar renders
10616
- // _card > _row > [_tools > (_add, _modes > PermissionSelect, input.left)]
10562
+ // _card > _row > [_tools > (_add, input[type=file] hidden, _modes >
10563
+ // PermissionSelect, input.left)]
10617
10564
  // [_trailing > (_standardControls > (input.right, input.model), _activity, _primary)]
10618
10565
  // with `_tools` / `_trailing` / `_standardControls` as inner flex groups
10619
10566
  // (0.1.7 added `_standardControls` and moved the ContextMeter out of the row
10620
- // 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
10621
10572
  // `display: contents` turns every control into a direct flex item of `_row`,
10622
10573
  // which is what makes a pure `order` reflow possible without touching a
10623
10574
  // single official component.
@@ -10631,8 +10582,21 @@ const ROOT = '[data-slot="conversation.composer.bar"] > [class$="_root"]';
10631
10582
  const RING_W = '30px';
10632
10583
  /** DSH 0.1.7's wrapper around input.right + input.model inside `_trailing`. */
10633
10584
  const STD = `${ROW} > [class$="_trailing"] > [class$="_standardControls"]`;
10634
- /** The model seat's own root inside the trailing group (long form so it beats the <=1023px pill rules in layout.css.ts). */
10635
- 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])`;
10636
10600
  /** The permission select's trigger button (structure only — the PermissionSelect hashed classes are never named). */
10637
10601
  const PERM = `${ROW} > [class$="_tools"] > [class$="_modes"]`;
10638
10602
  /**
@@ -10667,6 +10631,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10667
10631
  [data-mobile-nav="attach-picker"] {
10668
10632
  display: none !important;
10669
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
+ }
10670
10640
 
10671
10641
  @media (max-width: 767px) {
10672
10642
  /* --- 1. flatten the two official groups ---
@@ -10690,8 +10660,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10690
10660
  }
10691
10661
 
10692
10662
  /* --- 2. the running order ---
10693
- The attachment button (S3 placeholder, S7 wires it up) sits leftmost;
10694
- 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
10695
10669
  the two "insert something" affordances read as one cluster. */
10696
10670
  ${ROW} [data-mobile-nav="attach"] {
10697
10671
  order: 1 !important;
@@ -10699,6 +10673,14 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10699
10673
  ${ROW} > [class$="_tools"] > [class$="_add"] {
10700
10674
  order: 2 !important;
10701
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
+ }
10702
10684
  ${ROW} > [class$="_tools"] > [class$="_modes"] {
10703
10685
  order: 3 !important;
10704
10686
  /* Rigid on purpose. This seat holds the permission chip, whose own
@@ -10850,7 +10832,8 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10850
10832
  caps the pill so the row's elastic gap survives extreme model ids;
10851
10833
  the label rules below override the <=1023px grow rules in
10852
10834
  layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */
10853
- ${MODEL} > [class$="_trigger"] {
10835
+ ${MODEL} > [class$="_trigger"],
10836
+ ${MODEL} > button {
10854
10837
  background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
10855
10838
  height: 30px !important;
10856
10839
  width: auto !important;
@@ -10876,6 +10859,18 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10876
10859
  flex: none !important;
10877
10860
  white-space: nowrap !important;
10878
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
+ }
10879
10874
  /* PermissionSelect wraps its trigger in the Menu primitive's root span,
10880
10875
  which must shrink to the icon button's fixed width. */
10881
10876
  ${PERM} > span:has(> button[class$="_trigger"]) {
@@ -10943,90 +10938,97 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
10943
10938
  border-radius: 12px !important;
10944
10939
  font-size: 15px !important;
10945
10940
  }
10946
- /* --- 4a. third-party composer entries live in the model sheet ---
10947
- effects/model-sheet-extras.ts parks the \`conversation.input.right\`
10948
- container inside the model menu while that menu is open and puts it back
10949
- 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.
10950
10948
 
10951
- In the row: hidden, but ONLY once the effect has marked the container as
10952
- one it manages. Keying the hide off the marker rather than off "not
10953
- currently parked" is what makes the failure direction safe — if the
10954
- effect declines to act (nothing in the slot but the vision toggle, so
10955
- nothing worth moving) or never runs at all, the marker is absent, this
10956
- rule does not match, and the controls stay exactly where the host put
10957
- them. The inverse spelling hid them in the row whenever they were not in
10958
- the sheet, which in those same cases left them reachable from neither
10959
- place (caught in review, 2026-09-06).
10960
- Hiding by ancestor rather than by a display rule on the container itself
10961
- is what lets the parked copy style itself freely — same element, two
10962
- homes, two rule sets. \`!important\` because the slot container carries an
10963
- inline \`display: contents\`. */
10964
- ${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"]) {
10965
10959
  display: none !important;
10966
10960
  }
10967
- /* Parked in the sheet: a column of full-width rows in the sheet's own
10968
- language, so a relocated control reads as a sibling of 模型 and 推理等级
10969
- rather than a chip that wandered in. */
10970
- ${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"] {
10971
10969
  display: flex !important;
10972
- flex-direction: column !important;
10973
- gap: 2px !important;
10974
- width: 100% !important;
10975
- }
10976
- /* Undo the row's compaction on every parked control: back to full width and
10977
- the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
10978
- naming either plugin — subscriptions wraps its trigger in a positioning
10979
- div, vision-router puts its button straight in the slot. */
10980
- ${MODEL_MENU} > [data-zen-sheet-extras] > *,
10981
- ${MODEL_MENU} > [data-zen-sheet-extras] > * > button {
10982
- width: 100% !important;
10983
- 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;
10984
10975
  min-width: 0 !important;
10985
- 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;
10986
10983
  }
10987
- ${MODEL_MENU} > [data-zen-sheet-extras] button {
10988
- box-sizing: border-box !important;
10989
- min-height: 48px !important;
10990
- height: auto !important;
10991
- padding: 0 12px !important;
10992
- gap: 10px !important;
10993
- border-radius: 12px !important;
10994
- border: none !important;
10995
- background: transparent !important;
10996
- font-size: 15px !important;
10997
- /* Both plugins center their chip label, which is right for a pill in the
10998
- row and wrong for a full-width row: the sheet's own cells start their
10999
- label at the left edge. justify-content covers the flex triggers,
11000
- text-align the ones that are not flex containers. */
11001
- text-align: left !important;
11002
- justify-content: flex-start !important;
10984
+ [data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"]::-webkit-scrollbar {
10985
+ display: none !important;
11003
10986
  }
11004
- /* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it
11005
- parks beside the context ring), and that rule still matches in here on
11006
- ≤ 0.1.2 — the inline sheet is a DOM descendant of the composer bar even
11007
- though it paints as a fixed layer (the 0.1.5 body portal is not, where
11008
- this is a harmless no-op). Reset it so the sheet follows DOM order
11009
- instead of inheriting a decision that was about a different layout. */
11010
- ${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;
11011
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;
10999
+ }
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
11012
  }
11013
- /* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);
11014
- in the sheet it is a row, so its label comes back and the icon leads. */
11015
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {
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 {
11016
11017
  display: inline !important;
11017
11018
  }
11018
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {
11019
- width: 18px !important;
11020
- 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;
11021
11022
  }
11022
11023
  /* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
11023
- trigger. Inside the sheet — which is \`overflow: hidden\` so its rounded
11024
- top clips its content — that menu would be cut off. Same escape section 4
11025
- uses for the host's two menus: position: fixed leaves the clip behind
11026
- (the sheet sets \`transform: none\`, so nothing between here and the
11027
- viewport captures fixed positioning) and it lands as its own sheet on top
11028
- of this one. */
11029
- ${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"] {
11030
11032
  position: fixed !important;
11031
11033
  left: 0 !important;
11032
11034
  right: 0 !important;
@@ -11041,13 +11043,13 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11041
11043
  border-radius: 16px 16px 0 0 !important;
11042
11044
  border-bottom: none !important;
11043
11045
  padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
11044
- /* Above the model sheet it sits on (60, section 4), below the session-info
11045
- sheet's 70 (info.css.ts) — it is a child of the model sheet, not a peer
11046
- 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). */
11047
11049
  z-index: 61 !important;
11048
11050
  box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
11049
11051
  }
11050
- ${MODEL_MENU} > [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
11052
+ [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
11051
11053
  min-height: 48px !important;
11052
11054
  border-radius: 12px !important;
11053
11055
  font-size: 15px !important;
@@ -11075,7 +11077,10 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11075
11077
  body:has(${MODEL} > [class$="_menu"]) [data-chat-flow] + div,
11076
11078
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
11077
11079
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
11078
- 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 {
11079
11084
  display: none !important;
11080
11085
  }
11081
11086
  /* --- 5. input box: two lines minimum, five lines maximum ---
@@ -14209,164 +14214,288 @@ Object.defineProperty(exports, "__esModule", { value: true });
14209
14214
  exports.installModelSheetExtras = installModelSheetExtras;
14210
14215
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
14211
14216
  const PHONE_QUERY = '(max-width: 767px)';
14212
- /** The model pill itself. Absent in a subagent session — see `sync`. */
14213
- const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]';
14214
- /** The model pill's own popup, which section 4 of composer.css.ts turns into
14215
- * a bottom sheet — inline under the pill on ≤ 0.1.2, portaled to body on
14216
- * 0.1.5+ (same two spellings as composer.css.ts's MODEL_MENU; the body form
14217
- * is the only body-level `role=menu` with an `-menu` id, measured on
14218
- * 0.1.5-rc.2). Appending into the portal root is no different from the
14219
- * inline case: React leaves nodes it did not create alone, and the sheet
14220
- * still unmounts on close, which is what the detached-menu path handles. */
14221
- 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"]';
14222
14220
  /**
14223
- * The third-party composer controls this effect relocates. Both register into
14224
- * `conversation.input.right`; moving the slot container rather than its
14225
- * 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".
14226
14232
  */
14227
- 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(', ');
14228
14241
  /**
14229
- * dsh-vision-router's own mount marker. Used here only to answer "is there
14230
- * 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`.
14231
14244
  */
14232
14245
  const VISION_SELECTOR = '[data-vision-router-mode-toggle]';
14233
14246
  /**
14234
- * Set on the container while this effect manages it, in the row and in the
14235
- * sheet alike. It is the styling hook for both halves in composer.css.ts, and
14236
- * it is deliberately what the row's `display: none` keys off: without the
14237
- * marker the controls stay exactly where the host put them, so a version of
14238
- * this file that declines to act — or never runs at all — cannot leave them
14239
- * 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.
14240
14256
  */
14241
14257
  const MANAGED = 'data-zen-sheet-extras';
14242
14258
  /**
14243
- * 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?
14244
14266
  *
14245
14267
  * The row is one nowrap flex line whose only elastic item is the model pill
14246
14268
  * (composer.css.ts section 1), so entries here are paid for out of the model
14247
- * name. But that is only a problem once something WIDE lands in the slot —
14248
- * today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
14249
- * eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
14250
- * accommodates fine, and moving it on its own would cost a tap (it becomes
14251
- * 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).
14252
14274
  *
14253
- * So: relocate only when the slot holds an entry that is not the vision
14254
- * toggle. With no such entry — subscriptions not installed, or installed but
14255
- * not contributing on this model — nothing moves and the row keeps its one
14256
- * 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.
14257
14283
  *
14258
- * Why this rather than sniffing for subscriptions by name: the slot system
14259
- * adds no per-registration marker (measured — entries render straight into the
14260
- * container) and subscriptions marks nothing of its own, so naming it would
14261
- * mean adding a marker to our fork of that plugin and anchoring on it. That
14262
- * marker dies the day the fork is dropped, and the feature would go quiet
14263
- * with nothing to catch it. The width question is the real question anyway,
14264
- * 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.
14265
14288
  */
14266
- function worthMoving(extras) {
14267
- 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;
14268
14301
  }
14269
14302
  /**
14270
- * Phone: park the composer row's third-party controls inside the model sheet.
14271
- *
14272
- * With the speed chip and the vision toggle both present on a GPT model the
14273
- * row runs out of width. Both are model-scoped settings, so the model sheet —
14274
- * which already holds 模型 and 推理等级 — is where they belong (user request,
14275
- * 2026-09-06).
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.
14276
14306
  *
14277
- * Why move the real controls instead of drawing our own rows that drive them:
14278
- * the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
14279
- * has to script the original, which is a road this plugin does not go down —
14280
- * beyond the trusted-input wall that effect documents, our own rows would have
14281
- * to mirror every piece of state (speed tier, disabled, aria-pressed, whether
14282
- * a vision twin exists for the current model) and would rot on the next
14283
- * upstream restyle. Moving the node keeps one source of truth: their React
14284
- * roots go on owning and updating these elements, we only change where they
14285
- * hang.
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.
14286
14318
  *
14287
- * Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
14288
- * that would have killed the approach both hold: a relocated control survives
14289
- * host re-renders (typing into the composer re-renders the row and does not
14290
- * yank it back) and stays hit-testable at its new position.
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.
14291
14324
  *
14292
- * Restoring on close is not cosmetic. The sheet unmounts when it closes and
14293
- * anything still inside goes with it, so the controls would vanish from the
14294
- * page while their React roots kept updating detached nodes. The observer
14295
- * therefore watches the menu leaving too, and the parked node is held as a
14296
- * reference rather than looked up — once the menu is detached a document
14297
- * 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.
14298
14335
  */
14299
14336
  function installModelSheetExtras(ctx) {
14300
14337
  ctx.effect(() => {
14301
14338
  const phone = window.matchMedia(PHONE_QUERY);
14302
- /** The container while parked in the sheet, plus where to put it back. */
14303
- let parked = null;
14304
- const park = (node, menu) => {
14305
- if (parked !== null || node.parentElement === null)
14306
- return;
14307
- parked = { node, parent: node.parentElement, next: node.nextSibling };
14308
- 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;
14309
14351
  };
14310
- const unpark = () => {
14311
- if (parked === null)
14352
+ const park = (node, strip) => {
14353
+ if (node.parentElement === null)
14312
14354
  return;
14313
- const { node, parent, next } = parked;
14314
- parked = null;
14315
- // The menu may already be detached, having taken the container with it.
14316
- // The node itself is still live either way; re-home it only if its old
14317
- // parent is still on the page (when the whole composer went away, the
14318
- // owning React roots rebuild the slot on the next mount).
14319
- if (parent.isConnected)
14320
- 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 });
14321
14360
  };
14322
- /** Hand the container back to the host, marker and all. */
14323
- const release = (node) => {
14324
- unpark();
14325
- node?.removeAttribute(MANAGED);
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 = [];
14373
+ };
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;
14326
14427
  };
14327
14428
  const sync = () => {
14328
- const extras = document.querySelector(EXTRAS_SELECTOR);
14329
- if (extras === null) {
14330
- unpark();
14429
+ const bar = document.querySelector(BAR_SELECTOR);
14430
+ if (bar === null) {
14431
+ releaseAll();
14331
14432
  return;
14332
14433
  }
14333
- // No model pill, no sheet to park into — a subagent session has none.
14334
- // Claiming the slot there would hide the controls from the row with
14335
- // nowhere to show them instead, which is the unreachable state the
14336
- // marker is meant to prevent.
14337
- const seat = document.querySelector(MODEL_SEAT_SELECTOR);
14338
- if (!phone.matches || seat === null || !worthMoving(extras)) {
14339
- 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();
14340
14445
  return;
14341
14446
  }
14342
- extras.setAttribute(MANAGED, '');
14343
- const menu = document.querySelector(MENU_SELECTOR);
14344
- if (menu === null)
14345
- unpark();
14346
- else
14347
- 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
+ }
14348
14481
  };
14349
14482
  // Synchronous in the observer callback, never behind rAF: the callback is
14350
- // a microtask and runs before this frame paints, so the controls are in
14351
- // the sheet the first time it is drawn. A rAF would land a frame late and
14352
- // show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
14353
- // Body-level childList is the same shape modal-back.ts uses; aria-expanded
14354
- // 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.
14355
14489
  const observer = new MutationObserver(sync);
14356
- observer.observe(document.body, {
14357
- childList: true,
14358
- subtree: true,
14359
- attributes: true,
14360
- attributeFilter: ['aria-expanded'],
14361
- });
14490
+ observer.observe(document.body, { childList: true, subtree: true });
14362
14491
  phone.addEventListener('change', sync);
14363
14492
  sync();
14364
14493
  return () => {
14365
14494
  observer.disconnect();
14366
14495
  phone.removeEventListener('change', sync);
14367
- release(document.querySelector(EXTRAS_SELECTOR));
14496
+ releaseAll();
14368
14497
  };
14369
- }, 'dsh-mobile-nav: model sheet extras');
14498
+ }, 'dsh-mobile-nav: composer chips extras');
14370
14499
  }
14371
14500
  };
14372
14501
  __modules["client/effects/permission-sheet.js"] = function (require, module, exports) {