dsh-zen-remote 1.1.2 → 1.1.4

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