dsh-zen-remote 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -207,6 +207,7 @@ open http://127.0.0.1:3088/lan-gate/admin
207
207
  - 真 PWA:manifest + service worker,可装到主屏、可离线打开
208
208
  - 真 Web Push:VAPID + aes128gcm,通知默认不带对话正文
209
209
  - `push_notify` 工具:模型可在关键节点自己推一条,带限流
210
+ - 「内测声明」弹窗注入「不再弹出」可选项:远程访问每次刷新都会重弹声明,点一次后本设备记住选择、以后自动关闭
210
211
 
211
212
  深度说明:[界面](docs/interface.md) · [公网接入](docs/remote-access.md)
212
213
 
package/lib/client.js CHANGED
@@ -1692,24 +1692,39 @@ exports.BASE_CSS = `
1692
1692
  }
1693
1693
  }
1694
1694
 
1695
- /* ---------- beta-notice suppression (ALL widths — deliberate exception) ----
1696
- User-directed (2026-08-17): hide DSH's beta/preview notices everywhere,
1697
- including desktop. This is the ONE place this plugin intentionally breaks
1698
- its own ">=1024px strict no-op" rule — do not "fix" it back.
1699
- Selector notes:
1695
+ /* ---------- beta-notice handling (ALL widths — deliberate exception) ----
1696
+ User-directed (2026-08-17): tone down DSH's beta/preview notices
1697
+ everywhere, including desktop. This is the ONE place this plugin
1698
+ intentionally breaks its own ">=1024px strict no-op" rule.
1700
1699
  - The hero "预览版" badge only carries a hashed class; per repo convention
1701
1700
  the stable part is the suffix (_previewBadge), matched with [class$=].
1702
- - The first-run "内测声明" modal is hidden via its ARIA label. The label is
1703
- LOCALIZED zh is covered; if the UI runs in another language the dialog
1704
- shows (harmless, dismiss by hand). Hiding targets the presentation
1705
- wrapper via :has() so the modal's backdrop cannot linger invisibly and
1706
- block taps; the app never requires acknowledging this dialog to work. */
1701
+ - The first-run "内测声明" modal must NOT be hidden with CSS: while it is
1702
+ mounted it holds document #root inert, so display:none leaves the whole
1703
+ app unclickable (2026-08-18 incident). effects/welcome-notice.ts instead
1704
+ injects a "不再弹出" opt-out button; this styles it. */
1707
1705
  [class$="_previewBadge"] {
1708
1706
  display: none !important;
1709
1707
  }
1710
- [role="presentation"]:has(> [role="dialog"][aria-label="内测声明"]),
1711
- [role="dialog"][aria-label="内测声明"] {
1712
- display: none !important;
1708
+ /* Mirrors the official primitives Button (md capsule, outline variant):
1709
+ 36px height, 18px radius, --dsw-alias-border-l2 hairline. */
1710
+ .zen-welcome-optout {
1711
+ display: inline-flex;
1712
+ align-items: center;
1713
+ justify-content: center;
1714
+ height: 36px;
1715
+ padding: 0 14px;
1716
+ margin-right: 8px;
1717
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, .15));
1718
+ border-radius: 18px;
1719
+ background: transparent;
1720
+ color: var(--dsw-alias-label-primary, inherit);
1721
+ font-size: 14px;
1722
+ line-height: 22px;
1723
+ cursor: pointer;
1724
+ -webkit-tap-highlight-color: transparent;
1725
+ }
1726
+ .zen-welcome-optout:hover {
1727
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
1713
1728
  }
1714
1729
  `;
1715
1730
  };
@@ -4975,6 +4990,113 @@ exports.CHIPS_CSS = `/* ---------- chips row + settings entry (S5) ---------- */
4975
4990
  }
4976
4991
  `;
4977
4992
  };
