dsh-zen-remote 1.0.0 → 1.0.2

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.
Files changed (33) hide show
  1. package/README.md +20 -0
  2. package/docs/interface.md +1 -0
  3. package/lib/client.js +458 -33
  4. package/lib/types/client/effects/keyboard-guard.d.ts +27 -0
  5. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -0
  6. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  7. package/lib/types/client/effects/welcome-notice.d.ts +20 -0
  8. package/lib/types/client/effects/welcome-notice.d.ts.map +1 -0
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/styles/base.css.d.ts +1 -1
  11. package/lib/types/client/styles/base.css.d.ts.map +1 -1
  12. package/lib/types/client/styles/chips.css.d.ts +1 -1
  13. package/lib/types/client/styles/chips.css.d.ts.map +1 -1
  14. package/lib/types/client/styles/compat.css.d.ts +1 -1
  15. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  16. package/lib/types/client/styles/content.css.d.ts +2 -0
  17. package/lib/types/client/styles/content.css.d.ts.map +1 -0
  18. package/lib/types/client/styles/header.css.d.ts +1 -1
  19. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  20. package/lib/types/client/styles/index.d.ts +3 -2
  21. package/lib/types/client/styles/index.d.ts.map +1 -1
  22. package/package.json +7 -9
  23. package/pwa/app.css +7 -2
  24. package/src/client/effects/keyboard-guard.ts +121 -0
  25. package/src/client/effects/turn-fold.ts +14 -0
  26. package/src/client/effects/welcome-notice.ts +66 -0
  27. package/src/client/index.tsx +12 -0
  28. package/src/client/styles/base.css.ts +28 -13
  29. package/src/client/styles/chips.css.ts +10 -10
  30. package/src/client/styles/compat.css.ts +51 -0
  31. package/src/client/styles/content.css.ts +103 -0
  32. package/src/client/styles/header.css.ts +41 -7
  33. package/src/client/styles/index.ts +5 -3
