dsh-zen-remote 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2463,6 +2463,7 @@ __modules["styles/compat.css.js"] = function (require, module, exports) {
2463
2463
  // Do not reorder: styles/index.ts concatenates in this exact order.
2464
2464
  Object.defineProperty(exports, "__esModule", { value: true });
2465
2465
  exports.COMPAT_CSS = void 0;
2466
+ const attach_upload_ts_1 = require("./attach-upload.js");
2466
2467
  exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
2467
2468
  The linxin666 plugin suite extends the shell frame directly:
2468
2469
  - aionui-panel appends two trailing grid columns (explorer / preview)
@@ -3198,6 +3199,37 @@ exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
3198
3199
  display: none !important;
3199
3200
  }
3200
3201
  }
3202
+
3203
+ /* ---------- dsh-at-file compatibility ----------
3204
+ dsh-at-file registers its own \`conversation.input.dock\` entry
3205
+ ([data-at-file-dock]) and renders one row per \`@path\` token in the
3206
+ draft. That is exactly the same source this plugin's attachment chips
3207
+ read, so on a phone a single uploaded image produced TWO entries on the
3208
+ composer rail: our 48px thumbnail tile AND at-file's file-name row for
3209
+ the same token (reported 2026-08-22).
3210
+
3211
+ Drop at-file's row for the upload directory only. Everything else the
3212
+ user @-mentions by hand is at-file's own job and stays — this is not a
3213
+ blanket hide. The path is readable from the row's own title attribute
3214
+ (FilesDock passes \`title={mention.relative}\`), which is a rendered
3215
+ contract rather than a hashed class name.
3216
+
3217
+ Phone only, and deliberately so: at \u003e= 768px our chips are hidden by
3218
+ composer.css.ts, which leaves at-file's row as the ONLY thing on screen
3219
+ representing the attachment. Hiding it there would make an uploaded file
3220
+ invisible.
3221
+
3222
+ When every row was an upload the container itself is left empty, and an
3223
+ empty flex item still claims the rail's 6px gap — so the dock goes too
3224
+ unless at least one non-upload row survives. */
3225
+ @media (max-width: 767px) {
3226
+ [data-at-file-dock] [data-at-file-row]:has([title^="${attach_upload_ts_1.UPLOAD_DIR}/"]) {
3227
+ display: none !important;
3228
+ }
3229
+ [data-at-file-dock]:not(:has([data-at-file-row] [title]:not([title^="${attach_upload_ts_1.UPLOAD_DIR}/"]))) {
3230
+ display: none !important;
3231
+ }
3232
+ }
3201
3233
  `;
3202
3234
  };
3203
3235
  __modules["styles/misc.css.js"] = function (require, module, exports) {
@@ -4670,9 +4702,18 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
4670
4702
  /* --- 7b. attachment chips (S7.1) ---
4671
4703
  Our own dock entry opts OUT of the 26px pill cage above: an image chip is
4672
4704
  a 48px tile. The row sits tight under the card's top edge, so the chips
4673
- read as part of the composer rather than as a floating strip. */
4705
+ read as part of the composer rather than as a floating strip.
4706
+
4707
+ It also takes the LAST line of its own (line policy in 7c): a 48px tile
4708
+ beside the to-do chip on one nowrap line squeezed that chip down to an
4709
+ unreadable stub, with a long file name pushing the rest off-screen
4710
+ (reported 2026-08-22). Its own chips wrap rather than overflow, so
4711
+ several attachments stack instead of scrolling out of reach. */
4674
4712
  [data-slot="conversation.input.dock"] > [data-mobile-nav="attach-chips"] {
4675
4713
  display: flex !important;
4714
+ flex: 1 0 100% !important;
4715
+ flex-wrap: wrap !important;
4716
+ order: 2 !important;
4676
4717
  align-items: center !important;
4677
4718
  gap: 6px !important;
4678
4719
  max-width: none !important;
@@ -4682,6 +4723,57 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
4682
4723
  overflow: visible !important;
4683
4724
  margin-bottom: -2px;
4684
4725
  }
4726
+
4727
+ /* --- 7c. the queue strip opts OUT of the cage, on a line of its own ---
4728
+ Third entry that is not a pill. conversation.input.dock is a \`list\` slot
4729
+ and DSH's own QueueDock registers into it at order 20 (id \`queue\`,
4730
+ dsh-client-ui-conversation) — a 36px row per queued message, or a
4731
+ collapsible "N 条排队消息" header once there is more than one. The 26px
4732
+ cage above clipped it to a sliver: on a phone a queued message showed as
4733
+ a grey stub poking out from behind the input card, unreadable and with
4734
+ its edit / delete / send buttons cut off (reported 2026-08-22, same
4735
+ failure the to-do card hit in 7a).
4736
+
4737
+ Two things are restored. Geometry: the official row height back, and the
4738
+ dock's own composer-column sizing dropped — its width/max-width are
4739
+ calc()s over the composer card variables and its bottom margin is
4740
+ negative by design (upstream tucks the strip UNDER the card), none of
4741
+ which survives being a flex item of our chip rail.
4742
+
4743
+ Layout — the line policy for the whole rail, now that three things want
4744
+ room on it. The pills (the to-do card, a branch chip, whatever else
4745
+ registers) keep order 0 and share the top line, still scrolling
4746
+ horizontally. The queue takes the next line (order 1). The attachment
4747
+ chips take the last one (order 2, 7b), so what the user is about to send
4748
+ sits closest to the input it will be sent from. The rail only wraps
4749
+ while one of the two full-width entries exists, so a session with
4750
+ neither keeps the original single scrolling chip line. */
4751
+ [data-slot="conversation.input.dock"]:has(> [data-queue-dock], > [data-mobile-nav="attach-chips"]) {
4752
+ flex-wrap: wrap !important;
4753
+ }
4754
+ [data-slot="conversation.input.dock"] > [data-queue-dock] {
4755
+ flex: 1 0 100% !important;
4756
+ order: 1 !important;
4757
+ max-width: none !important;
4758
+ min-height: 0 !important;
4759
+ max-height: none !important;
4760
+ margin: 0 !important;
4761
+ padding: 0 !important;
4762
+ border-radius: 0 !important;
4763
+ overflow: visible !important;
4764
+ font-size: 13px !important;
4765
+ line-height: normal !important;
4766
+ }
4767
+ /* The panel is the dock's only child; upstream rounds its top corners only
4768
+ and drops the bottom border, because there it is half-hidden behind the
4769
+ card. On its own line it is a free-standing strip, so close it up. */
4770
+ [data-slot="conversation.input.dock"] > [data-queue-dock] > * {
4771
+ border-radius: 12px !important;
4772
+ }
4773
+ [data-slot="conversation.input.dock"] > [data-queue-dock] > *::after {
4774
+ border-bottom: 1px solid var(--dsw-alias-border-l1) !important;
4775
+ }
4776
+
4685
4777
  [data-mobile-nav="attach-chip"] {
4686
4778
  position: relative;
4687
4779
  display: inline-flex;
@@ -5140,1996 +5232,2103 @@ exports.INFO_CSS = `/* ---------- session-info sheet (< 768px) ---------- */
5140
5232
  }
5141
5233
  `;
5142
5234
  };
5143
- __modules["styles/turn-fold.css.js"] = function (require, module, exports) {
5235
+ __modules["client-config.js"] = function (require, module, exports) {
5144
5236
  "use strict";
5145
- // turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
5146
- // Everything that can hide content lives inside (max-width: 767px) OR behind
5147
- // html[data-mnav-desktop-fold] (the per-browser desktop opt-in, issue #2);
5148
- // the two markers this file reads (data-mnav-fold on a process element,
5149
- // data-mobile-nav="turn-fold" on the injected summary row) are only ever
5150
- // written by effects/turn-fold.ts, which detaches and wipes them when it is
5151
- // not active — so a non-opted-in tablet/desktop is a no-op twice over, by
5152
- // attribute and by scope. The rule block is authored once and emitted twice
5153
- // (phone media query / desktop attribute scope) so the two can never drift.
5154
- // Appended last in styles/index.ts; it shares no selector with any other
5155
- // file, the position just keeps the "phone files come after the shared
5156
- // <=1023px block" ordering intact.
5237
+ /**
5238
+ * The plugin row's client-facing knobs, fetched once per page.
5239
+ *
5240
+ * The client bundle ships statically and never sees the row config, so the
5241
+ * host republishes the client-relevant subset at {@link CLIENT_CONFIG_ROUTE}
5242
+ * (src/index.ts). Two effects read it — turn-fold's desktop switch and
5243
+ * keyboard-avoid's fallback tuning — so the request is memoized here rather
5244
+ * than fired once per reader.
5245
+ *
5246
+ * Defaults live in THIS file, not in the host: the route omits a knob the
5247
+ * row never set, so there is exactly one place each default is written and
5248
+ * no way for the two halves to drift apart on the value. The host's job is
5249
+ * only to validate and clamp what a user actually typed.
5250
+ *
5251
+ * Every read is total — a failed request, a non-JSON body, or a garbage
5252
+ * value all fall back to the defaults rather than throwing into an effect.
5253
+ */
5157
5254
  Object.defineProperty(exports, "__esModule", { value: true });
5158
- exports.TURN_FOLD_CSS = void 0;
5159
- /** The fold + chip rules, with every selector prefixed by `scope`. */
5160
- const rules = (scope) => `
5161
- /* Folded process: tool-call / context / command rows and the Think
5162
- disclosures inside an assistant step. !important because the official
5163
- flow item and ReasoningRow both set their own display. */
5164
- ${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
5165
- display: none !important;
5166
- }
5167
-
5168
- /* Compact chip, sized like the composer's own pills rather than a button:
5169
- it is a reading affordance in the middle of the message flow, so it has
5170
- to stay quieter than the content around it. The flow column is a flex
5171
- column with gap: 16px (ChatView.module.css) — align-self keeps the chip
5172
- from stretching, and the negative block margin trims that generous gap
5173
- back to something a 26px row can live in. */
5174
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"] {
5175
- display: inline-flex;
5176
- align-self: flex-start;
5177
- align-items: center;
5178
- gap: 6px;
5179
- margin: -4px 0;
5180
- padding: 0 10px;
5181
- height: 26px;
5182
- border: none;
5183
- border-radius: 13px;
5184
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
5185
- color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .55));
5186
- font: inherit;
5187
- font-size: 12px;
5188
- line-height: 26px;
5189
- white-space: nowrap;
5190
- cursor: pointer;
5191
- -webkit-tap-highlight-color: transparent;
5192
- }
5193
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
5194
- background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
5195
- }
5196
-
5197
- /* Leading dot: idle turns get a quiet mark, a running turn gets the same
5198
- business-primary colour the session header's own status dot uses
5199
- (styles/header.css.ts) plus a breathing pulse. */
5200
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
5201
- content: '';
5202
- flex: none;
5203
- width: 6px;
5204
- height: 6px;
5205
- border-radius: 50%;
5206
- background: currentColor;
5207
- opacity: .45;
5208
- }
5209
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
5210
- background: var(--dsw-alias-state-business-primary, #4f6ef7);
5211
- opacity: 1;
5212
- animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
5213
- }
5214
-
5215
- /* Chevron drawn from two borders — no icon injection, no mask image, and
5216
- it rotates to point up once the turn is open. */
5217
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
5218
- content: '';
5219
- flex: none;
5220
- width: 5px;
5221
- height: 5px;
5222
- margin-top: -3px;
5223
- border-right: 1.5px solid currentColor;
5224
- border-bottom: 1.5px solid currentColor;
5225
- transform: rotate(45deg);
5226
- transition: transform .16s var(--ds-ease-out, ease-in-out);
5227
- }
5228
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
5229
- margin-top: 3px;
5230
- transform: rotate(-135deg);
5231
- }
5232
-
5233
- @media (prefers-reduced-motion: reduce) {
5234
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
5235
- animation: none;
5236
- }
5237
- ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
5238
- transition: none;
5239
- }
5240
- }
5241
- `;
5242
- exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px, or desktop opt-in) ---------- */
5243
-
5244
- /* The summary row never paints outside its active scope, even if a
5245
- media-query change raced the effect's own cleanup. */
5246
- [data-mobile-nav="turn-fold"] {
5247
- display: none;
5255
+ exports.KEYBOARD_DEFAULTS = exports.CLIENT_CONFIG_ROUTE = void 0;
5256
+ exports.parseClientConfig = parseClientConfig;
5257
+ exports.clientConfig = clientConfig;
5258
+ /** Mirror of src/index.ts CLIENT_CONFIG_ROUTE (client tsconfig cannot reach it). */
5259
+ exports.CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config';
5260
+ /** Shipped tuning: the values measured on the one reported device (issue #1,
5261
+ * WeType ~315 CSS px on an 858px viewport) plus the Android IME under-report
5262
+ * pad. Changing these changes the default for every install — a per-device
5263
+ * fix belongs in that install's plugin row instead. */
5264
+ exports.KEYBOARD_DEFAULTS = {
5265
+ liftRatio: 0.42,
5266
+ liftMaxPx: 400,
5267
+ safetyPadPx: 15,
5268
+ };
5269
+ /** One published number, or the shipped default when the row left it unset
5270
+ * (the route omits the key) or the body is not what it claims to be. */
5271
+ function num(value, fallback) {
5272
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
5248
5273
  }
5249
-
5250
- @media (max-width: 767px) {${rules('')}}
5251
-
5252
- /* Desktop opt-in: the exact same block, keyed on the root attribute
5253
- effects/turn-fold.ts sets for a browser opted in via
5254
- ?mobile-nav-turn-fold=1 — active at every width. */
5255
- ${rules('html[data-mnav-desktop-fold]')}
5256
-
5257
- @keyframes dsh-mobile-nav-breathe {
5258
- 0%, 100% { opacity: 1; transform: scale(1); }
5259
- 50% { opacity: .4; transform: scale(.72); }
5274
+ /** Parse one route body. Exported for scripts/check-client-config.mjs. */
5275
+ function parseClientConfig(body) {
5276
+ const row = typeof body === 'object' && body !== null
5277
+ ? body
5278
+ : {};
5279
+ return {
5280
+ turnFoldDesktop: row['turnFoldDesktop'] === true,
5281
+ keyboard: {
5282
+ liftRatio: num(row['keyboardLiftRatio'], exports.KEYBOARD_DEFAULTS.liftRatio),
5283
+ liftMaxPx: num(row['keyboardLiftMaxPx'], exports.KEYBOARD_DEFAULTS.liftMaxPx),
5284
+ safetyPadPx: num(row['keyboardSafetyPadPx'], exports.KEYBOARD_DEFAULTS.safetyPadPx),
5285
+ },
5286
+ };
5287
+ }
5288
+ let cached;
5289
+ /** The row config, fetched at most once per page load. Never rejects. */
5290
+ function clientConfig() {
5291
+ cached ??= fetch(exports.CLIENT_CONFIG_ROUTE, { cache: 'no-store' })
5292
+ .then((res) => (res.ok ? res.json() : undefined))
5293
+ .catch(() => undefined)
5294
+ .then(parseClientConfig);
5295
+ return cached;
5260
5296
  }
5261
- `;
5262
5297
  };