4993
+ __modules["styles/content.css.js"] = function (require, module, exports) {
4994
+ "use strict";
4995
+ // content — chat markdown readability on phones (2026-08-18). Shares no
4996
+ // selector with any other file; appended last in styles/index.ts.
4997
+ Object.defineProperty(exports, "__esModule", { value: true });
4998
+ exports.CONTENT_CSS = void 0;
4999
+ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ---------- */
5000
+
5001
+ @media (max-width: 767px) {
5002
+ /* Inline code: upstream renders it display:inline-flex — an atomic inline
5003
+ box that can never wrap across lines, so one long code span walks off
5004
+ the right edge of the screen. Plain inline keeps the background pill
5005
+ per line fragment and lets long tokens break anywhere. Deliberately
5006
+ unscoped (any :not(pre) > code, not just the _markdown_ module):
5007
+ inline code also renders outside that container (user bubbles,
5008
+ reasoning rows), and on a phone there is no width at which an
5009
+ unwrappable inline box is right. !important beats the upstream
5010
+ display:inline-flex on specificity ties. */
5011
+ :not(pre) > code {
5012
+ display: inline !important;
5013
+ overflow-wrap: anywhere;
5014
+ /* iOS WebKit: overflow-wrap alone still failed to break long tokens in
5015
+ inline code on a real iPhone (screenshot-verified 2026-08-18, li >
5016
+ code path case) while desktop Chromium broke them fine. break-all is
5017
+ the everywhere-supported hammer; acceptable for code pills. */
5018
+ word-break: break-all;
5019
+ }
5020
+ /* Belt-and-suspenders for engines that compute soft-wrap opportunities
5021
+ from the block container rather than the inline itself (historical
5022
+ WebKit behavior): inherit anywhere through the whole transcript so the
5023
+ block side agrees. Prose is untouched until something would overflow;
5024
+ the table is already pinned wide by min-width: max-content below. */
5025
+ [data-chat-flow],
5026
+ [class*="_markdown_"] {
5027
+ overflow-wrap: anywhere;
5028
+ }
5029
+
5030
+ /* Markdown tables: the official _tableScroll_ wrapper is already a
5031
+ horizontal scroll viewport around a width:max-content table, but its
5032
+ cells are capped at min(30vw, 320px) — barely 120px on a phone, which
5033
+ crushes every column into a vertical word-stack. Let columns size to
5034
+ their content instead and read the overflow by scrolling the wrapper.
5035
+ 480px (not a vw cap — 85vw was ~320px on a 375px phone and still
5036
+ wrapped mid-length cells) keeps typical cells on one line; only truly
5037
+ prose-heavy cells wrap, instead of dragging the table kilometers
5038
+ wide. */
5039
+ [class*="_tableScroll_"] th,
5040
+ [class*="_tableScroll_"] td {
5041
+ max-width: 480px !important;
5042
+ }
5043
+
5044
+ /* iOS WebKit fallback: upstream's own table { width: max-content } is what
5045
+ keeps the table at its natural width inside the scroll wrapper, but
5046
+ WebKit's max-content support on table boxes is incomplete — on a real
5047
+ iPhone the table fell back to shrink-to-fit and every column got
5048
+ crushed to its min-width (the "ra/nk" vertical-word-stack screenshot,
5049
+ 2026-08-18). Redeclare the natural-width intent with both width and
5050
+ min-width so whichever keyword the engine honors wins; the wrapper
5051
+ already scrolls the overflow. */
5052
+ [class*="_tableScroll_"] table {
5053
+ width: max-content !important;
5054
+ min-width: max-content !important;
5055
+ max-width: none !important;
5056
+ }
5057
+
5058
+ /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
5059
+ 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
5060
+ [data-time-hover-root] gate holds them at opacity 0 until :hover /
5061
+ :focus-within) — a phone never hovers, so they were simply invisible;
5062
+ and even ignoring that, the row is nowrap + overflow:hidden (plus this
5063
+ plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
5064
+ text. On phones: always visible, own full-width line under the action
5065
+ icons, wrapping freely, one type size down. !important + the extra
5066
+ [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
5067
+ /* Pin the tail row's width chain before allowing wrap: with wrap enabled
5068
+ the row's intrinsic (shrink-to-fit) width collapses to its widest
5069
+ single icon, taking the whole tail down to ~16px. */
5070
+ [data-chat-flow-kind="turn-tail"] [class$="_root"],
5071
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
5072
+ width: 100%;
5073
+ }
5074
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
5075
+ flex-wrap: wrap;
5076
+ row-gap: 2px;
5077
+ }
5078
+ /* Wide companion to the rule below: mirrors the upstream hover-gate
5079
+ selector shape directly, with no assumptions about the tail's DOM
5080
+ structure, so the stats can't stay invisible even if the phone's tail
5081
+ renders differently than desktop. */
5082
+ [data-time-hover-root] [class$="_timeEnd"],
5083
+ [data-time-hover-root] [class$="_timeStart"] {
5084
+ opacity: 1 !important;
5085
+ transition: none !important;
5086
+ }
5087
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
5088
+ opacity: 1 !important;
5089
+ flex-basis: 100% !important;
5090
+ white-space: normal !important;
5091
+ overflow: visible !important;
5092
+ text-overflow: clip !important;
5093
+ padding-left: 0 !important;
5094
+ font-size: 12px;
5095
+ line-height: 18px;
5096
+ }
5097
+ }
5098
+ `;
5099
+ };
4978
5100
  __modules["styles/index.js"] = function (require, module, exports) {
4979
5101
  "use strict";
4980
5102
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -4989,6 +5111,7 @@ const composer_css_ts_1 = require("./styles/composer.css.js");
4989
5111
  const info_css_ts_1 = require("./styles/info.css.js");
4990
5112
  const turn_fold_css_ts_1 = require("./styles/turn-fold.css.js");
4991
5113
  const chips_css_ts_1 = require("./styles/chips.css.js");
5114
+ const content_css_ts_1 = require("./styles/content.css.js");
4992
5115
  /**
4993
5116
  * All mobile styles, concatenated in the exact order of the original
4994
5117
  * single-file stylesheet (base → layout → compat → misc, where misc keeps
@@ -4999,10 +5122,11 @@ const chips_css_ts_1 = require("./styles/chips.css.js");
4999
5122
  * win that tie too), the turn-process fold (turn-fold, S8, which shares no
5000
5123
  * selector with any of them), and finally the chips row / settings entry /
5001
5124
  * portal fix (chips, S5) — all appended in this order so their <768px rules
5002
- * win ties against the shared <=1023px block. Injected as ONE
5003
- * <style data-plugin> tag do not reorder.
5125
+ * win ties against the shared <=1023px block, then the chat markdown
5126
+ * readability overrides (content, which shares no selector with any of
5127
+ * them). Injected as ONE <style data-plugin> tag — do not reorder.
5004
5128
  */
5005
- 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].join('\n');
5129
+ 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');
5006
5130
  };
5007
5131
  __modules["debug.js"] = function (require, module, exports) {
5008
5132
  "use strict";
@@ -6295,11 +6419,13 @@ function installTurnFold(ctx) {
6295
6419
  const running = flow.querySelector(':scope > [role="status"]') !== null;
6296
6420
  const groups = groupsOf(flow);
6297
6421
  const live = new Set();
6422
+ const liveItems = new Set();
6298
6423
  groups.forEach((group, index) => {
6299
6424
  if (group.items.length === 0)
6300
6425
  return;
6301
6426
  const open = expanded.has(group.key);
6302
6427
  for (const item of group.items) {
6428
+ liveItems.add(item);
6303
6429
  item.setAttribute(FOLD, '');
6304
6430
  if (open)
6305
6431
  item.setAttribute(OPEN, '');
@@ -6332,6 +6458,18 @@ function installTurnFold(ctx) {
6332
6458
  if (key === undefined || !live.has(key))
6333
6459
  chip.remove();
6334
6460
  }
6461
+ // Unmark elements that stopped being process items. Without this, an
6462
+ // assistant-step row folded whole while it streamed ONLY reasoning kept
6463
+ // its row-level fold after prose started arriving in the same DOM node
6464
+ // (React updates the row in place), hiding the streaming reply until a
6465
+ // completion re-render rebuilt the row — the "reply only appears when
6466
+ // the turn finishes" phone bug (2026-08-18).
6467
+ for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
6468
+ if (liveItems.has(stale))
6469
+ continue;
6470
+ stale.removeAttribute(FOLD);
6471
+ stale.removeAttribute(OPEN);
6472
+ }
6335
6473
  };
6336
6474
  const schedule = () => {
6337
6475
  // The locale subscription below lives outside attach/detach, so this
@@ -6405,6 +6543,198 @@ function installTurnFold(ctx) {
6405
6543
  }, 'dsh-mobile-nav: turn process fold');
6406
6544
  }
6407
6545
  };
6546
+ __modules["effects/welcome-notice.js"] = function (require, module, exports) {
6547
+ "use strict";
6548
+ Object.defineProperty(exports, "__esModule", { value: true });
6549
+ exports.installWelcomeNoticeOptOut = installWelcomeNoticeOptOut;
6550
+ /** Per-browser opt-out flag; localStorage because remote (non-loopback)
6551
+ connections never persist the acknowledgement host-side. */
6552
+ const OPTOUT_KEY = 'dsh-zen-remote:welcome-notice-optout';
6553
+ /** Localized aria-labels of the DSH first-run notice dialog
6554
+ (@deepseek-ai/dsh-client-ui-settings-models WELCOME_NOTICE_COPY). */
6555
+ const NOTICE_LABELS = ['内测声明', 'Internal Testing Notice'];
6556
+ /**
6557
+ * "内测声明" first-run notice, opt-out half (ALL widths — same user-directed
6558
+ * exception as the _previewBadge rule in styles/base.css.ts).
6559
+ *
6560
+ * Why not CSS: the notice dialog keeps document #root inert while MOUNTED and
6561
+ * only restores it on unmount (its acknowledge button). display:none hid the
6562
+ * dialog without unmounting it, which left the whole app permanently
6563
+ * unclickable on remote connections (2026-08-18 incident) — remote browsers
6564
+ * re-mount the notice on every page load because their acknowledgement is
6565
+ * memory-only (`connection.isLoopback ? "host" : "memory"` upstream).
6566
+ *
6567
+ * So the dialog now shows normally, plus one injected "不再弹出" button. The
6568
+ * user chooses: acknowledging via that button records the opt-out in this
6569
+ * browser's localStorage, and later mounts are auto-acknowledged by clicking
6570
+ * the dialog's own continue button — the component unmounts through its
6571
+ * normal path and #root's inert is properly restored.
6572
+ */
6573
+ function installWelcomeNoticeOptOut(ctx) {
6574
+ ctx.effect(() => {
6575
+ const handle = (dialog) => {
6576
+ // The notice's action row holds exactly one button ("继续"/"Continue").
6577
+ const continueBtn = dialog.querySelector('button');
6578
+ if (continueBtn === null)
6579
+ return;
6580
+ if (localStorage.getItem(OPTOUT_KEY) !== null) {
6581
+ continueBtn.click();
6582
+ return;
6583
+ }
6584
+ const row = continueBtn.parentElement;
6585
+ if (row === null || row.querySelector('.zen-welcome-optout') !== null)
6586
+ return;
6587
+ const optOut = document.createElement('button');
6588
+ optOut.type = 'button';
6589
+ optOut.className = 'zen-welcome-optout';
6590
+ optOut.textContent =
6591
+ dialog.getAttribute('aria-label') === '内测声明' ? '不再弹出' : "Don't show again";
6592
+ optOut.addEventListener('click', () => {
6593
+ localStorage.setItem(OPTOUT_KEY, '1');
6594
+ continueBtn.click();
6595
+ });
6596
+ row.insertBefore(optOut, continueBtn);
6597
+ };
6598
+ const scan = () => {
6599
+ for (const label of NOTICE_LABELS) {
6600
+ const dialog = document.querySelector(`[role="dialog"][aria-label="${label}"]`);
6601
+ if (dialog !== null)
6602
+ handle(dialog);
6603
+ }
6604
+ };
6605
+ // The dialog portals straight under <body>, so childList on body is
6606
+ // enough; scan() once first in case it mounted before this plugin loaded.
6607
+ scan();
6608
+ const observer = new MutationObserver(scan);
6609
+ observer.observe(document.body, { childList: true });
6610
+ return () => observer.disconnect();
6611
+ }, 'dsh-mobile-nav: welcome-notice opt-out');
6612
+ }
6613
+ };
6614
+ __modules["effects/keyboard-guard.js"] = function (require, module, exports) {
6615
+ "use strict";
6616
+ Object.defineProperty(exports, "__esModule", { value: true });
6617
+ exports.installKeyboardGuard = installKeyboardGuard;
6618
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
6619
+ const PHONE_QUERY = '(max-width: 767px)';
6620
+ /** The official composer slot wrapper (same marker composer.css.ts styles). */
6621
+ const COMPOSER = '[data-slot="conversation.composer.bar"]';
6622
+ /** A tap/keystroke older than this no longer explains a focus. */
6623
+ const INTENT_WINDOW_MS = 1000;
6624
+ /**
6625
+ * S9 — keep the phone keyboard down until the user asks for it.
6626
+ *
6627
+ * dsh-client-ui-conversation focuses the composer textarea on every
6628
+ * sessionId change (lib/client.js:3423 — el.focus({preventScroll:true}) in a
6629
+ * [locked, sessionId] effect). Sensible on desktop; on a phone it pops the
6630
+ * software keyboard over half the screen every time a session opens.
6631
+ *
6632
+ * Rule: focus on the composer textarea survives only when the user asked for
6633
+ * it — a tap on the textarea itself or typing on a hardware keyboard.
6634
+ * Anything else (session-open autofocus, push-deep-link opens, the refocus
6635
+ * side effects of the other composer buttons — slash-command toggle, attach,
6636
+ * send) is blurred.
6637
+ *
6638
+ * Two triggers, because one is not enough:
6639
+ * - focusin catches the autofocus the moment it happens;
6640
+ * - a body MutationObserver re-runs the check after transcript swaps
6641
+ * (opening a session re-renders the flow but may reuse the same textarea,
6642
+ * and a focus that landed before this plugin loaded never fired focusin
6643
+ * for us at all).
6644
+ * Once focus is user-granted it stays granted until the textarea blurs, so
6645
+ * the observer never yanks a keyboard the user opened (e.g. while the agent
6646
+ * streams and the user pauses typing).
6647
+ */
6648
+ function installKeyboardGuard(ctx) {
6649
+ ctx.effect(() => {
6650
+ const narrow = window.matchMedia(PHONE_QUERY);
6651
+ let lastIntent = 0;
6652
+ let granted = false;
6653
+ let observer = null;
6654
+ let frame = 0;
6655
+ const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
6656
+ ? node
6657
+ : null;
6658
+ const onPointerDown = (event) => {
6659
+ // Only the textarea itself grants the keyboard. A tap on any other
6660
+ // composer control (slash-command toggle, attach, model menu, send)
6661
+ // must not: several of them refocus the input as a side effect, which
6662
+ // popped the keyboard on every command-button tap.
6663
+ if (composerTextarea(event.target) !== null) {
6664
+ lastIntent = Date.now();
6665
+ granted = true;
6666
+ }
6667
+ };
6668
+ const onKeyDown = () => {
6669
+ lastIntent = Date.now();
6670
+ if (composerTextarea(document.activeElement) !== null)
6671
+ granted = true;
6672
+ };
6673
+ const sweep = () => {
6674
+ const el = composerTextarea(document.activeElement);
6675
+ if (el === null)
6676
+ return;
6677
+ if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
6678
+ granted = true;
6679
+ return;
6680
+ }
6681
+ el.blur();
6682
+ };
6683
+ const onFocusIn = (event) => {
6684
+ if (composerTextarea(event.target) === null)
6685
+ return;
6686
+ sweep();
6687
+ };
6688
+ const onFocusOut = (event) => {
6689
+ if (composerTextarea(event.target) === null)
6690
+ return;
6691
+ granted = false;
6692
+ };
6693
+ const schedule = () => {
6694
+ if (observer === null || frame !== 0)
6695
+ return;
6696
+ // setTimeout, not requestAnimationFrame: rAF pauses in hidden/
6697
+ // backgrounded pages, where the autofocus still happens — the sweep
6698
+ // must run there too or the keyboard pops when the page is foregrounded.
6699
+ frame = window.setTimeout(() => {
6700
+ frame = 0;
6701
+ sweep();
6702
+ }, 100);
6703
+ };
6704
+ const attach = () => {
6705
+ if (observer !== null)
6706
+ return;
6707
+ document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true });
6708
+ document.addEventListener('keydown', onKeyDown, { capture: true, passive: true });
6709
+ document.addEventListener('focusin', onFocusIn, true);
6710
+ document.addEventListener('focusout', onFocusOut, true);
6711
+ observer = new MutationObserver(schedule);
6712
+ observer.observe(document.body, { childList: true, subtree: true });
6713
+ sweep();
6714
+ };
6715
+ const detach = () => {
6716
+ document.removeEventListener('pointerdown', onPointerDown, true);
6717
+ document.removeEventListener('keydown', onKeyDown, true);
6718
+ document.removeEventListener('focusin', onFocusIn, true);
6719
+ document.removeEventListener('focusout', onFocusOut, true);
6720
+ observer?.disconnect();
6721
+ observer = null;
6722
+ if (frame !== 0)
6723
+ window.clearTimeout(frame);
6724
+ frame = 0;
6725
+ granted = false;
6726
+ };
6727
+ if (narrow.matches)
6728
+ attach();
6729
+ const onChange = (event) => (event.matches ? attach() : detach());
6730
+ narrow.addEventListener('change', onChange);
6731
+ return () => {
6732
+ narrow.removeEventListener('change', onChange);
6733
+ detach();
6734
+ };
6735
+ }, 'dsh-mobile-nav: composer keyboard guard');
6736
+ }
6737
+ };
6408
6738
  __modules["index.js"] = function (require, module, exports) {
6409
6739
  "use strict";
6410
6740
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -6426,6 +6756,8 @@ const aionui_compat_ts_1 = require("./effects/aionui-compat.js");
6426
6756
  const header_status_ts_1 = require("./effects/header-status.js");
6427
6757
  const gestures_ts_1 = require("./effects/gestures.js");
6428
6758
  const turn_fold_ts_1 = require("./effects/turn-fold.js");
6759
+ const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
6760
+ const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
6429
6761
  const locales_ts_1 = require("./locales.js");
6430
6762
  /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
6431
6763
  exports.inject = ['slots', 'layout', 'locale', 'sessionLogDownload', 'sessions', 'workspaces'];
@@ -6469,6 +6801,14 @@ function apply(ctx) {
6469
6801
  // effects/turn-fold.ts for why the keyed conversation.chat.node seat could
6470
6802
  // not carry this.
6471
6803
  (0, turn_fold_ts_1.installTurnFold)(ctx);
6804
+ // "内测声明" first-run notice: keep it visible (CSS-hiding it leaked the
6805
+ // dialog's #root inert lock — see effects/welcome-notice.ts) and offer a
6806
+ // per-browser "不再弹出" opt-out instead.
6807
+ (0, welcome_notice_ts_1.installWelcomeNoticeOptOut)(ctx);
6808
+ // S9: opening a session must not pop the phone keyboard — the official
6809
+ // composer autofocuses on every sessionId change; keep focus only when the
6810
+ // user tapped the composer (or typed) themselves.
6811
+ (0, keyboard_guard_ts_1.installKeyboardGuard)(ctx);
6472
6812
  // Page-stack store (apply world) — created before any registration so
6473
6813
  // every slot below (the phone home screen, the session header's back
6474
6814
  // button) shares the exact same handle/instance.
@@ -0,0 +1,27 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
2
+ /**
3
+ * S9 — keep the phone keyboard down until the user asks for it.
4
+ *
5
+ * dsh-client-ui-conversation focuses the composer textarea on every
6
+ * sessionId change (lib/client.js:3423 — el.focus({preventScroll:true}) in a
7
+ * [locked, sessionId] effect). Sensible on desktop; on a phone it pops the
8
+ * software keyboard over half the screen every time a session opens.
9
+ *
10
+ * Rule: focus on the composer textarea survives only when the user asked for
11
+ * it — a tap on the textarea itself or typing on a hardware keyboard.
12
+ * Anything else (session-open autofocus, push-deep-link opens, the refocus
13
+ * side effects of the other composer buttons — slash-command toggle, attach,
14
+ * send) is blurred.
15
+ *
16
+ * Two triggers, because one is not enough:
17
+ * - focusin catches the autofocus the moment it happens;
18
+ * - a body MutationObserver re-runs the check after transcript swaps
19
+ * (opening a session re-renders the flow but may reuse the same textarea,
20
+ * and a focus that landed before this plugin loaded never fired focusin
21
+ * for us at all).
22
+ * Once focus is user-granted it stays granted until the textarea blurs, so
23
+ * the observer never yanks a keyboard the user opened (e.g. while the agent
24
+ * streams and the user pauses typing).
25
+ */
26
+ export declare function installKeyboardGuard(ctx: ClientContext): void;
27
+ //# sourceMappingURL=keyboard-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard-guard.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/keyboard-guard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAW3E;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqF7D"}
@@ -1 +1 @@
1
- {"version":3,"file":"turn-fold.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/turn-fold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAqH3E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAsHxD"}
1
+ {"version":3,"file":"turn-fold.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/turn-fold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAqH3E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAoIxD"}
@@ -0,0 +1,20 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
2
+ /**
3
+ * "内测声明" first-run notice, opt-out half (ALL widths — same user-directed
4
+ * exception as the _previewBadge rule in styles/base.css.ts).
5
+ *
6
+ * Why not CSS: the notice dialog keeps document #root inert while MOUNTED and
7
+ * only restores it on unmount (its acknowledge button). display:none hid the
8
+ * dialog without unmounting it, which left the whole app permanently
9
+ * unclickable on remote connections (2026-08-18 incident) — remote browsers
10
+ * re-mount the notice on every page load because their acknowledgement is
11
+ * memory-only (`connection.isLoopback ? "host" : "memory"` upstream).
12
+ *
13
+ * So the dialog now shows normally, plus one injected "不再弹出" button. The
14
+ * user chooses: acknowledging via that button records the opt-out in this
15
+ * browser's localStorage, and later mounts are auto-acknowledged by clicking
16
+ * the dialog's own continue button — the component unmounts through its
17
+ * normal path and #root's inert is properly restored.
18
+ */
19
+ export declare function installWelcomeNoticeOptOut(ctx: ClientContext): void;
20
+ //# sourceMappingURL=welcome-notice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"welcome-notice.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/welcome-notice.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAU3E;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAsCnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAA0B,MAAM,wCAAwC,CAAA;AAkBnG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,sCAAsC;QACtC,WAAW,EAAE,YAAY,CAAA;KAC1B;CACF;AAED,0GAA0G;AAC1G,eAAO,MAAM,MAAM,UAAgF,CAAA;AAEnG;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqL9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAA0B,MAAM,wCAAwC,CAAA;AAoBnG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,sCAAsC;QACtC,WAAW,EAAE,YAAY,CAAA;KAC1B;CACF;AAED,0GAA0G;AAC1G,eAAO,MAAM,MAAM,UAAgF,CAAA;AAEnG;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA+L9C"}
@@ -1,2 +1,2 @@
1
- export declare const BASE_CSS = "\n/* ---------- safe-area variables (S2.1, 2026-08-17) ----------\n Every safe-area use in this stylesheet reads --mnav-sat / --mnav-sab\n instead of env() directly. Same computed value by default, but the\n indirection gives one place to override: ?mobile-nav-inset=54 (client/\n debug.ts) writes a fake inset onto the root element, so a desktop CDP\n run can regress notch layout \u2014 env(safe-area-inset-*) is hard 0 in every\n desktop browser, which is exactly why the header and the workbench\n panel shipped broken to a real iPhone. */\n:root {\n --mnav-sat: env(safe-area-inset-top, 0px);\n --mnav-sab: env(safe-area-inset-bottom, 0px);\n}\n\n/* ---------- base control styles (rendered at any width, hidden where unused) ---------- */\n\n[data-mobile-nav=\"toggle\"],\n[data-mobile-nav=\"files\"] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n flex: none;\n padding: 0;\n border: none;\n border-radius: 50%;\n background: transparent;\n color: var(--dsw-alias-label-secondary, inherit);\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"toggle\"]:hover,\n[data-mobile-nav=\"files\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n}\n[data-mobile-nav=\"toggle\"]:focus-visible,\n[data-mobile-nav=\"files\"]:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);\n outline-offset: 1px;\n}\n\n/* Drawer footer actions: the relocated Session log download plus the Files\n action that opens the dsh-web-ui explorer sheet. */\n[data-mobile-nav=\"drawer-actions\"] {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n[data-mobile-nav=\"session-log\"],\n[data-mobile-nav=\"explorer\"] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n height: 34px;\n padding: 0 12px;\n border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));\n border-radius: 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary, inherit);\n font-family: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"session-log\"]:hover:not(:disabled),\n[data-mobile-nav=\"explorer\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n}\n[data-mobile-nav=\"session-log\"]:disabled {\n color: var(--dsw-alias-label-dimmed, rgba(0, 0, 0, .35));\n cursor: default;\n}\n\n/* Floating fallback button (hero / blank phases without a session header).\n The top clears the camera band below the status bar; when the client has\n set viewport-fit=cover the safe-area inset moves it below the notch too. */\n[data-mobile-nav=\"fab\"] {\n position: absolute;\n top: calc(var(--mnav-sat) + 72px);\n left: 10px;\n z-index: 21;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n height: 38px;\n padding: 0;\n border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));\n border-radius: 50%;\n background: var(--dsw-alias-button-floating-fill, #ffffff);\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n box-shadow: 0 2px 12px rgba(0, 0, 0, .18);\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"fab\"]:hover {\n background: var(--dsw-alias-button-floating-hover, rgba(0, 0, 0, .08));\n}\n[data-mobile-nav=\"fab\"]:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);\n outline-offset: 2px;\n}\n\n/* Dimmed backdrop under the open drawer; above every column, below the drawer. */\n[data-mobile-nav=\"backdrop\"] {\n position: absolute;\n inset: 0;\n z-index: 30;\n background: rgba(0, 0, 0, .45);\n cursor: pointer;\n animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);\n -webkit-tap-highlight-color: transparent;\n}\n@keyframes dsh-mobile-nav-fade {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n/* Settings sheet entrance: the official dialog mounts with no animation at\n all, so it snaps in. Fade + slight rise/scale reads as a proper sheet. */\n@keyframes dsh-mobile-nav-sheet-in {\n from {\n opacity: 0;\n transform: translateY(14px) scale(.98);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n/* Preview sheet rise: the aionui preview column opens as a bottom sheet. */\n@keyframes dsh-mobile-nav-sheet-up {\n from {\n opacity: 0;\n transform: translateY(28px);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n\n/* ---------- beta-notice suppression (ALL widths \u2014 deliberate exception) ----\n User-directed (2026-08-17): hide DSH's beta/preview notices everywhere,\n including desktop. This is the ONE place this plugin intentionally breaks\n its own \">=1024px strict no-op\" rule \u2014 do not \"fix\" it back.\n Selector notes:\n - The hero \"\u9884\u89C8\u7248\" badge only carries a hashed class; per repo convention\n the stable part is the suffix (_previewBadge), matched with [class$=].\n - The first-run \"\u5185\u6D4B\u58F0\u660E\" modal is hidden via its ARIA label. The label is\n LOCALIZED \u2014 zh is covered; if the UI runs in another language the dialog\n shows (harmless, dismiss by hand). Hiding targets the presentation\n wrapper via :has() so the modal's backdrop cannot linger invisibly and\n block taps; the app never requires acknowledging this dialog to work. */\n[class$=\"_previewBadge\"] {\n display: none !important;\n}\n[role=\"presentation\"]:has(> [role=\"dialog\"][aria-label=\"\u5185\u6D4B\u58F0\u660E\"]),\n[role=\"dialog\"][aria-label=\"\u5185\u6D4B\u58F0\u660E\"] {\n display: none !important;\n}\n";
1
+ export declare const BASE_CSS = "\n/* ---------- safe-area variables (S2.1, 2026-08-17) ----------\n Every safe-area use in this stylesheet reads --mnav-sat / --mnav-sab\n instead of env() directly. Same computed value by default, but the\n indirection gives one place to override: ?mobile-nav-inset=54 (client/\n debug.ts) writes a fake inset onto the root element, so a desktop CDP\n run can regress notch layout \u2014 env(safe-area-inset-*) is hard 0 in every\n desktop browser, which is exactly why the header and the workbench\n panel shipped broken to a real iPhone. */\n:root {\n --mnav-sat: env(safe-area-inset-top, 0px);\n --mnav-sab: env(safe-area-inset-bottom, 0px);\n}\n\n/* ---------- base control styles (rendered at any width, hidden where unused) ---------- */\n\n[data-mobile-nav=\"toggle\"],\n[data-mobile-nav=\"files\"] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n flex: none;\n padding: 0;\n border: none;\n border-radius: 50%;\n background: transparent;\n color: var(--dsw-alias-label-secondary, inherit);\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"toggle\"]:hover,\n[data-mobile-nav=\"files\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n}\n[data-mobile-nav=\"toggle\"]:focus-visible,\n[data-mobile-nav=\"files\"]:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);\n outline-offset: 1px;\n}\n\n/* Drawer footer actions: the relocated Session log download plus the Files\n action that opens the dsh-web-ui explorer sheet. */\n[data-mobile-nav=\"drawer-actions\"] {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n[data-mobile-nav=\"session-log\"],\n[data-mobile-nav=\"explorer\"] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n height: 34px;\n padding: 0 12px;\n border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));\n border-radius: 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary, inherit);\n font-family: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"session-log\"]:hover:not(:disabled),\n[data-mobile-nav=\"explorer\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n}\n[data-mobile-nav=\"session-log\"]:disabled {\n color: var(--dsw-alias-label-dimmed, rgba(0, 0, 0, .35));\n cursor: default;\n}\n\n/* Floating fallback button (hero / blank phases without a session header).\n The top clears the camera band below the status bar; when the client has\n set viewport-fit=cover the safe-area inset moves it below the notch too. */\n[data-mobile-nav=\"fab\"] {\n position: absolute;\n top: calc(var(--mnav-sat) + 72px);\n left: 10px;\n z-index: 21;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n height: 38px;\n padding: 0;\n border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));\n border-radius: 50%;\n background: var(--dsw-alias-button-floating-fill, #ffffff);\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n box-shadow: 0 2px 12px rgba(0, 0, 0, .18);\n -webkit-tap-highlight-color: transparent;\n}\n[data-mobile-nav=\"fab\"]:hover {\n background: var(--dsw-alias-button-floating-hover, rgba(0, 0, 0, .08));\n}\n[data-mobile-nav=\"fab\"]:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);\n outline-offset: 2px;\n}\n\n/* Dimmed backdrop under the open drawer; above every column, below the drawer. */\n[data-mobile-nav=\"backdrop\"] {\n position: absolute;\n inset: 0;\n z-index: 30;\n background: rgba(0, 0, 0, .45);\n cursor: pointer;\n animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);\n -webkit-tap-highlight-color: transparent;\n}\n@keyframes dsh-mobile-nav-fade {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n/* Settings sheet entrance: the official dialog mounts with no animation at\n all, so it snaps in. Fade + slight rise/scale reads as a proper sheet. */\n@keyframes dsh-mobile-nav-sheet-in {\n from {\n opacity: 0;\n transform: translateY(14px) scale(.98);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n/* Preview sheet rise: the aionui preview column opens as a bottom sheet. */\n@keyframes dsh-mobile-nav-sheet-up {\n from {\n opacity: 0;\n transform: translateY(28px);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n\n/* ---------- beta-notice handling (ALL widths \u2014 deliberate exception) ----\n User-directed (2026-08-17): tone down DSH's beta/preview notices\n everywhere, including desktop. This is the ONE place this plugin\n intentionally breaks its own \">=1024px strict no-op\" rule.\n - The hero \"\u9884\u89C8\u7248\" badge only carries a hashed class; per repo convention\n the stable part is the suffix (_previewBadge), matched with [class$=].\n - The first-run \"\u5185\u6D4B\u58F0\u660E\" modal must NOT be hidden with CSS: while it is\n mounted it holds document #root inert, so display:none leaves the whole\n app unclickable (2026-08-18 incident). effects/welcome-notice.ts instead\n injects a \"\u4E0D\u518D\u5F39\u51FA\" opt-out button; this styles it. */\n[class$=\"_previewBadge\"] {\n display: none !important;\n}\n/* Mirrors the official primitives Button (md capsule, outline variant):\n 36px height, 18px radius, --dsw-alias-border-l2 hairline. */\n.zen-welcome-optout {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n height: 36px;\n padding: 0 14px;\n margin-right: 8px;\n border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, .15));\n border-radius: 18px;\n background: transparent;\n color: var(--dsw-alias-label-primary, inherit);\n font-size: 14px;\n line-height: 22px;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n}\n.zen-welcome-optout:hover {\n background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));\n}\n";
2
2
  //# sourceMappingURL=base.css.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"base.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/base.css.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,moLAmKpB,CAAA"}
1
+ {"version":3,"file":"base.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/base.css.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,y/LAkLpB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export declare const CONTENT_CSS = "/* ---------- chat markdown content (< 768px) ---------- */\n\n@media (max-width: 767px) {\n /* Inline code: upstream renders it display:inline-flex \u2014 an atomic inline\n box that can never wrap across lines, so one long code span walks off\n the right edge of the screen. Plain inline keeps the background pill\n per line fragment and lets long tokens break anywhere. Deliberately\n unscoped (any :not(pre) > code, not just the _markdown_ module):\n inline code also renders outside that container (user bubbles,\n reasoning rows), and on a phone there is no width at which an\n unwrappable inline box is right. !important beats the upstream\n display:inline-flex on specificity ties. */\n :not(pre) > code {\n display: inline !important;\n overflow-wrap: anywhere;\n /* iOS WebKit: overflow-wrap alone still failed to break long tokens in\n inline code on a real iPhone (screenshot-verified 2026-08-18, li >\n code path case) while desktop Chromium broke them fine. break-all is\n the everywhere-supported hammer; acceptable for code pills. */\n word-break: break-all;\n }\n /* Belt-and-suspenders for engines that compute soft-wrap opportunities\n from the block container rather than the inline itself (historical\n WebKit behavior): inherit anywhere through the whole transcript so the\n block side agrees. Prose is untouched until something would overflow;\n the table is already pinned wide by min-width: max-content below. */\n [data-chat-flow],\n [class*=\"_markdown_\"] {\n overflow-wrap: anywhere;\n }\n\n /* Markdown tables: the official _tableScroll_ wrapper is already a\n horizontal scroll viewport around a width:max-content table, but its\n cells are capped at min(30vw, 320px) \u2014 barely 120px on a phone, which\n crushes every column into a vertical word-stack. Let columns size to\n their content instead and read the overflow by scrolling the wrapper.\n 480px (not a vw cap \u2014 85vw was ~320px on a 375px phone and still\n wrapped mid-length cells) keeps typical cells on one line; only truly\n prose-heavy cells wrap, instead of dragging the table kilometers\n wide. */\n [class*=\"_tableScroll_\"] th,\n [class*=\"_tableScroll_\"] td {\n max-width: 480px !important;\n }\n\n /* iOS WebKit fallback: upstream's own table { width: max-content } is what\n keeps the table at its natural width inside the scroll wrapper, but\n WebKit's max-content support on table boxes is incomplete \u2014 on a real\n iPhone the table fell back to shrink-to-fit and every column got\n crushed to its min-width (the \"ra/nk\" vertical-word-stack screenshot,\n 2026-08-18). Redeclare the natural-width intent with both width and\n min-width so whichever keyword the engine honors wins; the wrapper\n already scrolls the overflow. */\n [class*=\"_tableScroll_\"] table {\n width: max-content !important;\n min-width: max-content !important;\n max-width: none !important;\n }\n\n /* Turn-tail metadata (2026-08-18): the timing stats (\"18:06 \u00B7 \u7528\u65F6 29\u79D2 \u00B7\n \u9996 token 0.9\u79D2 \u00B7 163 tok/s\") are desktop-hover-revealed (official\n [data-time-hover-root] gate holds them at opacity 0 until :hover /\n :focus-within) \u2014 a phone never hovers, so they were simply invisible;\n and even ignoring that, the row is nowrap + overflow:hidden (plus this\n plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the\n text. On phones: always visible, own full-width line under the action\n icons, wrapping freely, one type size down. !important + the extra\n [class$=\"_actions\"] hop out-specifies both upstream and layout.css.ts. */\n /* Pin the tail row's width chain before allowing wrap: with wrap enabled\n the row's intrinsic (shrink-to-fit) width collapses to its widest\n single icon, taking the whole tail down to ~16px. */\n [data-chat-flow-kind=\"turn-tail\"] [class$=\"_root\"],\n [data-chat-flow-kind=\"turn-tail\"] [class$=\"_actions\"] {\n width: 100%;\n }\n [data-chat-flow-kind=\"turn-tail\"] [class$=\"_actions\"] {\n flex-wrap: wrap;\n row-gap: 2px;\n }\n /* Wide companion to the rule below: mirrors the upstream hover-gate\n selector shape directly, with no assumptions about the tail's DOM\n structure, so the stats can't stay invisible even if the phone's tail\n renders differently than desktop. */\n [data-time-hover-root] [class$=\"_timeEnd\"],\n [data-time-hover-root] [class$=\"_timeStart\"] {\n opacity: 1 !important;\n transition: none !important;\n }\n [data-chat-flow-kind=\"turn-tail\"] [class$=\"_actions\"] [class$=\"_timeEnd\"] {\n opacity: 1 !important;\n flex-basis: 100% !important;\n white-space: normal !important;\n overflow: visible !important;\n text-overflow: clip !important;\n padding-left: 0 !important;\n font-size: 12px;\n line-height: 18px;\n }\n}\n";
2
+ //# sourceMappingURL=content.css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/content.css.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,WAAW,i6JAmGvB,CAAA"}
@@ -8,8 +8,9 @@
8
8
  * win that tie too), the turn-process fold (turn-fold, S8, which shares no
9
9
  * selector with any of them), and finally the chips row / settings entry /
10
10
  * portal fix (chips, S5) — all appended in this order so their <768px rules
11
- * win ties against the shared <=1023px block. Injected as ONE
12
- * <style data-plugin> tag do not reorder.
11
+ * win ties against the shared <=1023px block, then the chat markdown
12
+ * readability overrides (content, which shares no selector with any of
13
+ * them). Injected as ONE <style data-plugin> tag — do not reorder.
13
14
  */
14
15
  export declare const MOBILE_CSS: string;
15
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/index.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU,QAAkI,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/index.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU,QAA+I,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pwa/app.css CHANGED
@@ -86,9 +86,14 @@ html:not([data-lan-device="desktop"]) [data-slot="conversation"] [data-phase] {
86
86
 
87
87
  /* Scroll long code lines horizontally instead of letting them widen the
88
88
  * page. dsh-mobile-nav has no equivalent rule; dropping this would let a
89
- * long code block push the whole phone layout into horizontal scroll. */
89
+ * long code block push the whole phone layout into horizontal scroll.
90
+ * pre + pre code ONLY: the old bare `code` selector also hit INLINE code,
91
+ * whose white-space:pre vetoed every wrapping property and clipped long
92
+ * paths off-screen on phones — while desktop loopback (which never loads
93
+ * this file) wrapped fine. Days of "why only on the phone" (2026-08-18);
94
+ * inline-code wrapping is owned by styles/content.css.ts. */
90
95
  html:not([data-lan-device="desktop"]) pre,
91
- html:not([data-lan-device="desktop"]) code {
96
+ html:not([data-lan-device="desktop"]) pre code {
92
97
  white-space: pre;
93
98
  overflow-x: auto;
94
99
  -webkit-overflow-scrolling: touch;
@@ -0,0 +1,121 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+
3
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
4
+ const PHONE_QUERY = '(max-width: 767px)'
5
+
6
+ /** The official composer slot wrapper (same marker composer.css.ts styles). */
7
+ const COMPOSER = '[data-slot="conversation.composer.bar"]'
8
+
9
+ /** A tap/keystroke older than this no longer explains a focus. */
10
+ const INTENT_WINDOW_MS = 1000
11
+
12
+ /**
13
+ * S9 — keep the phone keyboard down until the user asks for it.
14
+ *
15
+ * dsh-client-ui-conversation focuses the composer textarea on every
16
+ * sessionId change (lib/client.js:3423 — el.focus({preventScroll:true}) in a
17
+ * [locked, sessionId] effect). Sensible on desktop; on a phone it pops the
18
+ * software keyboard over half the screen every time a session opens.
19
+ *
20
+ * Rule: focus on the composer textarea survives only when the user asked for
21
+ * it — a tap on the textarea itself or typing on a hardware keyboard.
22
+ * Anything else (session-open autofocus, push-deep-link opens, the refocus
23
+ * side effects of the other composer buttons — slash-command toggle, attach,
24
+ * send) is blurred.
25
+ *
26
+ * Two triggers, because one is not enough:
27
+ * - focusin catches the autofocus the moment it happens;
28
+ * - a body MutationObserver re-runs the check after transcript swaps
29
+ * (opening a session re-renders the flow but may reuse the same textarea,
30
+ * and a focus that landed before this plugin loaded never fired focusin
31
+ * for us at all).
32
+ * Once focus is user-granted it stays granted until the textarea blurs, so
33
+ * the observer never yanks a keyboard the user opened (e.g. while the agent
34
+ * streams and the user pauses typing).
35
+ */
36
+ export function installKeyboardGuard(ctx: ClientContext): void {
37
+ ctx.effect(() => {
38
+ const narrow = window.matchMedia(PHONE_QUERY)
39
+ let lastIntent = 0
40
+ let granted = false
41
+ let observer: MutationObserver | null = null
42
+ let frame = 0
43
+
44
+ const composerTextarea = (node: unknown): HTMLElement | null =>
45
+ node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
46
+ ? node
47
+ : null
48
+
49
+ const onPointerDown = (event: PointerEvent): void => {
50
+ // Only the textarea itself grants the keyboard. A tap on any other
51
+ // composer control (slash-command toggle, attach, model menu, send)
52
+ // must not: several of them refocus the input as a side effect, which
53
+ // popped the keyboard on every command-button tap.
54
+ if (composerTextarea(event.target) !== null) {
55
+ lastIntent = Date.now()
56
+ granted = true
57
+ }
58
+ }
59
+ const onKeyDown = (): void => {
60
+ lastIntent = Date.now()
61
+ if (composerTextarea(document.activeElement) !== null) granted = true
62
+ }
63
+ const sweep = (): void => {
64
+ const el = composerTextarea(document.activeElement)
65
+ if (el === null) return
66
+ if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
67
+ granted = true
68
+ return
69
+ }
70
+ el.blur()
71
+ }
72
+ const onFocusIn = (event: FocusEvent): void => {
73
+ if (composerTextarea(event.target) === null) return
74
+ sweep()
75
+ }
76
+ const onFocusOut = (event: FocusEvent): void => {
77
+ if (composerTextarea(event.target) === null) return
78
+ granted = false
79
+ }
80
+ const schedule = (): void => {
81
+ if (observer === null || frame !== 0) return
82
+ // setTimeout, not requestAnimationFrame: rAF pauses in hidden/
83
+ // backgrounded pages, where the autofocus still happens — the sweep
84
+ // must run there too or the keyboard pops when the page is foregrounded.
85
+ frame = window.setTimeout(() => {
86
+ frame = 0
87
+ sweep()
88
+ }, 100)
89
+ }
90
+
91
+ const attach = (): void => {
92
+ if (observer !== null) return
93
+ document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true })
94
+ document.addEventListener('keydown', onKeyDown, { capture: true, passive: true })
95
+ document.addEventListener('focusin', onFocusIn, true)
96
+ document.addEventListener('focusout', onFocusOut, true)
97
+ observer = new MutationObserver(schedule)
98
+ observer.observe(document.body, { childList: true, subtree: true })
99
+ sweep()
100
+ }
101
+ const detach = (): void => {
102
+ document.removeEventListener('pointerdown', onPointerDown, true)
103
+ document.removeEventListener('keydown', onKeyDown, true)
104
+ document.removeEventListener('focusin', onFocusIn, true)
105
+ document.removeEventListener('focusout', onFocusOut, true)
106
+ observer?.disconnect()
107
+ observer = null
108
+ if (frame !== 0) window.clearTimeout(frame)
109
+ frame = 0
110
+ granted = false
111
+ }
112
+
113
+ if (narrow.matches) attach()
114
+ const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
115
+ narrow.addEventListener('change', onChange)
116
+ return () => {
117
+ narrow.removeEventListener('change', onChange)
118
+ detach()
119
+ }
120
+ }, 'dsh-mobile-nav: composer keyboard guard')
121
+ }
@@ -160,11 +160,13 @@ export function installTurnFold(ctx: ClientContext): void {
160
160
  const running = flow.querySelector(':scope > [role="status"]') !== null
161
161
  const groups = groupsOf(flow)
162
162
  const live = new Set<string>()
163
+ const liveItems = new Set<Element>()
163
164
 
164
165
  groups.forEach((group, index) => {
165
166
  if (group.items.length === 0) return
166
167
  const open = expanded.has(group.key)
167
168
  for (const item of group.items) {
169
+ liveItems.add(item)
168
170
  item.setAttribute(FOLD, '')
169
171
  if (open) item.setAttribute(OPEN, '')
170
172
  else item.removeAttribute(OPEN)
@@ -189,6 +191,18 @@ export function installTurnFold(ctx: ClientContext): void {
189
191
  const key = chip.dataset['foldGroup']
190
192
  if (key === undefined || !live.has(key)) chip.remove()
191
193
  }
194
+
195
+ // Unmark elements that stopped being process items. Without this, an
196
+ // assistant-step row folded whole while it streamed ONLY reasoning kept
197
+ // its row-level fold after prose started arriving in the same DOM node
198
+ // (React updates the row in place), hiding the streaming reply until a
199
+ // completion re-render rebuilt the row — the "reply only appears when
200
+ // the turn finishes" phone bug (2026-08-18).
201
+ for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
202
+ if (liveItems.has(stale)) continue
203
+ stale.removeAttribute(FOLD)
204
+ stale.removeAttribute(OPEN)
205
+ }
192
206
  }
193
207
 
194
208
  const schedule = (): void => {
@@ -0,0 +1,66 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+
3
+ /** Per-browser opt-out flag; localStorage because remote (non-loopback)
4
+ connections never persist the acknowledgement host-side. */
5
+ const OPTOUT_KEY = 'dsh-zen-remote:welcome-notice-optout'
6
+
7
+ /** Localized aria-labels of the DSH first-run notice dialog
8
+ (@deepseek-ai/dsh-client-ui-settings-models WELCOME_NOTICE_COPY). */
9
+ const NOTICE_LABELS = ['内测声明', 'Internal Testing Notice']
10
+
11
+ /**
12
+ * "内测声明" first-run notice, opt-out half (ALL widths — same user-directed
13
+ * exception as the _previewBadge rule in styles/base.css.ts).
14
+ *
15
+ * Why not CSS: the notice dialog keeps document #root inert while MOUNTED and
16
+ * only restores it on unmount (its acknowledge button). display:none hid the
17
+ * dialog without unmounting it, which left the whole app permanently
18
+ * unclickable on remote connections (2026-08-18 incident) — remote browsers
19
+ * re-mount the notice on every page load because their acknowledgement is
20
+ * memory-only (`connection.isLoopback ? "host" : "memory"` upstream).
21
+ *
22
+ * So the dialog now shows normally, plus one injected "不再弹出" button. The
23
+ * user chooses: acknowledging via that button records the opt-out in this
24
+ * browser's localStorage, and later mounts are auto-acknowledged by clicking
25
+ * the dialog's own continue button — the component unmounts through its
26
+ * normal path and #root's inert is properly restored.
27
+ */
28
+ export function installWelcomeNoticeOptOut(ctx: ClientContext): void {
29
+ ctx.effect(() => {
30
+ const handle = (dialog: HTMLElement): void => {
31
+ // The notice's action row holds exactly one button ("继续"/"Continue").
32
+ const continueBtn = dialog.querySelector('button')
33
+ if (continueBtn === null) return
34
+ if (localStorage.getItem(OPTOUT_KEY) !== null) {
35
+ continueBtn.click()
36
+ return
37
+ }
38
+ const row = continueBtn.parentElement
39
+ if (row === null || row.querySelector('.zen-welcome-optout') !== null) return
40
+ const optOut = document.createElement('button')
41
+ optOut.type = 'button'
42
+ optOut.className = 'zen-welcome-optout'
43
+ optOut.textContent =
44
+ dialog.getAttribute('aria-label') === '内测声明' ? '不再弹出' : "Don't show again"
45
+ optOut.addEventListener('click', () => {
46
+ localStorage.setItem(OPTOUT_KEY, '1')
47
+ continueBtn.click()
48
+ })
49
+ row.insertBefore(optOut, continueBtn)
50
+ }
51
+ const scan = (): void => {
52
+ for (const label of NOTICE_LABELS) {
53
+ const dialog = document.querySelector<HTMLElement>(
54
+ `[role="dialog"][aria-label="${label}"]`,
55
+ )
56
+ if (dialog !== null) handle(dialog)
57
+ }
58
+ }
59
+ // The dialog portals straight under <body>, so childList on body is
60
+ // enough; scan() once first in case it mounted before this plugin loaded.
61
+ scan()
62
+ const observer = new MutationObserver(scan)
63
+ observer.observe(document.body, { childList: true })
64
+ return () => observer.disconnect()
65
+ }, 'dsh-mobile-nav: welcome-notice opt-out')
66
+ }
@@ -15,6 +15,8 @@ import { installAionuiCompat } from './effects/aionui-compat.ts'
15
15
  import { installHeaderStatusDot } from './effects/header-status.ts'
16
16
  import { installGestures } from './effects/gestures.ts'
17
17
  import { installTurnFold } from './effects/turn-fold.ts'
18
+ import { installWelcomeNoticeOptOut } from './effects/welcome-notice.ts'
19
+ import { installKeyboardGuard } from './effects/keyboard-guard.ts'
18
20
  import { NS, en, zh } from './locales.ts'
19
21
  import type { MobileNavKey } from './locales.ts'
20
22
 
@@ -78,6 +80,16 @@ export function apply(ctx: ClientContext): void {
78
80
  // not carry this.
79
81
  installTurnFold(ctx)
80
82
 
83
+ // "内测声明" first-run notice: keep it visible (CSS-hiding it leaked the
84
+ // dialog's #root inert lock — see effects/welcome-notice.ts) and offer a
85
+ // per-browser "不再弹出" opt-out instead.
86
+ installWelcomeNoticeOptOut(ctx)
87
+
88
+ // S9: opening a session must not pop the phone keyboard — the official
89
+ // composer autofocuses on every sessionId change; keep focus only when the
90
+ // user tapped the composer (or typed) themselves.
91
+ installKeyboardGuard(ctx)
92
+
81
93
  // Page-stack store (apply world) — created before any registration so
82
94
  // every slot below (the phone home screen, the session header's back
83
95
  // button) shares the exact same handle/instance.
@@ -145,23 +145,38 @@ export const BASE_CSS = `
145
145
  }