@@ -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
  `
@@ -88,10 +88,14 @@ export const CHIPS_CSS = `/* ---------- chips row + settings entry (S5) --------
88
88
  }
89
89
 
90
90
  /* Plugin-entry chips row: one horizontally-scrolling line of 34px pills
91
- between the workspace title and the session list (prototype spec). A
92
- thin scrollbar is the overflow affordance (AGENTS.md: an early
93
- no-scrollbar version silently cut off the last tab with no way to
94
- discover more content). */
91
+ between the workspace title and the session list (prototype spec).
92
+ Scrollbar hidden on BOTH engines (2026-08-18 user decision): iOS
93
+ overlay scrollbars never showed anyway, but desktop browsers painted a
94
+ permanent 2px strip under the pills. This revisits the AGENTS.md
95
+ "silently cut off the last tab" lesson deliberately — with 4+ chips the
96
+ row now always overflows mid-chip (a partially visible pill is the
97
+ scroll affordance), unlike the early incident where the chips fit
98
+ flush and the hidden overflow was undiscoverable. */
95
99
  [data-mobile-nav="chip-row"] {
96
100
  flex: 0 0 auto;
97
101
  display: flex;
@@ -101,14 +105,10 @@ export const CHIPS_CSS = `/* ---------- chips row + settings entry (S5) --------
101
105
  overflow-x: auto;
102
106
  overscroll-behavior: contain;
103
107
  -webkit-overflow-scrolling: touch;
104
- scrollbar-width: thin;
108
+ scrollbar-width: none;
105
109
  }
106
110
  [data-mobile-nav="chip-row"]::-webkit-scrollbar {
107
- height: 2px;
108
- }
109
- [data-mobile-nav="chip-row"]::-webkit-scrollbar-thumb {
110
- background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .16));
111
- border-radius: 2px;
111
+ display: none;
112
112
  }
113
113
  [data-mobile-nav="chip"],
114
114
  [data-mobile-nav="chip-more"] {
@@ -678,4 +678,55 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
678
678
  background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
679
679
  }
680
680
  }
681
+
682
+ /* ---------- dsh-agent-teams: phone floater reposition (2026-08-18) ----------
683
+ THIRD-PARTY COMPAT RULE — @nanmicoder/dsh-agent-teams (the AgentTeams
684
+ activity floater: a collapsed badge + expandable panel, body-portaled
685
+ under its own mount marker div[data-agent-teams-host]). Its own CSS
686
+ fixes both at top:56px (<=640px) viewport coordinates and never reads
687
+ env(safe-area-inset-*), so on a notched iPhone the badge lands INSIDE
688
+ the safe-area + this plugin's 76px session header band — overlapping
689
+ the info and workbench buttons (real-device report, 2026-08-18).
690
+ Move both below the header instead: safe-area + 76px header chrome
691
+ (48px title row + 28px view-switch row, header.css.ts) + 8px gap.
692
+ Anchored on the plugin's own mount marker + class-suffix selectors per
693
+ this repo's hashed-class convention — when dsh-agent-teams is not
694
+ installed the marker never exists and this whole section is inert
695
+ (same presence-gating as the dsh-better-sidebar rules above).
696
+ !important because their positions come from html-level CSS vars
697
+ (--agent-teams-panel-top et al.) whose style tag order vs ours is not
698
+ guaranteed. z-index: their 2147483000 would float the badge over every
699
+ one of this plugin's own overlays (drawer 40, info sheet 55-57, close
700
+ pill 70); 30 keeps it above content but below all of them. */
701
+ @media (max-width: 767px) {
702
+ [data-agent-teams-host] [class$="_badge"],
703
+ [data-agent-teams-host] [class$="_panel"] {
704
+ top: calc(var(--mnav-sat) + 84px) !important;
705
+ right: 10px !important;
706
+ z-index: 30 !important;
707
+ }
708
+ [data-agent-teams-host] [class$="_panel"] {
709
+ left: 10px !important;
710
+ width: auto !important;
711
+ /* Their min-height:min(560px, calc(100dvh - 56 - 56)) resolves against
712
+ the OLD top var — on short phones it exceeds the space left below
713
+ the moved-down top edge and would force overflow; content-sized with
714
+ a hard cap replaces it. 76px bottom clearance keeps the collapsed
715
+ composer's top edge visible under the panel. */
716
+ min-height: 0 !important;
717
+ max-height: calc(100dvh - var(--mnav-sat) - 84px - var(--mnav-sab) - 76px) !important;
718
+ }
719
+ /* The floater belongs to the SESSION page: on the home list it hovered
720
+ over the session rows (user feedback, 2026-08-18). The phone page
721
+ stack is this plugin's own overlay — the official conversation (and
722
+ with it the floater's "current session") stays mounted underneath, so
723
+ agent-teams keeps rendering it; hide it whenever the home level is the
724
+ visible one (data-view flips to "session" inside a session, and the
725
+ badge/panel come right back). :has() is already a documented
726
+ requirement of this plugin (Chromium 105+, docs/interface.md). */
727
+ body:has([data-mobile-nav="home"][data-view="home"]) [data-agent-teams-host] [class$="_badge"],
728
+ body:has([data-mobile-nav="home"][data-view="home"]) [data-agent-teams-host] [class$="_panel"] {
729
+ display: none !important;
730
+ }
731
+ }
681
732
  `
@@ -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
+ `
@@ -157,21 +157,55 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
157
157
  min-width: 0;
158
158
  overflow: hidden;
159
159
  }
160
- /* Only the current session's own name is the "title" — the parent chain
161
- (subagent breadcrumbs) is the "多余项" the design calls out to hide,
162
- while the current segment (and its running dot, see below) stays. */
163
- [data-phase] header [class$="_crumbSeg"]:not(:last-child) {
160
+ /* Subagent sessions keep ONE ancestor crumb (2026-08-18): the PC header
161
+ lets you tap the parent session's title to switch back — that affordance
162
+ was lost on phone when the whole parent chain was hidden as "多余项".
163
+ Show only the immediate parent (for agent-teams member sessions that IS
164
+ the captain/main session; deeper ancestors stay hidden — a 3-level chain
165
+ cannot fit the ~206px center column at 390px). The parent crumb is the
166
+ official breadcrumb <button> (dsh-client-ui-conversation renders
167
+ open(parentId) as its onClick), so tapping needs no JS from this plugin,
168
+ and main sessions (chain length 1) have no :nth-last-child(2) seg at
169
+ all — the centered single title is pixel-identical to before. */
170
+ [data-phase] header [class$="_crumbSeg"]:not(:nth-last-child(-n+2)) {
164
171
  display: none !important;
165
172
  }
173
+ [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ flex: none;
177
+ min-width: 0;
178
+ max-width: 38%;
179
+ }
180
+ /* Ancestors BEFORE the parent seg are hidden, which would leave the
181
+ parent seg's own leading "/" dangling at the start of the title. */
182
+ [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumbSep"] {
183
+ display: none !important;
184
+ }
185
+ /* Parent crumb: small tertiary text so the 19px current title stays the
186
+ visual anchor; min-height keeps a finger-sized target inside the 48px
187
+ row. Specificity (0,4,1) outranks the shared 19px _crumb rule below. */
188
+ [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumb"] {
189
+ font-size: 13px;
190
+ font-weight: 400;
191
+ color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .45));
192
+ min-height: 44px;
193
+ display: inline-flex;
194
+ align-items: center;
195
+ padding: 0 2px 0 6px;
196
+ touch-action: manipulation;
197
+ -webkit-tap-highlight-color: transparent;
198
+ }
166
199
  [data-phase] header [class$="_crumbSeg"]:last-child {
167
200
  display: flex;
168
201
  align-items: center;
169
202
  min-width: 0;
170
203
  max-width: 100%;
171
204
  }
172
- [data-phase] header [class$="_crumbSeg"]:last-child [class$="_crumbSep"] {
173
- display: none !important;
174
- }
205
+ /* The last seg's own "/" is the divider between parent and current title —
206
+ it only exists when there IS an ancestor (index > 0), so it can simply
207
+ stay visible now that the parent seg shows. (It used to be hidden here:
208
+ with the parent hidden it dangled before the title.) */
175
209
  /* BOTH selectors are required: [class$=] matches the whole class
176
210
  ATTRIBUTE's suffix, and the current-session title button's attribute is
177
211
  "wSkVaW_crumb wSkVaW_crumbCurrent" — it ends in _crumbCurrent, so the _crumb
@@ -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')