5263
- __modules["styles/chips.css.js"] = function (require, module, exports) {
5298
+ __modules["locales.js"] = function (require, module, exports) {
5264
5299
  "use strict";
5265
- // chips — S5 (2026-08-17): the home-screen plugin-entry chips row, its
5266
- // customize sheet, the settings gear button, and the settings/usage-stats
5267
- // dialog portal fix. Appended LAST in index.ts (after home.css.ts) so its
5268
- // `display` overrides win any tie against the phone shell's blanket
5269
- // `[data-mobile-nav="frame"] > :first-child { display: none }` rule — though
5270
- // the extra `:has()` clause already outranks it on specificity alone.
5271
5300
  Object.defineProperty(exports, "__esModule", { value: true });
5272
- exports.CHIPS_CSS = void 0;
5273
- exports.CHIPS_CSS = `/* ---------- chips row + settings entry (S5) ---------- */
5274
-
5275
- @media (max-width: 767px) {
5276
- /* --- settings dialog / usage-stats panel / scheduled-tasks dialog portal fix ---
5277
- dsh-client-ui-settings-general's SettingsRoot (trigger button +
5278
- position:fixed;z-index:1000 modal), dsh-usage-stats' sidebar-footer
5279
- badge (position:fixed panel), and @opendsh/dsh-plugin-scheduled-tasks'
5280
- "定时任务" trigger (position:fixed;z-index:120 \`.dshst-overlay\`,
5281
- verified against 0.2.0 — real-device report 2026-08-17: chip click
5282
- produced no visible window) all mount as PLAIN CHILDREN of the sidebar
5283
- tree — not a React portal to document.body — inside
5284
- sidebar.settings / sidebar.footer.action
5285
- (dsh-client-ui-sidebar's SidebarRoot: footArea > settingsArea /
5286
- footerActions). home.css.ts sets the sidebar ROOT
5287
- (\`[data-mobile-nav="frame"] > :first-child\`) to display:none on the
5288
- phone breakpoint (the app shell replaced the drawer) — and display:none
5289
- removes the WHOLE subtree from the render tree, including
5290
- position:fixed descendants, which cannot escape it the way they escape
5291
- a merely-offscreen or opacity:0 ancestor. A \`.click()\` on the (still
5292
- DOM-present) hidden trigger still flips its component-local \`open\`
5293
- state and mounts the aria-modal dialog (synthetic dispatch bypasses
5294
- hit-testing — S2.1 precedent), but nothing paints without this fix.
5295
-
5296
- Un-hiding the WHOLE sidebar would surface the session tree / new-session
5297
- button underneath the dialog, so this is scoped to exactly the sidebar
5298
- root plus its OWN direct children that are not on the path to
5299
- footArea: display:contents on the root removes its own box (no
5300
- duplicate page background/padding, no grid-column reflow) while
5301
- letting footArea/footerActions/settingsArea resume their OWN official
5302
- (non-none) display — logoRow / newSession / regionArea are hidden
5303
- explicitly instead. :has() matches against the DOM tree regardless of
5304
- the scope element's own computed display, so this reads correctly even
5305
- though the whole subtree started at display:none (same precedent as
5306
- every other :has()-gated rule in this stylesheet, e.g. the settings
5307
- sheet adaptation below, which depends on exactly this becoming
5308
- visible).
5309
-
5310
- \`:first-child\` is a layout-column WRAPPER
5311
- (\`pI_x6G_sidebarCol\`, dsh-client-ui-layout's AppFrame grid cell), not
5312
- SidebarRoot's own div — SidebarRoot (\`hHd-Xa_root\`) sits one more empty
5313
- unstyled div below it (measured 2026-08-17: sidebarCol > div (no
5314
- class) > div.hHd-Xa_root > logoRow/newSession/regionArea/footArea).
5315
- display:contents on sidebarCol lets that whole chain resume its own
5316
- official (non-none) display with no other rule needed, but the
5317
- "hide the other three" selectors below must therefore be DESCENDANT
5318
- (unscoped depth), not direct-child \`>\`, or they silently match
5319
- nothing — confirmed live: an earlier \`>\`-only version left
5320
- logoRow/newSession/regionArea at \`display: flex\`, which is only
5321
- hidden from view by being fully underneath the usage-stats panel's
5322
- opaque body, not by this rule. */
5323
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
5324
- display: contents !important;
5325
- }
5326
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
5327
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
5328
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
5329
- display: none !important;
5330
- }
5331
-
5332
- /* Home-top settings gear: 44pt touch target, pushed to the row's end by
5333
- its own auto margin (home-top itself stays a plain flex row). */
5334
- [data-mobile-nav="home-settings"] {
5335
- flex: none;
5336
- display: inline-flex;
5337
- align-items: center;
5338
- justify-content: center;
5339
- width: 44px;
5340
- height: 44px;
5341
- margin-left: auto;
5342
- padding: 0;
5343
- border: none;
5344
- background: transparent;
5345
- color: var(--dsw-alias-label-secondary, inherit);
5346
- cursor: pointer;
5347
- touch-action: manipulation;
5348
- -webkit-tap-highlight-color: transparent;
5349
- }
5350
- [data-mobile-nav="home-settings"]:active {
5351
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
5352
- border-radius: 50%;
5353
- }
5354
-
5355
- /* Plugin-entry chips row: one horizontally-scrolling line of 34px pills
5356
- between the workspace title and the session list (prototype spec).
5357
- Scrollbar hidden on BOTH engines (2026-08-18 user decision): iOS
5358
- overlay scrollbars never showed anyway, but desktop browsers painted a
5359
- permanent 2px strip under the pills. This revisits the AGENTS.md
5360
- "silently cut off the last tab" lesson deliberately — with 4+ chips the
5361
- row now always overflows mid-chip (a partially visible pill is the
5362
- scroll affordance), unlike the early incident where the chips fit
5363
- flush and the hidden overflow was undiscoverable. */
5364
- [data-mobile-nav="chip-row"] {
5365
- flex: 0 0 auto;
5366
- display: flex;
5367
- align-items: center;
5368
- gap: 8px;
5369
- padding: 0 16px 10px;
5370
- overflow-x: auto;
5371
- overscroll-behavior: contain;
5372
- -webkit-overflow-scrolling: touch;
5373
- scrollbar-width: none;
5374
- }
5375
- [data-mobile-nav="chip-row"]::-webkit-scrollbar {
5376
- display: none;
5377
- }
5378
- [data-mobile-nav="chip"],
5379
- [data-mobile-nav="chip-more"] {
5380
- flex: none;
5381
- display: inline-flex;
5382
- align-items: center;
5383
- gap: 6px;
5384
- height: 34px;
5385
- padding: 0 14px;
5386
- border: none;
5387
- border-radius: 17px;
5388
- /* Same surface as the list rows: the home page is bg-base since the
5389
- 2026-08-20 swap (see home.css.ts), so a bg-base chip would disappear
5390
- into it. */
5391
- background: var(--dsw-specific-sidebar-fill, #f5f5f5);
5392
- color: var(--dsw-alias-label-primary, inherit);
5393
- font-family: inherit;
5394
- font-size: 13px;
5395
- font-weight: 500;
5396
- white-space: nowrap;
5397
- cursor: pointer;
5398
- touch-action: manipulation;
5399
- -webkit-tap-highlight-color: transparent;
5400
- box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
5401
- }
5402
- [data-mobile-nav="chip-more"] {
5403
- padding: 0 9px;
5404
- }
5405
- [data-mobile-nav="chip"]:active,
5406
- [data-mobile-nav="chip-more"]:active {
5407
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
5408
- }
5409
-
5410
- /* Harvested chip icon (S5.1): the cloned <svg> keeps whatever width/height
5411
- the source plugin gave it (18px for the one verified live example) —
5412
- force it down to the same 16px box every hand-registered chip icon
5413
- uses, CSS wins over the presentational SVG attributes. */
5414
- [data-mobile-nav="chip-harvest-icon"] {
5415
- display: inline-flex;
5416
- flex: none;
5417
- }
5418
- [data-mobile-nav="chip-harvest-icon"] svg {
5419
- display: block;
5420
- width: 16px;
5421
- height: 16px;
5422
- }
5423
-
5424
- /* Customize sheet: toggle rows inside the SAME home-sheet-layer/mask/sheet
5425
- chrome the workspace switcher declares in home.css.ts — S6's
5426
- drag-to-close and mask-click-close bind to
5427
- \`[data-mobile-nav="home-sheet"]\` generically (effects/gestures.ts), so
5428
- this second use of the marker needs no new gesture wiring. */
5429
- [data-mobile-nav="chip-toggle-row"] {
5430
- display: flex;
5431
- align-items: center;
5432
- gap: 10px;
5433
- width: 100%;
5434
- /* Real-device fix: without border-box this row's used width is
5435
- 100% (of the sheet's own already-padded content box) PLUS the
5436
- 12px+12px padding below, overflowing the sheet by 24px and
5437
- shoving the flex:none toggle off its right edge (2026-08-17
5438
- report: "开关跑到屏幕外"). home-sheet-item next door in
5439
- home.css.ts has the identical width:100%+padding shape and the
5440
- same latent bug, just with no trailing element to visibly clip. */
5441
- box-sizing: border-box;
5442
- min-height: 48px;
5443
- padding: 0 12px;
5444
- color: inherit;
5445
- }
5446
- [data-mobile-nav="chip-toggle-label"] {
5447
- flex: 1 1 auto;
5448
- min-width: 0;
5449
- font-size: 15px;
5450
- overflow: hidden;
5451
- text-overflow: ellipsis;
5452
- white-space: nowrap;
5453
- }
5454
- [data-mobile-nav="chip-toggle"] {
5455
- flex: none;
5456
- position: relative;
5457
- width: 42px;
5458
- height: 26px;
5459
- border: none;
5460
- border-radius: 13px;
5461
- background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .16));
5462
- cursor: pointer;
5463
- touch-action: manipulation;
5464
- -webkit-tap-highlight-color: transparent;
5465
- transition: background .15s var(--ds-ease-in-out, ease-in-out);
5466
- }
5467
- [data-mobile-nav="chip-toggle"]::after {
5468
- content: '';
5469
- position: absolute;
5470
- top: 2px;
5471
- left: 2px;
5472
- width: 22px;
5473
- height: 22px;
5474
- border-radius: 50%;
5475
- background: #ffffff;
5476
- box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
5477
- transition: transform .15s var(--ds-ease-in-out, ease-in-out);
5478
- }
5479
- [data-mobile-nav="chip-toggle"][aria-checked="true"] {
5480
- background: var(--dsw-alias-button-primary-fill, #1a1a1a);
5481
- }
5482
- [data-mobile-nav="chip-toggle"][aria-checked="true"]::after {
5483
- transform: translateX(16px);
5484
- }
5485
- @media (prefers-reduced-motion: reduce) {
5486
- [data-mobile-nav="chip-toggle"],
5487
- [data-mobile-nav="chip-toggle"]::after {
5488
- transition: none !important;
5489
- }
5490
- }
5491
- }
5492
- `;
5301
+ exports.en = exports.zh = exports.NS = void 0;
5302
+ /** `mobileNav` namespace dictionaries: drawer controls. */
5303
+ exports.NS = 'mobileNav';
5304
+ /** Simplified Chinese dictionary (the key-set source of truth). */
5305
+ exports.zh = {
5306
+ 'open': '打开目录',
5307
+ 'close': '收起目录',
5308
+ 'backdrop': '点击关闭目录',
5309
+ 'sessionLog': '导出会话日志',
5310
+ 'files': '文件浏览',
5311
+ 'previewFullscreen': '全屏预览',
5312
+ 'previewExitFullscreen': '退出全屏',
5313
+ 'allWorkspaces': '全部',
5314
+ 'switchWorkspace': '切换工作区',
5315
+ 'newSession': '新建会话',
5316
+ 'noSessions': '还没有会话,点右下角加号开始',
5317
+ 'homeStatusOngoing': '运行中',
5318
+ 'homeStatusWarning': '待处理',
5319
+ 'homeStatusDone': '已完成',
5320
+ 'backToList': '返回会话列表',
5321
+ 'sessionInfo': '会话信息',
5322
+ 'workbench': '工作台',
5323
+ 'workbenchClose': '关闭工作台',
5324
+ 'switchView': '切换视图',
5325
+ 'attach': '添加附件',
5326
+ 'attachBusy': '上传中…',
5327
+ 'attachFailed': '上传失败,点按重试',
5328
+ 'attachRemove': '移除附件 {name}',
5329
+ 'infoClose': '关闭',
5330
+ 'infoMode': '模式',
5331
+ 'infoCwdFallback': '未知目录',
5332
+ 'infoSubagents': '{count} 个子代理',
5333
+ 'infoJobs': '{count} 个后台任务',
5334
+ 'activityOpen': '查看子代理与后台任务',
5335
+ 'infoStatTurns': '轮次',
5336
+ 'infoStatSteps': '步骤',
5337
+ 'infoStatTtft': '首字延迟',
5338
+ 'infoStatLlm': '模型耗时',
5339
+ 'infoStatTool': '工具耗时',
5340
+ 'infoStatCacheHit': '缓存命中',
5341
+ 'infoTokenFlow': 'Token {io}',
5342
+ 'infoExport': '导出日志',
5343
+ 'infoRename': '重命名',
5344
+ 'infoRenamePrompt': '会话新名称',
5345
+ 'infoFork': 'Fork 会话',
5346
+ 'infoArchive': '归档',
5347
+ 'infoArchiveConfirm': '归档后将从会话列表隐藏,确定继续?',
5348
+ 'infoActionError': '操作失败:{message}',
5349
+ 'turnFold': '过程 · {count} 步',
5350
+ 'turnFoldRunning': '进行中 · {count} 步',
5351
+ 'settings': '设置',
5352
+ 'chipTaskboard': '任务看板',
5353
+ 'chipSsh': 'SSH',
5354
+ 'chipUsage': '用量',
5355
+ 'chipCustomize': '自定义入口',
5493
5356
  };
5494
- __modules["styles/content.css.js"] = function (require, module, exports) {
5357
+ /** English dictionary, key-identical to the Chinese source of truth. */
5358
+ exports.en = {
5359
+ 'open': 'Open directory',
5360
+ 'close': 'Close directory',
5361
+ 'backdrop': 'Click to close directory',
5362
+ 'sessionLog': 'Session log',
5363
+ 'files': 'Files',
5364
+ 'previewFullscreen': 'Fullscreen preview',
5365
+ 'previewExitFullscreen': 'Exit fullscreen',
5366
+ 'allWorkspaces': 'All',
5367
+ 'switchWorkspace': 'Switch workspace',
5368
+ 'newSession': 'New session',
5369
+ 'noSessions': 'No sessions yet — tap + to start one',
5370
+ 'homeStatusOngoing': 'Running',
5371
+ 'homeStatusWarning': 'Needs attention',
5372
+ 'homeStatusDone': 'Done',
5373
+ 'backToList': 'Back to sessions',
5374
+ 'sessionInfo': 'Session info',
5375
+ 'workbench': 'Workbench',
5376
+ 'workbenchClose': 'Close workbench',
5377
+ 'switchView': 'Switch view',
5378
+ 'attach': 'Add attachment',
5379
+ 'attachBusy': 'Uploading…',
5380
+ 'attachFailed': 'Upload failed — tap to retry',
5381
+ 'attachRemove': 'Remove attachment {name}',
5382
+ 'infoClose': 'Close',
5383
+ 'infoMode': 'Mode',
5384
+ 'infoCwdFallback': 'Unknown directory',
5385
+ 'infoSubagents': '{count} subagents',
5386
+ 'infoJobs': '{count} background tasks',
5387
+ 'activityOpen': 'View subagents and background tasks',
5388
+ 'infoStatTurns': 'Turns',
5389
+ 'infoStatSteps': 'Steps',
5390
+ 'infoStatTtft': 'TTFT',
5391
+ 'infoStatLlm': 'LLM time',
5392
+ 'infoStatTool': 'Tool time',
5393
+ 'infoStatCacheHit': 'Cache hit',
5394
+ 'infoTokenFlow': 'Tokens {io}',
5395
+ 'infoExport': 'Export log',
5396
+ 'infoRename': 'Rename',
5397
+ 'infoRenamePrompt': 'New session name',
5398
+ 'infoFork': 'Fork session',
5399
+ 'infoArchive': 'Archive',
5400
+ 'infoArchiveConfirm': 'Archiving hides this session from the list. Continue?',
5401
+ 'infoActionError': 'Action failed: {message}',
5402
+ 'turnFold': 'Process · {count} steps',
5403
+ 'turnFoldRunning': 'Working · {count} steps',
5404
+ 'settings': 'Settings',
5405
+ 'chipTaskboard': 'Task board',
5406
+ 'chipSsh': 'SSH',
5407
+ 'chipUsage': 'Usage',
5408
+ 'chipCustomize': 'Customize shortcuts',
5409
+ };
5410
+ };
5411
+ __modules["effects/turn-fold.js"] = function (require, module, exports) {
5495
5412
  "use strict";
5496
- // content — chat markdown readability on phones (2026-08-18). Shares no
5497
- // selector with any other file; appended last in styles/index.ts.
5498
5413
  Object.defineProperty(exports, "__esModule", { value: true });
5499
- exports.CONTENT_CSS = void 0;
5500
- exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ---------- */
5501
-
5502
- @media (max-width: 767px) {
5503
- /* Inline code: upstream renders it display:inline-flex — an atomic inline
5504
- box that can never wrap across lines, so one long code span walks off
5505
- the right edge of the screen. Plain inline keeps the background pill
5506
- per line fragment and lets long tokens break anywhere. Deliberately
5507
- unscoped (any :not(pre) > code, not just the _markdown_ module):
5508
- inline code also renders outside that container (user bubbles,
5509
- reasoning rows), and on a phone there is no width at which an
5510
- unwrappable inline box is right. !important beats the upstream
5511
- display:inline-flex on specificity ties. */
5512
- :not(pre) > code {
5513
- display: inline !important;
5514
- overflow-wrap: anywhere;
5515
- /* iOS WebKit: overflow-wrap alone still failed to break long tokens in
5516
- inline code on a real iPhone (screenshot-verified 2026-08-18, li >
5517
- code path case) while desktop Chromium broke them fine. break-all is
5518
- the everywhere-supported hammer; acceptable for code pills. */
5519
- word-break: break-all;
5520
- }
5521
- /* Belt-and-suspenders for engines that compute soft-wrap opportunities
5522
- from the block container rather than the inline itself (historical
5523
- WebKit behavior): inherit anywhere through the whole transcript so the
5524
- block side agrees. Prose is untouched until something would overflow;
5525
- the table is already pinned wide by min-width: max-content below. */
5526
- [data-chat-flow],
5527
- [class*="_markdown_"] {
5528
- overflow-wrap: anywhere;
5529
- }
5530
-
5531
- /* Transcript density (2026-08-20): the desktop prose rhythm is 28px of
5532
- leading on 15px text — a 1.87 ratio that reads generously on a wide
5533
- column but wastes a phone screen, where the same paragraph is three
5534
- times as many lines. Unitless 1.65 so nested blocks (code, quotes,
5535
- lists) recompute from their OWN font-size instead of inheriting a fixed
5536
- 28px that is far too loose for 12-13px chrome text. 1.65 is the floor I
5537
- am willing to go to for mixed CJK/Latin: the glyphs are full-height with
5538
- no descender relief, so tighter starts to look stacked. Phone only —
5539
- the desktop column keeps the official rhythm. */
5540
- [class*="_markdown_"] {
5541
- line-height: 1.65;
5542
- }
5543
- /* Paragraph rhythm. Each paragraph is its own markdown block and the
5544
- separation comes from the flex container's row-gap, not from margins —
5545
- at the stock 16px the paragraph baseline step was 40.75px against a
5546
- 24.75px line step, i.e. 1.65x, which is what reads as airy. 12px brings
5547
- the step to ~36.75px: still clearly more than one line so paragraphs
5548
- never run together, but noticeably denser. Scoped by :has() to
5549
- containers that actually hold prose blocks, so tool cards and other
5550
- flex bodies with the same hashed suffix keep their own spacing. */
5551
- [data-phase] [class$="_body"]:has(> [class*="_markdown_"]) {
5552
- row-gap: 12px !important;
5553
- }
5554
-
5555
- /* Markdown tables: the official _tableScroll_ wrapper is already a
5556
- horizontal scroll viewport around a width:max-content table, but its
5557
- cells are capped at min(30vw, 320px) — barely 120px on a phone, which
5558
- crushes every column into a vertical word-stack. Let columns size to
5559
- their content instead and read the overflow by scrolling the wrapper.
5560
- 480px (not a vw cap — 85vw was ~320px on a 375px phone and still
5561
- wrapped mid-length cells) keeps typical cells on one line; only truly
5562
- prose-heavy cells wrap, instead of dragging the table kilometers
5563
- wide. */
5564
- [class*="_tableScroll_"] th,
5565
- [class*="_tableScroll_"] td {
5566
- max-width: 480px !important;
5567
- }
5568
-
5569
- /* iOS WebKit fallback: upstream's own table { width: max-content } is what
5570
- keeps the table at its natural width inside the scroll wrapper, but
5571
- WebKit's max-content support on table boxes is incomplete — on a real
5572
- iPhone the table fell back to shrink-to-fit and every column got
5573
- crushed to its min-width (the "ra/nk" vertical-word-stack screenshot,
5574
- 2026-08-18). Redeclare the natural-width intent with both width and
5575
- min-width so whichever keyword the engine honors wins; the wrapper
5576
- already scrolls the overflow. */
5577
- [class*="_tableScroll_"] table {
5578
- width: max-content !important;
5579
- min-width: max-content !important;
5580
- max-width: none !important;
5581
- }
5582
-
5583
- /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
5584
- 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
5585
- [data-time-hover-root] gate holds them at opacity 0 until :hover /
5586
- :focus-within) — a phone never hovers, so they were simply invisible;
5587
- and even ignoring that, the row is nowrap + overflow:hidden (plus this
5588
- plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
5589
- text. On phones: always visible, own full-width line under the action
5590
- icons, wrapping freely, one type size down. !important + the extra
5591
- [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
5592
- /* Pin the tail row's width chain before allowing wrap: with wrap enabled
5593
- the row's intrinsic (shrink-to-fit) width collapses to its widest
5594
- single icon, taking the whole tail down to ~16px. */
5595
- [data-chat-flow-kind="turn-tail"] [class$="_root"],
5596
- [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
5597
- width: 100%;
5598
- }
5599
- [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
5600
- flex-wrap: wrap;
5601
- row-gap: 2px;
5602
- }
5603
- /* Wide companion to the rule below: mirrors the upstream hover-gate
5604
- selector shape directly, with no assumptions about the tail's DOM
5605
- structure, so the stats can't stay invisible even if the phone's tail
5606
- renders differently than desktop. */
5607
- [data-time-hover-root] [class$="_timeEnd"],
5608
- [data-time-hover-root] [class$="_timeStart"] {
5609
- opacity: 1 !important;
5610
- transition: none !important;
5611
- }
5612
- [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
5613
- opacity: 1 !important;
5614
- flex-basis: 100% !important;
5615
- white-space: normal !important;
5616
- overflow: visible !important;
5617
- text-overflow: clip !important;
5618
- padding-left: 0 !important;
5619
- font-size: 12px;
5620
- line-height: 18px;
5621
- }
5414
+ exports.OPEN = exports.THINK = exports.PROCESS_KINDS = void 0;
5415
+ exports.installTurnFold = installTurnFold;
5416
+ const client_config_ts_1 = require("./client-config.js");
5417
+ const locales_ts_1 = require("./locales.js");
5418
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
5419
+ const PHONE_QUERY = '(max-width: 767px)';
5420
+ /** Desktop fold (issue #2) is switched on either way:
5421
+ * - plugin row `config.turnFoldDesktop: true` — the host republishes it at
5422
+ * {@link CLIENT_CONFIG_ROUTE} because the client bundle ships statically
5423
+ * and never sees the row config;
5424
+ * - per-browser override, same convention as the debug flag: a URL param
5425
+ * writes localStorage (`?mobile-nav-turn-fold=1` opts this browser in,
5426
+ * `=0` opts back out). */
5427
+ const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop';
5428
+ const DESKTOP_PARAM = 'mobile-nav-turn-fold';
5429
+ /** Root attribute the stylesheet keys the width-independent rules on. */
5430
+ const DESKTOP_ATTR = 'data-mnav-desktop-fold';
5431
+ /** Read (and, when the URL param is present, persist) the per-browser opt-in. */
5432
+ function desktopOptIn() {
5433
+ const param = new URLSearchParams(location.search).get(DESKTOP_PARAM);
5434
+ if (param === '1')
5435
+ localStorage.setItem(DESKTOP_KEY, '1');
5436
+ else if (param === '0')
5437
+ localStorage.removeItem(DESKTOP_KEY);
5438
+ return localStorage.getItem(DESKTOP_KEY) === '1';
5622
5439
  }
5623
- `;
5624
- };
5625
- __modules["styles/index.js"] = function (require, module, exports) {
5626
- "use strict";
5627
- Object.defineProperty(exports, "__esModule", { value: true });
5628
- exports.MOBILE_CSS = void 0;
5629
- const base_css_ts_1 = require("./styles/base.css.js");
5630
- const layout_css_ts_1 = require("./styles/layout.css.js");
5631
- const compat_css_ts_1 = require("./styles/compat.css.js");
5632
- const misc_css_ts_1 = require("./styles/misc.css.js");
5633
- const home_css_ts_1 = require("./styles/home.css.js");
5634
- const header_css_ts_1 = require("./styles/header.css.js");
5635
- const composer_css_ts_1 = require("./styles/composer.css.js");
5636
- const info_css_ts_1 = require("./styles/info.css.js");
5637
- const turn_fold_css_ts_1 = require("./styles/turn-fold.css.js");
5638
- const chips_css_ts_1 = require("./styles/chips.css.js");
5639
- const content_css_ts_1 = require("./styles/content.css.js");
5440
+ /** True when the plugin row asks for desktop fold; false on any failure —
5441
+ * the shared reader in client-config.ts already swallows those. */
5442
+ async function desktopConfigured() {
5443
+ return (await (0, client_config_ts_1.clientConfig)()).turnFoldDesktop;
5444
+ }
5445
+ /** ChatView's flow column (dsh-client-ui-conversation lib/client.js:5512).
5446
+ * Trajectory and every other view render their own tree and never carry this
5447
+ * marker, so scoping here is what keeps this effect Chat-only. */
5448
+ const FLOW = '[data-chat-flow]';
5449
+ /** Marker + selector of the injected summary row. */
5450
+ const CHIP = 'turn-fold';
5451
+ const CHIP_SELECTOR = '[data-mobile-nav="turn-fold"]';
5452
+ /** Whole-row node kinds that are turn *process*, not conversation content.
5453
+ * Keys are ChatNodeSeat's own `data-chat-flow-kind` dispatch values
5454
+ * (registerChatNodeRenderers, lib/client.js:9322): tool calls, injected
5455
+ * context rows, and slash-command rows. Everything else stays visible —
5456
+ * `user`/`steering` (the question), `assistant-step` (the prose, folded
5457
+ * per-block below), `turn-tail` (the footer with its actions), and the
5458
+ * error notices `turn-error` / `turn-max-tokens` / `model-retry`, which are
5459
+ * exactly what a reader must not have to hunt for. */
5460
+ exports.PROCESS_KINDS = ['context', 'tool-call', 'command'];
5461
+ const PROCESS_KIND_SET = new Set(exports.PROCESS_KINDS);
5462
+ /** Node kinds that open a new turn group. */
5463
+ const TURN_HEAD_KINDS = new Set(['user', 'steering']);
5464
+ /** ReasoningRow's own marker (lib/client.js:8966) — the Think disclosure
5465
+ * rendered inside an assistant-step row, beside that step's prose. */
5466
+ exports.THINK = '[data-variant="think"]';
5467
+ /** Attribute the phone stylesheet turns into `display: none`. Still written
5468
+ * per element for the one case the stylesheet cannot express on its own: an
5469
+ * assistant-step row whose entire body is reasoning, which has to go as a
5470
+ * whole row (see {@link foldsWholeRow}). */
5471
+ const FOLD = 'data-mnav-fold';
5472
+ /** Per-item override written while its turn is expanded. */
5473
+ exports.OPEN = 'data-mnav-fold-open';
5474
+ /** Root attribute written for as long as this effect is attached — i.e. for
5475
+ * exactly the widths where the fold is live. The stylesheet keys its
5476
+ * born-folded rules on it, which is what makes a newly inserted tool call
5477
+ * hidden at its FIRST paint instead of after the next rescan (the "row
5478
+ * flashes in, then folds away" report, 2026-08-22); it also keeps the
5479
+ * stylesheet a no-op — content visible — if this effect never runs. */
5480
+ const ACTIVE_ATTR = 'data-mnav-fold-on';
5640
5481
  /**
5641
- * All mobile styles, concatenated in the exact order of the original
5642
- * single-file stylesheet (base → layout → compat → misc, where misc keeps
5643
- * composer → tablet → desktop), followed by the phone app shell (home), the
5644
- * session-header reflow (header), the composer reflow (composer), and the
5645
- * session-info sheet (info, which must come last of all: it re-shows a
5646
- * header.utilities child header.css.ts hides by default, so its rule has to
5647
- * win that tie too), the turn-process fold (turn-fold, S8, which shares no
5648
- * selector with any of them), and finally the chips row / settings entry /
5649
- * portal fix (chips, S5) — all appended in this order so their <768px rules
5650
- * win ties against the shared <=1023px block, then the chat markdown
5651
- * readability overrides (content, which shares no selector with any of
5652
- * them). Injected as ONE <style data-plugin> tag — do not reorder.
5482
+ * True when the reasoning rows are the ONLY thing this row renders, so the
5483
+ * whole flow item has to go: the flow column is `display: flex` with
5484
+ * `gap: 16px`, and an emptied-out flex item still claims its gap — the
5485
+ * reader would see the folded steps as a column of blank bands.
5486
+ * Structural test, no class names: every ancestor from the reasoning rows'
5487
+ * shared parent up to the flow item must be an only child.
5653
5488
  */
5654
- exports.MOBILE_CSS = [base_css_ts_1.BASE_CSS, layout_css_ts_1.LAYOUT_CSS, compat_css_ts_1.COMPAT_CSS, misc_css_ts_1.MISC_CSS, home_css_ts_1.HOME_CSS, header_css_ts_1.HEADER_CSS, composer_css_ts_1.COMPOSER_CSS, info_css_ts_1.INFO_CSS, turn_fold_css_ts_1.TURN_FOLD_CSS, chips_css_ts_1.CHIPS_CSS, content_css_ts_1.CONTENT_CSS].join('\n');
5655
- };
5656
- __modules["debug.js"] = function (require, module, exports) {
5657
- "use strict";
5658
- Object.defineProperty(exports, "__esModule", { value: true });
5659
- exports.installDebugBadge = installDebugBadge;
5489
+ function foldsWholeRow(row, thinks) {
5490
+ const first = thinks[0];
5491
+ if (first === undefined)
5492
+ return false;
5493
+ const body = first.parentElement;
5494
+ if (body === null || body.childElementCount !== thinks.length)
5495
+ return false;
5496
+ let node = body;
5497
+ while (node !== row) {
5498
+ const parent = node.parentElement;
5499
+ if (parent === null)
5500
+ return false;
5501
+ if (parent.childElementCount !== 1)
5502
+ return false;
5503
+ node = parent;
5504
+ }
5505
+ return true;
5506
+ }
5507
+ /** The process items one flow row contributes: the row itself, its reasoning
5508
+ * rows, or nothing at all. */
5509
+ function processItems(row) {
5510
+ const kind = row.getAttribute('data-chat-flow-kind');
5511
+ if (kind === null)
5512
+ return [];
5513
+ if (PROCESS_KIND_SET.has(kind))
5514
+ return [row];
5515
+ if (kind !== 'assistant-step')
5516
+ return [];
5517
+ const thinks = [...row.querySelectorAll(exports.THINK)];
5518
+ if (thinks.length === 0)
5519
+ return [];
5520
+ return foldsWholeRow(row, thinks) ? [row] : thinks;
5521
+ }
5660
5522
  /**
5661
- * Debug badge — ?mobile-nav-debug=1
5662
- * Renders a live state overlay (URL, viewport, media queries, shell chrome,
5663
- * aionui columns, genui cards, captured errors) so a phone-side repro can be
5664
- * diagnosed without guessing. No-op unless the query param is present.
5523
+ * The summary row sitting immediately before `anchor`, created if absent.
5524
+ * Idempotent by construction — React re-rendering the flow either leaves our
5525
+ * button where it is (it only ever inserts and removes its OWN nodes) or
5526
+ * drops it, and the next rescan puts it back.
5665
5527
  */
5666
- function installDebugBadge(ctx) {
5667
- /**
5668
- * Fake safe-area inset — ?mobile-nav-inset=54 or ?mobile-nav-inset=54,34
5669
- * env(safe-area-inset-*) is hard 0 on every desktop browser (CDP included),
5670
- * so notch bugs are invisible until a real phone loads the page — the S2.1
5671
- * hotfix exists because of exactly that. Overriding --mnav-sat / --mnav-sab
5672
- * (see styles/base.css.ts) on the root element reproduces a notch anywhere.
5673
- * No param = the variables keep their env() values = zero behaviour change.
5674
- *
5675
- * One value fakes the TOP inset only (the historic behaviour — every S1-S3
5676
- * verification recipe passes `=54`, and they must keep meaning what they
5677
- * did). A second, comma-separated value fakes the BOTTOM inset too:
5678
- * `=54,34` is the iPhone notch + home-indicator pair. The bottom half was
5679
- * added in S4.1 because the home-bar padding it guards (composer clearance
5680
- * over the indicator) is otherwise untestable off-device for exactly the
5681
- * same reason the top half exists.
5682
- */
5683
- ctx.effect(() => {
5684
- const raw = new URLSearchParams(location.search).get('mobile-nav-inset');
5685
- if (raw === null)
5686
- return () => { };
5687
- const [topRaw, bottomRaw] = raw.split(',');
5688
- const top = Number(topRaw);
5689
- if (!Number.isFinite(top))
5690
- return () => { };
5691
- const root = document.documentElement;
5692
- root.style.setProperty('--mnav-sat', `${top}px`);
5693
- const bottom = bottomRaw === undefined ? Number.NaN : Number(bottomRaw);
5694
- if (Number.isFinite(bottom))
5695
- root.style.setProperty('--mnav-sab', `${bottom}px`);
5696
- return () => {
5697
- root.style.removeProperty('--mnav-sat');
5698
- root.style.removeProperty('--mnav-sab');
5699
- };
5700
- }, 'dsh-mobile-nav: fake safe-area inset');
5701
- const DEBUG_KEY = 'dsh-mobile-nav.debug';
5702
- /**
5703
- * No-URL toggle — 5 quick taps on the home screen's top bar.
5704
- * A standalone PWA has no address bar, and the paired-device cookie lives
5705
- * in the PWA's own jar (Safari hits the pairing wall instead), so the
5706
- * ?mobile-nav-debug=1 param is unreachable exactly where the badge is
5707
- * needed most.
5708
- *
5709
- * The target is deliberately NOT [data-mobile-nav="ws-switch"] any more:
5710
- * that button opens the workspace sheet on the FIRST tap, so taps 2..5
5711
- * land on the sheet backdrop, `closest()` misses, and the counter never
5712
- * reaches five — the original binding only ever fired by luck, and it left
5713
- * a user stuck inside debug mode with no way back out. The logo img has no
5714
- * click handler at all; the home-top container minus the switch button is
5715
- * the fallback for when the icon 404s and the img is not rendered.
5716
- */
5528
+ function chipBefore(anchor) {
5529
+ const previous = anchor.previousElementSibling;
5530
+ if (previous instanceof HTMLElement && previous.dataset['mobileNav'] === CHIP)
5531
+ return previous;
5532
+ const parent = anchor.parentElement;
5533
+ if (parent === null)
5534
+ return null;
5535
+ const chip = document.createElement('button');
5536
+ chip.type = 'button';
5537
+ chip.dataset['mobileNav'] = CHIP;
5538
+ parent.insertBefore(chip, anchor);
5539
+ return chip;
5540
+ }
5541
+ /** Split the flow column into turns: a group runs from one user/steering row
5542
+ * up to (not including) the next. Rows that precede the first user row —
5543
+ * a turn whose question has not been paged in yet — form a leading group of
5544
+ * their own, so their process never becomes unreachable. */
5545
+ function groupsOf(flow) {
5546
+ const groups = [];
5547
+ let current;
5548
+ for (const row of flow.children) {
5549
+ const kind = row.getAttribute('data-chat-flow-kind');
5550
+ if (kind === null)
5551
+ continue;
5552
+ if (current === undefined || TURN_HEAD_KINDS.has(kind)) {
5553
+ current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [] };
5554
+ groups.push(current);
5555
+ }
5556
+ current.items.push(...processItems(row));
5557
+ }
5558
+ return groups;
5559
+ }
5560
+ /**
5561
+ * S8 — collapse a turn's process into one summary row (< 768px by default;
5562
+ * every width when the plugin row sets `config.turnFoldDesktop` or a browser
5563
+ * opts itself in via `?mobile-nav-turn-fold=1`).
5564
+ *
5565
+ * Route taken: DOM marking, not the `conversation.chat.node` keyed slot.
5566
+ * That slot dispatches on node kind and a second registration at a key
5567
+ * SHADOWS the official renderer (dsh-client-ui-slots index.d.ts:542 — the
5568
+ * cell's lowest-priority live entry renders, there is no wrapping form and
5569
+ * no `children` handle to the shadowed component). Taking `tool-call` would
5570
+ * mean re-implementing every tool's presentation, its `t` seat comes from
5571
+ * the entry's own declared locale namespace and its services from the
5572
+ * registrant's own inject face — neither is reachable from here — and the
5573
+ * registration is global, so the desktop no-op would be gone too.
5574
+ *
5575
+ * The DOM route needs no class names: ChatNodeSeat stamps every row with
5576
+ * `data-chat-flow-kind` / `data-chat-flow-key` (lib/client.js:5228) and
5577
+ * ReasoningRow stamps `data-variant="think"` — turn grouping is pure
5578
+ * structure (the run of rows following one `user` row), never text.
5579
+ *
5580
+ * ponytail: one rAF-coalesced full rescan per DOM mutation batch, O(rows).
5581
+ * If a very long session ever makes streaming feel heavy, narrow the
5582
+ * observer to the flow column's own childList and diff instead.
5583
+ */
5584
+ function installTurnFold(ctx) {
5717
5585
  ctx.effect(() => {
5718
- let taps = 0;
5719
- let firstTap = 0;
5720
- const onTap = (event) => {
5721
- if (!(event.target instanceof Element))
5722
- return;
5723
- const inTopBar = event.target.closest('[data-mobile-nav="home-logo"]') !== null
5724
- || (event.target.closest('[data-mobile-nav="home-top"]') !== null
5725
- && event.target.closest('[data-mobile-nav="ws-switch"]') === null);
5726
- if (!inTopBar)
5586
+ const narrow = window.matchMedia(PHONE_QUERY);
5587
+ const t = ctx.locale.bind(locales_ts_1.NS);
5588
+ /** Turns the reader opened. Deliberately not persisted (spec: a reload
5589
+ * returns to the default folded state). */
5590
+ const expanded = new Set();
5591
+ let observer = null;
5592
+ let frame = 0;
5593
+ const label = (chip, count, running) => {
5594
+ const next = running ? t('turnFoldRunning', { count }) : t('turnFold', { count });
5595
+ if (chip.textContent !== next)
5596
+ chip.textContent = next;
5597
+ };
5598
+ const scan = () => {
5599
+ const flow = document.querySelector(FLOW);
5600
+ if (flow === null)
5727
5601
  return;
5728
- const now = Date.now();
5729
- if (now - firstTap > 2500) {
5730
- taps = 0;
5731
- firstTap = now;
5732
- }
5733
- taps += 1;
5734
- if (taps >= 5) {
5735
- if (localStorage.getItem(DEBUG_KEY) === '1')
5736
- localStorage.removeItem(DEBUG_KEY);
5602
+ // TurnStatus, ChatView's own running banner — present only while the
5603
+ // last turn is still working (lib/client.js:5548).
5604
+ const running = flow.querySelector(':scope > [role="status"]') !== null;
5605
+ const groups = groupsOf(flow);
5606
+ const live = new Set();
5607
+ const liveItems = new Set();
5608
+ groups.forEach((group, index) => {
5609
+ if (group.items.length === 0)
5610
+ return;
5611
+ const open = expanded.has(group.key);
5612
+ for (const item of group.items) {
5613
+ liveItems.add(item);
5614
+ item.setAttribute(FOLD, '');
5615
+ if (open)
5616
+ item.setAttribute(exports.OPEN, '');
5617
+ else
5618
+ item.removeAttribute(exports.OPEN);
5619
+ }
5620
+ const first = group.items[0];
5621
+ if (first === undefined)
5622
+ return;
5623
+ const anchor = first.closest('[data-chat-flow-key]') ?? first;
5624
+ const chip = chipBefore(anchor);
5625
+ if (chip === null)
5626
+ return;
5627
+ chip.dataset['foldGroup'] = group.key;
5628
+ chip.setAttribute('aria-expanded', open ? 'true' : 'false');
5629
+ if (open)
5630
+ chip.dataset['open'] = '';
5737
5631
  else
5738
- localStorage.setItem(DEBUG_KEY, '1');
5739
- location.reload();
5632
+ delete chip.dataset['open'];
5633
+ const groupRunning = running && index === groups.length - 1;
5634
+ if (groupRunning)
5635
+ chip.dataset['running'] = '';
5636
+ else
5637
+ delete chip.dataset['running'];
5638
+ label(chip, group.items.length, groupRunning);
5639
+ live.add(group.key);
5640
+ });
5641
+ for (const chip of flow.querySelectorAll(CHIP_SELECTOR)) {
5642
+ const key = chip.dataset['foldGroup'];
5643
+ if (key === undefined || !live.has(key))
5644
+ chip.remove();
5645
+ }
5646
+ // Unmark elements that stopped being process items. Without this, an
5647
+ // assistant-step row folded whole while it streamed ONLY reasoning kept
5648
+ // its row-level fold after prose started arriving in the same DOM node
5649
+ // (React updates the row in place), hiding the streaming reply until a
5650
+ // completion re-render rebuilt the row — the "reply only appears when
5651
+ // the turn finishes" phone bug (2026-08-18).
5652
+ for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
5653
+ if (liveItems.has(stale))
5654
+ continue;
5655
+ stale.removeAttribute(FOLD);
5656
+ stale.removeAttribute(exports.OPEN);
5740
5657
  }
5741
5658
  };
5742
- document.addEventListener('click', onTap, true);
5743
- return () => document.removeEventListener('click', onTap, true);
5744
- }, 'dsh-mobile-nav: debug badge tap toggle');
5745
- ctx.effect(() => {
5746
- const enabled = new URLSearchParams(location.search).has('mobile-nav-debug')
5747
- || localStorage.getItem('dsh-mobile-nav.debug') === '1';
5748
- if (!enabled)
5749
- return () => { };
5750
- const errors = [];
5751
- const onError = (event) => errors.push(`ERR ${event.message.slice(0, 120)}`);
5752
- const onRejection = (event) => errors.push(`REJ ${String(event.reason).slice(0, 120)}`);
5753
- window.addEventListener('error', onError);
5754
- window.addEventListener('unhandledrejection', onRejection);
5755
- /* Probe element: reads the REAL env(safe-area-inset-*) as computed padding
5756
- — the --mnav-* vars can be faked by the inset param, this cannot. */
5757
- const probe = document.createElement('div');
5758
- probe.style.cssText =
5759
- 'position:fixed;visibility:hidden;pointer-events:none;' +
5760
- 'padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)';
5761
- document.body.appendChild(probe);
5762
- const badge = document.createElement('div');
5763
- badge.style.cssText = [
5764
- /* Sits BELOW the notch on purpose: at the old flat top:40px the badge
5765
- painted into the status-bar strip and covered the session header,
5766
- which read on a screenshot as "the header ran under the notch in
5767
- debug mode" — a measurement artefact that cost a whole round of
5768
- diagnosis. env() here, not --mnav-sat: the badge must stay put even
5769
- when the inset param is faking that variable. */
5770
- 'position:fixed', 'top:calc(env(safe-area-inset-top, 0px) + 8px)', 'right:6px', 'z-index:2147483000',
5771
- 'background:rgba(0,0,0,.82)', 'color:#fff', 'font:11px/1.5 ui-monospace,monospace',
5772
- 'padding:8px 10px', 'border-radius:8px', 'max-width:94vw', 'max-height:70vh',
5773
- /* pointer-events must be auto for the escape hatch below — with `none`
5774
- the badge could only be dismissed by editing localStorage, which is
5775
- not reachable from a standalone PWA. touch-action/user-select keep
5776
- the double tap from being read as zoom or text selection. */
5777
- 'overflow:auto', 'white-space:pre-wrap', 'pointer-events:auto',
5778
- 'touch-action:manipulation', '-webkit-user-select:none', 'user-select:none',
5779
- ].join(';');
5780
- /* Escape hatch: two taps on the badge within 600ms turn debug off. The
5781
- enable gesture lives on the home screen, which a user deep inside a
5782
- session cannot reach without first getting out of the way of the badge
5783
- — so the off switch belongs on the badge itself. */
5784
- /* The full read-out is tall enough to cover the session list, and with
5785
- pointer-events:auto (required for the escape hatch) it swallowed every
5786
- tap under it — the user could not open a session with debug on. The
5787
- badge therefore starts COLLAPSED (a one-line pill showing vh, the
5788
- number that matters for the viewport-shrink bug) and only expands on
5789
- demand. Single tap toggles collapsed/expanded (deferred 620ms so it
5790
- can be distinguished from the exit gesture); double tap still exits. */
5791
- let lastTap = 0;
5792
- let collapsed = true;
5793
- let singleTapTimer;
5794
- const onBadgeTap = (event) => {
5795
- event.preventDefault();
5796
- event.stopPropagation();
5797
- const now = Date.now();
5798
- if (now - lastTap < 600) {
5799
- if (singleTapTimer !== undefined)
5800
- clearTimeout(singleTapTimer);
5801
- localStorage.removeItem(DEBUG_KEY);
5802
- const url = new URL(location.href);
5803
- url.searchParams.delete('mobile-nav-debug');
5804
- location.replace(url.toString());
5659
+ const schedule = () => {
5660
+ // The locale subscription below lives outside attach/detach, so this
5661
+ // guard is what keeps a late dictionary registration from marking up
5662
+ // the flow at >= 768px (real WebKit regression: chips and fold
5663
+ // attributes appeared at 1280px — invisible, since every rule that
5664
+ // hides anything is inside the phone media query, but still wrong).
5665
+ if (observer === null)
5805
5666
  return;
5806
- }
5807
- lastTap = now;
5808
- singleTapTimer = setTimeout(() => {
5809
- collapsed = !collapsed;
5810
- paint();
5811
- }, 620);
5667
+ if (frame !== 0)
5668
+ return;
5669
+ frame = requestAnimationFrame(() => {
5670
+ frame = 0;
5671
+ scan();
5672
+ // Drop the records our own chip insertions just queued, so a rescan
5673
+ // can never re-trigger itself (the debug-badge feedback-loop freeze).
5674
+ observer?.takeRecords();
5675
+ });
5812
5676
  };
5813
- badge.addEventListener('click', onBadgeTap);
5814
- const read = () => {
5815
- const q = (sel) => !!document.querySelector(sel);
5816
- const vis = (sel) => {
5817
- const el = document.querySelector(sel);
5818
- return el === null ? 'absent' : getComputedStyle(el).visibility;
5819
- };
5820
- const frame = document.querySelector('[data-mobile-nav="frame"]');
5821
- if (collapsed)
5822
- return `DBG vh ${innerHeight}/${screen.height} ▸ 单击展开 双击关闭`;
5823
- return [
5824
- '▾ 单击收起 · 双击关闭 debug',
5825
- `URL ${location.pathname}${location.search}`,
5826
- `W ${innerWidth} x ${innerHeight} dpr ${devicePixelRatio}`,
5827
- `mq≤1023 ${matchMedia('(max-width: 1023px)').matches} mq≥1024 ${matchMedia('(min-width: 1024px)').matches}`,
5828
- `css ${q('style[data-plugin-css*="mobile"]')} frame ${!!frame}`,
5829
- `previewCol ${vis('[data-aionui-preview-col]')} explorerCol ${vis('[data-aionui-explorer-col]')}`,
5830
- `previewOpen ${frame?.hasAttribute('data-aionui-preview-open') ?? '?'} explorerOpen ${frame?.hasAttribute('data-aionui-explorer-open') ?? '?'} previewFull ${frame?.hasAttribute('data-mobile-preview-full') ?? '?'}`,
5831
- `header ${vis('[data-phase] header')} h${Math.round(document.querySelector('[data-phase] header')?.getBoundingClientRect().height ?? 0)} composer ${q('textarea')}`,
5832
- `sat ${getComputedStyle(document.documentElement).getPropertyValue('--mnav-sat').trim() || '?'} sab ${getComputedStyle(document.documentElement).getPropertyValue('--mnav-sab').trim() || '?'}`,
5833
- (() => {
5834
- /* Bottom-gap forensics: real env values + who ends where. */
5835
- const ps = getComputedStyle(probe);
5836
- const bottomOf = (sel) => {
5837
- const el = document.querySelector(sel);
5838
- return el === null ? '—' : String(Math.round(el.getBoundingClientRect().bottom));
5839
- };
5840
- /* Composer ledger: bottom/gap-to-viewport for every layer between
5841
- the textarea and the screen edge, so a "white band under the
5842
- composer" screenshot says WHICH layer owns it instead of only
5843
- where the textarea ends. Anchors per AGENTS.md: InputBar root is
5844
- the padding carrier, _card is the rounded visual box, _composerSeat
5845
- is the sticky seat, _scrollBody is the real scroll container. */
5846
- const pair = (sel) => {
5847
- const el = document.querySelector(sel);
5848
- if (el === null)
5849
- return '—';
5850
- const b = el.getBoundingClientRect().bottom;
5851
- return `${Math.round(b)}/${Math.round(innerHeight - b)}`;
5852
- };
5853
- const barRoot = document.querySelector('[data-slot="conversation.composer.bar"] > [class$="_root"]');
5854
- /* Top-edge forensics: rect.top of the surfaces that consume
5855
- --mnav-sat. These separate the two competing readings of the
5856
- device numbers — a viewport SUNK below the status bar (origin at
5857
- screen y=59, so a working sat padding puts the header at 118 and
5858
- the top gap is doubled) versus a viewport anchored at screen y=0
5859
- that is merely 59px short at the bottom (header at 59, top
5860
- correct, the missing 59 showing as a band under the composer).
5861
- innerHeight alone cannot tell them apart; the viewport ORIGIN can,
5862
- hence screenY / visualViewport offsets / the edge markers. */
5863
- const topOf = (sel) => {
5864
- const el = document.querySelector(sel);
5865
- return el === null ? '—' : String(Math.round(el.getBoundingClientRect().top));
5866
- };
5867
- const framePadTop = frame === null ? '—' : getComputedStyle(frame).paddingTop;
5868
- const vv = window.visualViewport;
5869
- return [
5870
- `env sat ${ps.paddingTop} sab ${ps.paddingBottom}`,
5871
- `screen ${screen.width}x${screen.height} avail ${screen.availWidth}x${screen.availHeight}`,
5872
- `vh ${innerHeight} vv ${Math.round(vv?.height ?? -1)} outH ${outerHeight}`,
5873
- `scrXY ${screenX},${screenY} vvOff ${Math.round(vv?.offsetTop ?? -1)} vvPage ${Math.round(vv?.pageTop ?? -1)} vvScale ${vv?.scale ?? '?'}`,
5874
- /* The ENFORCED state, not a re-derivation: installSunkInset()
5875
- stamps data-mnav-sunk, and its absence means the override is
5876
- not running at all (the inset param owns the variables). sabOvr
5877
- is the receipt — the inline value it wrote, or (env) when the
5878
- normal compensation is in force. */
5879
- `sunk? ${document.documentElement.dataset.mnavSunk ?? 'n/a'} standalone ${matchMedia('(display-mode: standalone)').matches} sabOvr ${document.documentElement.style.getPropertyValue('--mnav-sab') || '(env)'}`,
5880
- `frameTop ${topOf('[data-mobile-nav="frame"]')} padTop ${framePadTop} headerTop ${topOf('[data-phase] header')} homeTopY ${topOf('[data-mobile-nav="home-top"]')}`,
5881
- `botFrame ${bottomOf('[data-mobile-nav="frame"]')} composer ${bottomOf('[data-phase] textarea')}`,
5882
- `taB ${pair('[data-phase] textarea')} cardB ${pair('[data-slot="conversation.composer.bar"] [class$="_card"]')}`,
5883
- `barB ${pair('[data-slot="conversation.composer.bar"] > [class$="_root"]')} seatB ${pair('[class$="_composerSeat"]')} scrollB ${pair('[class$="_scrollBody"]')}`,
5884
- `sabPad ${barRoot === null ? '—' : getComputedStyle(barRoot).paddingBottom}`,
5885
- `botList ${bottomOf('[data-mobile-nav="home-list"]')} fab ${bottomOf('[data-mobile-nav="home-fab"]')}`,
5886
- `cssLen ${document.querySelector('style[data-plugin-css*="mobile"]')?.textContent?.length ?? 0}`,
5887
- ].join('\n');
5888
- })(),
5889
- `genui cards ${document.querySelectorAll('[data-genui]').length} panel ${q('[data-genui-panel]')}`,
5890
- `phase ${document.querySelector('[data-phase]')?.getAttribute('data-phase') ?? '?'}`,
5891
- `errs ${errors.slice(-5).join(' | ') || 'none'}`,
5892
- ].join('\n');
5677
+ const onClick = (event) => {
5678
+ const target = event.target;
5679
+ if (!(target instanceof Element))
5680
+ return;
5681
+ const chip = target.closest(CHIP_SELECTOR);
5682
+ const key = chip?.dataset['foldGroup'];
5683
+ if (key === undefined)
5684
+ return;
5685
+ if (expanded.has(key))
5686
+ expanded.delete(key);
5687
+ else
5688
+ expanded.add(key);
5689
+ scan();
5893
5690
  };
5894
- /* Feedback-loop guards: painting the badge mutates the body subtree the
5895
- observer watches — without these two checks the observer retriggers
5896
- itself forever and freezes the page (the badge was rarely actually
5897
- enabled before, which is how this survived). */
5898
- const paint = () => {
5899
- const next = read();
5900
- if (next !== badge.textContent)
5901
- badge.textContent = next;
5691
+ const clear = () => {
5692
+ for (const chip of document.querySelectorAll(CHIP_SELECTOR))
5693
+ chip.remove();
5694
+ for (const item of document.querySelectorAll(`[${FOLD}]`)) {
5695
+ item.removeAttribute(FOLD);
5696
+ item.removeAttribute(exports.OPEN);
5697
+ }
5902
5698
  };
5903
- paint();
5904
- const observer = new MutationObserver((mutations) => {
5905
- if (mutations.every((m) => m.target === badge || badge.contains(m.target) || m.target === probe))
5699
+ const attach = () => {
5700
+ if (observer !== null)
5906
5701
  return;
5907
- paint();
5908
- });
5909
- observer.observe(document.body, { childList: true, subtree: true, attributes: true });
5910
- const timer = setInterval(paint, 1500);
5911
- document.body.appendChild(badge);
5702
+ document.documentElement.setAttribute(ACTIVE_ATTR, '');
5703
+ observer = new MutationObserver(schedule);
5704
+ observer.observe(document.body, { childList: true, subtree: true });
5705
+ document.addEventListener('click', onClick);
5706
+ scan();
5707
+ };
5708
+ const detach = () => {
5709
+ document.documentElement.removeAttribute(ACTIVE_ATTR);
5710
+ observer?.disconnect();
5711
+ observer = null;
5712
+ document.removeEventListener('click', onClick);
5713
+ if (frame !== 0)
5714
+ cancelAnimationFrame(frame);
5715
+ frame = 0;
5716
+ expanded.clear();
5717
+ clear();
5718
+ };
5719
+ const onChange = (event) => (event.matches ? attach() : detach());
5720
+ let disposed = false;
5721
+ let desktop = false;
5722
+ /** Attach at every width and drop the breakpoint listener — the
5723
+ * stylesheet's html[DESKTOP_ATTR] rules take over from the phone media
5724
+ * query. Idempotent: the config answer and the local opt-in may both
5725
+ * ask for it. */
5726
+ const enableDesktop = () => {
5727
+ if (desktop || disposed)
5728
+ return;
5729
+ desktop = true;
5730
+ document.documentElement.setAttribute(DESKTOP_ATTR, '');
5731
+ narrow.removeEventListener('change', onChange);
5732
+ attach();
5733
+ };
5734
+ if (narrow.matches)
5735
+ attach();
5736
+ narrow.addEventListener('change', onChange);
5737
+ if (desktopOptIn())
5738
+ enableDesktop();
5739
+ // The row config arrives async; a late enable just attaches then. Not
5740
+ // awaited before phone attach — folding must not wait on a round-trip.
5741
+ else
5742
+ void desktopConfigured().then((on) => (on ? enableDesktop() : undefined));
5743
+ // A language switch changes the chip copy of every already-rendered turn.
5744
+ const stopLocale = ctx.locale.subscribe(schedule);
5912
5745
  return () => {
5913
- window.removeEventListener('error', onError);
5914
- window.removeEventListener('unhandledrejection', onRejection);
5915
- observer.disconnect();
5916
- clearInterval(timer);
5917
- probe.remove();
5918
- badge.remove();
5746
+ disposed = true;
5747
+ document.documentElement.removeAttribute(DESKTOP_ATTR);
5748
+ narrow.removeEventListener('change', onChange);
5749
+ stopLocale();
5750
+ detach();
5919
5751
  };
5920
- }, 'dsh-mobile-nav: debug badge');
5752
+ }, 'dsh-mobile-nav: turn process fold');
5753
+ }
5754
+ };
5755
+ __modules["styles/turn-fold.css.js"] = function (require, module, exports) {
5756
+ "use strict";
5757
+ // turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
5758
+ // Two blocks, both inert unless the fold is actually running:
5759
+ // * BORN_FOLDED, keyed on html[data-mnav-fold-on] — the root attribute
5760
+ // effects/turn-fold.ts holds for as long as it is attached, so the block
5761
+ // is live at exactly the widths the fold is. It hides process rows by
5762
+ // the HOST's own markers, which means a row is folded at its first paint
5763
+ // rather than a frame after the effect notices it (2026-08-22).
5764
+ // * the `rules()` block below, scoped to (max-width: 767px) OR
5765
+ // html[data-mnav-desktop-fold] (the per-browser desktop opt-in, issue
5766
+ // #2), reading markers this plugin writes: data-mnav-fold on a process
5767
+ // element, data-mobile-nav="turn-fold" on the injected summary row.
5768
+ // effects/turn-fold.ts wipes both when it detaches, so a non-opted-in
5769
+ // tablet/desktop is a no-op twice over, by attribute and by scope. It is
5770
+ // authored once and emitted twice so the two scopes can never drift.
5771
+ // Appended last in styles/index.ts; it shares no selector with any other
5772
+ // file, the position just keeps the "phone files come after the shared
5773
+ // <=1023px block" ordering intact.
5774
+ Object.defineProperty(exports, "__esModule", { value: true });
5775
+ exports.TURN_FOLD_CSS = void 0;
5776
+ const turn_fold_ts_1 = require("./effects/turn-fold.js");
5777
+ /** Mirror of the root attribute effects/turn-fold.ts holds while attached. */
5778
+ const ACTIVE = 'data-mnav-fold-on';
5779
+ /**
5780
+ * Born folded — the reason a new tool call no longer blinks into view.
5781
+ *
5782
+ * These selectors read the HOST's own markers (ChatNodeSeat's
5783
+ * `data-chat-flow-kind`, ReasoningRow's `data-variant`), which React sets in
5784
+ * the same commit that inserts the row, so the row is already
5785
+ * `display: none` at its first paint. effects/turn-fold.ts still rescans on
5786
+ * the next animation frame, but by then it is only writing the per-turn
5787
+ * {@link OPEN} override and the summary chip — it is no longer what hides
5788
+ * anything, which is what the reader used to see as a one-frame flash
5789
+ * (2026-08-22).
5790
+ *
5791
+ * Keyed on the root attribute the effect holds while it is attached, so this
5792
+ * block is live for exactly the widths the fold is, and hides nothing at all
5793
+ * when the effect never ran.
5794
+ *
5795
+ * One case stays with the effect: an assistant-step row whose whole body is
5796
+ * reasoning has to be hidden as a row (an emptied-out flex item still claims
5797
+ * the flow column's 16px gap), and "this row has nothing but Think in it" is
5798
+ * not a selector. Its rows therefore still fold a frame late — as blank
5799
+ * space, not as content, since the Think rows below are hidden here.
5800
+ */
5801
+ const BORN_FOLDED = [
5802
+ ...turn_fold_ts_1.PROCESS_KINDS.map((kind) => `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="${kind}"]:not([${turn_fold_ts_1.OPEN}])`),
5803
+ `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"] ${turn_fold_ts_1.THINK}:not([${turn_fold_ts_1.OPEN}])`,
5804
+ ].join(',\n');
5805
+ /** The fold + chip rules, with every selector prefixed by `scope`. */
5806
+ const rules = (scope) => `
5807
+ /* Folded process: tool-call / context / command rows and the Think
5808
+ disclosures inside an assistant step. !important because the official
5809
+ flow item and ReasoningRow both set their own display. */
5810
+ ${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
5811
+ display: none !important;
5812
+ }
5813
+
5814
+ /* Compact chip, sized like the composer's own pills rather than a button:
5815
+ it is a reading affordance in the middle of the message flow, so it has
5816
+ to stay quieter than the content around it. The flow column is a flex
5817
+ column with gap: 16px (ChatView.module.css) — align-self keeps the chip
5818
+ from stretching, and the negative block margin trims that generous gap
5819
+ back to something a 26px row can live in. */
5820
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"] {
5821
+ display: inline-flex;
5822
+ align-self: flex-start;
5823
+ align-items: center;
5824
+ gap: 6px;
5825
+ margin: -4px 0;
5826
+ padding: 0 10px;
5827
+ height: 26px;
5828
+ border: none;
5829
+ border-radius: 13px;
5830
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
5831
+ color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .55));
5832
+ font: inherit;
5833
+ font-size: 12px;
5834
+ line-height: 26px;
5835
+ white-space: nowrap;
5836
+ cursor: pointer;
5837
+ -webkit-tap-highlight-color: transparent;
5838
+ }
5839
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
5840
+ background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
5841
+ }
5842
+
5843
+ /* Leading dot: idle turns get a quiet mark, a running turn gets the same
5844
+ business-primary colour the session header's own status dot uses
5845
+ (styles/header.css.ts) plus a breathing pulse. */
5846
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
5847
+ content: '';
5848
+ flex: none;
5849
+ width: 6px;
5850
+ height: 6px;
5851
+ border-radius: 50%;
5852
+ background: currentColor;
5853
+ opacity: .45;
5854
+ }
5855
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
5856
+ background: var(--dsw-alias-state-business-primary, #4f6ef7);
5857
+ opacity: 1;
5858
+ animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
5859
+ }
5860
+
5861
+ /* Chevron drawn from two borders — no icon injection, no mask image, and
5862
+ it rotates to point up once the turn is open. */
5863
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
5864
+ content: '';
5865
+ flex: none;
5866
+ width: 5px;
5867
+ height: 5px;
5868
+ margin-top: -3px;
5869
+ border-right: 1.5px solid currentColor;
5870
+ border-bottom: 1.5px solid currentColor;
5871
+ transform: rotate(45deg);
5872
+ transition: transform .16s var(--ds-ease-out, ease-in-out);
5873
+ }
5874
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
5875
+ margin-top: 3px;
5876
+ transform: rotate(-135deg);
5877
+ }
5878
+
5879
+ @media (prefers-reduced-motion: reduce) {
5880
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
5881
+ animation: none;
5882
+ }
5883
+ ${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
5884
+ transition: none;
5885
+ }
5886
+ }
5887
+ `;
5888
+ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px, or desktop opt-in) ---------- */
5889
+
5890
+ /* Process rows and Think disclosures start folded, before any script has
5891
+ looked at them — see BORN_FOLDED above. */
5892
+ ${BORN_FOLDED} {
5893
+ display: none !important;
5894
+ }
5895
+
5896
+ /* The summary row never paints outside its active scope, even if a
5897
+ media-query change raced the effect's own cleanup. */
5898
+ [data-mobile-nav="turn-fold"] {
5899
+ display: none;
5900
+ }
5901
+
5902
+ @media (max-width: 767px) {${rules('')}}
5903
+
5904
+ /* Desktop opt-in: the exact same block, keyed on the root attribute
5905
+ effects/turn-fold.ts sets for a browser opted in via
5906
+ ?mobile-nav-turn-fold=1 — active at every width. */
5907
+ ${rules('html[data-mnav-desktop-fold]')}
5908
+
5909
+ @keyframes dsh-mobile-nav-breathe {
5910
+ 0%, 100% { opacity: 1; transform: scale(1); }
5911
+ 50% { opacity: .4; transform: scale(.72); }
5912
+ }
5913
+ `;
5914
+ };
5915
+ __modules["styles/chips.css.js"] = function (require, module, exports) {
5916
+ "use strict";
5917
+ // chips — S5 (2026-08-17): the home-screen plugin-entry chips row, its
5918
+ // customize sheet, the settings gear button, and the settings/usage-stats
5919
+ // dialog portal fix. Appended LAST in index.ts (after home.css.ts) so its
5920
+ // `display` overrides win any tie against the phone shell's blanket
5921
+ // `[data-mobile-nav="frame"] > :first-child { display: none }` rule — though
5922
+ // the extra `:has()` clause already outranks it on specificity alone.
5923
+ Object.defineProperty(exports, "__esModule", { value: true });
5924
+ exports.CHIPS_CSS = void 0;
5925
+ exports.CHIPS_CSS = `/* ---------- chips row + settings entry (S5) ---------- */
5926
+
5927
+ @media (max-width: 767px) {
5928
+ /* --- settings dialog / usage-stats panel / scheduled-tasks dialog portal fix ---
5929
+ dsh-client-ui-settings-general's SettingsRoot (trigger button +
5930
+ position:fixed;z-index:1000 modal), dsh-usage-stats' sidebar-footer
5931
+ badge (position:fixed panel), and @opendsh/dsh-plugin-scheduled-tasks'
5932
+ "定时任务" trigger (position:fixed;z-index:120 \`.dshst-overlay\`,
5933
+ verified against 0.2.0 — real-device report 2026-08-17: chip click
5934
+ produced no visible window) all mount as PLAIN CHILDREN of the sidebar
5935
+ tree — not a React portal to document.body — inside
5936
+ sidebar.settings / sidebar.footer.action
5937
+ (dsh-client-ui-sidebar's SidebarRoot: footArea > settingsArea /
5938
+ footerActions). home.css.ts sets the sidebar ROOT
5939
+ (\`[data-mobile-nav="frame"] > :first-child\`) to display:none on the
5940
+ phone breakpoint (the app shell replaced the drawer) — and display:none
5941
+ removes the WHOLE subtree from the render tree, including
5942
+ position:fixed descendants, which cannot escape it the way they escape
5943
+ a merely-offscreen or opacity:0 ancestor. A \`.click()\` on the (still
5944
+ DOM-present) hidden trigger still flips its component-local \`open\`
5945
+ state and mounts the aria-modal dialog (synthetic dispatch bypasses
5946
+ hit-testing — S2.1 precedent), but nothing paints without this fix.
5947
+
5948
+ Un-hiding the WHOLE sidebar would surface the session tree / new-session
5949
+ button underneath the dialog, so this is scoped to exactly the sidebar
5950
+ root plus its OWN direct children that are not on the path to
5951
+ footArea: display:contents on the root removes its own box (no
5952
+ duplicate page background/padding, no grid-column reflow) while
5953
+ letting footArea/footerActions/settingsArea resume their OWN official
5954
+ (non-none) display — logoRow / newSession / regionArea are hidden
5955
+ explicitly instead. :has() matches against the DOM tree regardless of
5956
+ the scope element's own computed display, so this reads correctly even
5957
+ though the whole subtree started at display:none (same precedent as
5958
+ every other :has()-gated rule in this stylesheet, e.g. the settings
5959
+ sheet adaptation below, which depends on exactly this becoming
5960
+ visible).
5961
+
5962
+ \`:first-child\` is a layout-column WRAPPER
5963
+ (\`pI_x6G_sidebarCol\`, dsh-client-ui-layout's AppFrame grid cell), not
5964
+ SidebarRoot's own div — SidebarRoot (\`hHd-Xa_root\`) sits one more empty
5965
+ unstyled div below it (measured 2026-08-17: sidebarCol > div (no
5966
+ class) > div.hHd-Xa_root > logoRow/newSession/regionArea/footArea).
5967
+ display:contents on sidebarCol lets that whole chain resume its own
5968
+ official (non-none) display with no other rule needed, but the
5969
+ "hide the other three" selectors below must therefore be DESCENDANT
5970
+ (unscoped depth), not direct-child \`>\`, or they silently match
5971
+ nothing — confirmed live: an earlier \`>\`-only version left
5972
+ logoRow/newSession/regionArea at \`display: flex\`, which is only
5973
+ hidden from view by being fully underneath the usage-stats panel's
5974
+ opaque body, not by this rule. */
5975
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
5976
+ display: contents !important;
5977
+ }
5978
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
5979
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
5980
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
5981
+ display: none !important;
5982
+ }
5983
+
5984
+ /* Home-top settings gear: 44pt touch target, pushed to the row's end by
5985
+ its own auto margin (home-top itself stays a plain flex row). */
5986
+ [data-mobile-nav="home-settings"] {
5987
+ flex: none;
5988
+ display: inline-flex;
5989
+ align-items: center;
5990
+ justify-content: center;
5991
+ width: 44px;
5992
+ height: 44px;
5993
+ margin-left: auto;
5994
+ padding: 0;
5995
+ border: none;
5996
+ background: transparent;
5997
+ color: var(--dsw-alias-label-secondary, inherit);
5998
+ cursor: pointer;
5999
+ touch-action: manipulation;
6000
+ -webkit-tap-highlight-color: transparent;
6001
+ }
6002
+ [data-mobile-nav="home-settings"]:active {
6003
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
6004
+ border-radius: 50%;
6005
+ }
6006
+
6007
+ /* Plugin-entry chips row: one horizontally-scrolling line of 34px pills
6008
+ between the workspace title and the session list (prototype spec).
6009
+ Scrollbar hidden on BOTH engines (2026-08-18 user decision): iOS
6010
+ overlay scrollbars never showed anyway, but desktop browsers painted a
6011
+ permanent 2px strip under the pills. This revisits the AGENTS.md
6012
+ "silently cut off the last tab" lesson deliberately — with 4+ chips the
6013
+ row now always overflows mid-chip (a partially visible pill is the
6014
+ scroll affordance), unlike the early incident where the chips fit
6015
+ flush and the hidden overflow was undiscoverable. */
6016
+ [data-mobile-nav="chip-row"] {
6017
+ flex: 0 0 auto;
6018
+ display: flex;
6019
+ align-items: center;
6020
+ gap: 8px;
6021
+ padding: 0 16px 10px;
6022
+ overflow-x: auto;
6023
+ overscroll-behavior: contain;
6024
+ -webkit-overflow-scrolling: touch;
6025
+ scrollbar-width: none;
6026
+ }
6027
+ [data-mobile-nav="chip-row"]::-webkit-scrollbar {
6028
+ display: none;
6029
+ }
6030
+ [data-mobile-nav="chip"],
6031
+ [data-mobile-nav="chip-more"] {
6032
+ flex: none;
6033
+ display: inline-flex;
6034
+ align-items: center;
6035
+ gap: 6px;
6036
+ height: 34px;
6037
+ padding: 0 14px;
6038
+ border: none;
6039
+ border-radius: 17px;
6040
+ /* Same surface as the list rows: the home page is bg-base since the
6041
+ 2026-08-20 swap (see home.css.ts), so a bg-base chip would disappear
6042
+ into it. */
6043
+ background: var(--dsw-specific-sidebar-fill, #f5f5f5);
6044
+ color: var(--dsw-alias-label-primary, inherit);
6045
+ font-family: inherit;
6046
+ font-size: 13px;
6047
+ font-weight: 500;
6048
+ white-space: nowrap;
6049
+ cursor: pointer;
6050
+ touch-action: manipulation;
6051
+ -webkit-tap-highlight-color: transparent;
6052
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
6053
+ }
6054
+ [data-mobile-nav="chip-more"] {
6055
+ padding: 0 9px;
6056
+ }
6057
+ [data-mobile-nav="chip"]:active,
6058
+ [data-mobile-nav="chip-more"]:active {
6059
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
6060
+ }
6061
+
6062
+ /* Harvested chip icon (S5.1): the cloned <svg> keeps whatever width/height
6063
+ the source plugin gave it (18px for the one verified live example) —
6064
+ force it down to the same 16px box every hand-registered chip icon
6065
+ uses, CSS wins over the presentational SVG attributes. */
6066
+ [data-mobile-nav="chip-harvest-icon"] {
6067
+ display: inline-flex;
6068
+ flex: none;
6069
+ }
6070
+ [data-mobile-nav="chip-harvest-icon"] svg {
6071
+ display: block;
6072
+ width: 16px;
6073
+ height: 16px;
6074
+ }
6075
+
6076
+ /* Customize sheet: toggle rows inside the SAME home-sheet-layer/mask/sheet
6077
+ chrome the workspace switcher declares in home.css.ts — S6's
6078
+ drag-to-close and mask-click-close bind to
6079
+ \`[data-mobile-nav="home-sheet"]\` generically (effects/gestures.ts), so
6080
+ this second use of the marker needs no new gesture wiring. */
6081
+ [data-mobile-nav="chip-toggle-row"] {
6082
+ display: flex;
6083
+ align-items: center;
6084
+ gap: 10px;
6085
+ width: 100%;
6086
+ /* Real-device fix: without border-box this row's used width is
6087
+ 100% (of the sheet's own already-padded content box) PLUS the
6088
+ 12px+12px padding below, overflowing the sheet by 24px and
6089
+ shoving the flex:none toggle off its right edge (2026-08-17
6090
+ report: "开关跑到屏幕外"). home-sheet-item next door in
6091
+ home.css.ts has the identical width:100%+padding shape and the
6092
+ same latent bug, just with no trailing element to visibly clip. */
6093
+ box-sizing: border-box;
6094
+ min-height: 48px;
6095
+ padding: 0 12px;
6096
+ color: inherit;
6097
+ }
6098
+ [data-mobile-nav="chip-toggle-label"] {
6099
+ flex: 1 1 auto;
6100
+ min-width: 0;
6101
+ font-size: 15px;
6102
+ overflow: hidden;
6103
+ text-overflow: ellipsis;
6104
+ white-space: nowrap;
6105
+ }
6106
+ [data-mobile-nav="chip-toggle"] {
6107
+ flex: none;
6108
+ position: relative;
6109
+ width: 42px;
6110
+ height: 26px;
6111
+ border: none;
6112
+ border-radius: 13px;
6113
+ background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .16));
6114
+ cursor: pointer;
6115
+ touch-action: manipulation;
6116
+ -webkit-tap-highlight-color: transparent;
6117
+ transition: background .15s var(--ds-ease-in-out, ease-in-out);
6118
+ }
6119
+ [data-mobile-nav="chip-toggle"]::after {
6120
+ content: '';
6121
+ position: absolute;
6122
+ top: 2px;
6123
+ left: 2px;
6124
+ width: 22px;
6125
+ height: 22px;
6126
+ border-radius: 50%;
6127
+ background: #ffffff;
6128
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
6129
+ transition: transform .15s var(--ds-ease-in-out, ease-in-out);
6130
+ }
6131
+ [data-mobile-nav="chip-toggle"][aria-checked="true"] {
6132
+ background: var(--dsw-alias-button-primary-fill, #1a1a1a);
6133
+ }
6134
+ [data-mobile-nav="chip-toggle"][aria-checked="true"]::after {
6135
+ transform: translateX(16px);
6136
+ }
6137
+ @media (prefers-reduced-motion: reduce) {
6138
+ [data-mobile-nav="chip-toggle"],
6139
+ [data-mobile-nav="chip-toggle"]::after {
6140
+ transition: none !important;
6141
+ }
6142
+ }
5921
6143
  }