146
146
  }
147
147
 
148
- /* ---------- beta-notice suppression (ALL widths — deliberate exception) ----
149
- User-directed (2026-08-17): hide DSH's beta/preview notices everywhere,
150
- including desktop. This is the ONE place this plugin intentionally breaks
151
- its own ">=1024px strict no-op" rule — do not "fix" it back.
152
- Selector notes:
148
+ /* ---------- beta-notice handling (ALL widths — deliberate exception) ----
149
+ User-directed (2026-08-17): tone down DSH's beta/preview notices
150
+ everywhere, including desktop. This is the ONE place this plugin
151
+ intentionally breaks its own ">=1024px strict no-op" rule.
153
152
  - The hero "预览版" badge only carries a hashed class; per repo convention
154
153
  the stable part is the suffix (_previewBadge), matched with [class$=].
155
- - The first-run "内测声明" modal is hidden via its ARIA label. The label is
156
- LOCALIZED zh is covered; if the UI runs in another language the dialog
157
- shows (harmless, dismiss by hand). Hiding targets the presentation
158
- wrapper via :has() so the modal's backdrop cannot linger invisibly and
159
- block taps; the app never requires acknowledging this dialog to work. */
154
+ - The first-run "内测声明" modal must NOT be hidden with CSS: while it is
155
+ mounted it holds document #root inert, so display:none leaves the whole
156
+ app unclickable (2026-08-18 incident). effects/welcome-notice.ts instead
157
+ injects a "不再弹出" opt-out button; this styles it. */
160
158
  [class$="_previewBadge"] {
161
159
  display: none !important;
162
160
  }
