dsh-zen-remote 2.2.7 → 2.2.9
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/interface.md +6 -4
- package/docs/remote-access.md +1 -0
- package/lib/client-routes.js +52 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +506 -280
- package/lib/relay-client.js +71 -0
- package/lib/relay-client.js.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +15 -7
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/effects/model-sheet-extras.d.ts +29 -25
- package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +39 -2
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +19 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +10 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-attach-upload.mjs +42 -0
- package/src/client/MobileAttachButton.tsx +23 -12
- package/src/client/MobileAttachChips.tsx +0 -7
- package/src/client/effects/model-sheet-extras.ts +233 -111
- package/src/client/styles/composer.css.ts +142 -83
- package/src/client-data/remote-status.ts +128 -2
- package/src/client-routes.ts +53 -0
- package/src/relay-client.ts +76 -0
- package/lib/types/client/host-attach.d.ts +0 -38
- package/lib/types/client/host-attach.d.ts.map +0 -1
- package/src/client/host-attach.ts +0 -54
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
|
|
7331
|
-
*
|
|
7332
|
-
*
|
|
7333
|
-
*
|
|
7334
|
-
*
|
|
7335
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
7363
|
-
//
|
|
7364
|
-
|
|
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 >
|
|
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).
|
|
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
|
-
/**
|
|
10635
|
-
|
|
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
|
-
|
|
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
|
|
10947
|
-
effects/model-sheet-extras.ts
|
|
10948
|
-
|
|
10949
|
-
|
|
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
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10959
|
-
|
|
10960
|
-
|
|
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
|
-
/*
|
|
10968
|
-
|
|
10969
|
-
|
|
10970
|
-
|
|
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-
|
|
10973
|
-
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10988
|
-
|
|
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
|
-
/*
|
|
11005
|
-
|
|
11006
|
-
|
|
11007
|
-
|
|
11008
|
-
|
|
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;
|
|
11012
10999
|
}
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
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 {
|
|
11016
11017
|
display: inline !important;
|
|
11017
11018
|
}
|
|
11018
|
-
|
|
11019
|
-
width:
|
|
11020
|
-
height:
|
|
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
|
|
11024
|
-
|
|
11025
|
-
uses for the host's two menus: position: fixed leaves the clip
|
|
11026
|
-
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
|
|
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
|
|
11045
|
-
|
|
11046
|
-
|
|
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
|
-
|
|
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
|
|
14213
|
-
|
|
14214
|
-
|
|
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
|
-
*
|
|
14224
|
-
*
|
|
14225
|
-
*
|
|
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 =
|
|
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
|
|
14230
|
-
*
|
|
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
|
-
*
|
|
14235
|
-
*
|
|
14236
|
-
*
|
|
14237
|
-
*
|
|
14238
|
-
*
|
|
14239
|
-
*
|
|
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
|
-
*
|
|
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
|
|
14248
|
-
*
|
|
14249
|
-
*
|
|
14250
|
-
* accommodates fine, and moving it on its own would cost
|
|
14251
|
-
*
|
|
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:
|
|
14254
|
-
* toggle. With no such entry — subscriptions not
|
|
14255
|
-
* not contributing on this model — nothing moves
|
|
14256
|
-
*
|
|
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
|
-
*
|
|
14259
|
-
*
|
|
14260
|
-
*
|
|
14261
|
-
*
|
|
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(
|
|
14267
|
-
|
|
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:
|
|
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.
|
|
14271
14306
|
*
|
|
14272
|
-
*
|
|
14273
|
-
*
|
|
14274
|
-
*
|
|
14275
|
-
*
|
|
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.
|
|
14276
14318
|
*
|
|
14277
|
-
*
|
|
14278
|
-
* the
|
|
14279
|
-
*
|
|
14280
|
-
*
|
|
14281
|
-
*
|
|
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.
|
|
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.
|
|
14286
14324
|
*
|
|
14287
|
-
*
|
|
14288
|
-
*
|
|
14289
|
-
*
|
|
14290
|
-
*
|
|
14291
|
-
*
|
|
14292
|
-
*
|
|
14293
|
-
*
|
|
14294
|
-
*
|
|
14295
|
-
*
|
|
14296
|
-
*
|
|
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
|
-
/**
|
|
14303
|
-
let parked =
|
|
14304
|
-
|
|
14305
|
-
|
|
14306
|
-
|
|
14307
|
-
|
|
14308
|
-
|
|
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
|
|
14311
|
-
if (
|
|
14352
|
+
const park = (node, strip) => {
|
|
14353
|
+
if (node.parentElement === null)
|
|
14312
14354
|
return;
|
|
14313
|
-
const
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
|
|
14317
|
-
|
|
14318
|
-
|
|
14319
|
-
|
|
14320
|
-
|
|
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 = [];
|
|
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;
|
|
14321
14415
|
};
|
|
14322
|
-
|
|
14323
|
-
|
|
14324
|
-
|
|
14325
|
-
|
|
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
|
|
14329
|
-
if (
|
|
14330
|
-
|
|
14429
|
+
const bar = document.querySelector(BAR_SELECTOR);
|
|
14430
|
+
if (bar === null) {
|
|
14431
|
+
releaseAll();
|
|
14331
14432
|
return;
|
|
14332
14433
|
}
|
|
14333
|
-
//
|
|
14334
|
-
//
|
|
14335
|
-
|
|
14336
|
-
|
|
14337
|
-
|
|
14338
|
-
|
|
14339
|
-
|
|
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
|
-
|
|
14343
|
-
const
|
|
14344
|
-
|
|
14345
|
-
|
|
14346
|
-
|
|
14347
|
-
|
|
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
|
|
14351
|
-
//
|
|
14352
|
-
//
|
|
14353
|
-
//
|
|
14354
|
-
//
|
|
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
|
-
|
|
14496
|
+
releaseAll();
|
|
14368
14497
|
};
|
|
14369
|
-
}, 'dsh-mobile-nav:
|
|
14498
|
+
}, 'dsh-mobile-nav: composer chips extras');
|
|
14370
14499
|
}
|
|
14371
14500
|
};
|
|
14372
14501
|
__modules["client/effects/permission-sheet.js"] = function (require, module, exports) {
|
|
@@ -17449,7 +17578,7 @@ __modules["client-data/remote-status.js"] = function (require, module, exports)
|
|
|
17449
17578
|
* - `getRemoteStatusStore()` is the page-wide singleton.
|
|
17450
17579
|
*/
|
|
17451
17580
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17452
|
-
exports.REMOTE_STATUS_POLL_MS = exports.documentVisibility = exports.BRIDGE_STAMP_ATTRIBUTE = exports.BRIDGE_STAMP_PLUGIN = exports.CLIENT_UNSHARE_ROUTE = exports.REMOTE_STATUS_ROUTE = void 0;
|
|
17581
|
+
exports.REMOTE_STATUS_POLL_MS = exports.windowNetworkHint = exports.documentVisibility = exports.BRIDGE_STAMP_ATTRIBUTE = exports.BRIDGE_STAMP_PLUGIN = exports.CLIENT_UNSHARE_ROUTE = exports.NETWORK_HINT_ROUTE = exports.REMOTE_STATUS_ROUTE = void 0;
|
|
17453
17582
|
exports.parseRemoteStatusBody = parseRemoteStatusBody;
|
|
17454
17583
|
exports.shouldStampBridgeAttribute = shouldStampBridgeAttribute;
|
|
17455
17584
|
exports.createZhRemoteStatusFormatter = createZhRemoteStatusFormatter;
|
|
@@ -17464,6 +17593,8 @@ const locales_ts_1 = require("./client/locales.js");
|
|
|
17464
17593
|
/** Same-origin client route feeding this store (host half: T34,
|
|
17465
17594
|
* src/client-routes.ts). */
|
|
17466
17595
|
exports.REMOTE_STATUS_ROUTE = '/_dsh/zen-remote/client/remote-status';
|
|
17596
|
+
/** the browser's network-restored hint (POST). */
|
|
17597
|
+
exports.NETWORK_HINT_ROUTE = '/_dsh/zen-remote/client/network-hint';
|
|
17467
17598
|
/** Same-origin client route closing one remote session from this machine. */
|
|
17468
17599
|
exports.CLIENT_UNSHARE_ROUTE = '/_dsh/zen-remote/client/unshare';
|
|
17469
17600
|
/** Parse one GET body tolerantly: a garbage shape is "not online yet", a
|
|
@@ -17573,18 +17704,50 @@ exports.documentVisibility = {
|
|
|
17573
17704
|
return () => { document.removeEventListener('visibilitychange', listener); };
|
|
17574
17705
|
},
|
|
17575
17706
|
};
|
|
17707
|
+
/** The real source (Node / SSR: never notifying — a host has no navigator). */
|
|
17708
|
+
exports.windowNetworkHint = {
|
|
17709
|
+
subscribe: (listener) => {
|
|
17710
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function')
|
|
17711
|
+
return () => { };
|
|
17712
|
+
const onOnline = () => { listener(); };
|
|
17713
|
+
const onVisibility = () => {
|
|
17714
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'visible')
|
|
17715
|
+
listener();
|
|
17716
|
+
};
|
|
17717
|
+
window.addEventListener('online', onOnline);
|
|
17718
|
+
if (typeof document !== 'undefined')
|
|
17719
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
17720
|
+
return () => {
|
|
17721
|
+
window.removeEventListener('online', onOnline);
|
|
17722
|
+
if (typeof document !== 'undefined')
|
|
17723
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
17724
|
+
};
|
|
17725
|
+
},
|
|
17726
|
+
};
|
|
17576
17727
|
/** Poll cadence while the page is visible and at least one part is mounted
|
|
17577
17728
|
* (T34: 15 秒). */
|
|
17578
17729
|
exports.REMOTE_STATUS_POLL_MS = 15_000;
|
|
17730
|
+
/** The minimum gap between two client-side network hints — the same 3s the
|
|
17731
|
+
* host's `networkRestored()` enforces, so the two never fight. `hintGapMs`
|
|
17732
|
+
* injects a smaller one (tests). */
|
|
17733
|
+
const NETWORK_HINT_CLIENT_GAP_MS = 3_000;
|
|
17579
17734
|
/**
|
|
17580
17735
|
* Build one store over an injectable fetch and visibility source — the
|
|
17581
17736
|
* shares-store discipline: latest-wins (an earlier request answering late
|
|
17582
17737
|
* never overwrites a newer table), every GET failure fails its own round,
|
|
17583
17738
|
* the timer is real while visible and gone while hidden, and becoming
|
|
17584
17739
|
* visible pulls once immediately. `pollMs` exists for tests: the shipped
|
|
17585
|
-
* cadence is far too slow to observe.
|
|
17740
|
+
* cadence is far too slow to observe. `hintGapMs` exists for the same
|
|
17741
|
+
* reason: the shipped hint gap (default 3000) is far too slow to observe.
|
|
17586
17742
|
*/
|
|
17587
|
-
function createRemoteStatusStore(fetchImpl, visibility = exports.documentVisibility, pollMs = exports.REMOTE_STATUS_POLL_MS
|
|
17743
|
+
function createRemoteStatusStore(fetchImpl, visibility = exports.documentVisibility, pollMs = exports.REMOTE_STATUS_POLL_MS,
|
|
17744
|
+
/** the network-restored hint source. Omitted → no
|
|
17745
|
+
* hint wiring (a host mount, or a test). */
|
|
17746
|
+
networkHint,
|
|
17747
|
+
/** the minimum gap between two client-side hints, ms; default 3000 (the
|
|
17748
|
+
* same anti-storm gap the host enforces). Exists for tests, like
|
|
17749
|
+
* `pollMs`: the shipped gap is far too slow to observe. */
|
|
17750
|
+
hintGapMs = NETWORK_HINT_CLIENT_GAP_MS) {
|
|
17588
17751
|
const gate = (0, settings_form_ts_1.createLatestGate)();
|
|
17589
17752
|
const listeners = new Set();
|
|
17590
17753
|
let snapshot = Object.freeze({ ready: false, view: undefined });
|
|
@@ -17694,18 +17857,74 @@ function createRemoteStatusStore(fetchImpl, visibility = exports.documentVisibil
|
|
|
17694
17857
|
startTimer();
|
|
17695
17858
|
}
|
|
17696
17859
|
};
|
|
17860
|
+
// ONE controlled round per hint.
|
|
17861
|
+
//
|
|
17862
|
+
// The chain is: hint → POST client/network-hint → host runs probeHealth()
|
|
17863
|
+
// (authenticated GET /ping) → only a reachable host advances to
|
|
17864
|
+
// networkRestored() (which is itself offline-only + 3s-gapped) → the next
|
|
17865
|
+
// status poll reflects the new state, and the intercept layer's $zr/events
|
|
17866
|
+
// leg reopens on its own once the relay reads online.
|
|
17867
|
+
//
|
|
17868
|
+
// THREE client-side guards keep a flapping machine from turning the hint
|
|
17869
|
+
// into a poll: a healthy-link gate (the last status read said `online` —
|
|
17870
|
+
// nothing to retire), an in-flight slot (one hint at a time), and a
|
|
17871
|
+
// minimum gap (the same 3s the host enforces, so the two never fight).
|
|
17872
|
+
let hintInFlight = false;
|
|
17873
|
+
let lastHintAt = 0;
|
|
17874
|
+
const sendNetworkHint = async () => {
|
|
17875
|
+
// The healthy-link gate: a hint exists to retire a stale `offline`. While
|
|
17876
|
+
// the last status read says `online`, a tab switch or a browser `online`
|
|
17877
|
+
// event must not spend the hint→ping→refresh triple. `view` undefined
|
|
17878
|
+
// (no GET answered yet) passes — conservative before the first read.
|
|
17879
|
+
if (snapshot.view?.state === 'online')
|
|
17880
|
+
return;
|
|
17881
|
+
if (hintInFlight)
|
|
17882
|
+
return;
|
|
17883
|
+
const now = Date.now();
|
|
17884
|
+
if (now - lastHintAt < hintGapMs)
|
|
17885
|
+
return;
|
|
17886
|
+
lastHintAt = now;
|
|
17887
|
+
hintInFlight = true;
|
|
17888
|
+
try {
|
|
17889
|
+
await fetchImpl(exports.NETWORK_HINT_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
17890
|
+
}
|
|
17891
|
+
catch {
|
|
17892
|
+
// A hint that cannot be delivered is simply dropped — the poll and the
|
|
17893
|
+
// streams are still the truth, and the next event tries again.
|
|
17894
|
+
}
|
|
17895
|
+
finally {
|
|
17896
|
+
hintInFlight = false;
|
|
17897
|
+
// Whatever the hint achieved, re-read the status so the UI stops
|
|
17898
|
+
// showing a stale `offline` the moment the host is back.
|
|
17899
|
+
void refresh();
|
|
17900
|
+
}
|
|
17901
|
+
};
|
|
17902
|
+
let offNetworkHint;
|
|
17903
|
+
const startHintWiring = () => {
|
|
17904
|
+
if (networkHint === undefined || offNetworkHint !== undefined)
|
|
17905
|
+
return;
|
|
17906
|
+
offNetworkHint = networkHint.subscribe(() => { void sendNetworkHint(); });
|
|
17907
|
+
};
|
|
17697
17908
|
return {
|
|
17698
17909
|
getSnapshot: () => snapshot,
|
|
17699
17910
|
subscribe(listener) {
|
|
17700
17911
|
listeners.add(listener);
|
|
17701
17912
|
syncPolling();
|
|
17913
|
+
startHintWiring();
|
|
17702
17914
|
return () => {
|
|
17703
17915
|
listeners.delete(listener);
|
|
17704
17916
|
syncPolling();
|
|
17917
|
+
// The hint listener follows the subscribers: no page, no hints.
|
|
17918
|
+
if (listeners.size === 0 && offNetworkHint !== undefined) {
|
|
17919
|
+
offNetworkHint();
|
|
17920
|
+
offNetworkHint = undefined;
|
|
17921
|
+
}
|
|
17705
17922
|
};
|
|
17706
17923
|
},
|
|
17707
17924
|
refresh,
|
|
17708
17925
|
unshare,
|
|
17926
|
+
/** explicit hint trigger (tests / manual probe). */
|
|
17927
|
+
notifyNetworkRestored: () => { void sendNetworkHint(); },
|
|
17709
17928
|
};
|
|
17710
17929
|
}
|
|
17711
17930
|
let singleton;
|
|
@@ -17724,7 +17943,14 @@ function subscribeIfVirtual(store, sessionId, listener) {
|
|
|
17724
17943
|
* matter how many of the icon and banner parts are mounted at once. Created
|
|
17725
17944
|
* lazily over the real fetch, so check scripts never construct it. */
|
|
17726
17945
|
function getRemoteStatusStore() {
|
|
17727
|
-
|
|
17946
|
+
// the REAL entry wiring. The network-hint source is
|
|
17947
|
+
// passed here and nowhere else, so every mounted remote part (icon + banner)
|
|
17948
|
+
// shares ONE store and therefore ONE hint subscription — no per-component
|
|
17949
|
+
// listeners, no duplicate POSTs.
|
|
17950
|
+
//
|
|
17951
|
+
// Node / SSR: `windowNetworkHint.subscribe` returns a no-op detacher and
|
|
17952
|
+
// never fires (there is no `window`), so a host mount is unaffected.
|
|
17953
|
+
singleton ??= createRemoteStatusStore((input, init) => fetch(input, init), exports.documentVisibility, exports.REMOTE_STATUS_POLL_MS, exports.windowNetworkHint);
|
|
17728
17954
|
return singleton;
|
|
17729
17955
|
}
|
|
17730
17956
|
};
|