6144
+ `;
5922
6145
  };
5923
- __modules["effects/phone-chrome.js"] = function (require, module, exports) {
6146
+ __modules["styles/content.css.js"] = function (require, module, exports) {
5924
6147
  "use strict";
6148
+ // content — chat markdown readability on phones (2026-08-18). Shares no
6149
+ // selector with any other file; appended last in styles/index.ts.
5925
6150
  Object.defineProperty(exports, "__esModule", { value: true });
5926
- exports.installPhoneChrome = installPhoneChrome;
5927
- exports.isViewportSunkBelowStatusBar = isViewportSunkBelowStatusBar;
5928
- exports.installViewportHeal = installViewportHeal;
5929
- exports.installSunkInset = installSunkInset;
5930
- /**
5931
- * Phone chrome: KEEP the system status bar (no fullscreen) and make it
5932
- * blend into the page. On narrow screens:
5933
- * - The viewport meta gains viewport-fit=cover, so env(safe-area-inset-top)
5934
- * is the real status-bar / notch height and the stylesheet can push every
5935
- * surface below it (off notched phones, or in a browser tab where the
5936
- * layout viewport already sits below the status bar, the inset is 0 and
5937
- * nothing shifts).
5938
- * - A theme-color meta tracks the shell background (the official theme is
5939
- * toggled by body[data-ds-dark-theme], which flips --dsw-alias-bg-base):
5940
- * Android then paints the status bar / URL bar with the page's own base
5941
- * color, so the status bar reads as part of the UI instead of a foreign
5942
- * strip. The drawer paints the same strip on iOS / notch displays.
5943
- * - gesturestart is suppressed as the legacy-iOS fallback for double-tap
5944
- * zoom; modern browsers are covered by the stylesheet's
5945
- * touch-action: manipulation (which keeps pan and pinch zoom).
5946
- */
5947
- function installPhoneChrome(ctx) {
5948
- ctx.effect(() => {
5949
- const narrow = window.matchMedia('(max-width: 1023px)');
5950
- const viewport = document.querySelector('meta[name="viewport"]');
5951
- const originalViewport = viewport?.content ?? '';
5952
- const themeMeta = document.createElement('meta');
5953
- themeMeta.name = 'theme-color';
5954
- const bodyBg = () => getComputedStyle(document.body).backgroundColor;
5955
- const sync = () => {
5956
- if (viewport !== null)
5957
- viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover';
5958
- themeMeta.content = bodyBg();
5959
- if (themeMeta.parentElement === null)
5960
- document.head.appendChild(themeMeta);
5961
- };
5962
- const restore = () => {
5963
- if (viewport !== null)
5964
- viewport.content = originalViewport;
5965
- themeMeta.remove();
5966
- };
5967
- const onGestureStart = (event) => event.preventDefault();
5968
- if (narrow.matches)
5969
- sync();
5970
- const onChange = (event) => (event.matches ? sync() : restore());
5971
- narrow.addEventListener('change', onChange);
5972
- const observer = new MutationObserver(() => {
5973
- if (narrow.matches)
5974
- themeMeta.content = bodyBg();
5975
- });
5976
- observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] });
5977
- document.addEventListener('gesturestart', onGestureStart);
5978
- return () => {
5979
- narrow.removeEventListener('change', onChange);
5980
- observer.disconnect();
5981
- document.removeEventListener('gesturestart', onGestureStart);
5982
- restore();
5983
- };
5984
- }, 'dsh-mobile-nav: status bar theme + viewport + zoom guard');
6151
+ exports.CONTENT_CSS = void 0;
6152
+ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ---------- */
6153
+
6154
+ @media (max-width: 767px) {
6155
+ /* Inline code: upstream renders it display:inline-flex — an atomic inline
6156
+ box that can never wrap across lines, so one long code span walks off
6157
+ the right edge of the screen. Plain inline keeps the background pill
6158
+ per line fragment and lets long tokens break anywhere. Deliberately
6159
+ unscoped (any :not(pre) > code, not just the _markdown_ module):
6160
+ inline code also renders outside that container (user bubbles,
6161
+ reasoning rows), and on a phone there is no width at which an
6162
+ unwrappable inline box is right. !important beats the upstream
6163
+ display:inline-flex on specificity ties. */
6164
+ :not(pre) > code {
6165
+ display: inline !important;
6166
+ overflow-wrap: anywhere;
6167
+ /* iOS WebKit: overflow-wrap alone still failed to break long tokens in
6168
+ inline code on a real iPhone (screenshot-verified 2026-08-18, li >
6169
+ code path case) while desktop Chromium broke them fine. break-all is
6170
+ the everywhere-supported hammer; acceptable for code pills. */
6171
+ word-break: break-all;
6172
+ }
6173
+ /* Belt-and-suspenders for engines that compute soft-wrap opportunities
6174
+ from the block container rather than the inline itself (historical
6175
+ WebKit behavior): inherit anywhere through the whole transcript so the
6176
+ block side agrees. Prose is untouched until something would overflow;
6177
+ the table is already pinned wide by min-width: max-content below. */
6178
+ [data-chat-flow],
6179
+ [class*="_markdown_"] {
6180
+ overflow-wrap: anywhere;
6181
+ }
6182
+
6183
+ /* Transcript density (2026-08-20): the desktop prose rhythm is 28px of
6184
+ leading on 15px text — a 1.87 ratio that reads generously on a wide
6185
+ column but wastes a phone screen, where the same paragraph is three
6186
+ times as many lines. Unitless 1.65 so nested blocks (code, quotes,
6187
+ lists) recompute from their OWN font-size instead of inheriting a fixed
6188
+ 28px that is far too loose for 12-13px chrome text. 1.65 is the floor I
6189
+ am willing to go to for mixed CJK/Latin: the glyphs are full-height with
6190
+ no descender relief, so tighter starts to look stacked. Phone only —
6191
+ the desktop column keeps the official rhythm. */
6192
+ [class*="_markdown_"] {
6193
+ line-height: 1.65;
6194
+ }
6195
+ /* Paragraph rhythm. Each paragraph is its own markdown block and the
6196
+ separation comes from the flex container's row-gap, not from margins —
6197
+ at the stock 16px the paragraph baseline step was 40.75px against a
6198
+ 24.75px line step, i.e. 1.65x, which is what reads as airy. 12px brings
6199
+ the step to ~36.75px: still clearly more than one line so paragraphs
6200
+ never run together, but noticeably denser. Scoped by :has() to
6201
+ containers that actually hold prose blocks, so tool cards and other
6202
+ flex bodies with the same hashed suffix keep their own spacing. */
6203
+ [data-phase] [class$="_body"]:has(> [class*="_markdown_"]) {
6204
+ row-gap: 12px !important;
6205
+ }
6206
+
6207
+ /* Markdown tables: the official _tableScroll_ wrapper is already a
6208
+ horizontal scroll viewport around a width:max-content table, but its
6209
+ cells are capped at min(30vw, 320px) — barely 120px on a phone, which
6210
+ crushes every column into a vertical word-stack. Let columns size to
6211
+ their content instead and read the overflow by scrolling the wrapper.
6212
+ 480px (not a vw cap — 85vw was ~320px on a 375px phone and still
6213
+ wrapped mid-length cells) keeps typical cells on one line; only truly
6214
+ prose-heavy cells wrap, instead of dragging the table kilometers
6215
+ wide. */
6216
+ [class*="_tableScroll_"] th,
6217
+ [class*="_tableScroll_"] td {
6218
+ max-width: 480px !important;
6219
+ }
6220
+
6221
+ /* iOS WebKit fallback: upstream's own table { width: max-content } is what
6222
+ keeps the table at its natural width inside the scroll wrapper, but
6223
+ WebKit's max-content support on table boxes is incomplete — on a real
6224
+ iPhone the table fell back to shrink-to-fit and every column got
6225
+ crushed to its min-width (the "ra/nk" vertical-word-stack screenshot,
6226
+ 2026-08-18). Redeclare the natural-width intent with both width and
6227
+ min-width so whichever keyword the engine honors wins; the wrapper
6228
+ already scrolls the overflow. */
6229
+ [class*="_tableScroll_"] table {
6230
+ width: max-content !important;
6231
+ min-width: max-content !important;
6232
+ max-width: none !important;
6233
+ }
6234
+
6235
+ /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
6236
+ 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
6237
+ [data-time-hover-root] gate holds them at opacity 0 until :hover /
6238
+ :focus-within) — a phone never hovers, so they were simply invisible;
6239
+ and even ignoring that, the row is nowrap + overflow:hidden (plus this
6240
+ plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
6241
+ text. On phones: always visible, own full-width line under the action
6242
+ icons, wrapping freely, one type size down. !important + the extra
6243
+ [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
6244
+ /* Pin the tail row's width chain before allowing wrap: with wrap enabled
6245
+ the row's intrinsic (shrink-to-fit) width collapses to its widest
6246
+ single icon, taking the whole tail down to ~16px. */
6247
+ [data-chat-flow-kind="turn-tail"] [class$="_root"],
6248
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
6249
+ width: 100%;
6250
+ }
6251
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
6252
+ flex-wrap: wrap;
6253
+ row-gap: 2px;
6254
+ }
6255
+ /* Wide companion to the rule below: mirrors the upstream hover-gate
6256
+ selector shape directly, with no assumptions about the tail's DOM
6257
+ structure, so the stats can't stay invisible even if the phone's tail
6258
+ renders differently than desktop. */
6259
+ [data-time-hover-root] [class$="_timeEnd"],
6260
+ [data-time-hover-root] [class$="_timeStart"] {
6261
+ opacity: 1 !important;
6262
+ transition: none !important;
6263
+ }
6264
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
6265
+ opacity: 1 !important;
6266
+ flex-basis: 100% !important;
6267
+ white-space: normal !important;
6268
+ overflow: visible !important;
6269
+ text-overflow: clip !important;
6270
+ padding-left: 0 !important;
6271
+ font-size: 12px;
6272
+ line-height: 18px;
6273
+ }
5985
6274
  }
6275
+ `;
6276
+ };
6277
+ __modules["styles/index.js"] = function (require, module, exports) {
6278
+ "use strict";
6279
+ Object.defineProperty(exports, "__esModule", { value: true });
6280
+ exports.MOBILE_CSS = void 0;
6281
+ const base_css_ts_1 = require("./styles/base.css.js");
6282
+ const layout_css_ts_1 = require("./styles/layout.css.js");
6283
+ const compat_css_ts_1 = require("./styles/compat.css.js");
6284
+ const misc_css_ts_1 = require("./styles/misc.css.js");
6285
+ const home_css_ts_1 = require("./styles/home.css.js");
6286
+ const header_css_ts_1 = require("./styles/header.css.js");
6287
+ const composer_css_ts_1 = require("./styles/composer.css.js");
6288
+ const info_css_ts_1 = require("./styles/info.css.js");
6289
+ const turn_fold_css_ts_1 = require("./styles/turn-fold.css.js");
6290
+ const chips_css_ts_1 = require("./styles/chips.css.js");
6291
+ const content_css_ts_1 = require("./styles/content.css.js");
5986
6292
  /**
5987
- * Does the layout viewport sit shorter than the screen by at least the top
5988
- * inset env() still claims? (S1.2 predicate, wired in S1.3.)
5989
- *
5990
- * Device numbers (iPhone, iOS 26.5, standalone PWA, 393x852): innerHeight
5991
- * 793, env top 59 (= 852 - 793), env bottom 34, frame bottom 793 flush.
5992
- * The same device in a Safari TAB reads innerHeight 695 with every surface
5993
- * flush and every design value correct, which is what rules our own CSS out.
5994
- *
5995
- * What the true-branch means, settled by S1.2 + S1.3 device evidence: the
5996
- * viewport is anchored at screen y=0 and merely cut 59px short at the BOTTOM
5997
- * — not pushed down below the status bar. So the top inset is paid exactly
5998
- * once and must be left alone, while the bottom inset is a lie: the home
5999
- * indicator lives in the dead strip below the page. installSunkInset() acts
6000
- * on that, and only on --mnav-sab.
6001
- *
6002
- * The `envTop > 0` guard is what keeps every other standalone install out:
6003
- * landscape iPhone (top inset 0, the notch moves to left/right), iPad, and
6004
- * Android — where standalone also loses status-bar height off innerHeight but
6005
- * reports env top 0 — all fall through to false. Kept pure so the boundaries
6006
- * are checkable off-device: scripts/check-sunk-viewport.mjs.
6293
+ * All mobile styles, concatenated in the exact order of the original
6294
+ * single-file stylesheet (base → layout → compat → misc, where misc keeps
6295
+ * composer → tablet → desktop), followed by the phone app shell (home), the
6296
+ * session-header reflow (header), the composer reflow (composer), and the
6297
+ * session-info sheet (info, which must come last of all: it re-shows a
6298
+ * header.utilities child header.css.ts hides by default, so its rule has to
6299
+ * win that tie too), the turn-process fold (turn-fold, S8, which shares no
6300
+ * selector with any of them), and finally the chips row / settings entry /
6301
+ * portal fix (chips, S5) — all appended in this order so their <768px rules
6302
+ * win ties against the shared <=1023px block, then the chat markdown
6303
+ * readability overrides (content, which shares no selector with any of
6304
+ * them). Injected as ONE <style data-plugin> tag — do not reorder.
6007
6305
  */
6008
- function isViewportSunkBelowStatusBar(input) {
6009
- return input.standalone
6010
- && input.envTop > 0
6011
- && input.screenHeight - input.innerHeight >= input.envTop;
6012
- }
6306
+ exports.MOBILE_CSS = [base_css_ts_1.BASE_CSS, layout_css_ts_1.LAYOUT_CSS, compat_css_ts_1.COMPAT_CSS, misc_css_ts_1.MISC_CSS, home_css_ts_1.HOME_CSS, header_css_ts_1.HEADER_CSS, composer_css_ts_1.COMPOSER_CSS, info_css_ts_1.INFO_CSS, turn_fold_css_ts_1.TURN_FOLD_CSS, chips_css_ts_1.CHIPS_CSS, content_css_ts_1.CONTENT_CSS].join('\n');
6307
+ };
6308
+ __modules["debug.js"] = function (require, module, exports) {
6309
+ "use strict";
6310
+ Object.defineProperty(exports, "__esModule", { value: true });
6311
+ exports.installDebugBadge = installDebugBadge;
6013
6312
  /**
6014
- * iOS standalone-PWA keyboard shrink (S1.2, 2026-08-17) — the ~60px white
6015
- * band under the composer AND under the session list.
6016
- *
6017
- * Documented WebKit defect: the first time the software keyboard opens inside
6018
- * a home-screen (standalone) PWA, the layout viewport permanently loses the
6019
- * status-bar height for the rest of the app session. innerHeight,
6020
- * visualViewport.height and 100dvh all report the shrunken value together, so
6021
- * nothing inside the page can see anything wrong — the page simply ends early
6022
- * and the strip below it is system background that no CSS can reach. Reported
6023
- * as 932 -> 873 on an iPhone Pro Max; the user's device reads 852 -> 793.
6024
- * Both are exactly one status bar. See
6025
- * https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d
6026
- *
6027
- * This is why the reported symptom is asymmetric and why the earlier
6028
- * "double-counted top inset" reading was wrong: the top edge is genuinely
6029
- * correct (env top 59 is paid once, the viewport starts at screen y=0), the
6030
- * bottom is simply 59px short. It also explains a chat client being hit
6031
- * hardest — the trigger is typing, which happens on the first message.
6032
- *
6033
- * The only known cure is to make WebKit re-measure: drop a full-viewport
6034
- * element out of the box tree and force a synchronous reflow. Two departures
6035
- * from the published recipe:
6036
- * - scrollTop is saved and restored around the toggle. Un-boxing an ancestor
6037
- * resets every descendant scroller to 0, which in a chat client means the
6038
- * conversation jumps to its first message. Both happen inside one task, so
6039
- * no frame is ever painted in between and nothing flickers.
6040
- * - the baseline is the tallest innerHeight this session has actually seen,
6041
- * not the screen height. On any device where the viewport is legitimately
6042
- * short, the baseline equals the current height and this never fires.
6043
- *
6044
- * Standalone-gated, so a browser tab (and every desktop, CDP included) is a
6045
- * strict no-op — which is also why the fix cannot be regression-tested here
6046
- * and has to be confirmed on the device.
6313
+ * Debug badge — ?mobile-nav-debug=1
6314
+ * Renders a live state overlay (URL, viewport, media queries, shell chrome,
6315
+ * aionui columns, genui cards, captured errors) so a phone-side repro can be
6316
+ * diagnosed without guessing. No-op unless the query param is present.
6047
6317
  */
6048
- function installViewportHeal(ctx) {
6318
+ function installDebugBadge(ctx) {
6319
+ /**
6320
+ * Fake safe-area inset — ?mobile-nav-inset=54 or ?mobile-nav-inset=54,34
6321
+ * env(safe-area-inset-*) is hard 0 on every desktop browser (CDP included),
6322
+ * so notch bugs are invisible until a real phone loads the page — the S2.1
6323
+ * hotfix exists because of exactly that. Overriding --mnav-sat / --mnav-sab
6324
+ * (see styles/base.css.ts) on the root element reproduces a notch anywhere.
6325
+ * No param = the variables keep their env() values = zero behaviour change.
6326
+ *
6327
+ * One value fakes the TOP inset only (the historic behaviour — every S1-S3
6328
+ * verification recipe passes `=54`, and they must keep meaning what they
6329
+ * did). A second, comma-separated value fakes the BOTTOM inset too:
6330
+ * `=54,34` is the iPhone notch + home-indicator pair. The bottom half was
6331
+ * added in S4.1 because the home-bar padding it guards (composer clearance
6332
+ * over the indicator) is otherwise untestable off-device for exactly the
6333
+ * same reason the top half exists.
6334
+ */
6049
6335
  ctx.effect(() => {
6050
- const standalone = window.matchMedia('(display-mode: standalone)').matches
6051
- || navigator.standalone === true;
6052
- /* iOS-only on purpose. The ceiling below leans on screen.height being the
6053
- height the viewport SHOULD have, which holds for an iPhone standalone
6054
- app under viewport-fit=cover and does not hold on Android, where the
6055
- navigation bar makes screen.height permanently larger than any viewport
6056
- and every keyboard dismiss would trigger a pointless reflow. */
6057
- if (!standalone || !/iPad|iPhone|iPod/.test(navigator.userAgent))
6336
+ const raw = new URLSearchParams(location.search).get('mobile-nav-inset');
6337
+ if (raw === null)
6058
6338
  return () => { };
6059
- /* Ceiling = the tallest this session has seen, floored at the screen —
6060
- the screen half matters because the shrink outlives a page reload, so a
6061
- PWA reopened into the broken state would otherwise measure 793 as
6062
- "normal" and never heal itself. */
6063
- let tallest = window.innerHeight;
6064
- const ceiling = () => Math.max(tallest, window.screen.height);
6065
- const onResize = () => {
6066
- if (window.innerHeight > tallest)
6067
- tallest = window.innerHeight;
6068
- };
6069
- /* Give up after three ineffective attempts per trigger: on a device whose
6070
- viewport is short for a reason a reflow cannot fix (the iOS 26.x
6071
- standalone regression family) retrying forever buys nothing. The two
6072
- budgets are separate on purpose — the cold-start attempts would
6073
- otherwise spend the whole allowance before the user ever types, and the
6074
- keyboard shrink IS reflow-curable even on a device whose cold start is
6075
- not. */
6076
- const strikes = { boot: 0, blur: 0 };
6077
- const heal = (kind) => {
6078
- if (strikes[kind] >= 3 || ceiling() - window.innerHeight <= 4)
6079
- return;
6080
- const frame = document.querySelector('[data-mobile-nav="frame"]');
6081
- if (frame === null)
6082
- return;
6083
- const scroller = document.querySelector('[class$="_scrollBody"]');
6084
- const scrollTop = scroller?.scrollTop ?? 0;
6085
- const previous = frame.style.display;
6086
- frame.style.display = 'none';
6087
- void frame.offsetHeight; /* forces the synchronous reflow that re-measures */
6088
- frame.style.display = previous;
6089
- if (scroller !== null)
6090
- scroller.scrollTop = scrollTop;
6091
- strikes[kind] = ceiling() - window.innerHeight > 4 ? strikes[kind] + 1 : 0;
6092
- };
6093
- /* Blur is when the keyboard starts closing; the viewport settles a beat
6094
- later. Two attempts because the dismiss animation length is not
6095
- specified anywhere, and a heal that runs too early is a silent no-op. */
6096
- const timers = [];
6097
- const onFocusOut = (event) => {
6098
- if (!(event.target instanceof HTMLElement))
6099
- return;
6100
- if (event.target.matches('textarea, input') !== true)
6101
- return;
6102
- timers.push(window.setTimeout(() => heal('blur'), 300), window.setTimeout(() => heal('blur'), 900));
6339
+ const [topRaw, bottomRaw] = raw.split(',');
6340
+ const top = Number(topRaw);
6341
+ if (!Number.isFinite(top))
6342
+ return () => { };
6343
+ const root = document.documentElement;
6344
+ root.style.setProperty('--mnav-sat', `${top}px`);
6345
+ const bottom = bottomRaw === undefined ? Number.NaN : Number(bottomRaw);
6346
+ if (Number.isFinite(bottom))
6347
+ root.style.setProperty('--mnav-sab', `${bottom}px`);
6348
+ return () => {
6349
+ root.style.removeProperty('--mnav-sat');
6350
+ root.style.removeProperty('--mnav-sab');
6103
6351
  };
6104
- /* Cold start (S1.3): the user's iOS 26.5 device is already 59px short on
6105
- a freshly killed-and-reopened app that never saw the keyboard, so the
6106
- keyboard trigger alone is not the whole story. Same reflow, tried once
6107
- the shell has mounted and again after it has settled, plus every return
6108
- to the foreground — iOS re-lays a backgrounded PWA out and that is
6109
- another moment the height can come back wrong. Whether a reflow can win
6110
- against a system-level regression is unknowable off-device; the strike
6111
- budget is what keeps a losing attempt cheap. */
6112
- timers.push(window.setTimeout(() => heal('boot'), 1000), window.setTimeout(() => heal('boot'), 3000));
6113
- const onVisible = () => {
6114
- if (document.visibilityState !== 'visible')
6352
+ }, 'dsh-mobile-nav: fake safe-area inset');
6353
+ const DEBUG_KEY = 'dsh-mobile-nav.debug';
6354
+ /**
6355
+ * No-URL toggle — 5 quick taps on the home screen's top bar.
6356
+ * A standalone PWA has no address bar, and the paired-device cookie lives
6357
+ * in the PWA's own jar (Safari hits the pairing wall instead), so the
6358
+ * ?mobile-nav-debug=1 param is unreachable exactly where the badge is
6359
+ * needed most.
6360
+ *
6361
+ * The target is deliberately NOT [data-mobile-nav="ws-switch"] any more:
6362
+ * that button opens the workspace sheet on the FIRST tap, so taps 2..5
6363
+ * land on the sheet backdrop, `closest()` misses, and the counter never
6364
+ * reaches five — the original binding only ever fired by luck, and it left
6365
+ * a user stuck inside debug mode with no way back out. The logo img has no
6366
+ * click handler at all; the home-top container minus the switch button is
6367
+ * the fallback for when the icon 404s and the img is not rendered.
6368
+ */
6369
+ ctx.effect(() => {
6370
+ let taps = 0;
6371
+ let firstTap = 0;
6372
+ const onTap = (event) => {
6373
+ if (!(event.target instanceof Element))
6115
6374
  return;
6116
- timers.push(window.setTimeout(() => heal('boot'), 300));
6117
- };
6118
- window.addEventListener('resize', onResize);
6119
- document.addEventListener('focusout', onFocusOut, true);
6120
- document.addEventListener('visibilitychange', onVisible);
6121
- return () => {
6122
- window.removeEventListener('resize', onResize);
6123
- document.removeEventListener('focusout', onFocusOut, true);
6124
- document.removeEventListener('visibilitychange', onVisible);
6125
- for (const t of timers)
6126
- clearTimeout(t);
6375
+ const inTopBar = event.target.closest('[data-mobile-nav="home-logo"]') !== null
6376
+ || (event.target.closest('[data-mobile-nav="home-top"]') !== null
6377
+ && event.target.closest('[data-mobile-nav="ws-switch"]') === null);
6378
+ if (!inTopBar)
6379
+ return;
6380
+ const now = Date.now();
6381
+ if (now - firstTap > 2500) {
6382
+ taps = 0;
6383
+ firstTap = now;
6384
+ }
6385
+ taps += 1;
6386
+ if (taps >= 5) {
6387
+ if (localStorage.getItem(DEBUG_KEY) === '1')
6388
+ localStorage.removeItem(DEBUG_KEY);
6389
+ else
6390
+ localStorage.setItem(DEBUG_KEY, '1');
6391
+ location.reload();
6392
+ }
6127
6393
  };
6128
- }, 'dsh-mobile-nav: iOS viewport-shrink heal (boot / foreground / keyboard)');
6129
- }
6130
- /**
6131
- * Sunk-viewport compensation (S1.3, 2026-08-17) — zeroes --mnav-sab ONLY.
6132
- *
6133
- * When isViewportSunkBelowStatusBar() holds, the layout viewport is 59px
6134
- * shorter than the screen while env() still reports the full pair of insets.
6135
- * The bottom one is then a lie with a cost: the home indicator sits in the
6136
- * dead strip BELOW the page, so the 34px we reserve for it is 34px of blank
6137
- * page stacked on top of an already-blank system band. Zeroing it does not
6138
- * fix the band (nothing in the page can) but stops us widening it.
6139
- *
6140
- * --mnav-sat is deliberately left alone: the top edge is measured correct on
6141
- * the device (header sits right under the notch), and the S1.2 round already
6142
- * burned a cycle on the theory that it was double-counted.
6143
- *
6144
- * Reversible by design. The predicate is re-evaluated on every resize, so a
6145
- * viewport that comes back to full height — the heal above winning, or Apple
6146
- * shipping the fix — drops the override and the normal env() compensation
6147
- * returns without a reload. `data-mnav-sunk` on <html> is what the debug
6148
- * badge reads, so the badge reports the state that is actually in force
6149
- * rather than re-deriving it.
6150
- */
6151
- function installSunkInset(ctx) {
6394
+ document.addEventListener('click', onTap, true);
6395
+ return () => document.removeEventListener('click', onTap, true);
6396
+ }, 'dsh-mobile-nav: debug badge tap toggle');
6152
6397
  ctx.effect(() => {
6153
- /* ?mobile-nav-inset outranks this: both write the same inline property on
6154
- <html>, and the debug param exists precisely to fake insets that this
6155
- would then erase. Param present = stay out entirely, attribute included,
6156
- so the badge shows "n/a" instead of a state nobody is enforcing. */
6157
- if (new URLSearchParams(location.search).has('mobile-nav-inset'))
6398
+ const enabled = new URLSearchParams(location.search).has('mobile-nav-debug')
6399
+ || localStorage.getItem('dsh-mobile-nav.debug') === '1';
6400
+ if (!enabled)
6158
6401
  return () => { };
6159
- const root = document.documentElement;
6160
- /* env() is only readable through a real element's computed style. */
6402
+ const errors = [];
6403
+ const onError = (event) => errors.push(`ERR ${event.message.slice(0, 120)}`);
6404
+ const onRejection = (event) => errors.push(`REJ ${String(event.reason).slice(0, 120)}`);
6405
+ window.addEventListener('error', onError);
6406
+ window.addEventListener('unhandledrejection', onRejection);
6407
+ /* Probe element: reads the REAL env(safe-area-inset-*) as computed padding
6408
+ — the --mnav-* vars can be faked by the inset param, this cannot. */
6161
6409
  const probe = document.createElement('div');
6162
6410
  probe.style.cssText =
6163
- 'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' +
6164
- 'padding-top:env(safe-area-inset-top,0px)';
6411
+ 'position:fixed;visibility:hidden;pointer-events:none;' +
6412
+ 'padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)';
6165
6413
  document.body.appendChild(probe);
6166
- const sync = () => {
6167
- const sunk = isViewportSunkBelowStatusBar({
6168
- standalone: window.matchMedia('(display-mode: standalone)').matches
6169
- || navigator.standalone === true,
6170
- screenHeight: window.screen.height,
6171
- innerHeight: window.innerHeight,
6172
- envTop: Number.parseFloat(getComputedStyle(probe).paddingTop) || 0,
6173
- });
6174
- root.dataset.mnavSunk = sunk ? '1' : '0';
6175
- if (sunk)
6176
- root.style.setProperty('--mnav-sab', '0px');
6177
- else
6178
- root.style.removeProperty('--mnav-sab');
6414
+ const badge = document.createElement('div');
6415
+ badge.style.cssText = [
6416
+ /* Sits BELOW the notch on purpose: at the old flat top:40px the badge
6417
+ painted into the status-bar strip and covered the session header,
6418
+ which read on a screenshot as "the header ran under the notch in
6419
+ debug mode" — a measurement artefact that cost a whole round of
6420
+ diagnosis. env() here, not --mnav-sat: the badge must stay put even
6421
+ when the inset param is faking that variable. */
6422
+ 'position:fixed', 'top:calc(env(safe-area-inset-top, 0px) + 8px)', 'right:6px', 'z-index:2147483000',
6423
+ 'background:rgba(0,0,0,.82)', 'color:#fff', 'font:11px/1.5 ui-monospace,monospace',
6424
+ 'padding:8px 10px', 'border-radius:8px', 'max-width:94vw', 'max-height:70vh',
6425
+ /* pointer-events must be auto for the escape hatch below — with `none`
6426
+ the badge could only be dismissed by editing localStorage, which is
6427
+ not reachable from a standalone PWA. touch-action/user-select keep
6428
+ the double tap from being read as zoom or text selection. */
6429
+ 'overflow:auto', 'white-space:pre-wrap', 'pointer-events:auto',
6430
+ 'touch-action:manipulation', '-webkit-user-select:none', 'user-select:none',
6431
+ ].join(';');
6432
+ /* Escape hatch: two taps on the badge within 600ms turn debug off. The
6433
+ enable gesture lives on the home screen, which a user deep inside a
6434
+ session cannot reach without first getting out of the way of the badge
6435
+ — so the off switch belongs on the badge itself. */
6436
+ /* The full read-out is tall enough to cover the session list, and with
6437
+ pointer-events:auto (required for the escape hatch) it swallowed every
6438
+ tap under it — the user could not open a session with debug on. The
6439
+ badge therefore starts COLLAPSED (a one-line pill showing vh, the
6440
+ number that matters for the viewport-shrink bug) and only expands on
6441
+ demand. Single tap toggles collapsed/expanded (deferred 620ms so it
6442
+ can be distinguished from the exit gesture); double tap still exits. */
6443
+ let lastTap = 0;
6444
+ let collapsed = true;
6445
+ let singleTapTimer;
6446
+ const onBadgeTap = (event) => {
6447
+ event.preventDefault();
6448
+ event.stopPropagation();
6449
+ const now = Date.now();
6450
+ if (now - lastTap < 600) {
6451
+ if (singleTapTimer !== undefined)
6452
+ clearTimeout(singleTapTimer);
6453
+ localStorage.removeItem(DEBUG_KEY);
6454
+ const url = new URL(location.href);
6455
+ url.searchParams.delete('mobile-nav-debug');
6456
+ location.replace(url.toString());
6457
+ return;
6458
+ }
6459
+ lastTap = now;
6460
+ singleTapTimer = setTimeout(() => {
6461
+ collapsed = !collapsed;
6462
+ paint();
6463
+ }, 620);
6179
6464
  };
6180
- sync();
6181
- /* resize covers rotation and any height the heal above wins back.
6182
- Deliberately not visualViewport: it fires on every keyboard show/hide
6183
- while innerHeight, the value read here, does not move on iOS. */
6184
- window.addEventListener('resize', sync);
6185
- window.addEventListener('orientationchange', sync);
6465
+ badge.addEventListener('click', onBadgeTap);
6466
+ const read = () => {
6467
+ const q = (sel) => !!document.querySelector(sel);
6468
+ const vis = (sel) => {
6469
+ const el = document.querySelector(sel);
6470
+ return el === null ? 'absent' : getComputedStyle(el).visibility;
6471
+ };
6472
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
6473
+ if (collapsed)
6474
+ return `DBG vh ${innerHeight}/${screen.height} ▸ 单击展开 双击关闭`;
6475
+ return [
6476
+ '▾ 单击收起 · 双击关闭 debug',
6477
+ `URL ${location.pathname}${location.search}`,
6478
+ `W ${innerWidth} x ${innerHeight} dpr ${devicePixelRatio}`,
6479
+ `mq≤1023 ${matchMedia('(max-width: 1023px)').matches} mq≥1024 ${matchMedia('(min-width: 1024px)').matches}`,
6480
+ `css ${q('style[data-plugin-css*="mobile"]')} frame ${!!frame}`,
6481
+ `previewCol ${vis('[data-aionui-preview-col]')} explorerCol ${vis('[data-aionui-explorer-col]')}`,
6482
+ `previewOpen ${frame?.hasAttribute('data-aionui-preview-open') ?? '?'} explorerOpen ${frame?.hasAttribute('data-aionui-explorer-open') ?? '?'} previewFull ${frame?.hasAttribute('data-mobile-preview-full') ?? '?'}`,
6483
+ `header ${vis('[data-phase] header')} h${Math.round(document.querySelector('[data-phase] header')?.getBoundingClientRect().height ?? 0)} composer ${q('textarea')}`,
6484
+ `sat ${getComputedStyle(document.documentElement).getPropertyValue('--mnav-sat').trim() || '?'} sab ${getComputedStyle(document.documentElement).getPropertyValue('--mnav-sab').trim() || '?'}`,
6485
+ (() => {
6486
+ /* Bottom-gap forensics: real env values + who ends where. */
6487
+ const ps = getComputedStyle(probe);
6488
+ const bottomOf = (sel) => {
6489
+ const el = document.querySelector(sel);
6490
+ return el === null ? '—' : String(Math.round(el.getBoundingClientRect().bottom));
6491
+ };
6492
+ /* Composer ledger: bottom/gap-to-viewport for every layer between
6493
+ the textarea and the screen edge, so a "white band under the
6494
+ composer" screenshot says WHICH layer owns it instead of only
6495
+ where the textarea ends. Anchors per AGENTS.md: InputBar root is
6496
+ the padding carrier, _card is the rounded visual box, _composerSeat
6497
+ is the sticky seat, _scrollBody is the real scroll container. */
6498
+ const pair = (sel) => {
6499
+ const el = document.querySelector(sel);
6500
+ if (el === null)
6501
+ return '—';
6502
+ const b = el.getBoundingClientRect().bottom;
6503
+ return `${Math.round(b)}/${Math.round(innerHeight - b)}`;
6504
+ };
6505
+ const barRoot = document.querySelector('[data-slot="conversation.composer.bar"] > [class$="_root"]');
6506
+ /* Top-edge forensics: rect.top of the surfaces that consume
6507
+ --mnav-sat. These separate the two competing readings of the
6508
+ device numbers — a viewport SUNK below the status bar (origin at
6509
+ screen y=59, so a working sat padding puts the header at 118 and
6510
+ the top gap is doubled) versus a viewport anchored at screen y=0
6511
+ that is merely 59px short at the bottom (header at 59, top
6512
+ correct, the missing 59 showing as a band under the composer).
6513
+ innerHeight alone cannot tell them apart; the viewport ORIGIN can,
6514
+ hence screenY / visualViewport offsets / the edge markers. */
6515
+ const topOf = (sel) => {
6516
+ const el = document.querySelector(sel);
6517
+ return el === null ? '—' : String(Math.round(el.getBoundingClientRect().top));
6518
+ };
6519
+ const framePadTop = frame === null ? '—' : getComputedStyle(frame).paddingTop;
6520
+ const vv = window.visualViewport;
6521
+ return [
6522
+ `env sat ${ps.paddingTop} sab ${ps.paddingBottom}`,
6523
+ `screen ${screen.width}x${screen.height} avail ${screen.availWidth}x${screen.availHeight}`,
6524
+ `vh ${innerHeight} vv ${Math.round(vv?.height ?? -1)} outH ${outerHeight}`,
6525
+ `scrXY ${screenX},${screenY} vvOff ${Math.round(vv?.offsetTop ?? -1)} vvPage ${Math.round(vv?.pageTop ?? -1)} vvScale ${vv?.scale ?? '?'}`,
6526
+ /* The ENFORCED state, not a re-derivation: installSunkInset()
6527
+ stamps data-mnav-sunk, and its absence means the override is
6528
+ not running at all (the inset param owns the variables). sabOvr
6529
+ is the receipt — the inline value it wrote, or (env) when the
6530
+ normal compensation is in force. */
6531
+ `sunk? ${document.documentElement.dataset.mnavSunk ?? 'n/a'} standalone ${matchMedia('(display-mode: standalone)').matches} sabOvr ${document.documentElement.style.getPropertyValue('--mnav-sab') || '(env)'}`,
6532
+ `frameTop ${topOf('[data-mobile-nav="frame"]')} padTop ${framePadTop} headerTop ${topOf('[data-phase] header')} homeTopY ${topOf('[data-mobile-nav="home-top"]')}`,
6533
+ `botFrame ${bottomOf('[data-mobile-nav="frame"]')} composer ${bottomOf('[data-phase] textarea')}`,
6534
+ `taB ${pair('[data-phase] textarea')} cardB ${pair('[data-slot="conversation.composer.bar"] [class$="_card"]')}`,
6535
+ `barB ${pair('[data-slot="conversation.composer.bar"] > [class$="_root"]')} seatB ${pair('[class$="_composerSeat"]')} scrollB ${pair('[class$="_scrollBody"]')}`,
6536
+ `sabPad ${barRoot === null ? '—' : getComputedStyle(barRoot).paddingBottom}`,
6537
+ `botList ${bottomOf('[data-mobile-nav="home-list"]')} fab ${bottomOf('[data-mobile-nav="home-fab"]')}`,
6538
+ `cssLen ${document.querySelector('style[data-plugin-css*="mobile"]')?.textContent?.length ?? 0}`,
6539
+ ].join('\n');
6540
+ })(),
6541
+ `genui cards ${document.querySelectorAll('[data-genui]').length} panel ${q('[data-genui-panel]')}`,
6542
+ `phase ${document.querySelector('[data-phase]')?.getAttribute('data-phase') ?? '?'}`,
6543
+ `errs ${errors.slice(-5).join(' | ') || 'none'}`,
6544
+ ].join('\n');
6545
+ };
6546
+ /* Feedback-loop guards: painting the badge mutates the body subtree the
6547
+ observer watches — without these two checks the observer retriggers
6548
+ itself forever and freezes the page (the badge was rarely actually
6549
+ enabled before, which is how this survived). */
6550
+ const paint = () => {
6551
+ const next = read();
6552
+ if (next !== badge.textContent)
6553
+ badge.textContent = next;
6554
+ };
6555
+ paint();
6556
+ const observer = new MutationObserver((mutations) => {
6557
+ if (mutations.every((m) => m.target === badge || badge.contains(m.target) || m.target === probe))
6558
+ return;
6559
+ paint();
6560
+ });
6561
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
6562
+ const timer = setInterval(paint, 1500);
6563
+ document.body.appendChild(badge);
6186
6564
  return () => {
6187
- window.removeEventListener('resize', sync);
6188
- window.removeEventListener('orientationchange', sync);
6565
+ window.removeEventListener('error', onError);
6566
+ window.removeEventListener('unhandledrejection', onRejection);
6567
+ observer.disconnect();
6568
+ clearInterval(timer);
6189
6569
  probe.remove();
6190
- root.style.removeProperty('--mnav-sab');
6191
- delete root.dataset.mnavSunk;
6570
+ badge.remove();
6192
6571
  };
6193
- }, 'dsh-mobile-nav: sunk-viewport bottom-inset override');
6572
+ }, 'dsh-mobile-nav: debug badge');
6194
6573
  }
6195
6574
  };