163
- [role="presentation"]:has(> [role="dialog"][aria-label="内测声明"]),
164
- [role="dialog"][aria-label="内测声明"] {
165
- display: none !important;
161
+ /* Mirrors the official primitives Button (md capsule, outline variant):
162
+ 36px height, 18px radius, --dsw-alias-border-l2 hairline. */
163
+ .zen-welcome-optout {
164
+ display: inline-flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ height: 36px;
168
+ padding: 0 14px;
169
+ margin-right: 8px;
170
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, .15));
171
+ border-radius: 18px;
172
+ background: transparent;
173
+ color: var(--dsw-alias-label-primary, inherit);
174
+ font-size: 14px;
175
+ line-height: 22px;
176
+ cursor: pointer;
177
+ -webkit-tap-highlight-color: transparent;
178
+ }
179
+ .zen-welcome-optout:hover {
180
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
166
181
  }
167
182
  `
@@ -0,0 +1,103 @@
1
+ // content — chat markdown readability on phones (2026-08-18). Shares no
2
+ // selector with any other file; appended last in styles/index.ts.
3
+
4
+ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ---------- */
5
+
6
+ @media (max-width: 767px) {
7
+ /* Inline code: upstream renders it display:inline-flex — an atomic inline
8
+ box that can never wrap across lines, so one long code span walks off
9
+ the right edge of the screen. Plain inline keeps the background pill
10
+ per line fragment and lets long tokens break anywhere. Deliberately
11
+ unscoped (any :not(pre) > code, not just the _markdown_ module):
12
+ inline code also renders outside that container (user bubbles,
13
+ reasoning rows), and on a phone there is no width at which an
14
+ unwrappable inline box is right. !important beats the upstream
15
+ display:inline-flex on specificity ties. */
16
+ :not(pre) > code {
17
+ display: inline !important;
18
+ overflow-wrap: anywhere;
19
+ /* iOS WebKit: overflow-wrap alone still failed to break long tokens in
20
+ inline code on a real iPhone (screenshot-verified 2026-08-18, li >
21
+ code path case) while desktop Chromium broke them fine. break-all is
22
+ the everywhere-supported hammer; acceptable for code pills. */
23
+ word-break: break-all;
24
+ }
25
+ /* Belt-and-suspenders for engines that compute soft-wrap opportunities
26
+ from the block container rather than the inline itself (historical
27
+ WebKit behavior): inherit anywhere through the whole transcript so the
28
+ block side agrees. Prose is untouched until something would overflow;
29
+ the table is already pinned wide by min-width: max-content below. */
30
+ [data-chat-flow],
31
+ [class*="_markdown_"] {
32
+ overflow-wrap: anywhere;
33
+ }
34
+
35
+ /* Markdown tables: the official _tableScroll_ wrapper is already a
36
+ horizontal scroll viewport around a width:max-content table, but its
37
+ cells are capped at min(30vw, 320px) — barely 120px on a phone, which
38
+ crushes every column into a vertical word-stack. Let columns size to
39
+ their content instead and read the overflow by scrolling the wrapper.
40
+ 480px (not a vw cap — 85vw was ~320px on a 375px phone and still
41
+ wrapped mid-length cells) keeps typical cells on one line; only truly
42
+ prose-heavy cells wrap, instead of dragging the table kilometers
43
+ wide. */
44
+ [class*="_tableScroll_"] th,
45
+ [class*="_tableScroll_"] td {
46
+ max-width: 480px !important;
47
+ }
48
+
49
+ /* iOS WebKit fallback: upstream's own table { width: max-content } is what
50
+ keeps the table at its natural width inside the scroll wrapper, but
51
+ WebKit's max-content support on table boxes is incomplete — on a real
52
+ iPhone the table fell back to shrink-to-fit and every column got
53
+ crushed to its min-width (the "ra/nk" vertical-word-stack screenshot,
54
+ 2026-08-18). Redeclare the natural-width intent with both width and
55
+ min-width so whichever keyword the engine honors wins; the wrapper
56
+ already scrolls the overflow. */
57
+ [class*="_tableScroll_"] table {
58
+ width: max-content !important;
59
+ min-width: max-content !important;
60
+ max-width: none !important;
61
+ }
62
+
63
+ /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
64
+ 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
65
+ [data-time-hover-root] gate holds them at opacity 0 until :hover /
66
+ :focus-within) — a phone never hovers, so they were simply invisible;
67
+ and even ignoring that, the row is nowrap + overflow:hidden (plus this
68
+ plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
69
+ text. On phones: always visible, own full-width line under the action
70
+ icons, wrapping freely, one type size down. !important + the extra
71
+ [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
72
+ /* Pin the tail row's width chain before allowing wrap: with wrap enabled
73
+ the row's intrinsic (shrink-to-fit) width collapses to its widest
74
+ single icon, taking the whole tail down to ~16px. */
75
+ [data-chat-flow-kind="turn-tail"] [class$="_root"],
76
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
77
+ width: 100%;
78
+ }
79
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
80
+ flex-wrap: wrap;
81
+ row-gap: 2px;
82
+ }
83
+ /* Wide companion to the rule below: mirrors the upstream hover-gate
84
+ selector shape directly, with no assumptions about the tail's DOM
85
+ structure, so the stats can't stay invisible even if the phone's tail
86
+ renders differently than desktop. */
87
+ [data-time-hover-root] [class$="_timeEnd"],
88
+ [data-time-hover-root] [class$="_timeStart"] {
89
+ opacity: 1 !important;
90
+ transition: none !important;
91
+ }
92
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
93
+ opacity: 1 !important;
94
+ flex-basis: 100% !important;
95
+ white-space: normal !important;
96
+ overflow: visible !important;
97
+ text-overflow: clip !important;
98
+ padding-left: 0 !important;
99
+ font-size: 12px;
100
+ line-height: 18px;
101
+ }
102
+ }
103
+ `
@@ -8,6 +8,7 @@ import { COMPOSER_CSS } from './composer.css.ts'
8
8
  import { INFO_CSS } from './info.css.ts'
