dsh-zen-remote 1.1.12 → 1.1.13
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +3 -0
- package/lib/client.js +334 -7
- package/lib/types/client/effects/model-sheet-extras.d.ts +33 -0
- package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -0
- package/lib/types/client/index.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/package.json +1 -1
- package/pwa/app.css +47 -7
- package/src/client/effects/model-sheet-extras.ts +159 -0
- package/src/client/index.tsx +4 -0
- package/src/client/styles/composer.css.ts +170 -7
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
5
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0B7285?style=flat-square" alt="MIT"></a>
|
|
6
|
-
<img src="https://img.shields.io/badge/release-v1.1.
|
|
6
|
+
<img src="https://img.shields.io/badge/release-v1.1.13-5B4CF0?style=flat-square" alt="v1.1.13">
|
|
7
7
|
<img src="https://img.shields.io/badge/DSH-Web%20Profile-5B4CF0?style=flat-square" alt="DSH Web Profile">
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -45,7 +45,7 @@ Edit `~/.dsh/profiles/web/package.json` by hand — one line under `dependencies
|
|
|
45
45
|
```jsonc
|
|
46
46
|
{
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"dsh-zen-remote": "^1.1.
|
|
48
|
+
"dsh-zen-remote": "^1.1.13" // for local development: "link:/path/to/dsh-zen-remote"
|
|
49
49
|
},
|
|
50
50
|
"dsh": { "profile": { "bundles": [
|
|
51
51
|
"@deepseek-ai/dsh-base",
|
package/README.zh-CN.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
5
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0B7285?style=flat-square" alt="MIT"></a>
|
|
6
|
-
<img src="https://img.shields.io/badge/release-v1.1.
|
|
6
|
+
<img src="https://img.shields.io/badge/release-v1.1.13-5B4CF0?style=flat-square" alt="v1.1.13">
|
|
7
7
|
<img src="https://img.shields.io/badge/DSH-Web%20Profile-5B4CF0?style=flat-square" alt="DSH Web Profile">
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -45,7 +45,7 @@ dsh plugin add dsh-zen-remote
|
|
|
45
45
|
```jsonc
|
|
46
46
|
{
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"dsh-zen-remote": "^1.1.
|
|
48
|
+
"dsh-zen-remote": "^1.1.13" // 本地开发换成 "link:/path/to/dsh-zen-remote"
|
|
49
49
|
},
|
|
50
50
|
"dsh": { "profile": { "bundles": [
|
|
51
51
|
"@deepseek-ai/dsh-base",
|
package/docs/interface.md
CHANGED
|
@@ -119,6 +119,9 @@ composer 最左的回形针打开的是**手机本地**的文件选择器(iOS
|
|
|
119
119
|
|
|
120
120
|
**修复**
|
|
121
121
|
|
|
122
|
+
- composer 行里第三方插件的入口(`conversation.input.right`)在手机端整体移进模型弹层,
|
|
123
|
+
与「模型」「推理等级」并列成行——行是不换行的,模型名是唯一能让宽度的东西,
|
|
124
|
+
订阅插件的速度 chip(带文字约 70px)加上识图开关会把它挤没(2026-09-06 用户报)
|
|
122
125
|
- 适配 dsh-vision-router(2.0.1 / 2.1.1):composer 识图开关在手机端压成 28px 圆形
|
|
123
126
|
图标钮(眼睛为 CSS mask 图标,aria-label / aria-pressed 照旧),不再把
|
|
124
127
|
模型名挤出 composer 行;桌面端保持插件原样(见兼容插件清单)。
|
package/lib/client.js
CHANGED
|
@@ -5099,8 +5099,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5099
5099
|
flex: 0 0 auto !important;
|
|
5100
5100
|
gap: 6px !important;
|
|
5101
5101
|
}
|
|
5102
|
-
/* The
|
|
5103
|
-
the
|
|
5102
|
+
/* The elastic gap used to live here as \`margin-right: auto\`, which broke
|
|
5103
|
+
the moment a session had no model pill: a subagent session has none, so
|
|
5104
|
+
the gap vanished and the stop / send buttons bunched up in the middle of
|
|
5105
|
+
the row instead of sitting at the right edge (reported 2026-09-06). The
|
|
5106
|
+
gap belongs to the RIGHT group, not to the last item of the left one —
|
|
5107
|
+
see the two rules below the order list. */
|
|
5104
5108
|
${MODEL} {
|
|
5105
5109
|
order: 4 !important;
|
|
5106
5110
|
/* A definite flex-basis (not auto) breaks the circular sizing between
|
|
@@ -5111,7 +5115,6 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5111
5115
|
flex: 0 1 min(48vw, 200px) !important;
|
|
5112
5116
|
min-width: 0 !important;
|
|
5113
5117
|
max-width: min(48vw, 200px) !important;
|
|
5114
|
-
margin-right: auto !important;
|
|
5115
5118
|
}
|
|
5116
5119
|
/* Third-party input.right entries park next to the ring rather than
|
|
5117
5120
|
landing at order 0 (= far left) once the groups are flattened. */
|
|
@@ -5126,6 +5129,22 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5126
5129
|
${ROW} > [class$="_trailing"] > [class$="_primary"] {
|
|
5127
5130
|
order: 7 !important;
|
|
5128
5131
|
}
|
|
5132
|
+
/* The elastic gap. It sits in front of whichever of the two right-hand
|
|
5133
|
+
items comes first, so the group hugs the right edge no matter what the
|
|
5134
|
+
left side happens to contain — with a model pill, without one (subagent
|
|
5135
|
+
sessions), with or without the third-party entries at order 5.
|
|
5136
|
+
Both get \`margin-left: auto\` and the last rule takes it back off the
|
|
5137
|
+
send button whenever a ring precedes it: two auto margins would SHARE
|
|
5138
|
+
the free space and open a second gap between the ring and send. A
|
|
5139
|
+
sibling combinator answers "is there a ring before me" without assuming
|
|
5140
|
+
anything about the host's nesting. */
|
|
5141
|
+
${ROW} > [class$="_trailing"] > span[class$="_root"],
|
|
5142
|
+
${ROW} > [class$="_trailing"] > [class$="_primary"] {
|
|
5143
|
+
margin-left: auto !important;
|
|
5144
|
+
}
|
|
5145
|
+
${ROW} > [class$="_trailing"] > span[class$="_root"] ~ [class$="_primary"] {
|
|
5146
|
+
margin-left: 0 !important;
|
|
5147
|
+
}
|
|
5129
5148
|
|
|
5130
5149
|
/* --- 3. permission as icon-only pill; model as name+effort pill ---
|
|
5131
5150
|
(real-device round 2, 2026-08-17; model pill reworked 2026-08-25:
|
|
@@ -5246,6 +5265,115 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5246
5265
|
border-radius: 12px !important;
|
|
5247
5266
|
font-size: 15px !important;
|
|
5248
5267
|
}
|
|
5268
|
+
/* --- 4a. third-party composer entries live in the model sheet ---
|
|
5269
|
+
effects/model-sheet-extras.ts parks the \`conversation.input.right\`
|
|
5270
|
+
container inside the model menu while that menu is open and puts it back
|
|
5271
|
+
when it closes; these rules are the two halves of that trip.
|
|
5272
|
+
|
|
5273
|
+
In the row: hidden, but ONLY once the effect has marked the container as
|
|
5274
|
+
one it manages. Keying the hide off the marker rather than off "not
|
|
5275
|
+
currently parked" is what makes the failure direction safe — if the
|
|
5276
|
+
effect declines to act (nothing in the slot but the vision toggle, so
|
|
5277
|
+
nothing worth moving) or never runs at all, the marker is absent, this
|
|
5278
|
+
rule does not match, and the controls stay exactly where the host put
|
|
5279
|
+
them. The inverse spelling hid them in the row whenever they were not in
|
|
5280
|
+
the sheet, which in those same cases left them reachable from neither
|
|
5281
|
+
place (caught in review, 2026-09-06).
|
|
5282
|
+
Hiding by ancestor rather than by a display rule on the container itself
|
|
5283
|
+
is what lets the parked copy style itself freely — same element, two
|
|
5284
|
+
homes, two rule sets. \`!important\` because the slot container carries an
|
|
5285
|
+
inline \`display: contents\`. */
|
|
5286
|
+
${ROW} > [class$="_trailing"] > [data-slot="conversation.input.right"][data-zen-sheet-extras] {
|
|
5287
|
+
display: none !important;
|
|
5288
|
+
}
|
|
5289
|
+
/* Parked in the sheet: a column of full-width rows in the sheet's own
|
|
5290
|
+
language, so a relocated control reads as a sibling of 模型 and 推理等级
|
|
5291
|
+
rather than a chip that wandered in. */
|
|
5292
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] {
|
|
5293
|
+
display: flex !important;
|
|
5294
|
+
flex-direction: column !important;
|
|
5295
|
+
gap: 2px !important;
|
|
5296
|
+
width: 100% !important;
|
|
5297
|
+
}
|
|
5298
|
+
/* Undo the row's compaction on every parked control: back to full width and
|
|
5299
|
+
the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
|
|
5300
|
+
naming either plugin — subscriptions wraps its trigger in a positioning
|
|
5301
|
+
div, vision-router puts its button straight in the slot. */
|
|
5302
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > *,
|
|
5303
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > * > button {
|
|
5304
|
+
width: 100% !important;
|
|
5305
|
+
max-width: none !important;
|
|
5306
|
+
min-width: 0 !important;
|
|
5307
|
+
justify-content: flex-start !important;
|
|
5308
|
+
}
|
|
5309
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] button {
|
|
5310
|
+
box-sizing: border-box !important;
|
|
5311
|
+
min-height: 48px !important;
|
|
5312
|
+
height: auto !important;
|
|
5313
|
+
padding: 0 12px !important;
|
|
5314
|
+
gap: 10px !important;
|
|
5315
|
+
border-radius: 12px !important;
|
|
5316
|
+
border: none !important;
|
|
5317
|
+
background: transparent !important;
|
|
5318
|
+
font-size: 15px !important;
|
|
5319
|
+
/* Both plugins center their chip label, which is right for a pill in the
|
|
5320
|
+
row and wrong for a full-width row: the sheet's own cells start their
|
|
5321
|
+
label at the left edge. justify-content covers the flex triggers,
|
|
5322
|
+
text-align the ones that are not flex containers. */
|
|
5323
|
+
text-align: left !important;
|
|
5324
|
+
justify-content: flex-start !important;
|
|
5325
|
+
}
|
|
5326
|
+
/* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it
|
|
5327
|
+
parks beside the context ring), and that rule still matches in here — the
|
|
5328
|
+
sheet is a DOM descendant of the composer bar even though it paints as a
|
|
5329
|
+
fixed layer. Reset it so the sheet follows DOM order instead of
|
|
5330
|
+
inheriting a decision that was about a different layout. */
|
|
5331
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > * {
|
|
5332
|
+
order: 0 !important;
|
|
5333
|
+
}
|
|
5334
|
+
/* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);
|
|
5335
|
+
in the sheet it is a row, so its label comes back and the icon leads. */
|
|
5336
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {
|
|
5337
|
+
display: inline !important;
|
|
5338
|
+
}
|
|
5339
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {
|
|
5340
|
+
width: 18px !important;
|
|
5341
|
+
height: 18px !important;
|
|
5342
|
+
}
|
|
5343
|
+
/* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
|
|
5344
|
+
trigger. Inside the sheet — which is \`overflow: hidden\` so its rounded
|
|
5345
|
+
top clips its content — that menu would be cut off. Same escape section 4
|
|
5346
|
+
uses for the host's two menus: position: fixed leaves the clip behind
|
|
5347
|
+
(the sheet sets \`transform: none\`, so nothing between here and the
|
|
5348
|
+
viewport captures fixed positioning) and it lands as its own sheet on top
|
|
5349
|
+
of this one. */
|
|
5350
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [role="menu"] {
|
|
5351
|
+
position: fixed !important;
|
|
5352
|
+
left: 0 !important;
|
|
5353
|
+
right: 0 !important;
|
|
5354
|
+
bottom: 0 !important;
|
|
5355
|
+
top: auto !important;
|
|
5356
|
+
margin: 0 !important;
|
|
5357
|
+
width: 100% !important;
|
|
5358
|
+
min-width: 0 !important;
|
|
5359
|
+
max-width: none !important;
|
|
5360
|
+
max-height: min(70dvh, 520px) !important;
|
|
5361
|
+
box-sizing: border-box !important;
|
|
5362
|
+
border-radius: 16px 16px 0 0 !important;
|
|
5363
|
+
border-bottom: none !important;
|
|
5364
|
+
padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
|
|
5365
|
+
/* Above the model sheet it sits on (60, section 4), below the session-info
|
|
5366
|
+
sheet's 70 (info.css.ts) — it is a child of the model sheet, not a peer
|
|
5367
|
+
of the page's own layers. */
|
|
5368
|
+
z-index: 61 !important;
|
|
5369
|
+
box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
|
|
5370
|
+
}
|
|
5371
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
|
|
5372
|
+
min-height: 48px !important;
|
|
5373
|
+
border-radius: 12px !important;
|
|
5374
|
+
font-size: 15px !important;
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5249
5377
|
/* --- 4b. the official scroll-to-bottom button must not poke through the
|
|
5250
5378
|
sheet (real-device follow-up, 2026-08-17) ---
|
|
5251
5379
|
ChatView's own "jump to latest" button (aria-label t("chat.toBottom"))
|
|
@@ -5280,6 +5408,17 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5280
5408
|
[class$="_composerSeat"] {
|
|
5281
5409
|
--dsh-composer-text-max-height: 124px;
|
|
5282
5410
|
}
|
|
5411
|
+
/* Pull the text in from the card's left border. Upstream insets the editing
|
|
5412
|
+
host by 16px while the control row below it starts 8px in (row padding,
|
|
5413
|
+
section 1), so the placeholder sat noticeably further out than the
|
|
5414
|
+
buttons under it and the field read as over-padded on a phone (reported
|
|
5415
|
+
2026-09-06). 12px squares it with the 12px of clear space on the right,
|
|
5416
|
+
giving the text a symmetric box, and closes most of the gap with the
|
|
5417
|
+
button row. Anchored on the editing host's own contract attribute rather
|
|
5418
|
+
than a hashed class. */
|
|
5419
|
+
[data-slot="conversation.composer.bar"] [data-composer-input] {
|
|
5420
|
+
padding-left: 12px !important;
|
|
5421
|
+
}
|
|
5283
5422
|
|
|
5284
5423
|
/* --- 6. no divider above OR below the message list ---
|
|
5285
5424
|
Instead of a rule the messages butt against, they fade near both edges.
|
|
@@ -5371,7 +5510,13 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5371
5510
|
composer. Anchored on the official data-testid, a stable contract
|
|
5372
5511
|
marker rather than a hashed class name. */
|
|
5373
5512
|
[data-slot="conversation.input.dock"] > [data-testid="todo-panel"] {
|
|
5374
|
-
|
|
5513
|
+
/* A whole line to itself. It shared line 0 with the pills until the goal
|
|
5514
|
+
bar turned up (2026-09-06): two full-width things on one nowrap line
|
|
5515
|
+
meant the to-do header shrank to a stub and the goal bar ran off the
|
|
5516
|
+
right edge. Both are status strips you read at a glance, so they stack
|
|
5517
|
+
— see the line policy in 7c. */
|
|
5518
|
+
flex: 1 0 100% !important;
|
|
5519
|
+
order: 1 !important;
|
|
5375
5520
|
min-width: 0 !important;
|
|
5376
5521
|
max-width: none !important;
|
|
5377
5522
|
min-height: 0 !important;
|
|
@@ -5398,7 +5543,7 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5398
5543
|
display: flex !important;
|
|
5399
5544
|
flex: 1 0 100% !important;
|
|
5400
5545
|
flex-wrap: wrap !important;
|
|
5401
|
-
order:
|
|
5546
|
+
order: 4 !important;
|
|
5402
5547
|
align-items: center !important;
|
|
5403
5548
|
gap: 6px !important;
|
|
5404
5549
|
max-width: none !important;
|
|
@@ -5433,12 +5578,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5433
5578
|
sits closest to the input it will be sent from. The rail only wraps
|
|
5434
5579
|
while one of the two full-width entries exists, so a session with
|
|
5435
5580
|
neither keeps the original single scrolling chip line. */
|
|
5436
|
-
[data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"]) {
|
|
5581
|
+
[data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"], > [data-testid="todo-panel"], > [data-goal-bar]) {
|
|
5437
5582
|
flex-wrap: wrap !important;
|
|
5438
5583
|
}
|
|
5439
5584
|
[data-slot="conversation.input.dock"] > [data-queue-dock] {
|
|
5440
5585
|
flex: 1 0 100% !important;
|
|
5441
|
-
order:
|
|
5586
|
+
order: 3 !important;
|
|
5442
5587
|
max-width: none !important;
|
|
5443
5588
|
min-height: 0 !important;
|
|
5444
5589
|
max-height: none !important;
|
|
@@ -5449,6 +5594,24 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
5449
5594
|
font-size: 13px !important;
|
|
5450
5595
|
line-height: normal !important;
|
|
5451
5596
|
}
|
|
5597
|
+
/* dsh-client-ui-goal's status strip (its own \`data-goal-bar\` marker; it
|
|
5598
|
+
registers into this dock at order 10). Fourth thing here that is not a
|
|
5599
|
+
pill: a row carrying the objective text plus pause / edit / delete
|
|
5600
|
+
buttons, so the 26px cage clipped its buttons off the right edge and the
|
|
5601
|
+
nowrap line pushed what was left off screen (reported 2026-09-06). Same
|
|
5602
|
+
treatment the queue gets — a line of its own, official geometry back. */
|
|
5603
|
+
[data-slot="conversation.input.dock"] > [data-goal-bar] {
|
|
5604
|
+
flex: 1 0 100% !important;
|
|
5605
|
+
order: 2 !important;
|
|
5606
|
+
max-width: none !important;
|
|
5607
|
+
min-height: 0 !important;
|
|
5608
|
+
max-height: none !important;
|
|
5609
|
+
border-radius: 12px !important;
|
|
5610
|
+
overflow: visible !important;
|
|
5611
|
+
font-size: 13px !important;
|
|
5612
|
+
line-height: normal !important;
|
|
5613
|
+
}
|
|
5614
|
+
|
|
5452
5615
|
/* The panel is the dock's only child; upstream rounds its top corners only
|
|
5453
5616
|
and drops the bottom border, because there it is half-hidden behind the
|
|
5454
5617
|
card. On its own line it is a free-standing strip, so close it up. */
|
|
@@ -8339,6 +8502,166 @@ function installModalBack(ctx) {
|
|
|
8339
8502
|
}, 'dsh-zen-remote: modal back gesture');
|
|
8340
8503
|
}
|
|
8341
8504
|
};
|
|
8505
|
+
__modules["effects/model-sheet-extras.js"] = function (require, module, exports) {
|
|
8506
|
+
"use strict";
|
|
8507
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8508
|
+
exports.installModelSheetExtras = installModelSheetExtras;
|
|
8509
|
+
/** Phone breakpoint — same query every phone-only effect in this plugin uses. */
|
|
8510
|
+
const PHONE_QUERY = '(max-width: 767px)';
|
|
8511
|
+
/** The model pill itself. Absent in a subagent session — see `sync`. */
|
|
8512
|
+
const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]';
|
|
8513
|
+
/** The model pill's own popup, which section 4 of composer.css.ts turns into a bottom sheet. */
|
|
8514
|
+
const MENU_SELECTOR = '[data-slot="conversation.input.model"] [class$="_menu"]';
|
|
8515
|
+
/**
|
|
8516
|
+
* The third-party composer controls this effect relocates. Both register into
|
|
8517
|
+
* `conversation.input.right`; moving the slot container rather than its
|
|
8518
|
+
* children keeps them together and leaves exactly one node to put back.
|
|
8519
|
+
*/
|
|
8520
|
+
const EXTRAS_SELECTOR = '[data-slot="conversation.input.right"]';
|
|
8521
|
+
/**
|
|
8522
|
+
* dsh-vision-router's own mount marker. Used here only to answer "is there
|
|
8523
|
+
* anything in this slot BESIDES the vision toggle" — see `worthMoving`.
|
|
8524
|
+
*/
|
|
8525
|
+
const VISION_SELECTOR = '[data-vision-router-mode-toggle]';
|
|
8526
|
+
/**
|
|
8527
|
+
* Set on the container while this effect manages it, in the row and in the
|
|
8528
|
+
* sheet alike. It is the styling hook for both halves in composer.css.ts, and
|
|
8529
|
+
* it is deliberately what the row's `display: none` keys off: without the
|
|
8530
|
+
* marker the controls stay exactly where the host put them, so a version of
|
|
8531
|
+
* this file that declines to act — or never runs at all — cannot leave them
|
|
8532
|
+
* hidden in the row and absent from the sheet, i.e. unreachable.
|
|
8533
|
+
*/
|
|
8534
|
+
const MANAGED = 'data-zen-sheet-extras';
|
|
8535
|
+
/**
|
|
8536
|
+
* Is the slot holding anything that actually costs the row width?
|
|
8537
|
+
*
|
|
8538
|
+
* The row is one nowrap flex line whose only elastic item is the model pill
|
|
8539
|
+
* (composer.css.ts section 1), so entries here are paid for out of the model
|
|
8540
|
+
* name. But that is only a problem once something WIDE lands in the slot —
|
|
8541
|
+
* today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
|
|
8542
|
+
* eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
|
|
8543
|
+
* accommodates fine, and moving it on its own would cost a tap (it becomes
|
|
8544
|
+
* two: open the sheet, then toggle) to buy width nobody needed.
|
|
8545
|
+
*
|
|
8546
|
+
* So: relocate only when the slot holds an entry that is not the vision
|
|
8547
|
+
* toggle. With no such entry — subscriptions not installed, or installed but
|
|
8548
|
+
* not contributing on this model — nothing moves and the row keeps its one
|
|
8549
|
+
* icon, which is the behaviour asked for (user review, 2026-09-06).
|
|
8550
|
+
*
|
|
8551
|
+
* Why this rather than sniffing for subscriptions by name: the slot system
|
|
8552
|
+
* adds no per-registration marker (measured — entries render straight into the
|
|
8553
|
+
* container) and subscriptions marks nothing of its own, so naming it would
|
|
8554
|
+
* mean adding a marker to our fork of that plugin and anchoring on it. That
|
|
8555
|
+
* marker dies the day the fork is dropped, and the feature would go quiet
|
|
8556
|
+
* with nothing to catch it. The width question is the real question anyway,
|
|
8557
|
+
* and this asks it directly.
|
|
8558
|
+
*/
|
|
8559
|
+
function worthMoving(extras) {
|
|
8560
|
+
return [...extras.children].some(child => !child.matches(VISION_SELECTOR));
|
|
8561
|
+
}
|
|
8562
|
+
/**
|
|
8563
|
+
* Phone: park the composer row's third-party controls inside the model sheet.
|
|
8564
|
+
*
|
|
8565
|
+
* With the speed chip and the vision toggle both present on a GPT model the
|
|
8566
|
+
* row runs out of width. Both are model-scoped settings, so the model sheet —
|
|
8567
|
+
* which already holds 模型 and 推理等级 — is where they belong (user request,
|
|
8568
|
+
* 2026-09-06).
|
|
8569
|
+
*
|
|
8570
|
+
* Why move the real controls instead of drawing our own rows that drive them:
|
|
8571
|
+
* the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
|
|
8572
|
+
* has to script the original, which is a road this plugin does not go down —
|
|
8573
|
+
* beyond the trusted-input wall that effect documents, our own rows would have
|
|
8574
|
+
* to mirror every piece of state (speed tier, disabled, aria-pressed, whether
|
|
8575
|
+
* a vision twin exists for the current model) and would rot on the next
|
|
8576
|
+
* upstream restyle. Moving the node keeps one source of truth: their React
|
|
8577
|
+
* roots go on owning and updating these elements, we only change where they
|
|
8578
|
+
* hang.
|
|
8579
|
+
*
|
|
8580
|
+
* Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
|
|
8581
|
+
* that would have killed the approach both hold: a relocated control survives
|
|
8582
|
+
* host re-renders (typing into the composer re-renders the row and does not
|
|
8583
|
+
* yank it back) and stays hit-testable at its new position.
|
|
8584
|
+
*
|
|
8585
|
+
* Restoring on close is not cosmetic. The sheet unmounts when it closes and
|
|
8586
|
+
* anything still inside goes with it, so the controls would vanish from the
|
|
8587
|
+
* page while their React roots kept updating detached nodes. The observer
|
|
8588
|
+
* therefore watches the menu leaving too, and the parked node is held as a
|
|
8589
|
+
* reference rather than looked up — once the menu is detached a document
|
|
8590
|
+
* query can no longer find it.
|
|
8591
|
+
*/
|
|
8592
|
+
function installModelSheetExtras(ctx) {
|
|
8593
|
+
ctx.effect(() => {
|
|
8594
|
+
const phone = window.matchMedia(PHONE_QUERY);
|
|
8595
|
+
/** The container while parked in the sheet, plus where to put it back. */
|
|
8596
|
+
let parked = null;
|
|
8597
|
+
const park = (node, menu) => {
|
|
8598
|
+
if (parked !== null || node.parentElement === null)
|
|
8599
|
+
return;
|
|
8600
|
+
parked = { node, parent: node.parentElement, next: node.nextSibling };
|
|
8601
|
+
menu.appendChild(node);
|
|
8602
|
+
};
|
|
8603
|
+
const unpark = () => {
|
|
8604
|
+
if (parked === null)
|
|
8605
|
+
return;
|
|
8606
|
+
const { node, parent, next } = parked;
|
|
8607
|
+
parked = null;
|
|
8608
|
+
// The menu may already be detached, having taken the container with it.
|
|
8609
|
+
// The node itself is still live either way; re-home it only if its old
|
|
8610
|
+
// parent is still on the page (when the whole composer went away, the
|
|
8611
|
+
// owning React roots rebuild the slot on the next mount).
|
|
8612
|
+
if (parent.isConnected)
|
|
8613
|
+
parent.insertBefore(node, next);
|
|
8614
|
+
};
|
|
8615
|
+
/** Hand the container back to the host, marker and all. */
|
|
8616
|
+
const release = (node) => {
|
|
8617
|
+
unpark();
|
|
8618
|
+
node?.removeAttribute(MANAGED);
|
|
8619
|
+
};
|
|
8620
|
+
const sync = () => {
|
|
8621
|
+
const extras = document.querySelector(EXTRAS_SELECTOR);
|
|
8622
|
+
if (extras === null) {
|
|
8623
|
+
unpark();
|
|
8624
|
+
return;
|
|
8625
|
+
}
|
|
8626
|
+
// No model pill, no sheet to park into — a subagent session has none.
|
|
8627
|
+
// Claiming the slot there would hide the controls from the row with
|
|
8628
|
+
// nowhere to show them instead, which is the unreachable state the
|
|
8629
|
+
// marker is meant to prevent.
|
|
8630
|
+
const seat = document.querySelector(MODEL_SEAT_SELECTOR);
|
|
8631
|
+
if (!phone.matches || seat === null || !worthMoving(extras)) {
|
|
8632
|
+
release(extras);
|
|
8633
|
+
return;
|
|
8634
|
+
}
|
|
8635
|
+
extras.setAttribute(MANAGED, '');
|
|
8636
|
+
const menu = document.querySelector(MENU_SELECTOR);
|
|
8637
|
+
if (menu === null)
|
|
8638
|
+
unpark();
|
|
8639
|
+
else
|
|
8640
|
+
park(extras, menu);
|
|
8641
|
+
};
|
|
8642
|
+
// Synchronous in the observer callback, never behind rAF: the callback is
|
|
8643
|
+
// a microtask and runs before this frame paints, so the controls are in
|
|
8644
|
+
// the sheet the first time it is drawn. A rAF would land a frame late and
|
|
8645
|
+
// show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
|
|
8646
|
+
// Body-level childList is the same shape modal-back.ts uses; aria-expanded
|
|
8647
|
+
// is watched too so a close that reuses the node still re-syncs.
|
|
8648
|
+
const observer = new MutationObserver(sync);
|
|
8649
|
+
observer.observe(document.body, {
|
|
8650
|
+
childList: true,
|
|
8651
|
+
subtree: true,
|
|
8652
|
+
attributes: true,
|
|
8653
|
+
attributeFilter: ['aria-expanded'],
|
|
8654
|
+
});
|
|
8655
|
+
phone.addEventListener('change', sync);
|
|
8656
|
+
sync();
|
|
8657
|
+
return () => {
|
|
8658
|
+
observer.disconnect();
|
|
8659
|
+
phone.removeEventListener('change', sync);
|
|
8660
|
+
release(document.querySelector(EXTRAS_SELECTOR));
|
|
8661
|
+
};
|
|
8662
|
+
}, 'dsh-mobile-nav: model sheet extras');
|
|
8663
|
+
}
|
|
8664
|
+
};
|
|
8342
8665
|
__modules["effects/native-trigger-overlay.js"] = function (require, module, exports) {
|
|
8343
8666
|
"use strict";
|
|
8344
8667
|
/**
|
|
@@ -9017,6 +9340,7 @@ const header_status_ts_1 = require("./effects/header-status.js");
|
|
|
9017
9340
|
const gestures_ts_1 = require("./effects/gestures.js");
|
|
9018
9341
|
const turn_fold_ts_1 = require("./effects/turn-fold.js");
|
|
9019
9342
|
const modal_back_ts_1 = require("./effects/modal-back.js");
|
|
9343
|
+
const model_sheet_extras_ts_1 = require("./effects/model-sheet-extras.js");
|
|
9020
9344
|
const native_trigger_overlay_ts_1 = require("./effects/native-trigger-overlay.js");
|
|
9021
9345
|
const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
|
|
9022
9346
|
const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
|
|
@@ -9069,6 +9393,9 @@ function apply(ctx) {
|
|
|
9069
9393
|
(0, turn_fold_ts_1.installTurnFold)(ctx);
|
|
9070
9394
|
(0, modal_back_ts_1.installModalBack)(ctx);
|
|
9071
9395
|
(0, native_trigger_overlay_ts_1.installNativeTriggerOverlay)(ctx);
|
|
9396
|
+
// Third-party composer entries (speed chip, vision toggle) move into the
|
|
9397
|
+
// model sheet — the row has no width to spare and both are model settings.
|
|
9398
|
+
(0, model_sheet_extras_ts_1.installModelSheetExtras)(ctx);
|
|
9072
9399
|
// "内测声明" first-run notice: keep it visible (CSS-hiding it leaked the
|
|
9073
9400
|
// dialog's #root inert lock — see effects/welcome-notice.ts) and offer a
|
|
9074
9401
|
// per-browser "不再弹出" opt-out instead.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ClientContext } from '../compat/types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Phone: park the composer row's third-party controls inside the model sheet.
|
|
4
|
+
*
|
|
5
|
+
* With the speed chip and the vision toggle both present on a GPT model the
|
|
6
|
+
* row runs out of width. Both are model-scoped settings, so the model sheet —
|
|
7
|
+
* which already holds 模型 and 推理等级 — is where they belong (user request,
|
|
8
|
+
* 2026-09-06).
|
|
9
|
+
*
|
|
10
|
+
* Why move the real controls instead of drawing our own rows that drive them:
|
|
11
|
+
* the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
|
|
12
|
+
* has to script the original, which is a road this plugin does not go down —
|
|
13
|
+
* beyond the trusted-input wall that effect documents, our own rows would have
|
|
14
|
+
* to mirror every piece of state (speed tier, disabled, aria-pressed, whether
|
|
15
|
+
* a vision twin exists for the current model) and would rot on the next
|
|
16
|
+
* upstream restyle. Moving the node keeps one source of truth: their React
|
|
17
|
+
* roots go on owning and updating these elements, we only change where they
|
|
18
|
+
* hang.
|
|
19
|
+
*
|
|
20
|
+
* Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
|
|
21
|
+
* that would have killed the approach both hold: a relocated control survives
|
|
22
|
+
* host re-renders (typing into the composer re-renders the row and does not
|
|
23
|
+
* yank it back) and stays hit-testable at its new position.
|
|
24
|
+
*
|
|
25
|
+
* Restoring on close is not cosmetic. The sheet unmounts when it closes and
|
|
26
|
+
* anything still inside goes with it, so the controls would vanish from the
|
|
27
|
+
* page while their React roots kept updating detached nodes. The observer
|
|
28
|
+
* therefore watches the menu leaving too, and the parked node is held as a
|
|
29
|
+
* reference rather than looked up — once the menu is detached a document
|
|
30
|
+
* query can no longer find it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function installModelSheetExtras(ctx: ClientContext): void;
|
|
33
|
+
//# sourceMappingURL=model-sheet-extras.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model-sheet-extras.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/model-sheet-extras.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AA8DvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAkEhE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAA0B,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAA0B,MAAM,mBAAmB,CAAA;AAyB9E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,sCAAsC;QACtC,WAAW,EAAE,YAAY,CAAA;KAC1B;CACF;AAED,0GAA0G;AAC1G,eAAO,MAAM,MAAM,UAAgF,CAAA;AAmBnG;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAuO9C"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const COMPOSER_CSS = "/* ---------- phone composer (< 768px) ---------- */\n\n/* The file picker the attachment button drives (S7) \u2014 hidden at EVERY width,\n deliberately OUTSIDE the phone media block below. A bare <input type=\"file\">\n renders as a native \"Choose Files\" control, and the slot wrapper around it is\n `display: contents`, so an un-hidden one becomes a flex item of the official\n tool row. Scoping this to < 768px once put that control in the DESKTOP\n composer (caught on a live deploy, 2026-08-17): the button that drives it is\n phone-only, but the input it drives is in the DOM at all widths. */\n[data-mobile-nav=\"attach-picker\"] {\n display: none !important;\n}\n\n@media (max-width: 767px) {\n /* --- 1. flatten the two official groups ---\n `_tools` and `_trailing` only exist to cluster controls left/right.\n display:contents dissolves them into `_row`, so the six leaf controls\n become siblings in one flex line and `order` alone drives the layout:\n [attach \u00B7 + \u00B7 permission \u00B7 model] \u2026elastic gap\u2026 [context ring \u00B7 send]. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] {\n justify-content: flex-start !important;\n gap: 5px !important;\n padding: 2px 8px 8px !important;\n /* One line, always: the model pill is the only shrinkable item (its\n label ellipsizes), so wrapping would only ever push the send button\n to a second row (real-device report, 2026-08-25). */\n flex-wrap: nowrap !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] {\n display: contents !important;\n }\n\n /* --- 2. the running order ---\n The attachment button (S3 placeholder, S7 wires it up) sits leftmost;\n the official \"+\" command menu keeps its seat right next to it, where\n the two \"insert something\" affordances read as one cluster. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] [data-mobile-nav=\"attach\"] {\n order: 1 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_add\"] {\n order: 2 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] {\n order: 3 !important;\n /* Rigid on purpose. This seat holds the permission chip, whose own\n wrapper (the plugin's element inside _modes) is `flex: 0 0 auto` and\n will NOT follow the seat down \u2014 so a shrinkable seat does not make the\n chip narrower, it just slices the seat out from under a chip that\n stays 44px wide, and the overflow lands on top of the model pill.\n Measured 2026-09-04 on DSH 0.1.2 at 390px: seat 90-120 (29.7px) with\n the chip still 90-134, model starting at 125 \u2014 a 9px overlap, exactly\n the \"the two chips touch\" report. With the seat rigid: seat 90-134,\n model 139-246, overlap 0; and at 320px the model pill shrinks to 52px\n and still nothing overlaps.\n This also restores what section 1 says out loud \u2014 the model pill is\n the ONLY shrinkable item in the row. `min-width: 0` is gone with the\n shrink: it only ever mattered as the companion of `flex: 0 1`. */\n flex: 0 0 auto !important;\n gap: 6px !important;\n }\n /* The model seat carries the elastic gap: everything after it is pushed to\n the right edge, so no spacer element is needed. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] {\n order: 4 !important;\n /* A definite flex-basis (not auto) breaks the circular sizing between\n this seat and the trigger's max-width:100%: the seat's width comes\n from flex resolution, the trigger fills up to it, the label\n ellipsizes inside. Under nowrap pressure this is the only\n min-width:0 seat, so it absorbs all shrink. */\n flex: 0 1 min(48vw, 200px) !important;\n min-width: 0 !important;\n max-width: min(48vw, 200px) !important;\n margin-right: auto !important;\n }\n /* Third-party input.right entries park next to the ring rather than\n landing at order 0 (= far left) once the groups are flattened. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.right\"] > * {\n order: 5 !important;\n }\n /* ContextMeter \u2014 the only `span` ending in _root inside the row. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"] {\n order: 6 !important;\n flex: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [class$=\"_primary\"] {\n order: 7 !important;\n }\n\n /* --- 3. permission as icon-only pill; model as name+effort pill ---\n (real-device round 2, 2026-08-17; model pill reworked 2026-08-25:\n the user wants to SEE which model/effort is active, so only the\n permission trigger stays icon-only \u2014 its label is hidden and it\n collapses to a plain ~44x30 icon button. This AGREES with (rather than fights) the official\n container query (`@container (width <= 460px) { .trigger:has(.triggerIcon)\n .triggerLabel { display: none } }`) that S3 had to override \u2014 no need\n to override it back. Accessible name is unaffected: both official\n triggers already ship a descriptive aria-label independent of the\n visible text (PermissionSelect: t('input.accessMode', {name}); the\n model trigger: t('trigger.aria'/'trigger.ariaEffort')) \u2014 verified live\n in dsh-client-ui-conversation / dsh-client-ui-model-selection lib/\n client.js, 2026-08-17 \u2014 so hiding the label costs nothing for screen\n readers. The permission trigger only renders an icon for the\n \"read-only\" / \"workspace-write\" presets (permissionGlyphs in\n dsh-client-ui-conversation) \u2014 \"Full access\" and any host-configured\n preset name fall back to chevron-only, a known gap in the official\n markup this plugin cannot fill without inventing new icon meaning.\n The model trigger never renders an icon at all (only label + optional\n effort text + chevron), so its ::before below draws one from a\n primitives icon path (Sparkle \u2014 the closest existing \"model\" glyph) as\n a CSS-only pseudo-element: it survives React re-renders for free\n (unlike a DOM-injected node, which would need a MutationObserver, see\n the preview-full-toggle pitfall in AGENTS.md) and does not touch\n accessible-name computation (empty generated content). S3's\n rtl-ellipsis trick on the model label is simply inert under\n display:none now; left alone rather than unpicked. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [class$=\"_triggerLabel\"] {\n display: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] button[class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n width: 44px !important;\n height: 30px !important;\n max-width: none !important;\n min-width: 0 !important;\n padding: 0 !important;\n gap: 2px !important;\n justify-content: center !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n }\n /* Model trigger: name + effort pill (2026-08-25 user request \u2014 the\n icon-only form hid which model was active). The effort text must stay\n whole; the model NAME is the elastic part and ellipsizes. max-width\n caps the pill so the row's elastic gap survives extreme model ids;\n the label rules below override the <=1023px grow rules in\n layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n height: 30px !important;\n width: auto !important;\n max-width: 100% !important;\n min-width: 0 !important;\n padding: 0 10px !important;\n gap: 4px !important;\n justify-content: flex-start !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n font-size: 12px !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerLabel\"] {\n display: block !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n white-space: nowrap !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerEffort\"] {\n display: block !important;\n flex: none !important;\n white-space: nowrap !important;\n }\n /* PermissionSelect wraps its trigger in the Menu primitive's root span,\n which must shrink to the icon button's fixed width. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] > span:has(> button[class$=\"_trigger\"]) {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n }\n /* (2026-08-25) The model trigger's CSS-only Sparkle ::before icon is gone:\n the pill now shows the model name + effort text itself, so an icon would\n just eat label width. */\n\n /* --- 4. both menus become bottom sheets ---\n The permission menu is the Menu primitive (role=menu, absolute, side=top)\n and the model menu is ModelSelect's own `_menu` (absolute, bottom+right).\n Neither has a transformed ancestor between it and the viewport, so\n position:fixed re-anchors both to the screen edge. Only the shell moves \u2014\n the items, the two-level model panes and every selection handler stay\n official. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] {\n position: fixed !important;\n left: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n top: auto !important;\n transform: none !important;\n width: 100% !important;\n min-width: 0 !important;\n max-width: none !important;\n max-height: min(70dvh, 520px) !important;\n box-sizing: border-box !important;\n border-radius: 16px 16px 0 0 !important;\n border-bottom: none !important;\n padding: 8px 8px calc(8px + var(--mnav-sab)) !important;\n z-index: 60 !important;\n box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;\n }\n /* 44pt+ rows in both sheets (Menu items, model options, and the model\n sheet's two root cells that drill into the model / effort panes). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"] [role=\"menuitem\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_option\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_cell\"] {\n min-height: 48px !important;\n border-radius: 12px !important;\n font-size: 15px !important;\n }\n /* --- 4b. the official scroll-to-bottom button must not poke through the\n sheet (real-device follow-up, 2026-08-17) ---\n ChatView's own \"jump to latest\" button (aria-label t(\"chat.toBottom\"))\n is `position: sticky; z-index: 8` inside the message column, not\n `position: fixed` \u2014 it never escapes to the same top-level stacking\n context our sheets get promoted to, so raising the sheet's z-index\n further does nothing (measured live: it still rendered on top at\n z-index 60 vs 8). Rather than chase engine-specific stacking-context\n semantics (Chromium and WebKit do not always agree here \u2014 see AGENTS.md\n CDP/document.hidden lesson for another instance of that), this hides\n the button outright while either sheet is open and lets it reappear on\n close: a plain `display: none` behind a live `:has()` read is correct\n regardless of which stacking rules the engine happens to apply.\n Selector: no hashed classes (dsh-client-ui-conversation lib/client.js,\n verified 2026-08-17) \u2014 `data-chat-flow` is the one stable attribute on\n the message column, and the button's wrapper is its only sibling\n (ChatView only ever renders the column and, conditionally, this one\n slot), so the adjacent-sibling combinator pins it precisely. */\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"]) [data-chat-flow] + div,\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"]) [data-chat-flow] + div {\n display: none !important;\n }\n /* --- 5. input box: two lines minimum, five lines maximum ---\n The official autosizer drives the box off the hidden mirror's height, so\n a min-height on the mirror IS the min-height of the field; the scroll cap\n rides the official custom property. 52px = 2 x 24px line box + 4px pad,\n 124px = 5 lines. The hero card keeps its own one-line collapse (misc.css\n pins _scroll/_grow while the placeholder shows). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] [class$=\"_mirror\"] {\n min-height: 52px !important;\n }\n [class$=\"_composerSeat\"] {\n --dsh-composer-text-max-height: 124px;\n }\n\n /* --- 6. no divider above OR below the message list ---\n Instead of a rule the messages butt against, they fade near both edges.\n This used to be ONE mask-image on the message scroller; iOS WebKit\n turned that into fog over the header (user report 2026-08-23): a mask\n on a scroll container is applied in CONTENT coordinates there (the fade\n bands scroll away with the messages) and, worse, it breaks the\n container's own overflow clipping \u2014 content scrolled out of view keeps\n painting, straight over the header and status bar. Engine bug with no\n iOS escape hatch (every iOS browser is WebKit), so the fades moved off\n the scroller entirely:\n\n - fade-IN under the header: the header's own ::after, restyled into a\n 20px gradient strip \u2014 styles/header.css.ts (\"no header bottom line\").\n - fade-OUT above the composer: nothing of ours. The official composer\n seat already carries `background: linear-gradient(transparent 0,\n bg 36px)` on its sticky self (dsh-client-ui-conversation lib/\n client.js .wSkVaW_composerSeat) and that is the entire visible\n effect; the mask's own bottom band landed inside the safe-area\n padding below the card and painted nothing. If the bottom edge ever\n reads harsh, hang a ::before strip off the seat the same way the\n header does \u2014 do NOT put a mask back on the scroller.\n\n Painted strips instead of a mask is a fair trade only because the page\n background is one flat color \u2014 which it is (--dsw-alias-bg-base). */\n [class$=\"_composerSeat\"],\n [class$=\"_composerStack\"] {\n border-top: none !important;\n }\n\n /* --- 7. dock entries become mini chips above the input card ---\n conversation.input.dock is display:contents (inline style), so its\n entries are stacked rows of the composer column. Forcing the slot itself\n to flex turns them into ONE scrollable chip line outside and above the\n card. Written against the slot, not against any particular plugin's chip\n (the git branch chip and the todo panel are third-party and may not be\n installed at all). */\n [data-slot=\"conversation.input.dock\"] {\n display: flex !important;\n flex-flow: row nowrap !important;\n align-items: center !important;\n gap: 6px !important;\n min-width: 0 !important;\n margin: 0 16px !important;\n overflow-x: auto !important;\n overflow-y: hidden !important;\n scrollbar-width: none !important;\n /* The dock is itself a flex ITEM of the composer stack, and the\n new-session hero pins that stack to a fixed height. overflow-x:auto\n above makes this a scroll container, which drops a flex item's\n automatic minimum size to 0 \u2014 so the hero squeezed the dock to ~5px\n and the attachment chips slid under the card drawn after it (only the\n top sliver of a thumbnail showed; reported from a real phone,\n 2026-08-26). Refuse the squeeze: natural height, the stack centers a\n few px lower instead. In-chat the column is not height-pinned and this\n is a no-op. */\n flex-shrink: 0 !important;\n }\n [data-slot=\"conversation.input.dock\"]::-webkit-scrollbar {\n display: none !important;\n }\n [data-slot=\"conversation.input.dock\"] > * {\n flex: 0 0 auto !important;\n max-width: 70% !important;\n min-height: 26px !important;\n max-height: 26px !important;\n border-radius: 999px !important;\n font-size: 11.5px !important;\n line-height: 18px !important;\n overflow: hidden !important;\n }\n /* The git-graph chip carries a 34px tablet target from misc.css; that\n selector is more specific, so restate it at the phone breakpoint. */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip] {\n min-height: 26px !important;\n padding: 0 10px !important;\n font-size: 11.5px !important;\n }\n /* --- 7a. the native to-do card opts OUT of the 26px pill cage ---\n conversation.input.dock is not only a chip rail: DSH's own TodoPanel\n (dsh-client-ui-conversation) registers here, and it is an expandable\n card, not a pill \u2014 tapping its header renders the to-do list INSIDE\n itself. The cage's max-height:26px + overflow:hidden left that list\n rendered but clipped to nothing, so on a phone the bar looked dead:\n aria-expanded flipped, 8 rows mounted at 216px, and the card still\n painted 26px tall (measured live, 2026-08-20). Give it the official\n geometry back \u2014 full row, natural height, 12px radius. Its list caps\n itself at 180px with its own scroller, so this cannot run into the\n composer. Anchored on the official data-testid, a stable contract\n marker rather than a hashed class name. */\n [data-slot=\"conversation.input.dock\"] > [data-testid=\"todo-panel\"] {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n overflow: visible !important;\n border-radius: 12px !important;\n /* The cage shrinks dock text to 11.5px/18px; the card's rows inherit it\n and turn cramped. Restore the official 13px body scale. */\n font-size: 13px !important;\n line-height: normal !important;\n }\n\n /* --- 7b. attachment chips (S7.1) ---\n Our own dock entry opts OUT of the 26px pill cage above: an image chip is\n a 48px tile. The row sits tight under the card's top edge, so the chips\n read as part of the composer rather than as a floating strip.\n\n It also takes the LAST line of its own (line policy in 7c): a 48px tile\n beside the to-do chip on one nowrap line squeezed that chip down to an\n unreadable stub, with a long file name pushing the rest off-screen\n (reported 2026-08-22). Its own chips wrap rather than overflow, so\n several attachments stack instead of scrolling out of reach. */\n [data-slot=\"conversation.input.dock\"] > [data-mobile-nav=\"attach-chips\"] {\n display: flex !important;\n flex: 1 0 100% !important;\n flex-wrap: wrap !important;\n order: 2 !important;\n align-items: center !important;\n gap: 6px !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n border-radius: 0 !important;\n overflow: visible !important;\n margin-bottom: -2px;\n }\n\n /* --- 7c. the queue strip opts OUT of the cage, on a line of its own ---\n Third entry that is not a pill. conversation.input.dock is a `list` slot\n and DSH's own QueueDock registers into it at order 20 (id `queue`,\n dsh-client-ui-conversation) \u2014 a 36px row per queued message, or a\n collapsible \"N \u6761\u6392\u961F\u6D88\u606F\" header once there is more than one. The 26px\n cage above clipped it to a sliver: on a phone a queued message showed as\n a grey stub poking out from behind the input card, unreadable and with\n its edit / delete / send buttons cut off (reported 2026-08-22, same\n failure the to-do card hit in 7a).\n\n Two things are restored. Geometry: the official row height back, and the\n dock's own composer-column sizing dropped \u2014 its width/max-width are\n calc()s over the composer card variables and its bottom margin is\n negative by design (upstream tucks the strip UNDER the card), none of\n which survives being a flex item of our chip rail.\n\n Layout \u2014 the line policy for the whole rail, now that three things want\n room on it. The pills (the to-do card, a branch chip, whatever else\n registers) keep order 0 and share the top line, still scrolling\n horizontally. The queue takes the next line (order 1). The attachment\n chips take the last one (order 2, 7b), so what the user is about to send\n sits closest to the input it will be sent from. The rail only wraps\n while one of the two full-width entries exists, so a session with\n neither keeps the original single scrolling chip line. */\n [data-slot=\"conversation.input.dock\"]:has(> [data-queue-dock], > [data-mobile-nav=\"attach-chips\"]) {\n flex-wrap: wrap !important;\n }\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] {\n flex: 1 0 100% !important;\n order: 1 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n margin: 0 !important;\n padding: 0 !important;\n border-radius: 0 !important;\n overflow: visible !important;\n font-size: 13px !important;\n line-height: normal !important;\n }\n /* The panel is the dock's only child; upstream rounds its top corners only\n and drops the bottom border, because there it is half-hidden behind the\n card. On its own line it is a free-standing strip, so close it up. */\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] > * {\n border-radius: 12px !important;\n }\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] > *::after {\n border-bottom: 1px solid var(--dsw-alias-border-l1) !important;\n }\n\n [data-mobile-nav=\"attach-chip\"] {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 62vw;\n background: var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n }\n [data-mobile-nav=\"attach-chip\"][data-kind=\"file\"] {\n height: 26px;\n padding: 0 4px 0 8px;\n border-radius: 999px;\n font-size: 11.5px;\n line-height: 18px;\n }\n /* An image chip is the thumbnail: no name line, the filename lives in the\n title attribute (and in the draft text right below). */\n [data-mobile-nav=\"attach-chip\"][data-kind=\"image\"] {\n width: 48px;\n height: 48px;\n padding: 0;\n border-radius: 10px;\n overflow: hidden;\n }\n [data-mobile-nav=\"attach-chip-art\"] {\n position: relative;\n display: grid;\n place-items: center;\n flex: none;\n overflow: hidden;\n }\n [data-kind=\"file\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 16px;\n height: 16px;\n opacity: .7;\n }\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 100%;\n height: 100%;\n }\n /* The <img> is stacked over the paperclip, not swapped for it: a format the\n engine cannot decode (HEIC outside WebKit) simply paints nothing and the\n icon shows through \u2014 no onError state to carry. */\n [data-mobile-nav=\"attach-chip-art\"] img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .18));\n }\n [data-mobile-nav=\"attach-chip-name\"] {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n }\n [data-mobile-nav=\"attach-chip-remove\"] {\n display: grid;\n place-items: center;\n flex: none;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 999px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n touch-action: manipulation;\n }\n /* On a tile the \u00D7 floats in the corner over the picture, so it needs its own\n ground to stay legible against an arbitrary photo. */\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-remove\"] {\n position: absolute;\n top: 2px;\n right: 2px;\n background: rgba(0, 0, 0, .55);\n color: #fff;\n }\n [data-mobile-nav=\"attach-chip-remove\"]:active {\n transform: scale(.9);\n }\n\n /* --- 8. the official stats strip leaves the composer ---\n Its data moves into the session info card (S4). The row is the composer\n dock's own `_root` entry; the slot itself stays live for later entries. */\n [data-slot=\"conversation.composer.dock\"] > [class$=\"_root\"] {\n display: none !important;\n }\n\n /* --- 9. the attachment button (S7; its file input is hidden at the top of\n this file, at every width) --- */\n [data-mobile-nav=\"attach\"] {\n position: relative;\n width: 28px !important;\n height: 28px !important;\n flex: none !important;\n display: grid !important;\n place-items: center !important;\n padding: 0 !important;\n border: none !important;\n border-radius: 999px !important;\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n touch-action: manipulation;\n }\n [data-mobile-nav=\"attach\"]:active {\n transform: scale(.94);\n transition: transform .12s;\n }\n /* Busy: a ring sweeps around the paperclip. Drawn with a conic gradient in\n a pseudo-element rather than a spinner node, so the official React tree\n around us has nothing extra to re-render (same reasoning as the model\n pill's mask icon, S3.1). */\n [data-mobile-nav=\"attach\"][data-busy]::after {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 999px;\n background: conic-gradient(from 0deg, transparent 0 65%, currentColor 100%);\n mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n animation: mnav-attach-spin .9s linear infinite;\n pointer-events: none;\n }\n @keyframes mnav-attach-spin {\n to { transform: rotate(1turn); }\n }\n @media (prefers-reduced-motion: reduce) {\n [data-mobile-nav=\"attach\"][data-busy]::after {\n animation-duration: 3s;\n }\n }\n /* Failure note: one line above the button, tap-to-dismiss. Sits on the\n composer card (z 2) rather than in an overlay layer \u2014 nothing here needs\n to clear the permission/model sheets. */\n [data-mobile-nav=\"attach-error\"] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 0;\n z-index: 2;\n max-width: 62vw;\n padding: 5px 8px;\n border-radius: 8px;\n font-size: 12px;\n line-height: 1.3;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .82));\n color: var(--dsw-alias-label-primary, #fff);\n box-shadow: 0 2px 8px rgba(0, 0, 0, .24);\n }\n\n /* --- 9. home-indicator clearance (S4.1, 2026-08-17) ---\n Owned HERE, not by the gateway half. That plugin used to carry\n [data-slot=\"conversation.composer\"] { padding-bottom: max(env(safe-area-inset-bottom), 8px) }\n in both pwa/app.css and the gateway's inline DEVICE_CSS. Both were\n INERT and had always been: that slot element is `display: contents`\n (measured 2026-08-17 \u2014 the slot wrapper generates no box, so padding on\n it is discarded), which is why raising it never moved anything. The rule\n is deleted on the PWA side and restated here on an element that actually\n lays out, reading --mnav-sab so ?mobile-nav-inset=54,34 can regress it\n off-device (env() is hard 0 on desktop \u2014 the whole reason S2.1 exists).\n\n Target: the card should look EQUALLY inset on all four sides. This same\n element carries the card's side inset (`padding: 0 16px` officially,\n measured 16px left and 16px right at 390px), so the bottom gap is simply\n capped at that same 16px. Clearing the FULL 34px inset \u2014 the naive\n max(sab, 8px) \u2014 is what read as \"too thick\": it is more than double the\n side margin, so the card looked shoved up off the bottom edge.\n\n clamp() says all three requirements at once:\n - floor 8px -> a device with no home indicator (sab: 0) keeps the\n official 8px exactly, so this is a strict no-op there;\n - track sab -> a shallower inset than 16px is honoured as-is;\n - cap 16px -> a full-size indicator (sab: 34) lands on 16px, equal\n to the side inset, which is the look being asked for.\n Keep the 16px in step with the side padding above if that ever changes;\n that equality IS the spec here, not a coincidence. */\n [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;\n }\n\n /* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the\n layout viewport hidden behind the software keyboard into --mnav-kb-lift\n and stamps the html attribute while (and only while) it is non-zero \u2014\n a resting transform, even translateY(0), would silently become the\n containing block for any fixed-position descendant of the bar. In every\n environment where the browser's own focus-reveal works the attribute is\n simply never set.\n The target is the slot's _root CHILD, not the slot wrapper: the wrapper\n is display:contents \u2014 it generates no box, so a transform on it is a\n silent no-op (found live on-device via CDP, 2026-08-21; the variable and\n attribute were set and nothing moved). */\n html[data-mnav-kb] [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));\n }\n}\n\n/* The attachment button and its preview row only exist for the phone shell.\n Both render at every width (the slots are not breakpoint-aware), so both\n need hiding here \u2014 the dock-row rules that shape the chips live in the\n < 768px block and would otherwise leave a bare, unstyled chip line in the\n desktop composer. */\n@media (min-width: 768px) {\n [data-mobile-nav=\"attach\"],\n [data-mobile-nav=\"attach-chips\"] {\n display: none !important;\n }\n}\n";
|
|
1
|
+
export declare const COMPOSER_CSS = "/* ---------- phone composer (< 768px) ---------- */\n\n/* The file picker the attachment button drives (S7) \u2014 hidden at EVERY width,\n deliberately OUTSIDE the phone media block below. A bare <input type=\"file\">\n renders as a native \"Choose Files\" control, and the slot wrapper around it is\n `display: contents`, so an un-hidden one becomes a flex item of the official\n tool row. Scoping this to < 768px once put that control in the DESKTOP\n composer (caught on a live deploy, 2026-08-17): the button that drives it is\n phone-only, but the input it drives is in the DOM at all widths. */\n[data-mobile-nav=\"attach-picker\"] {\n display: none !important;\n}\n\n@media (max-width: 767px) {\n /* --- 1. flatten the two official groups ---\n `_tools` and `_trailing` only exist to cluster controls left/right.\n display:contents dissolves them into `_row`, so the six leaf controls\n become siblings in one flex line and `order` alone drives the layout:\n [attach \u00B7 + \u00B7 permission \u00B7 model] \u2026elastic gap\u2026 [context ring \u00B7 send]. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] {\n justify-content: flex-start !important;\n gap: 5px !important;\n padding: 2px 8px 8px !important;\n /* One line, always: the model pill is the only shrinkable item (its\n label ellipsizes), so wrapping would only ever push the send button\n to a second row (real-device report, 2026-08-25). */\n flex-wrap: nowrap !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] {\n display: contents !important;\n }\n\n /* --- 2. the running order ---\n The attachment button (S3 placeholder, S7 wires it up) sits leftmost;\n the official \"+\" command menu keeps its seat right next to it, where\n the two \"insert something\" affordances read as one cluster. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] [data-mobile-nav=\"attach\"] {\n order: 1 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_add\"] {\n order: 2 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] {\n order: 3 !important;\n /* Rigid on purpose. This seat holds the permission chip, whose own\n wrapper (the plugin's element inside _modes) is `flex: 0 0 auto` and\n will NOT follow the seat down \u2014 so a shrinkable seat does not make the\n chip narrower, it just slices the seat out from under a chip that\n stays 44px wide, and the overflow lands on top of the model pill.\n Measured 2026-09-04 on DSH 0.1.2 at 390px: seat 90-120 (29.7px) with\n the chip still 90-134, model starting at 125 \u2014 a 9px overlap, exactly\n the \"the two chips touch\" report. With the seat rigid: seat 90-134,\n model 139-246, overlap 0; and at 320px the model pill shrinks to 52px\n and still nothing overlaps.\n This also restores what section 1 says out loud \u2014 the model pill is\n the ONLY shrinkable item in the row. `min-width: 0` is gone with the\n shrink: it only ever mattered as the companion of `flex: 0 1`. */\n flex: 0 0 auto !important;\n gap: 6px !important;\n }\n /* The elastic gap used to live here as `margin-right: auto`, which broke\n the moment a session had no model pill: a subagent session has none, so\n the gap vanished and the stop / send buttons bunched up in the middle of\n the row instead of sitting at the right edge (reported 2026-09-06). The\n gap belongs to the RIGHT group, not to the last item of the left one \u2014\n see the two rules below the order list. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] {\n order: 4 !important;\n /* A definite flex-basis (not auto) breaks the circular sizing between\n this seat and the trigger's max-width:100%: the seat's width comes\n from flex resolution, the trigger fills up to it, the label\n ellipsizes inside. Under nowrap pressure this is the only\n min-width:0 seat, so it absorbs all shrink. */\n flex: 0 1 min(48vw, 200px) !important;\n min-width: 0 !important;\n max-width: min(48vw, 200px) !important;\n }\n /* Third-party input.right entries park next to the ring rather than\n landing at order 0 (= far left) once the groups are flattened. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.right\"] > * {\n order: 5 !important;\n }\n /* ContextMeter \u2014 the only `span` ending in _root inside the row. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"] {\n order: 6 !important;\n flex: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [class$=\"_primary\"] {\n order: 7 !important;\n }\n /* The elastic gap. It sits in front of whichever of the two right-hand\n items comes first, so the group hugs the right edge no matter what the\n left side happens to contain \u2014 with a model pill, without one (subagent\n sessions), with or without the third-party entries at order 5.\n Both get `margin-left: auto` and the last rule takes it back off the\n send button whenever a ring precedes it: two auto margins would SHARE\n the free space and open a second gap between the ring and send. A\n sibling combinator answers \"is there a ring before me\" without assuming\n anything about the host's nesting. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [class$=\"_primary\"] {\n margin-left: auto !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"] ~ [class$=\"_primary\"] {\n margin-left: 0 !important;\n }\n\n /* --- 3. permission as icon-only pill; model as name+effort pill ---\n (real-device round 2, 2026-08-17; model pill reworked 2026-08-25:\n the user wants to SEE which model/effort is active, so only the\n permission trigger stays icon-only \u2014 its label is hidden and it\n collapses to a plain ~44x30 icon button. This AGREES with (rather than fights) the official\n container query (`@container (width <= 460px) { .trigger:has(.triggerIcon)\n .triggerLabel { display: none } }`) that S3 had to override \u2014 no need\n to override it back. Accessible name is unaffected: both official\n triggers already ship a descriptive aria-label independent of the\n visible text (PermissionSelect: t('input.accessMode', {name}); the\n model trigger: t('trigger.aria'/'trigger.ariaEffort')) \u2014 verified live\n in dsh-client-ui-conversation / dsh-client-ui-model-selection lib/\n client.js, 2026-08-17 \u2014 so hiding the label costs nothing for screen\n readers. The permission trigger only renders an icon for the\n \"read-only\" / \"workspace-write\" presets (permissionGlyphs in\n dsh-client-ui-conversation) \u2014 \"Full access\" and any host-configured\n preset name fall back to chevron-only, a known gap in the official\n markup this plugin cannot fill without inventing new icon meaning.\n The model trigger never renders an icon at all (only label + optional\n effort text + chevron), so its ::before below draws one from a\n primitives icon path (Sparkle \u2014 the closest existing \"model\" glyph) as\n a CSS-only pseudo-element: it survives React re-renders for free\n (unlike a DOM-injected node, which would need a MutationObserver, see\n the preview-full-toggle pitfall in AGENTS.md) and does not touch\n accessible-name computation (empty generated content). S3's\n rtl-ellipsis trick on the model label is simply inert under\n display:none now; left alone rather than unpicked. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [class$=\"_triggerLabel\"] {\n display: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] button[class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n width: 44px !important;\n height: 30px !important;\n max-width: none !important;\n min-width: 0 !important;\n padding: 0 !important;\n gap: 2px !important;\n justify-content: center !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n }\n /* Model trigger: name + effort pill (2026-08-25 user request \u2014 the\n icon-only form hid which model was active). The effort text must stay\n whole; the model NAME is the elastic part and ellipsizes. max-width\n caps the pill so the row's elastic gap survives extreme model ids;\n the label rules below override the <=1023px grow rules in\n layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n height: 30px !important;\n width: auto !important;\n max-width: 100% !important;\n min-width: 0 !important;\n padding: 0 10px !important;\n gap: 4px !important;\n justify-content: flex-start !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n font-size: 12px !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerLabel\"] {\n display: block !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n white-space: nowrap !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerEffort\"] {\n display: block !important;\n flex: none !important;\n white-space: nowrap !important;\n }\n /* PermissionSelect wraps its trigger in the Menu primitive's root span,\n which must shrink to the icon button's fixed width. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] > span:has(> button[class$=\"_trigger\"]) {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n }\n /* (2026-08-25) The model trigger's CSS-only Sparkle ::before icon is gone:\n the pill now shows the model name + effort text itself, so an icon would\n just eat label width. */\n\n /* --- 4. both menus become bottom sheets ---\n The permission menu is the Menu primitive (role=menu, absolute, side=top)\n and the model menu is ModelSelect's own `_menu` (absolute, bottom+right).\n Neither has a transformed ancestor between it and the viewport, so\n position:fixed re-anchors both to the screen edge. Only the shell moves \u2014\n the items, the two-level model panes and every selection handler stay\n official. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] {\n position: fixed !important;\n left: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n top: auto !important;\n transform: none !important;\n width: 100% !important;\n min-width: 0 !important;\n max-width: none !important;\n max-height: min(70dvh, 520px) !important;\n box-sizing: border-box !important;\n border-radius: 16px 16px 0 0 !important;\n border-bottom: none !important;\n padding: 8px 8px calc(8px + var(--mnav-sab)) !important;\n z-index: 60 !important;\n box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;\n }\n /* 44pt+ rows in both sheets (Menu items, model options, and the model\n sheet's two root cells that drill into the model / effort panes). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"] [role=\"menuitem\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_option\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_cell\"] {\n min-height: 48px !important;\n border-radius: 12px !important;\n font-size: 15px !important;\n }\n /* --- 4a. third-party composer entries live in the model sheet ---\n effects/model-sheet-extras.ts parks the `conversation.input.right`\n container inside the model menu while that menu is open and puts it back\n when it closes; these rules are the two halves of that trip.\n\n In the row: hidden, but ONLY once the effect has marked the container as\n one it manages. Keying the hide off the marker rather than off \"not\n currently parked\" is what makes the failure direction safe \u2014 if the\n effect declines to act (nothing in the slot but the vision toggle, so\n nothing worth moving) or never runs at all, the marker is absent, this\n rule does not match, and the controls stay exactly where the host put\n them. The inverse spelling hid them in the row whenever they were not in\n the sheet, which in those same cases left them reachable from neither\n place (caught in review, 2026-09-06).\n Hiding by ancestor rather than by a display rule on the container itself\n is what lets the parked copy style itself freely \u2014 same element, two\n homes, two rule sets. `!important` because the slot container carries an\n inline `display: contents`. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.right\"][data-zen-sheet-extras] {\n display: none !important;\n }\n /* Parked in the sheet: a column of full-width rows in the sheet's own\n language, so a relocated control reads as a sibling of \u6A21\u578B and \u63A8\u7406\u7B49\u7EA7\n rather than a chip that wandered in. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] {\n display: flex !important;\n flex-direction: column !important;\n gap: 2px !important;\n width: 100% !important;\n }\n /* Undo the row's compaction on every parked control: back to full width and\n the 48px the sheet's own cells use. `> * > *` reaches both shapes without\n naming either plugin \u2014 subscriptions wraps its trigger in a positioning\n div, vision-router puts its button straight in the slot. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] > *,\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] > * > button {\n width: 100% !important;\n max-width: none !important;\n min-width: 0 !important;\n justify-content: flex-start !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] button {\n box-sizing: border-box !important;\n min-height: 48px !important;\n height: auto !important;\n padding: 0 12px !important;\n gap: 10px !important;\n border-radius: 12px !important;\n border: none !important;\n background: transparent !important;\n font-size: 15px !important;\n /* Both plugins center their chip label, which is right for a pill in the\n row and wrong for a full-width row: the sheet's own cells start their\n label at the left edge. justify-content covers the flex triggers,\n text-align the ones that are not flex containers. */\n text-align: left !important;\n justify-content: flex-start !important;\n }\n /* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it\n parks beside the context ring), and that rule still matches in here \u2014 the\n sheet is a DOM descendant of the composer bar even though it paints as a\n fixed layer. Reset it so the sheet follows DOM order instead of\n inheriting a decision that was about a different layout. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] > * {\n order: 0 !important;\n }\n /* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);\n in the sheet it is a row, so its label comes back and the icon leads. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {\n display: inline !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {\n width: 18px !important;\n height: 18px !important;\n }\n /* The speed chip opens a menu of its own, anchored `bottom: 100%` to its\n trigger. Inside the sheet \u2014 which is `overflow: hidden` so its rounded\n top clips its content \u2014 that menu would be cut off. Same escape section 4\n uses for the host's two menus: position: fixed leaves the clip behind\n (the sheet sets `transform: none`, so nothing between here and the\n viewport captures fixed positioning) and it lands as its own sheet on top\n of this one. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] [role=\"menu\"] {\n position: fixed !important;\n left: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n top: auto !important;\n margin: 0 !important;\n width: 100% !important;\n min-width: 0 !important;\n max-width: none !important;\n max-height: min(70dvh, 520px) !important;\n box-sizing: border-box !important;\n border-radius: 16px 16px 0 0 !important;\n border-bottom: none !important;\n padding: 8px 8px calc(8px + var(--mnav-sab)) !important;\n /* Above the model sheet it sits on (60, section 4), below the session-info\n sheet's 70 (info.css.ts) \u2014 it is a child of the model sheet, not a peer\n of the page's own layers. */\n z-index: 61 !important;\n box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] > [data-zen-sheet-extras] [role=\"menu\"] [role=\"menuitemradio\"] {\n min-height: 48px !important;\n border-radius: 12px !important;\n font-size: 15px !important;\n }\n\n /* --- 4b. the official scroll-to-bottom button must not poke through the\n sheet (real-device follow-up, 2026-08-17) ---\n ChatView's own \"jump to latest\" button (aria-label t(\"chat.toBottom\"))\n is `position: sticky; z-index: 8` inside the message column, not\n `position: fixed` \u2014 it never escapes to the same top-level stacking\n context our sheets get promoted to, so raising the sheet's z-index\n further does nothing (measured live: it still rendered on top at\n z-index 60 vs 8). Rather than chase engine-specific stacking-context\n semantics (Chromium and WebKit do not always agree here \u2014 see AGENTS.md\n CDP/document.hidden lesson for another instance of that), this hides\n the button outright while either sheet is open and lets it reappear on\n close: a plain `display: none` behind a live `:has()` read is correct\n regardless of which stacking rules the engine happens to apply.\n Selector: no hashed classes (dsh-client-ui-conversation lib/client.js,\n verified 2026-08-17) \u2014 `data-chat-flow` is the one stable attribute on\n the message column, and the button's wrapper is its only sibling\n (ChatView only ever renders the column and, conditionally, this one\n slot), so the adjacent-sibling combinator pins it precisely. */\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"]) [data-chat-flow] + div,\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"]) [data-chat-flow] + div {\n display: none !important;\n }\n /* --- 5. input box: two lines minimum, five lines maximum ---\n The official autosizer drives the box off the hidden mirror's height, so\n a min-height on the mirror IS the min-height of the field; the scroll cap\n rides the official custom property. 52px = 2 x 24px line box + 4px pad,\n 124px = 5 lines. The hero card keeps its own one-line collapse (misc.css\n pins _scroll/_grow while the placeholder shows). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] [class$=\"_mirror\"] {\n min-height: 52px !important;\n }\n [class$=\"_composerSeat\"] {\n --dsh-composer-text-max-height: 124px;\n }\n /* Pull the text in from the card's left border. Upstream insets the editing\n host by 16px while the control row below it starts 8px in (row padding,\n section 1), so the placeholder sat noticeably further out than the\n buttons under it and the field read as over-padded on a phone (reported\n 2026-09-06). 12px squares it with the 12px of clear space on the right,\n giving the text a symmetric box, and closes most of the gap with the\n button row. Anchored on the editing host's own contract attribute rather\n than a hashed class. */\n [data-slot=\"conversation.composer.bar\"] [data-composer-input] {\n padding-left: 12px !important;\n }\n\n /* --- 6. no divider above OR below the message list ---\n Instead of a rule the messages butt against, they fade near both edges.\n This used to be ONE mask-image on the message scroller; iOS WebKit\n turned that into fog over the header (user report 2026-08-23): a mask\n on a scroll container is applied in CONTENT coordinates there (the fade\n bands scroll away with the messages) and, worse, it breaks the\n container's own overflow clipping \u2014 content scrolled out of view keeps\n painting, straight over the header and status bar. Engine bug with no\n iOS escape hatch (every iOS browser is WebKit), so the fades moved off\n the scroller entirely:\n\n - fade-IN under the header: the header's own ::after, restyled into a\n 20px gradient strip \u2014 styles/header.css.ts (\"no header bottom line\").\n - fade-OUT above the composer: nothing of ours. The official composer\n seat already carries `background: linear-gradient(transparent 0,\n bg 36px)` on its sticky self (dsh-client-ui-conversation lib/\n client.js .wSkVaW_composerSeat) and that is the entire visible\n effect; the mask's own bottom band landed inside the safe-area\n padding below the card and painted nothing. If the bottom edge ever\n reads harsh, hang a ::before strip off the seat the same way the\n header does \u2014 do NOT put a mask back on the scroller.\n\n Painted strips instead of a mask is a fair trade only because the page\n background is one flat color \u2014 which it is (--dsw-alias-bg-base). */\n [class$=\"_composerSeat\"],\n [class$=\"_composerStack\"] {\n border-top: none !important;\n }\n\n /* --- 7. dock entries become mini chips above the input card ---\n conversation.input.dock is display:contents (inline style), so its\n entries are stacked rows of the composer column. Forcing the slot itself\n to flex turns them into ONE scrollable chip line outside and above the\n card. Written against the slot, not against any particular plugin's chip\n (the git branch chip and the todo panel are third-party and may not be\n installed at all). */\n [data-slot=\"conversation.input.dock\"] {\n display: flex !important;\n flex-flow: row nowrap !important;\n align-items: center !important;\n gap: 6px !important;\n min-width: 0 !important;\n margin: 0 16px !important;\n overflow-x: auto !important;\n overflow-y: hidden !important;\n scrollbar-width: none !important;\n /* The dock is itself a flex ITEM of the composer stack, and the\n new-session hero pins that stack to a fixed height. overflow-x:auto\n above makes this a scroll container, which drops a flex item's\n automatic minimum size to 0 \u2014 so the hero squeezed the dock to ~5px\n and the attachment chips slid under the card drawn after it (only the\n top sliver of a thumbnail showed; reported from a real phone,\n 2026-08-26). Refuse the squeeze: natural height, the stack centers a\n few px lower instead. In-chat the column is not height-pinned and this\n is a no-op. */\n flex-shrink: 0 !important;\n }\n [data-slot=\"conversation.input.dock\"]::-webkit-scrollbar {\n display: none !important;\n }\n [data-slot=\"conversation.input.dock\"] > * {\n flex: 0 0 auto !important;\n max-width: 70% !important;\n min-height: 26px !important;\n max-height: 26px !important;\n border-radius: 999px !important;\n font-size: 11.5px !important;\n line-height: 18px !important;\n overflow: hidden !important;\n }\n /* The git-graph chip carries a 34px tablet target from misc.css; that\n selector is more specific, so restate it at the phone breakpoint. */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip] {\n min-height: 26px !important;\n padding: 0 10px !important;\n font-size: 11.5px !important;\n }\n /* --- 7a. the native to-do card opts OUT of the 26px pill cage ---\n conversation.input.dock is not only a chip rail: DSH's own TodoPanel\n (dsh-client-ui-conversation) registers here, and it is an expandable\n card, not a pill \u2014 tapping its header renders the to-do list INSIDE\n itself. The cage's max-height:26px + overflow:hidden left that list\n rendered but clipped to nothing, so on a phone the bar looked dead:\n aria-expanded flipped, 8 rows mounted at 216px, and the card still\n painted 26px tall (measured live, 2026-08-20). Give it the official\n geometry back \u2014 full row, natural height, 12px radius. Its list caps\n itself at 180px with its own scroller, so this cannot run into the\n composer. Anchored on the official data-testid, a stable contract\n marker rather than a hashed class name. */\n [data-slot=\"conversation.input.dock\"] > [data-testid=\"todo-panel\"] {\n /* A whole line to itself. It shared line 0 with the pills until the goal\n bar turned up (2026-09-06): two full-width things on one nowrap line\n meant the to-do header shrank to a stub and the goal bar ran off the\n right edge. Both are status strips you read at a glance, so they stack\n \u2014 see the line policy in 7c. */\n flex: 1 0 100% !important;\n order: 1 !important;\n min-width: 0 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n overflow: visible !important;\n border-radius: 12px !important;\n /* The cage shrinks dock text to 11.5px/18px; the card's rows inherit it\n and turn cramped. Restore the official 13px body scale. */\n font-size: 13px !important;\n line-height: normal !important;\n }\n\n /* --- 7b. attachment chips (S7.1) ---\n Our own dock entry opts OUT of the 26px pill cage above: an image chip is\n a 48px tile. The row sits tight under the card's top edge, so the chips\n read as part of the composer rather than as a floating strip.\n\n It also takes the LAST line of its own (line policy in 7c): a 48px tile\n beside the to-do chip on one nowrap line squeezed that chip down to an\n unreadable stub, with a long file name pushing the rest off-screen\n (reported 2026-08-22). Its own chips wrap rather than overflow, so\n several attachments stack instead of scrolling out of reach. */\n [data-slot=\"conversation.input.dock\"] > [data-mobile-nav=\"attach-chips\"] {\n display: flex !important;\n flex: 1 0 100% !important;\n flex-wrap: wrap !important;\n order: 4 !important;\n align-items: center !important;\n gap: 6px !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n border-radius: 0 !important;\n overflow: visible !important;\n margin-bottom: -2px;\n }\n\n /* --- 7c. the queue strip opts OUT of the cage, on a line of its own ---\n Third entry that is not a pill. conversation.input.dock is a `list` slot\n and DSH's own QueueDock registers into it at order 20 (id `queue`,\n dsh-client-ui-conversation) \u2014 a 36px row per queued message, or a\n collapsible \"N \u6761\u6392\u961F\u6D88\u606F\" header once there is more than one. The 26px\n cage above clipped it to a sliver: on a phone a queued message showed as\n a grey stub poking out from behind the input card, unreadable and with\n its edit / delete / send buttons cut off (reported 2026-08-22, same\n failure the to-do card hit in 7a).\n\n Two things are restored. Geometry: the official row height back, and the\n dock's own composer-column sizing dropped \u2014 its width/max-width are\n calc()s over the composer card variables and its bottom margin is\n negative by design (upstream tucks the strip UNDER the card), none of\n which survives being a flex item of our chip rail.\n\n Layout \u2014 the line policy for the whole rail, now that three things want\n room on it. The pills (the to-do card, a branch chip, whatever else\n registers) keep order 0 and share the top line, still scrolling\n horizontally. The queue takes the next line (order 1). The attachment\n chips take the last one (order 2, 7b), so what the user is about to send\n sits closest to the input it will be sent from. The rail only wraps\n while one of the two full-width entries exists, so a session with\n neither keeps the original single scrolling chip line. */\n [data-slot=\"conversation.input.dock\"]:has(> [data-queue-dock], > [data-mobile-nav=\"attach-chips\"], > [data-testid=\"todo-panel\"], > [data-goal-bar]) {\n flex-wrap: wrap !important;\n }\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] {\n flex: 1 0 100% !important;\n order: 3 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n margin: 0 !important;\n padding: 0 !important;\n border-radius: 0 !important;\n overflow: visible !important;\n font-size: 13px !important;\n line-height: normal !important;\n }\n /* dsh-client-ui-goal's status strip (its own `data-goal-bar` marker; it\n registers into this dock at order 10). Fourth thing here that is not a\n pill: a row carrying the objective text plus pause / edit / delete\n buttons, so the 26px cage clipped its buttons off the right edge and the\n nowrap line pushed what was left off screen (reported 2026-09-06). Same\n treatment the queue gets \u2014 a line of its own, official geometry back. */\n [data-slot=\"conversation.input.dock\"] > [data-goal-bar] {\n flex: 1 0 100% !important;\n order: 2 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n border-radius: 12px !important;\n overflow: visible !important;\n font-size: 13px !important;\n line-height: normal !important;\n }\n\n /* The panel is the dock's only child; upstream rounds its top corners only\n and drops the bottom border, because there it is half-hidden behind the\n card. On its own line it is a free-standing strip, so close it up. */\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] > * {\n border-radius: 12px !important;\n }\n [data-slot=\"conversation.input.dock\"] > [data-queue-dock] > *::after {\n border-bottom: 1px solid var(--dsw-alias-border-l1) !important;\n }\n\n [data-mobile-nav=\"attach-chip\"] {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 62vw;\n background: var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n }\n [data-mobile-nav=\"attach-chip\"][data-kind=\"file\"] {\n height: 26px;\n padding: 0 4px 0 8px;\n border-radius: 999px;\n font-size: 11.5px;\n line-height: 18px;\n }\n /* An image chip is the thumbnail: no name line, the filename lives in the\n title attribute (and in the draft text right below). */\n [data-mobile-nav=\"attach-chip\"][data-kind=\"image\"] {\n width: 48px;\n height: 48px;\n padding: 0;\n border-radius: 10px;\n overflow: hidden;\n }\n [data-mobile-nav=\"attach-chip-art\"] {\n position: relative;\n display: grid;\n place-items: center;\n flex: none;\n overflow: hidden;\n }\n [data-kind=\"file\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 16px;\n height: 16px;\n opacity: .7;\n }\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 100%;\n height: 100%;\n }\n /* The <img> is stacked over the paperclip, not swapped for it: a format the\n engine cannot decode (HEIC outside WebKit) simply paints nothing and the\n icon shows through \u2014 no onError state to carry. */\n [data-mobile-nav=\"attach-chip-art\"] img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .18));\n }\n [data-mobile-nav=\"attach-chip-name\"] {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n }\n [data-mobile-nav=\"attach-chip-remove\"] {\n display: grid;\n place-items: center;\n flex: none;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 999px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n touch-action: manipulation;\n }\n /* On a tile the \u00D7 floats in the corner over the picture, so it needs its own\n ground to stay legible against an arbitrary photo. */\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-remove\"] {\n position: absolute;\n top: 2px;\n right: 2px;\n background: rgba(0, 0, 0, .55);\n color: #fff;\n }\n [data-mobile-nav=\"attach-chip-remove\"]:active {\n transform: scale(.9);\n }\n\n /* --- 8. the official stats strip leaves the composer ---\n Its data moves into the session info card (S4). The row is the composer\n dock's own `_root` entry; the slot itself stays live for later entries. */\n [data-slot=\"conversation.composer.dock\"] > [class$=\"_root\"] {\n display: none !important;\n }\n\n /* --- 9. the attachment button (S7; its file input is hidden at the top of\n this file, at every width) --- */\n [data-mobile-nav=\"attach\"] {\n position: relative;\n width: 28px !important;\n height: 28px !important;\n flex: none !important;\n display: grid !important;\n place-items: center !important;\n padding: 0 !important;\n border: none !important;\n border-radius: 999px !important;\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n touch-action: manipulation;\n }\n [data-mobile-nav=\"attach\"]:active {\n transform: scale(.94);\n transition: transform .12s;\n }\n /* Busy: a ring sweeps around the paperclip. Drawn with a conic gradient in\n a pseudo-element rather than a spinner node, so the official React tree\n around us has nothing extra to re-render (same reasoning as the model\n pill's mask icon, S3.1). */\n [data-mobile-nav=\"attach\"][data-busy]::after {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 999px;\n background: conic-gradient(from 0deg, transparent 0 65%, currentColor 100%);\n mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n animation: mnav-attach-spin .9s linear infinite;\n pointer-events: none;\n }\n @keyframes mnav-attach-spin {\n to { transform: rotate(1turn); }\n }\n @media (prefers-reduced-motion: reduce) {\n [data-mobile-nav=\"attach\"][data-busy]::after {\n animation-duration: 3s;\n }\n }\n /* Failure note: one line above the button, tap-to-dismiss. Sits on the\n composer card (z 2) rather than in an overlay layer \u2014 nothing here needs\n to clear the permission/model sheets. */\n [data-mobile-nav=\"attach-error\"] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 0;\n z-index: 2;\n max-width: 62vw;\n padding: 5px 8px;\n border-radius: 8px;\n font-size: 12px;\n line-height: 1.3;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .82));\n color: var(--dsw-alias-label-primary, #fff);\n box-shadow: 0 2px 8px rgba(0, 0, 0, .24);\n }\n\n /* --- 9. home-indicator clearance (S4.1, 2026-08-17) ---\n Owned HERE, not by the gateway half. That plugin used to carry\n [data-slot=\"conversation.composer\"] { padding-bottom: max(env(safe-area-inset-bottom), 8px) }\n in both pwa/app.css and the gateway's inline DEVICE_CSS. Both were\n INERT and had always been: that slot element is `display: contents`\n (measured 2026-08-17 \u2014 the slot wrapper generates no box, so padding on\n it is discarded), which is why raising it never moved anything. The rule\n is deleted on the PWA side and restated here on an element that actually\n lays out, reading --mnav-sab so ?mobile-nav-inset=54,34 can regress it\n off-device (env() is hard 0 on desktop \u2014 the whole reason S2.1 exists).\n\n Target: the card should look EQUALLY inset on all four sides. This same\n element carries the card's side inset (`padding: 0 16px` officially,\n measured 16px left and 16px right at 390px), so the bottom gap is simply\n capped at that same 16px. Clearing the FULL 34px inset \u2014 the naive\n max(sab, 8px) \u2014 is what read as \"too thick\": it is more than double the\n side margin, so the card looked shoved up off the bottom edge.\n\n clamp() says all three requirements at once:\n - floor 8px -> a device with no home indicator (sab: 0) keeps the\n official 8px exactly, so this is a strict no-op there;\n - track sab -> a shallower inset than 16px is honoured as-is;\n - cap 16px -> a full-size indicator (sab: 34) lands on 16px, equal\n to the side inset, which is the look being asked for.\n Keep the 16px in step with the side padding above if that ever changes;\n that equality IS the spec here, not a coincidence. */\n [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;\n }\n\n /* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the\n layout viewport hidden behind the software keyboard into --mnav-kb-lift\n and stamps the html attribute while (and only while) it is non-zero \u2014\n a resting transform, even translateY(0), would silently become the\n containing block for any fixed-position descendant of the bar. In every\n environment where the browser's own focus-reveal works the attribute is\n simply never set.\n The target is the slot's _root CHILD, not the slot wrapper: the wrapper\n is display:contents \u2014 it generates no box, so a transform on it is a\n silent no-op (found live on-device via CDP, 2026-08-21; the variable and\n attribute were set and nothing moved). */\n html[data-mnav-kb] [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));\n }\n}\n\n/* The attachment button and its preview row only exist for the phone shell.\n Both render at every width (the slots are not breakpoint-aware), so both\n need hiding here \u2014 the dock-row rules that shape the chips live in the\n < 768px block and would otherwise leave a bare, unstyled chip line in the\n desktop composer. */\n@media (min-width: 768px) {\n [data-mobile-nav=\"attach\"],\n [data-mobile-nav=\"attach-chips\"] {\n display: none !important;\n }\n}\n";
|
|
2
2
|
//# sourceMappingURL=composer.css.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"composer.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/composer.css.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,YAAY,
|
|
1
|
+
{"version":3,"file":"composer.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/composer.css.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,YAAY,w00CA8xBxB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-zen-remote",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pwa/app.css
CHANGED
|
@@ -50,13 +50,6 @@ html:not([data-lan-device="desktop"]) body {
|
|
|
50
50
|
overscroll-behavior-y: none;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* iOS Safari zooms the whole page when a focused input's font-size is below
|
|
54
|
-
* 16px. dsh-mobile-nav does not add this workaround, so it stays here. */
|
|
55
|
-
html:not([data-lan-device="desktop"]) [data-slot="conversation"] input,
|
|
56
|
-
html:not([data-lan-device="desktop"]) [data-slot="conversation"] textarea {
|
|
57
|
-
font-size: 16px;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
53
|
/* Apply the pinch-to-resize font scale. touch-gestures.js sets
|
|
61
54
|
* --dsh-pwa-font-scale via root.style.setProperty() on pinch; this is this
|
|
62
55
|
* plugin's own gesture feature, not a dsh-mobile-nav concern. */
|
|
@@ -67,6 +60,53 @@ html:not([data-lan-device="desktop"]) [data-slot="conversation"] [data-phase] {
|
|
|
67
60
|
font-size: calc(1em * var(--dsh-pwa-font-scale));
|
|
68
61
|
}
|
|
69
62
|
|
|
63
|
+
/* iOS Safari zooms the whole page when a focused input's font-size is below
|
|
64
|
+
* 16px, and it does NOT zoom back out when focus leaves — the page just stays
|
|
65
|
+
* magnified until the user pinches it back (reported 2026-09-06: the UI came
|
|
66
|
+
* back enlarged after a trip through a subagent session, and only leaving to
|
|
67
|
+
* the session list and re-entering cleared it).
|
|
68
|
+
*
|
|
69
|
+
* This was scoped to [data-slot="conversation"], which is an exact attribute
|
|
70
|
+
* match: it covers inputs inside the element whose slot is literally
|
|
71
|
+
* "conversation" and nothing in the dozens of conversation.* subtrees around
|
|
72
|
+
* it. Measured on the running UI: one input matched, while a visible 12px
|
|
73
|
+
* text field sat outside the rule — and the composer never triggered the zoom
|
|
74
|
+
* at all because it is contenteditable, not an input, which is why this went
|
|
75
|
+
* unnoticed for so long. So the guard now covers the whole mobile shell.
|
|
76
|
+
*
|
|
77
|
+
* File / checkbox / radio are excluded: they carry no text, the file inputs
|
|
78
|
+
* here are hidden anyway, and forcing 16px on them only disturbs layout.
|
|
79
|
+
*
|
|
80
|
+
* `[contenteditable]` is in the list because the thing users actually focus
|
|
81
|
+
* — the message composer — is one: a contenteditable div with role=textbox,
|
|
82
|
+
* not an input or a textarea. iOS treats it like any other editing host and
|
|
83
|
+
* zooms on it just the same, so covering only inputs left the single most
|
|
84
|
+
* focused element on the page unprotected, which is why widening this rule to
|
|
85
|
+
* every input still did not stop the page coming back magnified (2026-09-06).
|
|
86
|
+
* Measured before changing it: the composer computes 14px, and raising it to
|
|
87
|
+
* 16px moves no geometry at all — its line-height is a fixed 24px rather than
|
|
88
|
+
* a ratio, so the line box, the editor height and the card height are
|
|
89
|
+
* identical either way, and section 5 of composer.css.ts can go on counting
|
|
90
|
+
* lines in 24px units.
|
|
91
|
+
*
|
|
92
|
+
* It has to come AFTER the pinch-scale rule above, and say max() rather than
|
|
93
|
+
* a flat 16px. The composer's editing host carries `data-phase` of its own,
|
|
94
|
+
* so `[data-slot="conversation"] [data-phase]` matches it too — at exactly the
|
|
95
|
+
* same specificity as this rule (0,3,1). Source order was therefore the whole
|
|
96
|
+
* ballgame, and with this block sitting first the pinch rule quietly won and
|
|
97
|
+
* put the composer back to 1em. That is why the composer stayed at 14px even
|
|
98
|
+
* after this selector learned about contenteditable, and why the page went on
|
|
99
|
+
* zooming (measured 2026-09-06: both rules matched, the later one applied).
|
|
100
|
+
* max() keeps the two goals from fighting — a hard 16px floor so iOS never
|
|
101
|
+
* zooms, and everything above it still scaling with the pinch gesture.
|
|
102
|
+
*
|
|
103
|
+
* dsh-mobile-nav does not add this workaround, so it stays here. */
|
|
104
|
+
html:not([data-lan-device="desktop"]) input:not([type="file"]):not([type="checkbox"]):not([type="radio"]),
|
|
105
|
+
html:not([data-lan-device="desktop"]) textarea,
|
|
106
|
+
html:not([data-lan-device="desktop"]) [contenteditable]:not([contenteditable="false"]) {
|
|
107
|
+
font-size: max(16px, calc(1em * var(--dsh-pwa-font-scale, 1)));
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
/* Home-indicator clearance for the composer USED to live here, as
|
|
71
111
|
* [data-slot="conversation.composer"], [data-slot="conversation.input.bar"]
|
|
72
112
|
* { padding-bottom: max(env(safe-area-inset-bottom), 8px) }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
+
|
|
3
|
+
/** Phone breakpoint — same query every phone-only effect in this plugin uses. */
|
|
4
|
+
const PHONE_QUERY = '(max-width: 767px)'
|
|
5
|
+
|
|
6
|
+
/** The model pill itself. Absent in a subagent session — see `sync`. */
|
|
7
|
+
const MODEL_SEAT_SELECTOR = '[data-slot="conversation.input.model"]'
|
|
8
|
+
|
|
9
|
+
/** The model pill's own popup, which section 4 of composer.css.ts turns into a bottom sheet. */
|
|
10
|
+
const MENU_SELECTOR = '[data-slot="conversation.input.model"] [class$="_menu"]'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The third-party composer controls this effect relocates. Both register into
|
|
14
|
+
* `conversation.input.right`; moving the slot container rather than its
|
|
15
|
+
* children keeps them together and leaves exactly one node to put back.
|
|
16
|
+
*/
|
|
17
|
+
const EXTRAS_SELECTOR = '[data-slot="conversation.input.right"]'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* dsh-vision-router's own mount marker. Used here only to answer "is there
|
|
21
|
+
* anything in this slot BESIDES the vision toggle" — see `worthMoving`.
|
|
22
|
+
*/
|
|
23
|
+
const VISION_SELECTOR = '[data-vision-router-mode-toggle]'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set on the container while this effect manages it, in the row and in the
|
|
27
|
+
* sheet alike. It is the styling hook for both halves in composer.css.ts, and
|
|
28
|
+
* it is deliberately what the row's `display: none` keys off: without the
|
|
29
|
+
* marker the controls stay exactly where the host put them, so a version of
|
|
30
|
+
* this file that declines to act — or never runs at all — cannot leave them
|
|
31
|
+
* hidden in the row and absent from the sheet, i.e. unreachable.
|
|
32
|
+
*/
|
|
33
|
+
const MANAGED = 'data-zen-sheet-extras'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Is the slot holding anything that actually costs the row width?
|
|
37
|
+
*
|
|
38
|
+
* The row is one nowrap flex line whose only elastic item is the model pill
|
|
39
|
+
* (composer.css.ts section 1), so entries here are paid for out of the model
|
|
40
|
+
* name. But that is only a problem once something WIDE lands in the slot —
|
|
41
|
+
* today dsh-plugin-subscriptions' speed chip, which renders "速度 · 标准" and
|
|
42
|
+
* eats ~70px. dsh-vision-router's toggle alone is a 28px icon that the row
|
|
43
|
+
* accommodates fine, and moving it on its own would cost a tap (it becomes
|
|
44
|
+
* two: open the sheet, then toggle) to buy width nobody needed.
|
|
45
|
+
*
|
|
46
|
+
* So: relocate only when the slot holds an entry that is not the vision
|
|
47
|
+
* toggle. With no such entry — subscriptions not installed, or installed but
|
|
48
|
+
* not contributing on this model — nothing moves and the row keeps its one
|
|
49
|
+
* icon, which is the behaviour asked for (user review, 2026-09-06).
|
|
50
|
+
*
|
|
51
|
+
* Why this rather than sniffing for subscriptions by name: the slot system
|
|
52
|
+
* adds no per-registration marker (measured — entries render straight into the
|
|
53
|
+
* container) and subscriptions marks nothing of its own, so naming it would
|
|
54
|
+
* mean adding a marker to our fork of that plugin and anchoring on it. That
|
|
55
|
+
* marker dies the day the fork is dropped, and the feature would go quiet
|
|
56
|
+
* with nothing to catch it. The width question is the real question anyway,
|
|
57
|
+
* and this asks it directly.
|
|
58
|
+
*/
|
|
59
|
+
function worthMoving(extras: Element): boolean {
|
|
60
|
+
return [...extras.children].some(child => !child.matches(VISION_SELECTOR))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Phone: park the composer row's third-party controls inside the model sheet.
|
|
65
|
+
*
|
|
66
|
+
* With the speed chip and the vision toggle both present on a GPT model the
|
|
67
|
+
* row runs out of width. Both are model-scoped settings, so the model sheet —
|
|
68
|
+
* which already holds 模型 and 推理等级 — is where they belong (user request,
|
|
69
|
+
* 2026-09-06).
|
|
70
|
+
*
|
|
71
|
+
* Why move the real controls instead of drawing our own rows that drive them:
|
|
72
|
+
* the same reason native-trigger-overlay.ts moves the real trigger. A stand-in
|
|
73
|
+
* has to script the original, which is a road this plugin does not go down —
|
|
74
|
+
* beyond the trusted-input wall that effect documents, our own rows would have
|
|
75
|
+
* to mirror every piece of state (speed tier, disabled, aria-pressed, whether
|
|
76
|
+
* a vision twin exists for the current model) and would rot on the next
|
|
77
|
+
* upstream restyle. Moving the node keeps one source of truth: their React
|
|
78
|
+
* roots go on owning and updating these elements, we only change where they
|
|
79
|
+
* hang.
|
|
80
|
+
*
|
|
81
|
+
* Measured 2026-09-06 (DSH 0.1.2, 390px) before writing this — the two things
|
|
82
|
+
* that would have killed the approach both hold: a relocated control survives
|
|
83
|
+
* host re-renders (typing into the composer re-renders the row and does not
|
|
84
|
+
* yank it back) and stays hit-testable at its new position.
|
|
85
|
+
*
|
|
86
|
+
* Restoring on close is not cosmetic. The sheet unmounts when it closes and
|
|
87
|
+
* anything still inside goes with it, so the controls would vanish from the
|
|
88
|
+
* page while their React roots kept updating detached nodes. The observer
|
|
89
|
+
* therefore watches the menu leaving too, and the parked node is held as a
|
|
90
|
+
* reference rather than looked up — once the menu is detached a document
|
|
91
|
+
* query can no longer find it.
|
|
92
|
+
*/
|
|
93
|
+
export function installModelSheetExtras(ctx: ClientContext): void {
|
|
94
|
+
ctx.effect(() => {
|
|
95
|
+
const phone = window.matchMedia(PHONE_QUERY)
|
|
96
|
+
/** The container while parked in the sheet, plus where to put it back. */
|
|
97
|
+
let parked: { node: Element, parent: Element, next: Node | null } | null = null
|
|
98
|
+
|
|
99
|
+
const park = (node: Element, menu: Element): void => {
|
|
100
|
+
if (parked !== null || node.parentElement === null) return
|
|
101
|
+
parked = { node, parent: node.parentElement, next: node.nextSibling }
|
|
102
|
+
menu.appendChild(node)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const unpark = (): void => {
|
|
106
|
+
if (parked === null) return
|
|
107
|
+
const { node, parent, next } = parked
|
|
108
|
+
parked = null
|
|
109
|
+
// The menu may already be detached, having taken the container with it.
|
|
110
|
+
// The node itself is still live either way; re-home it only if its old
|
|
111
|
+
// parent is still on the page (when the whole composer went away, the
|
|
112
|
+
// owning React roots rebuild the slot on the next mount).
|
|
113
|
+
if (parent.isConnected) parent.insertBefore(node, next)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Hand the container back to the host, marker and all. */
|
|
117
|
+
const release = (node: Element | null): void => {
|
|
118
|
+
unpark()
|
|
119
|
+
node?.removeAttribute(MANAGED)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sync = (): void => {
|
|
123
|
+
const extras = document.querySelector(EXTRAS_SELECTOR)
|
|
124
|
+
if (extras === null) { unpark(); return }
|
|
125
|
+
// No model pill, no sheet to park into — a subagent session has none.
|
|
126
|
+
// Claiming the slot there would hide the controls from the row with
|
|
127
|
+
// nowhere to show them instead, which is the unreachable state the
|
|
128
|
+
// marker is meant to prevent.
|
|
129
|
+
const seat = document.querySelector(MODEL_SEAT_SELECTOR)
|
|
130
|
+
if (!phone.matches || seat === null || !worthMoving(extras)) { release(extras); return }
|
|
131
|
+
extras.setAttribute(MANAGED, '')
|
|
132
|
+
const menu = document.querySelector(MENU_SELECTOR)
|
|
133
|
+
if (menu === null) unpark()
|
|
134
|
+
else park(extras, menu)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Synchronous in the observer callback, never behind rAF: the callback is
|
|
138
|
+
// a microtask and runs before this frame paints, so the controls are in
|
|
139
|
+
// the sheet the first time it is drawn. A rAF would land a frame late and
|
|
140
|
+
// show one frame of the sheet without them (AGENTS.md's "晚一帧" rule).
|
|
141
|
+
// Body-level childList is the same shape modal-back.ts uses; aria-expanded
|
|
142
|
+
// is watched too so a close that reuses the node still re-syncs.
|
|
143
|
+
const observer = new MutationObserver(sync)
|
|
144
|
+
observer.observe(document.body, {
|
|
145
|
+
childList: true,
|
|
146
|
+
subtree: true,
|
|
147
|
+
attributes: true,
|
|
148
|
+
attributeFilter: ['aria-expanded'],
|
|
149
|
+
})
|
|
150
|
+
phone.addEventListener('change', sync)
|
|
151
|
+
sync()
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
observer.disconnect()
|
|
155
|
+
phone.removeEventListener('change', sync)
|
|
156
|
+
release(document.querySelector(EXTRAS_SELECTOR))
|
|
157
|
+
}
|
|
158
|
+
}, 'dsh-mobile-nav: model sheet extras')
|
|
159
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import { installHeaderStatusDot } from './effects/header-status.ts'
|
|
|
17
17
|
import { installGestures } from './effects/gestures.ts'
|
|
18
18
|
import { installTurnFold } from './effects/turn-fold.ts'
|
|
19
19
|
import { installModalBack } from './effects/modal-back.ts'
|
|
20
|
+
import { installModelSheetExtras } from './effects/model-sheet-extras.ts'
|
|
20
21
|
import { installNativeTriggerOverlay } from './effects/native-trigger-overlay.ts'
|
|
21
22
|
import { installWelcomeNoticeOptOut } from './effects/welcome-notice.ts'
|
|
22
23
|
import { installKeyboardGuard } from './effects/keyboard-guard.ts'
|
|
@@ -106,6 +107,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
106
107
|
installTurnFold(ctx)
|
|
107
108
|
installModalBack(ctx)
|
|
108
109
|
installNativeTriggerOverlay(ctx)
|
|
110
|
+
// Third-party composer entries (speed chip, vision toggle) move into the
|
|
111
|
+
// model sheet — the row has no width to spare and both are model settings.
|
|
112
|
+
installModelSheetExtras(ctx)
|
|
109
113
|
|
|
110
114
|
// "内测声明" first-run notice: keep it visible (CSS-hiding it leaked the
|
|
111
115
|
// dialog's #root inert lock — see effects/welcome-notice.ts) and offer a
|
|
@@ -81,8 +81,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
81
81
|
flex: 0 0 auto !important;
|
|
82
82
|
gap: 6px !important;
|
|
83
83
|
}
|
|
84
|
-
/* The
|
|
85
|
-
the
|
|
84
|
+
/* The elastic gap used to live here as \`margin-right: auto\`, which broke
|
|
85
|
+
the moment a session had no model pill: a subagent session has none, so
|
|
86
|
+
the gap vanished and the stop / send buttons bunched up in the middle of
|
|
87
|
+
the row instead of sitting at the right edge (reported 2026-09-06). The
|
|
88
|
+
gap belongs to the RIGHT group, not to the last item of the left one —
|
|
89
|
+
see the two rules below the order list. */
|
|
86
90
|
${MODEL} {
|
|
87
91
|
order: 4 !important;
|
|
88
92
|
/* A definite flex-basis (not auto) breaks the circular sizing between
|
|
@@ -93,7 +97,6 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
93
97
|
flex: 0 1 min(48vw, 200px) !important;
|
|
94
98
|
min-width: 0 !important;
|
|
95
99
|
max-width: min(48vw, 200px) !important;
|
|
96
|
-
margin-right: auto !important;
|
|
97
100
|
}
|
|
98
101
|
/* Third-party input.right entries park next to the ring rather than
|
|
99
102
|
landing at order 0 (= far left) once the groups are flattened. */
|
|
@@ -108,6 +111,22 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
108
111
|
${ROW} > [class$="_trailing"] > [class$="_primary"] {
|
|
109
112
|
order: 7 !important;
|
|
110
113
|
}
|
|
114
|
+
/* The elastic gap. It sits in front of whichever of the two right-hand
|
|
115
|
+
items comes first, so the group hugs the right edge no matter what the
|
|
116
|
+
left side happens to contain — with a model pill, without one (subagent
|
|
117
|
+
sessions), with or without the third-party entries at order 5.
|
|
118
|
+
Both get \`margin-left: auto\` and the last rule takes it back off the
|
|
119
|
+
send button whenever a ring precedes it: two auto margins would SHARE
|
|
120
|
+
the free space and open a second gap between the ring and send. A
|
|
121
|
+
sibling combinator answers "is there a ring before me" without assuming
|
|
122
|
+
anything about the host's nesting. */
|
|
123
|
+
${ROW} > [class$="_trailing"] > span[class$="_root"],
|
|
124
|
+
${ROW} > [class$="_trailing"] > [class$="_primary"] {
|
|
125
|
+
margin-left: auto !important;
|
|
126
|
+
}
|
|
127
|
+
${ROW} > [class$="_trailing"] > span[class$="_root"] ~ [class$="_primary"] {
|
|
128
|
+
margin-left: 0 !important;
|
|
129
|
+
}
|
|
111
130
|
|
|
112
131
|
/* --- 3. permission as icon-only pill; model as name+effort pill ---
|
|
113
132
|
(real-device round 2, 2026-08-17; model pill reworked 2026-08-25:
|
|
@@ -228,6 +247,115 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
228
247
|
border-radius: 12px !important;
|
|
229
248
|
font-size: 15px !important;
|
|
230
249
|
}
|
|
250
|
+
/* --- 4a. third-party composer entries live in the model sheet ---
|
|
251
|
+
effects/model-sheet-extras.ts parks the \`conversation.input.right\`
|
|
252
|
+
container inside the model menu while that menu is open and puts it back
|
|
253
|
+
when it closes; these rules are the two halves of that trip.
|
|
254
|
+
|
|
255
|
+
In the row: hidden, but ONLY once the effect has marked the container as
|
|
256
|
+
one it manages. Keying the hide off the marker rather than off "not
|
|
257
|
+
currently parked" is what makes the failure direction safe — if the
|
|
258
|
+
effect declines to act (nothing in the slot but the vision toggle, so
|
|
259
|
+
nothing worth moving) or never runs at all, the marker is absent, this
|
|
260
|
+
rule does not match, and the controls stay exactly where the host put
|
|
261
|
+
them. The inverse spelling hid them in the row whenever they were not in
|
|
262
|
+
the sheet, which in those same cases left them reachable from neither
|
|
263
|
+
place (caught in review, 2026-09-06).
|
|
264
|
+
Hiding by ancestor rather than by a display rule on the container itself
|
|
265
|
+
is what lets the parked copy style itself freely — same element, two
|
|
266
|
+
homes, two rule sets. \`!important\` because the slot container carries an
|
|
267
|
+
inline \`display: contents\`. */
|
|
268
|
+
${ROW} > [class$="_trailing"] > [data-slot="conversation.input.right"][data-zen-sheet-extras] {
|
|
269
|
+
display: none !important;
|
|
270
|
+
}
|
|
271
|
+
/* Parked in the sheet: a column of full-width rows in the sheet's own
|
|
272
|
+
language, so a relocated control reads as a sibling of 模型 and 推理等级
|
|
273
|
+
rather than a chip that wandered in. */
|
|
274
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] {
|
|
275
|
+
display: flex !important;
|
|
276
|
+
flex-direction: column !important;
|
|
277
|
+
gap: 2px !important;
|
|
278
|
+
width: 100% !important;
|
|
279
|
+
}
|
|
280
|
+
/* Undo the row's compaction on every parked control: back to full width and
|
|
281
|
+
the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
|
|
282
|
+
naming either plugin — subscriptions wraps its trigger in a positioning
|
|
283
|
+
div, vision-router puts its button straight in the slot. */
|
|
284
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > *,
|
|
285
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > * > button {
|
|
286
|
+
width: 100% !important;
|
|
287
|
+
max-width: none !important;
|
|
288
|
+
min-width: 0 !important;
|
|
289
|
+
justify-content: flex-start !important;
|
|
290
|
+
}
|
|
291
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] button {
|
|
292
|
+
box-sizing: border-box !important;
|
|
293
|
+
min-height: 48px !important;
|
|
294
|
+
height: auto !important;
|
|
295
|
+
padding: 0 12px !important;
|
|
296
|
+
gap: 10px !important;
|
|
297
|
+
border-radius: 12px !important;
|
|
298
|
+
border: none !important;
|
|
299
|
+
background: transparent !important;
|
|
300
|
+
font-size: 15px !important;
|
|
301
|
+
/* Both plugins center their chip label, which is right for a pill in the
|
|
302
|
+
row and wrong for a full-width row: the sheet's own cells start their
|
|
303
|
+
label at the left edge. justify-content covers the flex triggers,
|
|
304
|
+
text-align the ones that are not flex containers. */
|
|
305
|
+
text-align: left !important;
|
|
306
|
+
justify-content: flex-start !important;
|
|
307
|
+
}
|
|
308
|
+
/* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it
|
|
309
|
+
parks beside the context ring), and that rule still matches in here — the
|
|
310
|
+
sheet is a DOM descendant of the composer bar even though it paints as a
|
|
311
|
+
fixed layer. Reset it so the sheet follows DOM order instead of
|
|
312
|
+
inheriting a decision that was about a different layout. */
|
|
313
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] > * {
|
|
314
|
+
order: 0 !important;
|
|
315
|
+
}
|
|
316
|
+
/* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);
|
|
317
|
+
in the sheet it is a row, so its label comes back and the icon leads. */
|
|
318
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {
|
|
319
|
+
display: inline !important;
|
|
320
|
+
}
|
|
321
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {
|
|
322
|
+
width: 18px !important;
|
|
323
|
+
height: 18px !important;
|
|
324
|
+
}
|
|
325
|
+
/* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
|
|
326
|
+
trigger. Inside the sheet — which is \`overflow: hidden\` so its rounded
|
|
327
|
+
top clips its content — that menu would be cut off. Same escape section 4
|
|
328
|
+
uses for the host's two menus: position: fixed leaves the clip behind
|
|
329
|
+
(the sheet sets \`transform: none\`, so nothing between here and the
|
|
330
|
+
viewport captures fixed positioning) and it lands as its own sheet on top
|
|
331
|
+
of this one. */
|
|
332
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [role="menu"] {
|
|
333
|
+
position: fixed !important;
|
|
334
|
+
left: 0 !important;
|
|
335
|
+
right: 0 !important;
|
|
336
|
+
bottom: 0 !important;
|
|
337
|
+
top: auto !important;
|
|
338
|
+
margin: 0 !important;
|
|
339
|
+
width: 100% !important;
|
|
340
|
+
min-width: 0 !important;
|
|
341
|
+
max-width: none !important;
|
|
342
|
+
max-height: min(70dvh, 520px) !important;
|
|
343
|
+
box-sizing: border-box !important;
|
|
344
|
+
border-radius: 16px 16px 0 0 !important;
|
|
345
|
+
border-bottom: none !important;
|
|
346
|
+
padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
|
|
347
|
+
/* Above the model sheet it sits on (60, section 4), below the session-info
|
|
348
|
+
sheet's 70 (info.css.ts) — it is a child of the model sheet, not a peer
|
|
349
|
+
of the page's own layers. */
|
|
350
|
+
z-index: 61 !important;
|
|
351
|
+
box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
|
|
352
|
+
}
|
|
353
|
+
${MODEL} > [class$="_menu"] > [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
|
|
354
|
+
min-height: 48px !important;
|
|
355
|
+
border-radius: 12px !important;
|
|
356
|
+
font-size: 15px !important;
|
|
357
|
+
}
|
|
358
|
+
|
|
231
359
|
/* --- 4b. the official scroll-to-bottom button must not poke through the
|
|
232
360
|
sheet (real-device follow-up, 2026-08-17) ---
|
|
233
361
|
ChatView's own "jump to latest" button (aria-label t("chat.toBottom"))
|
|
@@ -262,6 +390,17 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
262
390
|
[class$="_composerSeat"] {
|
|
263
391
|
--dsh-composer-text-max-height: 124px;
|
|
264
392
|
}
|
|
393
|
+
/* Pull the text in from the card's left border. Upstream insets the editing
|
|
394
|
+
host by 16px while the control row below it starts 8px in (row padding,
|
|
395
|
+
section 1), so the placeholder sat noticeably further out than the
|
|
396
|
+
buttons under it and the field read as over-padded on a phone (reported
|
|
397
|
+
2026-09-06). 12px squares it with the 12px of clear space on the right,
|
|
398
|
+
giving the text a symmetric box, and closes most of the gap with the
|
|
399
|
+
button row. Anchored on the editing host's own contract attribute rather
|
|
400
|
+
than a hashed class. */
|
|
401
|
+
[data-slot="conversation.composer.bar"] [data-composer-input] {
|
|
402
|
+
padding-left: 12px !important;
|
|
403
|
+
}
|
|
265
404
|
|
|
266
405
|
/* --- 6. no divider above OR below the message list ---
|
|
267
406
|
Instead of a rule the messages butt against, they fade near both edges.
|
|
@@ -353,7 +492,13 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
353
492
|
composer. Anchored on the official data-testid, a stable contract
|
|
354
493
|
marker rather than a hashed class name. */
|
|
355
494
|
[data-slot="conversation.input.dock"] > [data-testid="todo-panel"] {
|
|
356
|
-
|
|
495
|
+
/* A whole line to itself. It shared line 0 with the pills until the goal
|
|
496
|
+
bar turned up (2026-09-06): two full-width things on one nowrap line
|
|
497
|
+
meant the to-do header shrank to a stub and the goal bar ran off the
|
|
498
|
+
right edge. Both are status strips you read at a glance, so they stack
|
|
499
|
+
— see the line policy in 7c. */
|
|
500
|
+
flex: 1 0 100% !important;
|
|
501
|
+
order: 1 !important;
|
|
357
502
|
min-width: 0 !important;
|
|
358
503
|
max-width: none !important;
|
|
359
504
|
min-height: 0 !important;
|
|
@@ -380,7 +525,7 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
380
525
|
display: flex !important;
|
|
381
526
|
flex: 1 0 100% !important;
|
|
382
527
|
flex-wrap: wrap !important;
|
|
383
|
-
order:
|
|
528
|
+
order: 4 !important;
|
|
384
529
|
align-items: center !important;
|
|
385
530
|
gap: 6px !important;
|
|
386
531
|
max-width: none !important;
|
|
@@ -415,12 +560,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
415
560
|
sits closest to the input it will be sent from. The rail only wraps
|
|
416
561
|
while one of the two full-width entries exists, so a session with
|
|
417
562
|
neither keeps the original single scrolling chip line. */
|
|
418
|
-
[data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"]) {
|
|
563
|
+
[data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"], > [data-testid="todo-panel"], > [data-goal-bar]) {
|
|
419
564
|
flex-wrap: wrap !important;
|
|
420
565
|
}
|
|
421
566
|
[data-slot="conversation.input.dock"] > [data-queue-dock] {
|
|
422
567
|
flex: 1 0 100% !important;
|
|
423
|
-
order:
|
|
568
|
+
order: 3 !important;
|
|
424
569
|
max-width: none !important;
|
|
425
570
|
min-height: 0 !important;
|
|
426
571
|
max-height: none !important;
|
|
@@ -431,6 +576,24 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
431
576
|
font-size: 13px !important;
|
|
432
577
|
line-height: normal !important;
|
|
433
578
|
}
|
|
579
|
+
/* dsh-client-ui-goal's status strip (its own \`data-goal-bar\` marker; it
|
|
580
|
+
registers into this dock at order 10). Fourth thing here that is not a
|
|
581
|
+
pill: a row carrying the objective text plus pause / edit / delete
|
|
582
|
+
buttons, so the 26px cage clipped its buttons off the right edge and the
|
|
583
|
+
nowrap line pushed what was left off screen (reported 2026-09-06). Same
|
|
584
|
+
treatment the queue gets — a line of its own, official geometry back. */
|
|
585
|
+
[data-slot="conversation.input.dock"] > [data-goal-bar] {
|
|
586
|
+
flex: 1 0 100% !important;
|
|
587
|
+
order: 2 !important;
|
|
588
|
+
max-width: none !important;
|
|
589
|
+
min-height: 0 !important;
|
|
590
|
+
max-height: none !important;
|
|
591
|
+
border-radius: 12px !important;
|
|
592
|
+
overflow: visible !important;
|
|
593
|
+
font-size: 13px !important;
|
|
594
|
+
line-height: normal !important;
|
|
595
|
+
}
|
|
596
|
+
|
|
434
597
|
/* The panel is the dock's only child; upstream rounds its top corners only
|
|
435
598
|
and drops the bottom border, because there it is half-hidden behind the
|
|
436
599
|
card. On its own line it is a free-standing strip, so close it up. */
|