6196
- __modules["effects/aionui-compat.js"] = function (require, module, exports) {
6575
+ __modules["effects/phone-chrome.js"] = function (require, module, exports) {
6197
6576
  "use strict";
6198
6577
  Object.defineProperty(exports, "__esModule", { value: true });
6199
- exports.installAionuiCompat = installAionuiCompat;
6200
- /** dsh-web-ui 兼容:explorer / preview 列的显隐标记与升起动画(同域同机制,合并一处)。 */
6201
- function installAionuiCompat(ctx) {
6202
- // dsh-web-ui compatibility: the aionui explorer column would render as a
6203
- // sheet over the whole mobile UI whenever its (persisted) expanded state
6204
- // is active — including right after a reload, with no way out (the
6205
- // suite's floating expand button only exists while collapsed). Instead
6206
- // of fighting the suite's store timing, the mobile stylesheet keeps the
6207
- // explorer column hidden by default and the header's Files action (plus
6208
- // the drawer footer entry) opens it via the `data-aionui-explorer-open`
6209
- // marker on the frame. This effect just clears that marker when the
6210
- // sheet's own collapse chevron is tapped, so closing is symmetric with
6211
- // opening.
6578
+ exports.installPhoneChrome = installPhoneChrome;
6579
+ exports.isViewportSunkBelowStatusBar = isViewportSunkBelowStatusBar;
6580
+ exports.installViewportHeal = installViewportHeal;
6581
+ exports.installSunkInset = installSunkInset;
6582
+ /**
6583
+ * Phone chrome: KEEP the system status bar (no fullscreen) and make it
6584
+ * blend into the page. On narrow screens:
6585
+ * - The viewport meta gains viewport-fit=cover, so env(safe-area-inset-top)
6586
+ * is the real status-bar / notch height and the stylesheet can push every
6587
+ * surface below it (off notched phones, or in a browser tab where the
6588
+ * layout viewport already sits below the status bar, the inset is 0 and
6589
+ * nothing shifts).
6590
+ * - A theme-color meta tracks the shell background (the official theme is
6591
+ * toggled by body[data-ds-dark-theme], which flips --dsw-alias-bg-base):
6592
+ * Android then paints the status bar / URL bar with the page's own base
6593
+ * color, so the status bar reads as part of the UI instead of a foreign
6594
+ * strip. The drawer paints the same strip on iOS / notch displays.
6595
+ * - gesturestart is suppressed as the legacy-iOS fallback for double-tap
6596
+ * zoom; modern browsers are covered by the stylesheet's
6597
+ * touch-action: manipulation (which keeps pan and pinch zoom).
6598
+ */
6599
+ function installPhoneChrome(ctx) {
6212
6600
  ctx.effect(() => {
6213
- // Arm on the CURRENT width and re-arm on every width change: the guard
6214
- // used to run once at apply time, so a wide→narrow transition (desktop
6215
- // resize, tablet split view) left the markers dead and the explorer /
6216
- // preview sheets could neither open nor close properly.
6217
6601
  const narrow = window.matchMedia('(max-width: 1023px)');
6218
- let cleanup;
6219
- const install = () => {
6220
- cleanup?.();
6221
- if (!narrow.matches) {
6222
- cleanup = undefined;
6223
- return;
6224
- }
6225
- const onChevronClick = (event) => {
6226
- const target = event.target;
6227
- if (target === null || !target.closest('.aionui-collapse-chevron'))
6228
- return;
6229
- document.querySelector('[data-mobile-nav="frame"]')?.removeAttribute('data-aionui-explorer-open');
6230
- };
6231
- document.addEventListener('click', onChevronClick, true);
6232
- cleanup = () => document.removeEventListener('click', onChevronClick, true);
6602
+ const viewport = document.querySelector('meta[name="viewport"]');
6603
+ const originalViewport = viewport?.content ?? '';
6604
+ const themeMeta = document.createElement('meta');
6605
+ themeMeta.name = 'theme-color';
6606
+ const bodyBg = () => getComputedStyle(document.body).backgroundColor;
6607
+ const sync = () => {
6608
+ if (viewport !== null)
6609
+ viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover';
6610
+ themeMeta.content = bodyBg();
6611
+ if (themeMeta.parentElement === null)
6612
+ document.head.appendChild(themeMeta);
6233
6613
  };
6234
- install();
6235
- narrow.addEventListener('change', install);
6236
- return () => {
6237
- narrow.removeEventListener('change', install);
6238
- cleanup?.();
6614
+ const restore = () => {
6615
+ if (viewport !== null)
6616
+ viewport.content = originalViewport;
6617
+ themeMeta.remove();
6239
6618
  };
6240
- }, 'dsh-mobile-nav: aionui explorer close marker');
6241
- // dsh-web-ui compatibility: the aionui preview column persists its open
6242
- // tabs in localStorage and restores them on load, which would pop the
6243
- // preview sheet over the fresh UI after a reload. Gate it like the
6244
- // explorer: the stylesheet keeps the column hidden unless the frame
6245
- // carries `data-aionui-preview-open`; this effect sets that marker when
6246
- // the user actually taps a file row in the explorer sheet, and clears it
6247
- // whenever the suite hides the column again (collapse chevron / tab
6248
- // close), so a restored-but-unwanted sheet never appears.
6249
- ctx.effect(() => {
6250
- // Arm on the CURRENT width and re-arm on every width change (see the
6251
- // explorer marker effect for why).
6252
- const narrow = window.matchMedia('(max-width: 1023px)');
6253
- let cleanup;
6254
- const install = () => {
6255
- cleanup?.();
6256
- if (!narrow.matches) {
6257
- cleanup = undefined;
6258
- return;
6259
- }
6260
- const frame = () => document.querySelector('[data-mobile-nav="frame"]');
6261
- // Closing the preview sheet also drops the fullscreen marker, so the
6262
- // next preview starts in the sheet layout again.
6263
- const closePreview = () => {
6264
- frame()?.removeAttribute('data-aionui-preview-open');
6265
- frame()?.removeAttribute('data-mobile-preview-full');
6266
- };
6267
- const onTap = (event) => {
6268
- const target = event.target;
6269
- if (target === null)
6270
- return;
6271
- const row = target.closest('[data-aionui-explorer-col] [class*="_treeRow"]');
6272
- if (row === null)
6273
- return;
6274
- // Only FILE rows open the preview sheet. Directory rows toggle
6275
- // expansion and must not pop the (possibly stale, restored-from-
6276
- // localStorage) preview tab over the tree. Substring matching:
6277
- // the suite's hashed classes carry a hash prefix, so the exact-token
6278
- // `class~=` form never matches and the trailing `$=` form misses
6279
- // selected rows (`_treeRowSelected`) and open arrows
6280
- // (`_treeArrowOpen`) — regressions of issue #8. The arrow gate must
6281
- // additionally exclude the leaf marker: FILE rows render a
6282
- // `_treeArrowEmpty` span whose class still contains the `_treeArrow`
6283
- // substring, so a bare substring match would treat every row as a
6284
- // directory and no preview would ever open.
6285
- if (row.querySelector('[class*="_treeArrow"]:not([class*="_treeArrowEmpty"])') !== null)
6286
- return;
6287
- frame()?.setAttribute('data-aionui-preview-open', '');
6288
- };
6289
- // The preview sheet's own collapse button (the two inward arrows in the
6290
- // tab bar) closes the AionUI store, but on mobile the suite's layout sync
6291
- // can be skipped while its shell-track mirror is not ready yet — in that
6292
- // case the inline visibility never flips to hidden and the visibility
6293
- // watcher below would never clear our marker. Clear it directly on the
6294
- // button click so the sheet always closes regardless of the suite's sync.
6295
- const onCollapse = (event) => {
6296
- const target = event.target;
6297
- if (target === null)
6298
- return;
6299
- if (target.closest('[data-aionui-preview-col] [class$="_panelCollapse"]') !== null) {
6300
- closePreview();
6301
- }
6302
- };
6303
- const sync = () => {
6304
- const pv = document.querySelector('[data-aionui-preview-col]');
6305
- if (pv === null)
6306
- return;
6307
- // Read the suite's inline visibility, not the computed value: while the
6308
- // `data-aionui-preview-open` marker is present our stylesheet forces the
6309
- // sheet visible with !important, so getComputedStyle() would never report
6310
- // hidden and the marker would never be cleared.
6311
- if (pv.style.visibility === 'hidden')
6312
- closePreview();
6313
- };
6314
- document.addEventListener('click', onTap, true);
6315
- document.addEventListener('click', onCollapse, true);
6316
- const observer = new MutationObserver(sync);
6317
- observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style'] });
6619
+ const onGestureStart = (event) => event.preventDefault();
6620
+ if (narrow.matches)
6318
6621
  sync();
6319
- cleanup = () => {
6320
- document.removeEventListener('click', onTap, true);
6321
- document.removeEventListener('click', onCollapse, true);
6322
- observer.disconnect();
6323
- };
6324
- };
6325
- install();
6326
- narrow.addEventListener('change', install);
6327
- return () => {
6328
- narrow.removeEventListener('change', install);
6329
- cleanup?.();
6330
- };
6331
- }, 'dsh-mobile-nav: preview sheet open marker');
6332
- // The dsh-web-ui explorer / preview columns toggle via `visibility`
6333
- // (their inline style), which never restarts a CSS animation — so the
6334
- // sheets would only animate on first mount. Replay the rise animation
6335
- // with the Web Animations API each time a column turns visible, then
6336
- // leave the resting state to the stylesheet.
6337
- ctx.effect(() => {
6338
- // Arm on the CURRENT width and re-arm on every width change (see the
6339
- // explorer marker effect for why).
6340
- const narrow = window.matchMedia('(max-width: 1023px)');
6341
- let cleanup;
6342
- const install = () => {
6343
- cleanup?.();
6344
- if (!narrow.matches) {
6345
- cleanup = undefined;
6346
- return;
6347
- }
6348
- const cols = ['[data-aionui-explorer-col]', '[data-aionui-preview-col]'];
6349
- const seen = new Map();
6350
- const play = (el) => {
6351
- el.animate([
6352
- { opacity: 0, transform: 'translateY(28px)' },
6353
- { opacity: 1, transform: 'none' },
6354
- ], { duration: 280, easing: 'cubic-bezier(.16, 1, .3, 1)', fill: 'backwards' });
6355
- };
6356
- const check = () => {
6357
- for (const sel of cols) {
6358
- const el = document.querySelector(sel);
6359
- if (el === null)
6360
- continue;
6361
- const visible = getComputedStyle(el).visibility === 'visible';
6362
- const prev = seen.get(sel) ?? false;
6363
- if (visible && !prev)
6364
- play(el);
6365
- seen.set(sel, visible);
6366
- }
6367
- };
6368
- const observer = new MutationObserver(check);
6369
- // Visibility flips come through inline style mutations (suite) or the
6370
- // explorer-open marker on the frame; class changes are watched too.
6371
- observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class', 'data-aionui-explorer-open'] });
6372
- check();
6373
- cleanup = () => {
6374
- observer.disconnect();
6375
- };
6376
- };
6377
- install();
6378
- narrow.addEventListener('change', install);
6622
+ const onChange = (event) => (event.matches ? sync() : restore());
6623
+ narrow.addEventListener('change', onChange);
6624
+ const observer = new MutationObserver(() => {
6625
+ if (narrow.matches)
6626
+ themeMeta.content = bodyBg();
6627
+ });
6628
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] });
6629
+ document.addEventListener('gesturestart', onGestureStart);
6379
6630
  return () => {
6380
- narrow.removeEventListener('change', install);
6381
- cleanup?.();
6631
+ narrow.removeEventListener('change', onChange);
6632
+ observer.disconnect();
6633
+ document.removeEventListener('gesturestart', onGestureStart);
6634
+ restore();
6382
6635
  };
6383
- }, 'dsh-mobile-nav: sheet rise animation replay');
6636
+ }, 'dsh-mobile-nav: status bar theme + viewport + zoom guard');
6637
+ }
6638
+ /**
6639
+ * Does the layout viewport sit shorter than the screen by at least the top
6640
+ * inset env() still claims? (S1.2 predicate, wired in S1.3.)
6641
+ *
6642
+ * Device numbers (iPhone, iOS 26.5, standalone PWA, 393x852): innerHeight
6643
+ * 793, env top 59 (= 852 - 793), env bottom 34, frame bottom 793 flush.
6644
+ * The same device in a Safari TAB reads innerHeight 695 with every surface
6645
+ * flush and every design value correct, which is what rules our own CSS out.
6646
+ *
6647
+ * What the true-branch means, settled by S1.2 + S1.3 device evidence: the
6648
+ * viewport is anchored at screen y=0 and merely cut 59px short at the BOTTOM
6649
+ * — not pushed down below the status bar. So the top inset is paid exactly
6650
+ * once and must be left alone, while the bottom inset is a lie: the home
6651
+ * indicator lives in the dead strip below the page. installSunkInset() acts
6652
+ * on that, and only on --mnav-sab.
6653
+ *
6654
+ * The `envTop > 0` guard is what keeps every other standalone install out:
6655
+ * landscape iPhone (top inset 0, the notch moves to left/right), iPad, and
6656
+ * Android — where standalone also loses status-bar height off innerHeight but
6657
+ * reports env top 0 — all fall through to false. Kept pure so the boundaries
6658
+ * are checkable off-device: scripts/check-sunk-viewport.mjs.
6659
+ */
6660
+ function isViewportSunkBelowStatusBar(input) {
6661
+ return input.standalone
6662
+ && input.envTop > 0
6663
+ && input.screenHeight - input.innerHeight >= input.envTop;
6384
6664
  }
