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 +1 -0
- package/lib/client.js +356 -16
- package/lib/types/client/effects/keyboard-guard.d.ts +27 -0
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/effects/welcome-notice.d.ts +20 -0
- package/lib/types/client/effects/welcome-notice.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/styles/base.css.d.ts +1 -1
- package/lib/types/client/styles/base.css.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +2 -0
- package/lib/types/client/styles/content.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +3 -2
- package/lib/types/client/styles/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/pwa/app.css +7 -2
- package/src/client/effects/keyboard-guard.ts +121 -0
- package/src/client/effects/turn-fold.ts +14 -0
- package/src/client/effects/welcome-notice.ts +66 -0
- package/src/client/index.tsx +12 -0
- package/src/client/styles/base.css.ts +28 -13
- package/src/client/styles/content.css.ts +103 -0
- package/src/client/styles/index.ts +5 -3
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
|
|
1696
|
-
User-directed (2026-08-17):
|
|
1697
|
-
including desktop. This is the ONE place this plugin
|
|
1698
|
-
its own ">=1024px strict no-op" rule
|
|
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
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
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
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
|
5003
|
-
*
|
|
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,
|
|
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;
|
|
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
|
|
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,
|
|
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
|
|
12
|
-
*
|
|
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":"
|
|
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.
|
|
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
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
149
|
-
User-directed (2026-08-17):
|
|
150
|
-
including desktop. This is the ONE place this plugin
|
|
151
|
-
its own ">=1024px strict no-op" rule
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
23
|
-
*
|
|
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')
|