9
9
  import { TURN_FOLD_CSS } from './turn-fold.css.ts'
10
10
  import { CHIPS_CSS } from './chips.css.ts'
11
+ import { CONTENT_CSS } from './content.css.ts'
11
12
 
12
13
  /**
13
14
  * All mobile styles, concatenated in the exact order of the original
@@ -19,7 +20,8 @@ import { CHIPS_CSS } from './chips.css.ts'
19
20
  * win that tie too), the turn-process fold (turn-fold, S8, which shares no
20
21
  * selector with any of them), and finally the chips row / settings entry /
21
22
  * portal fix (chips, S5) — all appended in this order so their <768px rules
22
- * win ties against the shared <=1023px block. Injected as ONE
23
- * <style data-plugin> tag do not reorder.
23
+ * win ties against the shared <=1023px block, then the chat markdown
24
+ * readability overrides (content, which shares no selector with any of
25
+ * them). Injected as ONE <style data-plugin> tag — do not reorder.
24
26
  */
25
- export const MOBILE_CSS = [BASE_CSS, LAYOUT_CSS, COMPAT_CSS, MISC_CSS, HOME_CSS, HEADER_CSS, COMPOSER_CSS, INFO_CSS, TURN_FOLD_CSS, CHIPS_CSS].join('\n')
27
+ export const MOBILE_CSS = [BASE_CSS, LAYOUT_CSS, COMPAT_CSS, MISC_CSS, HOME_CSS, HEADER_CSS, COMPOSER_CSS, INFO_CSS, TURN_FOLD_CSS, CHIPS_CSS, CONTENT_CSS].join('\n')