6385
- };
6386
- __modules["effects/header-status.js"] = function (require, module, exports) {
6387
- "use strict";
6388
- Object.defineProperty(exports, "__esModule", { value: true });
6389
- exports.installHeaderStatusDot = installHeaderStatusDot;
6390
- const session_dot_ts_1 = require("./session-dot.js");
6391
6665
  /**
6392
- * Session header running-status dot (S2). No official element exists to
6393
- * reposition (ConversationSessionHeader renders only the crumb title, the
6394
- * actions/utilities slots, and the tablist — dsh-client-ui-conversation
6395
- * lib/client.js:6949-7009), so this reads `ctx.sessions.list` directly
6396
- * (the same feed `useSessions` wraps) and stamps a data attribute the
6397
- * mobile stylesheet turns into a `::after` dot on the title crumb —
6398
- * "read data, self-draw" is the plan's documented fallback for this piece.
6399
- * Kept outside React: the dot must track the CURRENT session regardless of
6400
- * which component the header happens to mount, and a plain attribute +
6401
- * CSS avoids reaching into the official crumb's own DOM subtree.
6666
+ * iOS standalone-PWA keyboard shrink (S1.2, 2026-08-17) — the ~60px white
6667
+ * band under the composer AND under the session list.
6668
+ *
6669
+ * Documented WebKit defect: the first time the software keyboard opens inside
6670
+ * a home-screen (standalone) PWA, the layout viewport permanently loses the
6671
+ * status-bar height for the rest of the app session. innerHeight,
6672
+ * visualViewport.height and 100dvh all report the shrunken value together, so
6673
+ * nothing inside the page can see anything wrong — the page simply ends early
6674
+ * and the strip below it is system background that no CSS can reach. Reported
6675
+ * as 932 -> 873 on an iPhone Pro Max; the user's device reads 852 -> 793.
6676
+ * Both are exactly one status bar. See
6677
+ * https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d
6678
+ *
6679
+ * This is why the reported symptom is asymmetric and why the earlier
6680
+ * "double-counted top inset" reading was wrong: the top edge is genuinely
6681
+ * correct (env top 59 is paid once, the viewport starts at screen y=0), the
6682
+ * bottom is simply 59px short. It also explains a chat client being hit
6683
+ * hardest — the trigger is typing, which happens on the first message.
6684
+ *
6685
+ * The only known cure is to make WebKit re-measure: drop a full-viewport
6686
+ * element out of the box tree and force a synchronous reflow. Two departures
6687
+ * from the published recipe:
6688
+ * - scrollTop is saved and restored around the toggle. Un-boxing an ancestor
6689
+ * resets every descendant scroller to 0, which in a chat client means the
6690
+ * conversation jumps to its first message. Both happen inside one task, so
6691
+ * no frame is ever painted in between and nothing flickers.
6692
+ * - the baseline is the tallest innerHeight this session has actually seen,
6693
+ * not the screen height. On any device where the viewport is legitimately
6694
+ * short, the baseline equals the current height and this never fires.
6695
+ *
6696
+ * Standalone-gated, so a browser tab (and every desktop, CDP included) is a
6697
+ * strict no-op — which is also why the fix cannot be regression-tested here
6698
+ * and has to be confirmed on the device.
6402
6699
  */
6403
- function installHeaderStatusDot(ctx) {
6700
+ function installViewportHeal(ctx) {
6404
6701
  ctx.effect(() => {
6405
- const apply = () => {
6702
+ const standalone = window.matchMedia('(display-mode: standalone)').matches
6703
+ || navigator.standalone === true;
6704
+ /* iOS-only on purpose. The ceiling below leans on screen.height being the
6705
+ height the viewport SHOULD have, which holds for an iPhone standalone
6706
+ app under viewport-fit=cover and does not hold on Android, where the
6707
+ navigation bar makes screen.height permanently larger than any viewport
6708
+ and every keyboard dismiss would trigger a pointless reflow. */
6709
+ if (!standalone || !/iPad|iPhone|iPod/.test(navigator.userAgent))
6710
+ return () => { };
6711
+ /* Ceiling = the tallest this session has seen, floored at the screen —
6712
+ the screen half matters because the shrink outlives a page reload, so a
6713
+ PWA reopened into the broken state would otherwise measure 793 as
6714
+ "normal" and never heal itself. */
6715
+ let tallest = window.innerHeight;
6716
+ const ceiling = () => Math.max(tallest, window.screen.height);
6717
+ const onResize = () => {
6718
+ if (window.innerHeight > tallest)
6719
+ tallest = window.innerHeight;
6720
+ };
6721
+ /* Give up after three ineffective attempts per trigger: on a device whose
6722
+ viewport is short for a reason a reflow cannot fix (the iOS 26.x
6723
+ standalone regression family) retrying forever buys nothing. The two
6724
+ budgets are separate on purpose — the cold-start attempts would
6725
+ otherwise spend the whole allowance before the user ever types, and the
6726
+ keyboard shrink IS reflow-curable even on a device whose cold start is
6727
+ not. */
6728
+ const strikes = { boot: 0, blur: 0 };
6729
+ const heal = (kind) => {
6730
+ if (strikes[kind] >= 3 || ceiling() - window.innerHeight <= 4)
6731
+ return;
6406
6732
  const frame = document.querySelector('[data-mobile-nav="frame"]');
6407
6733
  if (frame === null)
6408
6734
  return;
6409
- const { current, byId } = ctx.sessions.list.getSnapshot();
6410
- const row = current === undefined ? undefined : byId[current];
6411
- const state = row === undefined ? undefined : (0, session_dot_ts_1.dotState)(row);
6412
- if (state === undefined)
6413
- frame.removeAttribute('data-mobile-nav-dot');
6414
- else
6415
- frame.setAttribute('data-mobile-nav-dot', state);
6735
+ const scroller = document.querySelector('[class$="_scrollBody"]');
6736
+ const scrollTop = scroller?.scrollTop ?? 0;
6737
+ const previous = frame.style.display;
6738
+ frame.style.display = 'none';
6739
+ void frame.offsetHeight; /* forces the synchronous reflow that re-measures */
6740
+ frame.style.display = previous;
6741
+ if (scroller !== null)
6742
+ scroller.scrollTop = scrollTop;
6743
+ strikes[kind] = ceiling() - window.innerHeight > 4 ? strikes[kind] + 1 : 0;
6416
6744
  };
6417
- apply();
6418
- return ctx.sessions.list.subscribe(apply);
6419
- }, 'dsh-mobile-nav: header status dot');
6420
- }
6421
- };
6422
- __modules["effects/gestures.js"] = function (require, module, exports) {
6423
- "use strict";
6424
- Object.defineProperty(exports, "__esModule", { value: true });
6425
- exports.installGestures = installGestures;
6426
- const nav_store_ts_1 = require("./nav-store.js");
6427
- /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
6428
- const PHONE_QUERY = '(max-width: 767px)';
6429
- /** This plugin's two dismissible sheets (session-info, home workspace/chips
6430
- * picker — MobileHome.tsx's home-sheet hosts both the workspace switcher and
6431
- * the S5 chip-customize list under the same marker). Official popupSelect
6432
- * menus (permission/model) are deliberately excluded — the design spec
6433
- * leaves those mask-tap-only, and they are the suite's own DOM, not ours to
6434
- * add gesture handling to. */
6435
- const SHEET_SELECTOR = '[data-mobile-nav="info-sheet"], [data-mobile-nav="home-sheet"]';
6436
- const CLOSE_DISTANCE = 80;
6437
- /** px/ms — a fast flick closes the sheet even short of CLOSE_DISTANCE. */
6438
- const CLOSE_VELOCITY = 0.5;
6439
- /** Below this the drag is a tap/jitter, not yet a pull — avoids hijacking
6440
- * the very first pixels of an upward scroll inside the sheet. */
6441
- const DRAG_COMMIT_PX = 4;
6442
- function prefersReducedMotion() {
6443
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
6444
- }
6445
- /** The sheet's own mask sibling — both this plugin's sheets name theirs
6446
- * with a "-mask" suffix (info-mask, home-sheet-mask) and already wire a
6447
- * click handler that calls the sheet's close(). Reusing that handler (a
6448
- * synthetic click) needs no access to the sheet's React state at all.
6449
- * Shared with the S6.1 edge-swipe-back priority chain below — both "drag
6450
- * down to dismiss" and "swipe back from the edge" close a sheet the exact
6451
- * same way. */
6452
- function closeSheet(sheet) {
6453
- sheet.parentElement?.querySelector('[data-mobile-nav$="-mask"]')?.click();
6745
+ /* Blur is when the keyboard starts closing; the viewport settles a beat
6746
+ later. Two attempts because the dismiss animation length is not
6747
+ specified anywhere, and a heal that runs too early is a silent no-op. */
6748
+ const timers = [];
6749
+ const onFocusOut = (event) => {
6750
+ if (!(event.target instanceof HTMLElement))
6751
+ return;
6752
+ if (event.target.matches('textarea, input') !== true)
6753
+ return;
6754
+ timers.push(window.setTimeout(() => heal('blur'), 300), window.setTimeout(() => heal('blur'), 900));
6755
+ };
6756
+ /* Cold start (S1.3): the user's iOS 26.5 device is already 59px short on
6757
+ a freshly killed-and-reopened app that never saw the keyboard, so the
6758
+ keyboard trigger alone is not the whole story. Same reflow, tried once
6759
+ the shell has mounted and again after it has settled, plus every return
6760
+ to the foreground — iOS re-lays a backgrounded PWA out and that is
6761
+ another moment the height can come back wrong. Whether a reflow can win
6762
+ against a system-level regression is unknowable off-device; the strike
6763
+ budget is what keeps a losing attempt cheap. */
6764
+ timers.push(window.setTimeout(() => heal('boot'), 1000), window.setTimeout(() => heal('boot'), 3000));
6765
+ const onVisible = () => {
6766
+ if (document.visibilityState !== 'visible')
6767
+ return;
6768
+ timers.push(window.setTimeout(() => heal('boot'), 300));
6769
+ };
6770
+ window.addEventListener('resize', onResize);
6771
+ document.addEventListener('focusout', onFocusOut, true);
6772
+ document.addEventListener('visibilitychange', onVisible);
6773
+ return () => {
6774
+ window.removeEventListener('resize', onResize);
6775
+ document.removeEventListener('focusout', onFocusOut, true);
6776
+ document.removeEventListener('visibilitychange', onVisible);
6777
+ for (const t of timers)
6778
+ clearTimeout(t);
6779
+ };
6780
+ }, 'dsh-mobile-nav: iOS viewport-shrink heal (boot / foreground / keyboard)');
6454
6781
  }
6455
6782
  /**
6456
- * S6.2 — sheet drag-to-close: this plugin's two own bottom sheets (session
6457
- * info, home workspace picker) support "drag down to dismiss", mirroring
6458
- * the mask-tap close they already have. Official popupSelect menus
6459
- * (permission/model) are excluded on purpose — the design spec leaves them
6460
- * mask-tap-only, and they are the suite's own DOM, not ours to add gesture
6461
- * handling to.
6783
+ * Sunk-viewport compensation (S1.3, 2026-08-17) — zeroes --mnav-sab ONLY.
6462
6784
  *
6463
- * touchmove is registered non-passive (unlike the edge-swipe-back gesture
6464
- * below) because this gesture needs to preventDefault once it takes over, so
6465
- * the sheet's own translateY tracks the finger instead of fighting the
6466
- * scroller's native rubber-band. Before that handoff — while the sheet's
6467
- * inner content isn't scrolled to top — nothing is intercepted at all, so
6468
- * a pull down first scrolls the content as normal (spec requirement).
6785
+ * When isViewportSunkBelowStatusBar() holds, the layout viewport is 59px
6786
+ * shorter than the screen while env() still reports the full pair of insets.
6787
+ * The bottom one is then a lie with a cost: the home indicator sits in the
6788
+ * dead strip BELOW the page, so the 34px we reserve for it is 34px of blank
6789
+ * page stacked on top of an already-blank system band. Zeroing it does not
6790
+ * fix the band (nothing in the page can) but stops us widening it.
6791
+ *
6792
+ * --mnav-sat is deliberately left alone: the top edge is measured correct on
6793
+ * the device (header sits right under the notch), and the S1.2 round already
6794
+ * burned a cycle on the theory that it was double-counted.
6795
+ *
6796
+ * Reversible by design. The predicate is re-evaluated on every resize, so a
6797
+ * viewport that comes back to full height — the heal above winning, or Apple
6798
+ * shipping the fix — drops the override and the normal env() compensation
6799
+ * returns without a reload. `data-mnav-sunk` on <html> is what the debug
6800
+ * badge reads, so the badge reports the state that is actually in force
6801
+ * rather than re-deriving it.
6469
6802
  */
6470
- function installSheetDragClose(ctx) {
6803
+ function installSunkInset(ctx) {
6804
+ ctx.effect(() => {
6805
+ /* ?mobile-nav-inset outranks this: both write the same inline property on
6806
+ <html>, and the debug param exists precisely to fake insets that this
6807
+ would then erase. Param present = stay out entirely, attribute included,
6808
+ so the badge shows "n/a" instead of a state nobody is enforcing. */
6809
+ if (new URLSearchParams(location.search).has('mobile-nav-inset'))
6810
+ return () => { };
6811
+ const root = document.documentElement;
6812
+ /* env() is only readable through a real element's computed style. */
6813
+ const probe = document.createElement('div');
6814
+ probe.style.cssText =
6815
+ 'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' +
6816
+ 'padding-top:env(safe-area-inset-top,0px)';
6817
+ document.body.appendChild(probe);
6818
+ const sync = () => {
6819
+ const sunk = isViewportSunkBelowStatusBar({
6820
+ standalone: window.matchMedia('(display-mode: standalone)').matches
6821
+ || navigator.standalone === true,
6822
+ screenHeight: window.screen.height,
6823
+ innerHeight: window.innerHeight,
6824
+ envTop: Number.parseFloat(getComputedStyle(probe).paddingTop) || 0,
6825
+ });
6826
+ root.dataset.mnavSunk = sunk ? '1' : '0';
6827
+ if (sunk)
6828
+ root.style.setProperty('--mnav-sab', '0px');
6829
+ else
6830
+ root.style.removeProperty('--mnav-sab');
6831
+ };
6832
+ sync();
6833
+ /* resize covers rotation and any height the heal above wins back.
6834
+ Deliberately not visualViewport: it fires on every keyboard show/hide
6835
+ while innerHeight, the value read here, does not move on iOS. */
6836
+ window.addEventListener('resize', sync);
6837
+ window.addEventListener('orientationchange', sync);
6838
+ return () => {
6839
+ window.removeEventListener('resize', sync);
6840
+ window.removeEventListener('orientationchange', sync);
6841
+ probe.remove();
6842
+ root.style.removeProperty('--mnav-sab');
6843
+ delete root.dataset.mnavSunk;
6844
+ };
6845
+ }, 'dsh-mobile-nav: sunk-viewport bottom-inset override');
6846
+ }
6847
+ };
6848
+ __modules["effects/aionui-compat.js"] = function (require, module, exports) {
6849
+ "use strict";
6850
+ Object.defineProperty(exports, "__esModule", { value: true });
6851
+ exports.installAionuiCompat = installAionuiCompat;
6852
+ /** dsh-web-ui 兼容:explorer / preview 列的显隐标记与升起动画(同域同机制,合并一处)。 */
6853
+ function installAionuiCompat(ctx) {
6854
+ // dsh-web-ui compatibility: the aionui explorer column would render as a
6855
+ // sheet over the whole mobile UI whenever its (persisted) expanded state
6856
+ // is active — including right after a reload, with no way out (the
6857
+ // suite's floating expand button only exists while collapsed). Instead
6858
+ // of fighting the suite's store timing, the mobile stylesheet keeps the
6859
+ // explorer column hidden by default and the header's Files action (plus
6860
+ // the drawer footer entry) opens it via the `data-aionui-explorer-open`
6861
+ // marker on the frame. This effect just clears that marker when the
6862
+ // sheet's own collapse chevron is tapped, so closing is symmetric with
6863
+ // opening.
6471
6864
  ctx.effect(() => {
6472
- const narrow = window.matchMedia(PHONE_QUERY);
6473
- let drag = null;
6474
- const settle = (sheet, close) => {
6475
- if (close) {
6476
- sheet.style.transform = '';
6477
- sheet.style.transition = '';
6478
- closeSheet(sheet);
6479
- return;
6480
- }
6481
- if (prefersReducedMotion()) {
6482
- sheet.style.transition = 'none';
6483
- sheet.style.transform = '';
6865
+ // Arm on the CURRENT width and re-arm on every width change: the guard
6866
+ // used to run once at apply time, so a wide→narrow transition (desktop
6867
+ // resize, tablet split view) left the markers dead and the explorer /
6868
+ // preview sheets could neither open nor close properly.
6869
+ const narrow = window.matchMedia('(max-width: 1023px)');
6870
+ let cleanup;
6871
+ const install = () => {
6872
+ cleanup?.();
6873
+ if (!narrow.matches) {
6874
+ cleanup = undefined;
6484
6875
  return;
6485
6876
  }
6486
- sheet.style.transition = 'transform .22s var(--ds-ease-out, ease-in-out)';
6487
- sheet.style.transform = '';
6488
- const clear = () => {
6489
- sheet.style.transition = '';
6490
- sheet.removeEventListener('transitionend', clear);
6877
+ const onChevronClick = (event) => {
6878
+ const target = event.target;
6879
+ if (target === null || !target.closest('.aionui-collapse-chevron'))
6880
+ return;
6881
+ document.querySelector('[data-mobile-nav="frame"]')?.removeAttribute('data-aionui-explorer-open');
6491
6882
  };
6492
- sheet.addEventListener('transitionend', clear);
6883
+ document.addEventListener('click', onChevronClick, true);
6884
+ cleanup = () => document.removeEventListener('click', onChevronClick, true);
6493
6885
  };
6494
- const onTouchStart = (event) => {
6495
- if (event.touches.length !== 1) {
6496
- drag = null;
6497
- return;
6498
- }
6499
- const touch = event.touches[0];
6500
- const target = event.target;
6501
- if (touch === undefined || !(target instanceof Element)) {
6502
- drag = null;
6503
- return;
6504
- }
6505
- const sheet = target.closest(SHEET_SELECTOR);
6506
- if (sheet === null) {
6507
- drag = null;
6508
- return;
6509
- }
6510
- drag = { sheet, startY: touch.clientY, lastY: touch.clientY, startTime: Date.now(), dragging: false };
6886
+ install();
6887
+ narrow.addEventListener('change', install);
6888
+ return () => {
6889
+ narrow.removeEventListener('change', install);
6890
+ cleanup?.();
6511
6891
  };
6512
- const onTouchMove = (event) => {
6513
- if (drag === null)
6514
- return;
6515
- const touch = event.touches[0];
6516
- if (touch === undefined)
6892
+ }, 'dsh-mobile-nav: aionui explorer close marker');
6893
+ // dsh-web-ui compatibility: the aionui preview column persists its open
6894
+ // tabs in localStorage and restores them on load, which would pop the
6895
+ // preview sheet over the fresh UI after a reload. Gate it like the
6896
+ // explorer: the stylesheet keeps the column hidden unless the frame
6897
+ // carries `data-aionui-preview-open`; this effect sets that marker when
6898
+ // the user actually taps a file row in the explorer sheet, and clears it
6899
+ // whenever the suite hides the column again (collapse chevron / tab
6900
+ // close), so a restored-but-unwanted sheet never appears.
6901
+ ctx.effect(() => {
6902
+ // Arm on the CURRENT width and re-arm on every width change (see the
6903
+ // explorer marker effect for why).
6904
+ const narrow = window.matchMedia('(max-width: 1023px)');
6905
+ let cleanup;
6906
+ const install = () => {
6907
+ cleanup?.();
6908
+ if (!narrow.matches) {
6909
+ cleanup = undefined;
6517
6910
  return;
6518
- drag.lastY = touch.clientY;
6519
- const dy = touch.clientY - drag.startY;
6520
- if (!drag.dragging) {
6521
- if (drag.sheet.scrollTop > 0)
6911
+ }
6912
+ const frame = () => document.querySelector('[data-mobile-nav="frame"]');
6913
+ // Closing the preview sheet also drops the fullscreen marker, so the
6914
+ // next preview starts in the sheet layout again.
6915
+ const closePreview = () => {
6916
+ frame()?.removeAttribute('data-aionui-preview-open');
6917
+ frame()?.removeAttribute('data-mobile-preview-full');
6918
+ };
6919
+ const onTap = (event) => {
6920
+ const target = event.target;
6921
+ if (target === null)
6522
6922
  return;
6523
- if (dy <= DRAG_COMMIT_PX)
6923
+ const row = target.closest('[data-aionui-explorer-col] [class*="_treeRow"]');
6924
+ if (row === null)
6524
6925
  return;
6525
- drag.dragging = true;
6526
- drag.sheet.style.transition = 'none';
6527
- }
6528
- event.preventDefault();
6529
- drag.sheet.style.transform = `translateY(${Math.max(0, dy)}px)`;
6530
- };
6531
- const finish = () => {
6532
- if (drag === null)
6533
- return;
6534
- const { sheet, dragging, startY, lastY, startTime } = drag;
6535
- drag = null;
6536
- if (!dragging)
6537
- return;
6538
- const dy = Math.max(0, lastY - startY);
6539
- const elapsed = Math.max(1, Date.now() - startTime);
6540
- const velocity = dy / elapsed;
6541
- settle(sheet, dy > CLOSE_DISTANCE || velocity > CLOSE_VELOCITY);
6542
- };
6543
- const attach = () => {
6544
- document.addEventListener('touchstart', onTouchStart, { passive: true });
6545
- document.addEventListener('touchmove', onTouchMove, { passive: false });
6546
- document.addEventListener('touchend', finish, { passive: true });
6547
- document.addEventListener('touchcancel', finish, { passive: true });
6548
- };
6549
- const detach = () => {
6550
- document.removeEventListener('touchstart', onTouchStart);
6551
- document.removeEventListener('touchmove', onTouchMove);
6552
- document.removeEventListener('touchend', finish);
6553
- document.removeEventListener('touchcancel', finish);
6554
- drag = null;
6555
- };
6556
- if (narrow.matches)
6557
- attach();
6558
- const onChange = (event) => (event.matches ? attach() : detach());
6559
- narrow.addEventListener('change', onChange);
6560
- return () => {
6561
- narrow.removeEventListener('change', onChange);
6562
- detach();
6563
- };
6564
- }, 'dsh-mobile-nav: sheet drag-to-close');
6565
- }
6566
- /** Left-edge start zone for S6.1's swipe-back, in CSS px from the viewport's
6567
- * left edge. This used to be the gateway half's own edge-swipe-back hot zone
6568
- * (history.back, useless against an SPA) — that gesture is removed in the
6569
- * same revision that adds this one, so the 24px strip changes owner instead
6570
- * of staying carved out (design doc "手势" row, 2026-08-17 fourth revision). */
6571
- const EDGE_ZONE_PX = 24;
6572
- const EDGE_SWIPE_MIN_DX = 90;
6573
- const EDGE_SWIPE_RATIO = 1.6;
6574
- /**
6575
- * dsh-better-sidebar's panel-open read (AGENTS.md pitfall "第三方浮层的开合
6576
- * 态可以纯 CSS `:has()` 读"): the panel's class ends in "_panel" only while
6577
- * open — "_panelHidden" is appended once closed, so the string no longer
6578
- * ends in "_panel" and this selector stops matching. Same anchor
6579
- * MobileSessionHeader.tsx's workbench button and its own close button use.
6580
- */
6581
- function betterSidebarPanelOpen() {
6582
- return document.querySelector('[data-dsh-better-sidebar] [class$="_panel"]') !== null;
6583
- }
6584
- /**
6585
- * S6.1 — left-edge swipe-back priority chain (design doc "手势" row, fourth
6586
- * revision): a swipe starting in the left {@link EDGE_ZONE_PX} always closes
6587
- * the TOPMOST dismissible surface rather than always navigating, so a user
6588
- * mid-workflow (info card open, workbench panel open) gets "close that"
6589
- * instead of "leave the session" from the same gesture. Checked in the
6590
- * design doc's own order:
6591
- * 1. this plugin's session-info sheet, if open
6592
- * 2. this plugin's home sheet (workspace switcher or S5 chip-customize),
6593
- * if open
6594
- * 3. dsh-better-sidebar's workbench panel, if open (代点 its own toggle —
6595
- * same anchor MobileSessionHeader.tsx's workbench/close buttons use)
6596
- * 4. otherwise, GO_HOME_EVENT — but only from the session view; the design
6597
- * doc calls out "在 home 层→无动作" explicitly, and the phone page
6598
- * stack's current level is read straight off MobileHome's own
6599
- * `data-view` attribute (no store handle needed here — effects/*.ts is
6600
- * already all DOM reads, matching every sibling effect in this file).
6601
- */
6602
- function handleEdgeSwipeBack() {
6603
- const sheet = document.querySelector(SHEET_SELECTOR);
6604
- if (sheet !== null) {
6605
- closeSheet(sheet);
6606
- return;
6607
- }
6608
- if (betterSidebarPanelOpen()) {
6609
- document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
6610
- return;
6611
- }
6612
- if (document.querySelector('[data-mobile-nav="home"]')?.getAttribute('data-view') === 'session') {
6613
- window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT));
6614
- }
6615
- }
6616
- /**
6617
- * S6.1 — left-edge swipe-back gesture install. Passive throughout (never
6618
- * calls preventDefault) and decided purely from the touchstart/touchend
6619
- * endpoints, exactly like the S6.1-predecessor content-area swipe this
6620
- * revision removes — so it never fights vertical (or, at the 24px strip,
6621
- * horizontal chip-row) scrolling, and needs no mid-drag tracking: the design
6622
- * doc explicitly allows "一步到位" over a followed-finger animation.
6623
- */
6624
- function installEdgeSwipeBack(ctx) {
6926
+ // Only FILE rows open the preview sheet. Directory rows toggle
6927
+ // expansion and must not pop the (possibly stale, restored-from-
6928
+ // localStorage) preview tab over the tree. Substring matching:
6929
+ // the suite's hashed classes carry a hash prefix, so the exact-token
6930
+ // `class~=` form never matches and the trailing `$=` form misses
6931
+ // selected rows (`_treeRowSelected`) and open arrows
6932
+ // (`_treeArrowOpen`) — regressions of issue #8. The arrow gate must
6933
+ // additionally exclude the leaf marker: FILE rows render a
6934
+ // `_treeArrowEmpty` span whose class still contains the `_treeArrow`
6935
+ // substring, so a bare substring match would treat every row as a
6936
+ // directory and no preview would ever open.
6937
+ if (row.querySelector('[class*="_treeArrow"]:not([class*="_treeArrowEmpty"])') !== null)
6938
+ return;
6939
+ frame()?.setAttribute('data-aionui-preview-open', '');
6940
+ };
6941
+ // The preview sheet's own collapse button (the two inward arrows in the
6942
+ // tab bar) closes the AionUI store, but on mobile the suite's layout sync
6943
+ // can be skipped while its shell-track mirror is not ready yet — in that
6944
+ // case the inline visibility never flips to hidden and the visibility
6945
+ // watcher below would never clear our marker. Clear it directly on the
6946
+ // button click so the sheet always closes regardless of the suite's sync.
6947
+ const onCollapse = (event) => {
6948
+ const target = event.target;
6949
+ if (target === null)
6950
+ return;
6951
+ if (target.closest('[data-aionui-preview-col] [class$="_panelCollapse"]') !== null) {
6952
+ closePreview();
6953
+ }
6954
+ };
6955
+ const sync = () => {
6956
+ const pv = document.querySelector('[data-aionui-preview-col]');
6957
+ if (pv === null)
6958
+ return;
6959
+ // Read the suite's inline visibility, not the computed value: while the
6960
+ // `data-aionui-preview-open` marker is present our stylesheet forces the
6961
+ // sheet visible with !important, so getComputedStyle() would never report
6962
+ // hidden and the marker would never be cleared.
6963
+ if (pv.style.visibility === 'hidden')
6964
+ closePreview();
6965
+ };
6966
+ document.addEventListener('click', onTap, true);
6967
+ document.addEventListener('click', onCollapse, true);
6968
+ const observer = new MutationObserver(sync);
6969
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style'] });
6970
+ sync();
6971
+ cleanup = () => {
6972
+ document.removeEventListener('click', onTap, true);
6973
+ document.removeEventListener('click', onCollapse, true);
6974
+ observer.disconnect();
6975
+ };
6976
+ };
6977
+ install();
6978
+ narrow.addEventListener('change', install);
6979
+ return () => {
6980
+ narrow.removeEventListener('change', install);
6981
+ cleanup?.();
6982
+ };
6983
+ }, 'dsh-mobile-nav: preview sheet open marker');
6984
+ // The dsh-web-ui explorer / preview columns toggle via `visibility`
6985
+ // (their inline style), which never restarts a CSS animation — so the
6986
+ // sheets would only animate on first mount. Replay the rise animation
6987
+ // with the Web Animations API each time a column turns visible, then
6988
+ // leave the resting state to the stylesheet.
6625
6989
  ctx.effect(() => {
6626
- const narrow = window.matchMedia(PHONE_QUERY);
6627
- let start = null;
6628
- const onTouchStart = (event) => {
6629
- if (event.touches.length !== 1) {
6630
- start = null;
6631
- return;
6632
- }
6633
- const touch = event.touches[0];
6634
- if (touch === undefined) {
6635
- start = null;
6990
+ // Arm on the CURRENT width and re-arm on every width change (see the
6991
+ // explorer marker effect for why).
6992
+ const narrow = window.matchMedia('(max-width: 1023px)');
6993
+ let cleanup;
6994
+ const install = () => {
6995
+ cleanup?.();
6996
+ if (!narrow.matches) {
6997
+ cleanup = undefined;
6636
6998
  return;
6637
6999
  }
6638
- start = { x: touch.clientX, y: touch.clientY, eligible: touch.clientX <= EDGE_ZONE_PX };
6639
- };
6640
- const onTouchEnd = (event) => {
6641
- const state = start;
6642
- start = null;
6643
- if (state === null || !state.eligible)
6644
- return;
6645
- const touch = event.changedTouches[0];
6646
- if (touch === undefined)
6647
- return;
6648
- const dx = touch.clientX - state.x;
6649
- const dy = touch.clientY - state.y;
6650
- if (dx <= EDGE_SWIPE_MIN_DX || Math.abs(dx) <= EDGE_SWIPE_RATIO * Math.abs(dy))
6651
- return;
6652
- handleEdgeSwipeBack();
6653
- };
6654
- const attach = () => {
6655
- document.addEventListener('touchstart', onTouchStart, { passive: true });
6656
- document.addEventListener('touchend', onTouchEnd, { passive: true });
6657
- };
6658
- const detach = () => {
6659
- document.removeEventListener('touchstart', onTouchStart);
6660
- document.removeEventListener('touchend', onTouchEnd);
6661
- start = null;
7000
+ const cols = ['[data-aionui-explorer-col]', '[data-aionui-preview-col]'];
7001
+ const seen = new Map();
7002
+ const play = (el) => {
7003
+ el.animate([
7004
+ { opacity: 0, transform: 'translateY(28px)' },
7005
+ { opacity: 1, transform: 'none' },
7006
+ ], { duration: 280, easing: 'cubic-bezier(.16, 1, .3, 1)', fill: 'backwards' });
7007
+ };
7008
+ const check = () => {
7009
+ for (const sel of cols) {
7010
+ const el = document.querySelector(sel);
7011
+ if (el === null)
7012
+ continue;
7013
+ const visible = getComputedStyle(el).visibility === 'visible';
7014
+ const prev = seen.get(sel) ?? false;
7015
+ if (visible && !prev)
7016
+ play(el);
7017
+ seen.set(sel, visible);
7018
+ }
7019
+ };
7020
+ const observer = new MutationObserver(check);
7021
+ // Visibility flips come through inline style mutations (suite) or the
7022
+ // explorer-open marker on the frame; class changes are watched too.
7023
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class', 'data-aionui-explorer-open'] });
7024
+ check();
7025
+ cleanup = () => {
7026
+ observer.disconnect();
7027
+ };
6662
7028
  };
6663
- if (narrow.matches)
6664
- attach();
6665
- const onChange = (event) => (event.matches ? attach() : detach());
6666
- narrow.addEventListener('change', onChange);
7029
+ install();
7030
+ narrow.addEventListener('change', install);
6667
7031
  return () => {
6668
- narrow.removeEventListener('change', onChange);
6669
- detach();
7032
+ narrow.removeEventListener('change', install);
7033
+ cleanup?.();
6670
7034
  };
6671
- }, 'dsh-mobile-nav: left-edge swipe-back');
6672
- }
6673
- /** S6: the two-gesture set — left-edge swipe-back and sheet drag-to-close.
6674
- * Both install/uninstall their own document listeners on the phone
6675
- * breakpoint's matchMedia change, so at >= 768px this is a true no-op (no
6676
- * listeners attached at all), matching every other effect in this file. */
6677
- function installGestures(ctx) {
6678
- installEdgeSwipeBack(ctx);
6679
- installSheetDragClose(ctx);
7035
+ }, 'dsh-mobile-nav: sheet rise animation replay');
6680
7036
  }
6681
7037
  };
6682
- __modules["locales.js"] = function (require, module, exports) {
7038
+ __modules["effects/header-status.js"] = function (require, module, exports) {
6683
7039
  "use strict";
6684
7040
  Object.defineProperty(exports, "__esModule", { value: true });
6685
- exports.en = exports.zh = exports.NS = void 0;
6686
- /** `mobileNav` namespace dictionaries: drawer controls. */
6687
- exports.NS = 'mobileNav';
6688
- /** Simplified Chinese dictionary (the key-set source of truth). */
6689
- exports.zh = {
6690
- 'open': '打开目录',
6691
- 'close': '收起目录',
6692
- 'backdrop': '点击关闭目录',
6693
- 'sessionLog': '导出会话日志',
6694
- 'files': '文件浏览',
6695
- 'previewFullscreen': '全屏预览',
6696
- 'previewExitFullscreen': '退出全屏',
6697
- 'allWorkspaces': '全部',
6698
- 'switchWorkspace': '切换工作区',
6699
- 'newSession': '新建会话',
6700
- 'noSessions': '还没有会话,点右下角加号开始',
6701
- 'homeStatusOngoing': '运行中',
6702
- 'homeStatusWarning': '待处理',
6703
- 'homeStatusDone': '已完成',
6704
- 'backToList': '返回会话列表',
6705
- 'sessionInfo': '会话信息',
6706
- 'workbench': '工作台',
6707
- 'workbenchClose': '关闭工作台',
6708
- 'switchView': '切换视图',
6709
- 'attach': '添加附件',
6710
- 'attachBusy': '上传中…',
6711
- 'attachFailed': '上传失败,点按重试',
6712
- 'attachRemove': '移除附件 {name}',
6713
- 'infoClose': '关闭',
6714
- 'infoMode': '模式',
6715
- 'infoCwdFallback': '未知目录',
6716
- 'infoSubagents': '{count} 个子代理',
6717
- 'infoJobs': '{count} 个后台任务',
6718
- 'activityOpen': '查看子代理与后台任务',
6719
- 'infoStatTurns': '轮次',
6720
- 'infoStatSteps': '步骤',
6721
- 'infoStatTtft': '首字延迟',
6722
- 'infoStatLlm': '模型耗时',
6723
- 'infoStatTool': '工具耗时',
6724
- 'infoStatCacheHit': '缓存命中',
6725
- 'infoTokenFlow': 'Token {io}',
6726
- 'infoExport': '导出日志',
6727
- 'infoRename': '重命名',
6728
- 'infoRenamePrompt': '会话新名称',
6729
- 'infoFork': 'Fork 会话',
6730
- 'infoArchive': '归档',
6731
- 'infoArchiveConfirm': '归档后将从会话列表隐藏,确定继续?',
6732
- 'infoActionError': '操作失败:{message}',
6733
- 'turnFold': '过程 · {count} 步',
6734
- 'turnFoldRunning': '进行中 · {count} 步',
6735
- 'settings': '设置',
6736
- 'chipTaskboard': '任务看板',
6737
- 'chipSsh': 'SSH',
6738
- 'chipUsage': '用量',
6739
- 'chipCustomize': '自定义入口',
6740
- };
6741
- /** English dictionary, key-identical to the Chinese source of truth. */
6742
- exports.en = {
6743
- 'open': 'Open directory',
6744
- 'close': 'Close directory',
6745
- 'backdrop': 'Click to close directory',
6746
- 'sessionLog': 'Session log',
6747
- 'files': 'Files',
6748
- 'previewFullscreen': 'Fullscreen preview',
6749
- 'previewExitFullscreen': 'Exit fullscreen',
6750
- 'allWorkspaces': 'All',
6751
- 'switchWorkspace': 'Switch workspace',
6752
- 'newSession': 'New session',
6753
- 'noSessions': 'No sessions yet — tap + to start one',
6754
- 'homeStatusOngoing': 'Running',
6755
- 'homeStatusWarning': 'Needs attention',
6756
- 'homeStatusDone': 'Done',
6757
- 'backToList': 'Back to sessions',
6758
- 'sessionInfo': 'Session info',
6759
- 'workbench': 'Workbench',
6760
- 'workbenchClose': 'Close workbench',
6761
- 'switchView': 'Switch view',
6762
- 'attach': 'Add attachment',
6763
- 'attachBusy': 'Uploading…',
6764
- 'attachFailed': 'Upload failed — tap to retry',
6765
- 'attachRemove': 'Remove attachment {name}',
6766
- 'infoClose': 'Close',
6767
- 'infoMode': 'Mode',
6768
- 'infoCwdFallback': 'Unknown directory',
6769
- 'infoSubagents': '{count} subagents',
6770
- 'infoJobs': '{count} background tasks',
6771
- 'activityOpen': 'View subagents and background tasks',
6772
- 'infoStatTurns': 'Turns',
6773
- 'infoStatSteps': 'Steps',
6774
- 'infoStatTtft': 'TTFT',
6775
- 'infoStatLlm': 'LLM time',
6776
- 'infoStatTool': 'Tool time',
6777
- 'infoStatCacheHit': 'Cache hit',
6778
- 'infoTokenFlow': 'Tokens {io}',
6779
- 'infoExport': 'Export log',
6780
- 'infoRename': 'Rename',
6781
- 'infoRenamePrompt': 'New session name',
6782
- 'infoFork': 'Fork session',
6783
- 'infoArchive': 'Archive',
6784
- 'infoArchiveConfirm': 'Archiving hides this session from the list. Continue?',
6785
- 'infoActionError': 'Action failed: {message}',
6786
- 'turnFold': 'Process · {count} steps',
6787
- 'turnFoldRunning': 'Working · {count} steps',
6788
- 'settings': 'Settings',
6789
- 'chipTaskboard': 'Task board',
6790
- 'chipSsh': 'SSH',
6791
- 'chipUsage': 'Usage',
6792
- 'chipCustomize': 'Customize shortcuts',
6793
- };
7041
+ exports.installHeaderStatusDot = installHeaderStatusDot;
7042
+ const session_dot_ts_1 = require("./session-dot.js");
7043
+ /**
7044
+ * Session header running-status dot (S2). No official element exists to
7045
+ * reposition (ConversationSessionHeader renders only the crumb title, the
7046
+ * actions/utilities slots, and the tablist — dsh-client-ui-conversation
7047
+ * lib/client.js:6949-7009), so this reads `ctx.sessions.list` directly
7048
+ * (the same feed `useSessions` wraps) and stamps a data attribute the
7049
+ * mobile stylesheet turns into a `::after` dot on the title crumb —
7050
+ * "read data, self-draw" is the plan's documented fallback for this piece.
7051
+ * Kept outside React: the dot must track the CURRENT session regardless of
7052
+ * which component the header happens to mount, and a plain attribute +
7053
+ * CSS avoids reaching into the official crumb's own DOM subtree.
7054
+ */
7055
+ function installHeaderStatusDot(ctx) {
7056
+ ctx.effect(() => {
7057
+ const apply = () => {
7058
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
7059
+ if (frame === null)
7060
+ return;
7061
+ const { current, byId } = ctx.sessions.list.getSnapshot();
7062
+ const row = current === undefined ? undefined : byId[current];
7063
+ const state = row === undefined ? undefined : (0, session_dot_ts_1.dotState)(row);
7064
+ if (state === undefined)
7065
+ frame.removeAttribute('data-mobile-nav-dot');
7066
+ else
7067
+ frame.setAttribute('data-mobile-nav-dot', state);
7068
+ };
7069
+ apply();
7070
+ return ctx.sessions.list.subscribe(apply);
7071
+ }, 'dsh-mobile-nav: header status dot');
7072
+ }
6794
7073
  };
6795
- __modules["effects/turn-fold.js"] = function (require, module, exports) {
7074
+ __modules["effects/gestures.js"] = function (require, module, exports) {
6796
7075
  "use strict";
6797
7076
  Object.defineProperty(exports, "__esModule", { value: true });
6798
- exports.installTurnFold = installTurnFold;
6799
- const locales_ts_1 = require("./locales.js");
7077
+ exports.installGestures = installGestures;
7078
+ const nav_store_ts_1 = require("./nav-store.js");
6800
7079
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
6801
7080
  const PHONE_QUERY = '(max-width: 767px)';
6802
- /** Desktop fold (issue #2) is switched on either way:
6803
- * - plugin row `config.turnFoldDesktop: true` — the host republishes it at
6804
- * {@link CLIENT_CONFIG_ROUTE} because the client bundle ships statically
6805
- * and never sees the row config;
6806
- * - per-browser override, same convention as the debug flag: a URL param
6807
- * writes localStorage (`?mobile-nav-turn-fold=1` opts this browser in,
6808
- * `=0` opts back out). */
6809
- const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop';
6810
- const DESKTOP_PARAM = 'mobile-nav-turn-fold';
6811
- /** Root attribute the stylesheet keys the width-independent rules on. */
6812
- const DESKTOP_ATTR = 'data-mnav-desktop-fold';
6813
- /** Mirror of src/index.ts CLIENT_CONFIG_ROUTE (client tsconfig cannot reach it). */
6814
- const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config';
6815
- /** Read (and, when the URL param is present, persist) the per-browser opt-in. */
6816
- function desktopOptIn() {
6817
- const param = new URLSearchParams(location.search).get(DESKTOP_PARAM);
6818
- if (param === '1')
6819
- localStorage.setItem(DESKTOP_KEY, '1');
6820
- else if (param === '0')
6821
- localStorage.removeItem(DESKTOP_KEY);
6822
- return localStorage.getItem(DESKTOP_KEY) === '1';
7081
+ /** This plugin's two dismissible sheets (session-info, home workspace/chips
7082
+ * picker — MobileHome.tsx's home-sheet hosts both the workspace switcher and
7083
+ * the S5 chip-customize list under the same marker). Official popupSelect
7084
+ * menus (permission/model) are deliberately excluded — the design spec
7085
+ * leaves those mask-tap-only, and they are the suite's own DOM, not ours to
7086
+ * add gesture handling to. */
7087
+ const SHEET_SELECTOR = '[data-mobile-nav="info-sheet"], [data-mobile-nav="home-sheet"]';
7088
+ const CLOSE_DISTANCE = 80;
7089
+ /** px/ms — a fast flick closes the sheet even short of CLOSE_DISTANCE. */
7090
+ const CLOSE_VELOCITY = 0.5;
7091
+ /** Below this the drag is a tap/jitter, not yet a pull — avoids hijacking
7092
+ * the very first pixels of an upward scroll inside the sheet. */
7093
+ const DRAG_COMMIT_PX = 4;
7094
+ function prefersReducedMotion() {
7095
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
6823
7096
  }
6824
- /** True when the plugin row asks for desktop fold; false on any failure. */
6825
- async function desktopConfigured() {
6826
- try {
6827
- const res = await fetch(CLIENT_CONFIG_ROUTE, { cache: 'no-store' });
6828
- if (!res.ok)
6829
- return false;
6830
- const body = await res.json();
6831
- return typeof body === 'object' && body !== null
6832
- && body.turnFoldDesktop === true;
6833
- }
6834
- catch {
6835
- return false;
6836
- }
7097
+ /** The sheet's own mask sibling — both this plugin's sheets name theirs
7098
+ * with a "-mask" suffix (info-mask, home-sheet-mask) and already wire a
7099
+ * click handler that calls the sheet's close(). Reusing that handler (a
7100
+ * synthetic click) needs no access to the sheet's React state at all.
7101
+ * Shared with the S6.1 edge-swipe-back priority chain below — both "drag
7102
+ * down to dismiss" and "swipe back from the edge" close a sheet the exact
7103
+ * same way. */
7104
+ function closeSheet(sheet) {
7105
+ sheet.parentElement?.querySelector('[data-mobile-nav$="-mask"]')?.click();
6837
7106
  }
6838
- /** ChatView's flow column (dsh-client-ui-conversation lib/client.js:5512).
6839
- * Trajectory and every other view render their own tree and never carry this
6840
- * marker, so scoping here is what keeps this effect Chat-only. */
6841
- const FLOW = '[data-chat-flow]';
6842
- /** Marker + selector of the injected summary row. */
6843
- const CHIP = 'turn-fold';
6844
- const CHIP_SELECTOR = '[data-mobile-nav="turn-fold"]';
6845
- /** Whole-row node kinds that are turn *process*, not conversation content.
6846
- * Keys are ChatNodeSeat's own `data-chat-flow-kind` dispatch values
6847
- * (registerChatNodeRenderers, lib/client.js:9322): tool calls, injected
6848
- * context rows, and slash-command rows. Everything else stays visible —
6849
- * `user`/`steering` (the question), `assistant-step` (the prose, folded
6850
- * per-block below), `turn-tail` (the footer with its actions), and the
6851
- * error notices `turn-error` / `turn-max-tokens` / `model-retry`, which are
6852
- * exactly what a reader must not have to hunt for. */
6853
- const PROCESS_KINDS = new Set(['context', 'tool-call', 'command']);
6854
- /** Node kinds that open a new turn group. */
6855
- const TURN_HEAD_KINDS = new Set(['user', 'steering']);
6856
- /** ReasoningRow's own marker (lib/client.js:8966) — the Think disclosure
6857
- * rendered inside an assistant-step row, beside that step's prose. */
6858
- const THINK = '[data-variant="think"]';
6859
- /** Attribute the phone stylesheet turns into `display: none`. */
6860
- const FOLD = 'data-mnav-fold';
6861
- /** Per-item override written while its turn is expanded. */
6862
- const OPEN = 'data-mnav-fold-open';
6863
7107
  /**
6864
- * True when the reasoning rows are the ONLY thing this row renders, so the
6865
- * whole flow item has to go: the flow column is `display: flex` with
6866
- * `gap: 16px`, and an emptied-out flex item still claims its gap — the
6867
- * reader would see the folded steps as a column of blank bands.
6868
- * Structural test, no class names: every ancestor from the reasoning rows'
6869
- * shared parent up to the flow item must be an only child.
7108
+ * S6.2 — sheet drag-to-close: this plugin's two own bottom sheets (session
7109
+ * info, home workspace picker) support "drag down to dismiss", mirroring
7110
+ * the mask-tap close they already have. Official popupSelect menus
7111
+ * (permission/model) are excluded on purpose — the design spec leaves them
7112
+ * mask-tap-only, and they are the suite's own DOM, not ours to add gesture
7113
+ * handling to.
7114
+ *
7115
+ * touchmove is registered non-passive (unlike the edge-swipe-back gesture
7116
+ * below) because this gesture needs to preventDefault once it takes over, so
7117
+ * the sheet's own translateY tracks the finger instead of fighting the
7118
+ * scroller's native rubber-band. Before that handoff — while the sheet's
7119
+ * inner content isn't scrolled to top — nothing is intercepted at all, so
7120
+ * a pull down first scrolls the content as normal (spec requirement).
6870
7121
  */
6871
- function foldsWholeRow(row, thinks) {
6872
- const first = thinks[0];
6873
- if (first === undefined)
6874
- return false;
6875
- const body = first.parentElement;
6876
- if (body === null || body.childElementCount !== thinks.length)
6877
- return false;
6878
- let node = body;
6879
- while (node !== row) {
6880
- const parent = node.parentElement;
6881
- if (parent === null)
6882
- return false;
6883
- if (parent.childElementCount !== 1)
6884
- return false;
6885
- node = parent;
6886
- }
6887
- return true;
6888
- }
6889
- /** The process items one flow row contributes: the row itself, its reasoning
6890
- * rows, or nothing at all. */
6891
- function processItems(row) {
6892
- const kind = row.getAttribute('data-chat-flow-kind');
6893
- if (kind === null)
6894
- return [];
6895
- if (PROCESS_KINDS.has(kind))
6896
- return [row];
6897
- if (kind !== 'assistant-step')
6898
- return [];
6899
- const thinks = [...row.querySelectorAll(THINK)];
6900
- if (thinks.length === 0)
6901
- return [];
6902
- return foldsWholeRow(row, thinks) ? [row] : thinks;
7122
+ function installSheetDragClose(ctx) {
7123
+ ctx.effect(() => {
7124
+ const narrow = window.matchMedia(PHONE_QUERY);
7125
+ let drag = null;
7126
+ const settle = (sheet, close) => {
7127
+ if (close) {
7128
+ sheet.style.transform = '';
7129
+ sheet.style.transition = '';
7130
+ closeSheet(sheet);
7131
+ return;
7132
+ }
7133
+ if (prefersReducedMotion()) {
7134
+ sheet.style.transition = 'none';
7135
+ sheet.style.transform = '';
7136
+ return;
7137
+ }
7138
+ sheet.style.transition = 'transform .22s var(--ds-ease-out, ease-in-out)';
7139
+ sheet.style.transform = '';
7140
+ const clear = () => {
7141
+ sheet.style.transition = '';
7142
+ sheet.removeEventListener('transitionend', clear);
7143
+ };
7144
+ sheet.addEventListener('transitionend', clear);
7145
+ };
7146
+ const onTouchStart = (event) => {
7147
+ if (event.touches.length !== 1) {
7148
+ drag = null;
7149
+ return;
7150
+ }
7151
+ const touch = event.touches[0];
7152
+ const target = event.target;
7153
+ if (touch === undefined || !(target instanceof Element)) {
7154
+ drag = null;
7155
+ return;
7156
+ }
7157
+ const sheet = target.closest(SHEET_SELECTOR);
7158
+ if (sheet === null) {
7159
+ drag = null;
7160
+ return;
7161
+ }
7162
+ drag = { sheet, startY: touch.clientY, lastY: touch.clientY, startTime: Date.now(), dragging: false };
7163
+ };
7164
+ const onTouchMove = (event) => {
7165
+ if (drag === null)
7166
+ return;
7167
+ const touch = event.touches[0];
7168
+ if (touch === undefined)
7169
+ return;
7170
+ drag.lastY = touch.clientY;
7171
+ const dy = touch.clientY - drag.startY;
7172
+ if (!drag.dragging) {
7173
+ if (drag.sheet.scrollTop > 0)
7174
+ return;
7175
+ if (dy <= DRAG_COMMIT_PX)
7176
+ return;
7177
+ drag.dragging = true;
7178
+ drag.sheet.style.transition = 'none';
7179
+ }
7180
+ event.preventDefault();
7181
+ drag.sheet.style.transform = `translateY(${Math.max(0, dy)}px)`;
7182
+ };
7183
+ const finish = () => {
7184
+ if (drag === null)
7185
+ return;
7186
+ const { sheet, dragging, startY, lastY, startTime } = drag;
7187
+ drag = null;
7188
+ if (!dragging)
7189
+ return;
7190
+ const dy = Math.max(0, lastY - startY);
7191
+ const elapsed = Math.max(1, Date.now() - startTime);
7192
+ const velocity = dy / elapsed;
7193
+ settle(sheet, dy > CLOSE_DISTANCE || velocity > CLOSE_VELOCITY);
7194
+ };
7195
+ const attach = () => {
7196
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
7197
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
7198
+ document.addEventListener('touchend', finish, { passive: true });
7199
+ document.addEventListener('touchcancel', finish, { passive: true });
7200
+ };
7201
+ const detach = () => {
7202
+ document.removeEventListener('touchstart', onTouchStart);
7203
+ document.removeEventListener('touchmove', onTouchMove);
7204
+ document.removeEventListener('touchend', finish);
7205
+ document.removeEventListener('touchcancel', finish);
7206
+ drag = null;
7207
+ };
7208
+ if (narrow.matches)
7209
+ attach();
7210
+ const onChange = (event) => (event.matches ? attach() : detach());
7211
+ narrow.addEventListener('change', onChange);
7212
+ return () => {
7213
+ narrow.removeEventListener('change', onChange);
7214
+ detach();
7215
+ };
7216
+ }, 'dsh-mobile-nav: sheet drag-to-close');
6903
7217
  }
7218
+ /** Left-edge start zone for S6.1's swipe-back, in CSS px from the viewport's
7219
+ * left edge. This used to be the gateway half's own edge-swipe-back hot zone
7220
+ * (history.back, useless against an SPA) — that gesture is removed in the
7221
+ * same revision that adds this one, so the 24px strip changes owner instead
7222
+ * of staying carved out (design doc "手势" row, 2026-08-17 fourth revision). */
7223
+ const EDGE_ZONE_PX = 24;
7224
+ const EDGE_SWIPE_MIN_DX = 90;
7225
+ const EDGE_SWIPE_RATIO = 1.6;
6904
7226
  /**
6905
- * The summary row sitting immediately before `anchor`, created if absent.
6906
- * Idempotent by construction — React re-rendering the flow either leaves our
6907
- * button where it is (it only ever inserts and removes its OWN nodes) or
6908
- * drops it, and the next rescan puts it back.
7227
+ * dsh-better-sidebar's panel-open read (AGENTS.md pitfall "第三方浮层的开合
7228
+ * 态可以纯 CSS `:has()` 读"): the panel's class ends in "_panel" only while
7229
+ * open — "_panelHidden" is appended once closed, so the string no longer
7230
+ * ends in "_panel" and this selector stops matching. Same anchor
7231
+ * MobileSessionHeader.tsx's workbench button and its own close button use.
6909
7232
  */
6910
- function chipBefore(anchor) {
6911
- const previous = anchor.previousElementSibling;
6912
- if (previous instanceof HTMLElement && previous.dataset['mobileNav'] === CHIP)
6913
- return previous;
6914
- const parent = anchor.parentElement;
6915
- if (parent === null)
6916
- return null;
6917
- const chip = document.createElement('button');
6918
- chip.type = 'button';
6919
- chip.dataset['mobileNav'] = CHIP;
6920
- parent.insertBefore(chip, anchor);
6921
- return chip;
7233
+ function betterSidebarPanelOpen() {
7234
+ return document.querySelector('[data-dsh-better-sidebar] [class$="_panel"]') !== null;
6922
7235
  }
6923
- /** Split the flow column into turns: a group runs from one user/steering row
6924
- * up to (not including) the next. Rows that precede the first user row —
6925
- * a turn whose question has not been paged in yet — form a leading group of
6926
- * their own, so their process never becomes unreachable. */
6927
- function groupsOf(flow) {
6928
- const groups = [];
6929
- let current;
6930
- for (const row of flow.children) {
6931
- const kind = row.getAttribute('data-chat-flow-kind');
6932
- if (kind === null)
6933
- continue;
6934
- if (current === undefined || TURN_HEAD_KINDS.has(kind)) {
6935
- current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [] };
6936
- groups.push(current);
6937
- }
6938
- current.items.push(...processItems(row));
7236
+ /**
7237
+ * S6.1 — left-edge swipe-back priority chain (design doc "手势" row, fourth
7238
+ * revision): a swipe starting in the left {@link EDGE_ZONE_PX} always closes
7239
+ * the TOPMOST dismissible surface rather than always navigating, so a user
7240
+ * mid-workflow (info card open, workbench panel open) gets "close that"
7241
+ * instead of "leave the session" from the same gesture. Checked in the
7242
+ * design doc's own order:
7243
+ * 1. this plugin's session-info sheet, if open
7244
+ * 2. this plugin's home sheet (workspace switcher or S5 chip-customize),
7245
+ * if open
7246
+ * 3. dsh-better-sidebar's workbench panel, if open (代点 its own toggle —
7247
+ * same anchor MobileSessionHeader.tsx's workbench/close buttons use)
7248
+ * 4. otherwise, GO_HOME_EVENT — but only from the session view; the design
7249
+ * doc calls out "在 home 层→无动作" explicitly, and the phone page
7250
+ * stack's current level is read straight off MobileHome's own
7251
+ * `data-view` attribute (no store handle needed here — effects/*.ts is
7252
+ * already all DOM reads, matching every sibling effect in this file).
7253
+ */
7254
+ function handleEdgeSwipeBack() {
7255
+ const sheet = document.querySelector(SHEET_SELECTOR);
7256
+ if (sheet !== null) {
7257
+ closeSheet(sheet);
7258
+ return;
7259
+ }
7260
+ if (betterSidebarPanelOpen()) {
7261
+ document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
7262
+ return;
7263
+ }
7264
+ if (document.querySelector('[data-mobile-nav="home"]')?.getAttribute('data-view') === 'session') {
7265
+ window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT));
6939
7266
  }
6940
- return groups;
6941
7267
  }
6942
7268
  /**
6943
- * S8 — collapse a turn's process into one summary row (< 768px by default;
6944
- * every width when the plugin row sets `config.turnFoldDesktop` or a browser
6945
- * opts itself in via `?mobile-nav-turn-fold=1`).
6946
- *
6947
- * Route taken: DOM marking, not the `conversation.chat.node` keyed slot.
6948
- * That slot dispatches on node kind and a second registration at a key
6949
- * SHADOWS the official renderer (dsh-client-ui-slots index.d.ts:542 — the
6950
- * cell's lowest-priority live entry renders, there is no wrapping form and
6951
- * no `children` handle to the shadowed component). Taking `tool-call` would
6952
- * mean re-implementing every tool's presentation, its `t` seat comes from
6953
- * the entry's own declared locale namespace and its services from the
6954
- * registrant's own inject face — neither is reachable from here — and the
6955
- * registration is global, so the desktop no-op would be gone too.
6956
- *
6957
- * The DOM route needs no class names: ChatNodeSeat stamps every row with
6958
- * `data-chat-flow-kind` / `data-chat-flow-key` (lib/client.js:5228) and
6959
- * ReasoningRow stamps `data-variant="think"` — turn grouping is pure
6960
- * structure (the run of rows following one `user` row), never text.
6961
- *
6962
- * ponytail: one rAF-coalesced full rescan per DOM mutation batch, O(rows).
6963
- * If a very long session ever makes streaming feel heavy, narrow the
6964
- * observer to the flow column's own childList and diff instead.
7269
+ * S6.1 — left-edge swipe-back gesture install. Passive throughout (never
7270
+ * calls preventDefault) and decided purely from the touchstart/touchend
7271
+ * endpoints, exactly like the S6.1-predecessor content-area swipe this
7272
+ * revision removes — so it never fights vertical (or, at the 24px strip,
7273
+ * horizontal chip-row) scrolling, and needs no mid-drag tracking: the design
7274
+ * doc explicitly allows "一步到位" over a followed-finger animation.
6965
7275
  */
6966
- function installTurnFold(ctx) {
7276
+ function installEdgeSwipeBack(ctx) {
6967
7277
  ctx.effect(() => {
6968
7278
  const narrow = window.matchMedia(PHONE_QUERY);
6969
- const t = ctx.locale.bind(locales_ts_1.NS);
6970
- /** Turns the reader opened. Deliberately not persisted (spec: a reload
6971
- * returns to the default folded state). */
6972
- const expanded = new Set();
6973
- let observer = null;
6974
- let frame = 0;
6975
- const label = (chip, count, running) => {
6976
- const next = running ? t('turnFoldRunning', { count }) : t('turnFold', { count });
6977
- if (chip.textContent !== next)
6978
- chip.textContent = next;
6979
- };
6980
- const scan = () => {
6981
- const flow = document.querySelector(FLOW);
6982
- if (flow === null)
7279
+ let start = null;
7280
+ const onTouchStart = (event) => {
7281
+ if (event.touches.length !== 1) {
7282
+ start = null;
6983
7283
  return;
6984
- // TurnStatus, ChatView's own running banner — present only while the
6985
- // last turn is still working (lib/client.js:5548).
6986
- const running = flow.querySelector(':scope > [role="status"]') !== null;
6987
- const groups = groupsOf(flow);
6988
- const live = new Set();
6989
- const liveItems = new Set();
6990
- groups.forEach((group, index) => {
6991
- if (group.items.length === 0)
6992
- return;
6993
- const open = expanded.has(group.key);
6994
- for (const item of group.items) {
6995
- liveItems.add(item);
6996
- item.setAttribute(FOLD, '');
6997
- if (open)
6998
- item.setAttribute(OPEN, '');
6999
- else
7000
- item.removeAttribute(OPEN);
7001
- }
7002
- const first = group.items[0];
7003
- if (first === undefined)
7004
- return;
7005
- const anchor = first.closest('[data-chat-flow-key]') ?? first;
7006
- const chip = chipBefore(anchor);
7007
- if (chip === null)
7008
- return;
7009
- chip.dataset['foldGroup'] = group.key;
7010
- chip.setAttribute('aria-expanded', open ? 'true' : 'false');
7011
- if (open)
7012
- chip.dataset['open'] = '';
7013
- else
7014
- delete chip.dataset['open'];
7015
- const groupRunning = running && index === groups.length - 1;
7016
- if (groupRunning)
7017
- chip.dataset['running'] = '';
7018
- else
7019
- delete chip.dataset['running'];
7020
- label(chip, group.items.length, groupRunning);
7021
- live.add(group.key);
7022
- });
7023
- for (const chip of flow.querySelectorAll(CHIP_SELECTOR)) {
7024
- const key = chip.dataset['foldGroup'];
7025
- if (key === undefined || !live.has(key))
7026
- chip.remove();
7027
7284
  }
7028
- // Unmark elements that stopped being process items. Without this, an
7029
- // assistant-step row folded whole while it streamed ONLY reasoning kept
7030
- // its row-level fold after prose started arriving in the same DOM node
7031
- // (React updates the row in place), hiding the streaming reply until a
7032
- // completion re-render rebuilt the row — the "reply only appears when
7033
- // the turn finishes" phone bug (2026-08-18).
7034
- for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
7035
- if (liveItems.has(stale))
7036
- continue;
7037
- stale.removeAttribute(FOLD);
7038
- stale.removeAttribute(OPEN);
7285
+ const touch = event.touches[0];
7286
+ if (touch === undefined) {
7287
+ start = null;
7288
+ return;
7039
7289
  }
7290
+ start = { x: touch.clientX, y: touch.clientY, eligible: touch.clientX <= EDGE_ZONE_PX };
7040
7291
  };
7041
- const schedule = () => {
7042
- // The locale subscription below lives outside attach/detach, so this
7043
- // guard is what keeps a late dictionary registration from marking up
7044
- // the flow at >= 768px (real WebKit regression: chips and fold
7045
- // attributes appeared at 1280px — invisible, since every rule that
7046
- // hides anything is inside the phone media query, but still wrong).
7047
- if (observer === null)
7048
- return;
7049
- if (frame !== 0)
7292
+ const onTouchEnd = (event) => {
7293
+ const state = start;
7294
+ start = null;
7295
+ if (state === null || !state.eligible)
7050
7296
  return;
7051
- frame = requestAnimationFrame(() => {
7052
- frame = 0;
7053
- scan();
7054
- // Drop the records our own chip insertions just queued, so a rescan
7055
- // can never re-trigger itself (the debug-badge feedback-loop freeze).
7056
- observer?.takeRecords();
7057
- });
7058
- };
7059
- const onClick = (event) => {
7060
- const target = event.target;
7061
- if (!(target instanceof Element))
7297
+ const touch = event.changedTouches[0];
7298
+ if (touch === undefined)
7062
7299
  return;
7063
- const chip = target.closest(CHIP_SELECTOR);
7064
- const key = chip?.dataset['foldGroup'];
7065
- if (key === undefined)
7300
+ const dx = touch.clientX - state.x;
7301
+ const dy = touch.clientY - state.y;
7302
+ if (dx <= EDGE_SWIPE_MIN_DX || Math.abs(dx) <= EDGE_SWIPE_RATIO * Math.abs(dy))
7066
7303
  return;
7067
- if (expanded.has(key))
7068
- expanded.delete(key);
7069
- else
7070
- expanded.add(key);
7071
- scan();
7072
- };
7073
- const clear = () => {
7074
- for (const chip of document.querySelectorAll(CHIP_SELECTOR))
7075
- chip.remove();
7076
- for (const item of document.querySelectorAll(`[${FOLD}]`)) {
7077
- item.removeAttribute(FOLD);
7078
- item.removeAttribute(OPEN);
7079
- }
7304
+ handleEdgeSwipeBack();
7080
7305
  };
7081
7306
  const attach = () => {
7082
- if (observer !== null)
7083
- return;
7084
- observer = new MutationObserver(schedule);
7085
- observer.observe(document.body, { childList: true, subtree: true });
7086
- document.addEventListener('click', onClick);
7087
- scan();
7307
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
7308
+ document.addEventListener('touchend', onTouchEnd, { passive: true });
7088
7309
  };
7089
7310
  const detach = () => {
7090
- observer?.disconnect();
7091
- observer = null;
7092
- document.removeEventListener('click', onClick);
7093
- if (frame !== 0)
7094
- cancelAnimationFrame(frame);
7095
- frame = 0;
7096
- expanded.clear();
7097
- clear();
7098
- };
7099
- const onChange = (event) => (event.matches ? attach() : detach());
7100
- let disposed = false;
7101
- let desktop = false;
7102
- /** Attach at every width and drop the breakpoint listener — the
7103
- * stylesheet's html[DESKTOP_ATTR] rules take over from the phone media
7104
- * query. Idempotent: the config answer and the local opt-in may both
7105
- * ask for it. */
7106
- const enableDesktop = () => {
7107
- if (desktop || disposed)
7108
- return;
7109
- desktop = true;
7110
- document.documentElement.setAttribute(DESKTOP_ATTR, '');
7111
- narrow.removeEventListener('change', onChange);
7112
- attach();
7311
+ document.removeEventListener('touchstart', onTouchStart);
7312
+ document.removeEventListener('touchend', onTouchEnd);
7313
+ start = null;
7113
7314
  };
7114
7315
  if (narrow.matches)
7115
7316
  attach();
7317
+ const onChange = (event) => (event.matches ? attach() : detach());
7116
7318
  narrow.addEventListener('change', onChange);
7117
- if (desktopOptIn())
7118
- enableDesktop();
7119
- // The row config arrives async; a late enable just attaches then. Not
7120
- // awaited before phone attach — folding must not wait on a round-trip.
7121
- else
7122
- void desktopConfigured().then((on) => (on ? enableDesktop() : undefined));
7123
- // A language switch changes the chip copy of every already-rendered turn.
7124
- const stopLocale = ctx.locale.subscribe(schedule);
7125
7319
  return () => {
7126
- disposed = true;
7127
- document.documentElement.removeAttribute(DESKTOP_ATTR);
7128
7320
  narrow.removeEventListener('change', onChange);
7129
- stopLocale();
7130
7321
  detach();
7131
7322
  };
7132
- }, 'dsh-mobile-nav: turn process fold');
7323
+ }, 'dsh-mobile-nav: left-edge swipe-back');
7324
+ }
7325
+ /** S6: the two-gesture set — left-edge swipe-back and sheet drag-to-close.
7326
+ * Both install/uninstall their own document listeners on the phone
7327
+ * breakpoint's matchMedia change, so at >= 768px this is a true no-op (no
7328
+ * listeners attached at all), matching every other effect in this file. */
7329
+ function installGestures(ctx) {
7330
+ installEdgeSwipeBack(ctx);
7331
+ installSheetDragClose(ctx);
7133
7332
  }
7134
7333
  };
7135
7334
  __modules["effects/modal-back.js"] = function (require, module, exports) {
@@ -7433,6 +7632,7 @@ exports.estimatedLift = estimatedLift;
7433
7632
  exports.keyboardLift = keyboardLift;
7434
7633
  exports.composerLift = composerLift;
7435
7634
  exports.installKeyboardAvoid = installKeyboardAvoid;
7635
+ const client_config_ts_1 = require("./client-config.js");
7436
7636
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
7437
7637
  const PHONE_QUERY = '(max-width: 767px)';
7438
7638
  /** Root CSS variable composer.css.ts turns into a composer lift. */
@@ -7455,13 +7655,18 @@ const MAX_SCALE = 1.01;
7455
7655
  *
7456
7656
  * ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
7457
7657
  * behaviour; iOS has one system keyboard whose height WebKit reports exactly,
7458
- * so padding there would be a visible gap bought for nothing. */
7459
- const SAFETY_PAD_PX = 15;
7460
- /** The safety pad this platform gets: the clearance above, or nothing off
7461
- * Android. UA sniffing is the right tool here — the target is a platform
7462
- * defect, not a feature that could be detected. */
7463
- function safetyPad(userAgent) {
7464
- return /Android/iu.test(userAgent) ? SAFETY_PAD_PX : 0;
7658
+ * so padding there would be a visible gap bought for nothing.
7659
+ *
7660
+ * The 15px default is what shipped; a device whose IME hides more of the
7661
+ * composer than that can raise it through the plugin row's
7662
+ * `config.keyboardSafetyPadPx` ({@link KeyboardTuning}). */
7663
+ const SAFETY_PAD_PX = client_config_ts_1.KEYBOARD_DEFAULTS.safetyPadPx;
7664
+ /** The safety pad this platform gets: the configured clearance, or nothing
7665
+ * off Android. UA sniffing is the right tool here — the target is a platform
7666
+ * defect, not a feature that could be detected.
7667
+ * @param padPx - the row's clearance; defaults to the shipped 15px. */
7668
+ function safetyPad(userAgent, padPx = SAFETY_PAD_PX) {
7669
+ return /Android/iu.test(userAgent) ? padPx : 0;
7465
7670
  }
7466
7671
  /** A viewport that lost at least this much against its no-keyboard baseline
7467
7672
  * has a keyboard up. Comfortably above the ~56px a collapsing URL bar moves,
@@ -7488,11 +7693,16 @@ const PROBE_EPSILON_PX = 8;
7488
7693
  * There is no signal to measure, so this is an estimate: the reporter's
7489
7694
  * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
7490
7695
  * 400px covers taller IME toolbars without stranding the composer mid-screen.
7491
- * ponytail: single heuristic constant; per-IME calibration only if reports
7492
- * show it misses.
7696
+ *
7697
+ * Both numbers are a guess about someone else's hardware, so both are knobs:
7698
+ * `config.keyboardLiftRatio` / `config.keyboardLiftMaxPx` on the plugin row
7699
+ * ({@link KeyboardTuning}). The shipped pair stays the default — an install
7700
+ * that never touches them behaves exactly as before.
7701
+ *
7702
+ * @param tuning - the row's tuning; defaults to the shipped estimate.
7493
7703
  */
7494
- function estimatedLift(innerHeight) {
7495
- return Math.min(Math.round(innerHeight * 0.42), 400);
7704
+ function estimatedLift(innerHeight, tuning = client_config_ts_1.KEYBOARD_DEFAULTS) {
7705
+ return Math.min(Math.round(innerHeight * tuning.liftRatio), tuning.liftMaxPx);
7496
7706
  }
7497
7707
  /**
7498
7708
  * How far the composer must rise so its bottom edge sits on the visual
@@ -7594,7 +7804,16 @@ function installKeyboardAvoid(ctx) {
7594
7804
  * it. Re-read on every sync that finds the composer unfocused, so a
7595
7805
  * rotation or a URL-bar change rebases it without extra listeners. */
7596
7806
  let baseline = 0;
7597
- const pad = safetyPad(navigator.userAgent);
7807
+ /** Row tuning for the estimate path. Starts on the shipped defaults and
7808
+ * is replaced once the config request lands (a few ms into the page, and
7809
+ * always long before a touch focus can raise a keyboard) — no re-sync is
7810
+ * forced, the next focus or viewport event picks the new values up. */
7811
+ let tuning = client_config_ts_1.KEYBOARD_DEFAULTS;
7812
+ let pad = safetyPad(navigator.userAgent);
7813
+ void (0, client_config_ts_1.clientConfig)().then((loaded) => {
7814
+ tuning = loaded.keyboard;
7815
+ pad = safetyPad(navigator.userAgent, tuning.safetyPadPx);
7816
+ });
7598
7817
  const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null;
7599
7818
  const sync = () => {
7600
7819
  const focused = composerTextarea(document.activeElement);
@@ -7677,7 +7896,7 @@ function installKeyboardAvoid(ctx) {
7677
7896
  // the probe below still runs and revokes the verdict if the viewport
7678
7897
  // turns out to react after all.
7679
7898
  if (localStorage.getItem(DUMB_KEY) === '1') {
7680
- estimate = estimatedLift(window.innerHeight);
7899
+ estimate = estimatedLift(window.innerHeight, tuning);
7681
7900
  sync();
7682
7901
  }
7683
7902
  const h0 = viewport.height;
@@ -7709,7 +7928,7 @@ function installKeyboardAvoid(ctx) {
7709
7928
  if (!composerTextarea(document.activeElement))
7710
7929
  return;
7711
7930
  localStorage.setItem(DUMB_KEY, '1');
7712
- estimate = estimatedLift(window.innerHeight);
7931
+ estimate = estimatedLift(window.innerHeight, tuning);
7713
7932
  sync();
7714
7933
  };
7715
7934
  probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);