dsh-zen-remote 1.1.6 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
5
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0B7285?style=flat-square" alt="MIT"></a>
|
|
6
|
-
<img src="https://img.shields.io/badge/release-v1.1.
|
|
6
|
+
<img src="https://img.shields.io/badge/release-v1.1.7-5B4CF0?style=flat-square" alt="v1.1.7">
|
|
7
7
|
<img src="https://img.shields.io/badge/DSH-Web%20Profile-5B4CF0?style=flat-square" alt="DSH Web Profile">
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -39,7 +39,7 @@ dsh plugin add dsh-zen-remote
|
|
|
39
39
|
```jsonc
|
|
40
40
|
{
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"dsh-zen-remote": "^1.1.
|
|
42
|
+
"dsh-zen-remote": "^1.1.7" // 本地开发换成 "link:/path/to/dsh-zen-remote"
|
|
43
43
|
},
|
|
44
44
|
"dsh": { "profile": { "bundles": [
|
|
45
45
|
"@deepseek-ai/dsh-base",
|
package/lib/client.js
CHANGED
|
@@ -5505,6 +5505,14 @@ const PHONE_QUERY = '(max-width: 767px)';
|
|
|
5505
5505
|
* `=0` opts back out). */
|
|
5506
5506
|
const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop';
|
|
5507
5507
|
const DESKTOP_PARAM = 'mobile-nav-turn-fold';
|
|
5508
|
+
/** Last answer the row config gave. The fetch is a round-trip and the phone
|
|
5509
|
+
* breakpoint is not, so a desktop reader used to watch a whole session render
|
|
5510
|
+
* unfolded and then collapse at once, every page load (issue #3). Remembering
|
|
5511
|
+
* the answer switches the fold on before first paint from the second load on.
|
|
5512
|
+
* Enable-only: turning the row knob back off clears this, so that load still
|
|
5513
|
+
* folds and the next one does not — a stale fold is a chip click away, a
|
|
5514
|
+
* one-way `disableDesktop` is a state machine nobody asked for. */
|
|
5515
|
+
const DESKTOP_CACHE_KEY = 'dsh-mobile-nav.turn-fold-desktop-seen';
|
|
5508
5516
|
/** Root attribute the stylesheet keys the width-independent rules on. */
|
|
5509
5517
|
const DESKTOP_ATTR = 'data-mnav-desktop-fold';
|
|
5510
5518
|
/** Read (and, when the URL param is present, persist) the per-browser opt-in. */
|
|
@@ -5543,10 +5551,12 @@ const TURN_HEAD_KINDS = new Set(['user', 'steering']);
|
|
|
5543
5551
|
/** ReasoningRow's own marker (lib/client.js:8966) — the Think disclosure
|
|
5544
5552
|
* rendered inside an assistant-step row, beside that step's prose. */
|
|
5545
5553
|
exports.THINK = '[data-variant="think"]';
|
|
5546
|
-
/** Attribute the phone stylesheet turns into `display: none`.
|
|
5547
|
-
*
|
|
5548
|
-
*
|
|
5549
|
-
*
|
|
5554
|
+
/** Attribute the phone stylesheet turns into `display: none`. Written for the
|
|
5555
|
+
* one case the stylesheet cannot express on its own: an assistant-step row
|
|
5556
|
+
* whose entire body is reasoning, which has to go as a whole row (see
|
|
5557
|
+
* {@link foldsWholeRow}). Because it is the only thing hiding that row, it is
|
|
5558
|
+
* written from {@link markWholeRows} in the observer callback rather than the
|
|
5559
|
+
* scan a frame later — see that function for what the frame cost. */
|
|
5550
5560
|
const FOLD = 'data-mnav-fold';
|
|
5551
5561
|
/** Per-item override written while its turn is expanded. */
|
|
5552
5562
|
exports.OPEN = 'data-mnav-fold-open';
|
|
@@ -5557,6 +5567,8 @@ exports.OPEN = 'data-mnav-fold-open';
|
|
|
5557
5567
|
* flashes in, then folds away" report, 2026-08-22); it also keeps the
|
|
5558
5568
|
* stylesheet a no-op — content visible — if this effect never runs. */
|
|
5559
5569
|
const ACTIVE_ATTR = 'data-mnav-fold-on';
|
|
5570
|
+
/** Selector of the one row kind that can fold whole — see {@link foldsWholeRow}. */
|
|
5571
|
+
const STEP_SELECTOR = '[data-chat-flow-kind="assistant-step"]';
|
|
5560
5572
|
/**
|
|
5561
5573
|
* True when the reasoning rows are the ONLY thing this row renders, so the
|
|
5562
5574
|
* whole flow item has to go: the flow column is `display: flex` with
|
|
@@ -5727,14 +5739,73 @@ function installTurnFold(ctx) {
|
|
|
5727
5739
|
// its row-level fold after prose started arriving in the same DOM node
|
|
5728
5740
|
// (React updates the row in place), hiding the streaming reply until a
|
|
5729
5741
|
// completion re-render rebuilt the row — the "reply only appears when
|
|
5730
|
-
// the turn finishes" phone bug (2026-08-18).
|
|
5731
|
-
|
|
5742
|
+
// the turn finishes" phone bug (2026-08-18). {@link OPEN} needs the
|
|
5743
|
+
// same sweep on its own account: it is what releases a row from the
|
|
5744
|
+
// stylesheet's reasoning rules, so a stale one shows the reasoning of
|
|
5745
|
+
// a turn the reader has since folded.
|
|
5746
|
+
for (const stale of flow.querySelectorAll(`[${FOLD}], [${exports.OPEN}]`)) {
|
|
5732
5747
|
if (liveItems.has(stale))
|
|
5733
5748
|
continue;
|
|
5734
5749
|
stale.removeAttribute(FOLD);
|
|
5735
5750
|
stale.removeAttribute(exports.OPEN);
|
|
5736
5751
|
}
|
|
5737
5752
|
};
|
|
5753
|
+
/**
|
|
5754
|
+
* Fold (or release) the reasoning-only rows this mutation batch touched,
|
|
5755
|
+
* synchronously.
|
|
5756
|
+
*
|
|
5757
|
+
* The full {@link scan} is rAF-coalesced, and React commits after that
|
|
5758
|
+
* frame's rAF phase, so a scan scheduled from a mutation lands one PAINTED
|
|
5759
|
+
* frame later. For everything else that is invisible — the stylesheet
|
|
5760
|
+
* already hid it by the host's own markers before the effect looked. For
|
|
5761
|
+
* the whole-row fold, which nothing but {@link FOLD} hides, the reader saw
|
|
5762
|
+
* that frame: a blank 16px band on the way in (the flow column's gap,
|
|
5763
|
+
* which an emptied flex item still claims), and — worse — the row still
|
|
5764
|
+
* hidden for a frame after prose started arriving into it, so the reply
|
|
5765
|
+
* appeared to stutter (issue #3).
|
|
5766
|
+
*
|
|
5767
|
+
* A MutationObserver callback is a microtask: it runs at the end of the
|
|
5768
|
+
* task that mutated the DOM, before that frame's rendering update. Marking
|
|
5769
|
+
* here is therefore never a frame late, in either direction.
|
|
5770
|
+
*
|
|
5771
|
+
* ponytail: O(rows touched by the batch), not O(rows). If streaming ever
|
|
5772
|
+
* feels heavy, the cheap win is skipping batches whose records are all
|
|
5773
|
+
* characterData — the fold verdict only changes when elements do.
|
|
5774
|
+
*/
|
|
5775
|
+
const markWholeRows = (records) => {
|
|
5776
|
+
const rows = new Set();
|
|
5777
|
+
for (const record of records) {
|
|
5778
|
+
const target = record.target;
|
|
5779
|
+
if (target instanceof Element) {
|
|
5780
|
+
const row = target.closest(STEP_SELECTOR);
|
|
5781
|
+
if (row !== null)
|
|
5782
|
+
rows.add(row);
|
|
5783
|
+
}
|
|
5784
|
+
for (const node of record.addedNodes) {
|
|
5785
|
+
if (!(node instanceof Element))
|
|
5786
|
+
continue;
|
|
5787
|
+
if (node.matches(STEP_SELECTOR))
|
|
5788
|
+
rows.add(node);
|
|
5789
|
+
for (const nested of node.querySelectorAll(STEP_SELECTOR))
|
|
5790
|
+
rows.add(nested);
|
|
5791
|
+
}
|
|
5792
|
+
}
|
|
5793
|
+
for (const row of rows) {
|
|
5794
|
+
// Exactly what {@link groupsOf} walks: a direct child of the chat flow
|
|
5795
|
+
// column. This is the scoping that keeps the effect Chat-only — the
|
|
5796
|
+
// mutation records reach the whole document, so without it a row in
|
|
5797
|
+
// any other view that seats chat nodes would get hidden with no chip
|
|
5798
|
+
// to bring it back. It also drops rows detached since the batch.
|
|
5799
|
+
const parent = row.parentElement;
|
|
5800
|
+
if (parent === null || !parent.matches(FLOW))
|
|
5801
|
+
continue;
|
|
5802
|
+
const thinks = [...row.querySelectorAll(exports.THINK)];
|
|
5803
|
+
if (thinks.length > 0 && foldsWholeRow(row, thinks))
|
|
5804
|
+
row.setAttribute(FOLD, '');
|
|
5805
|
+
else
|
|
5806
|
+
row.removeAttribute(FOLD);
|
|
5807
|
+
}
|
|
5808
|
+
};
|
|
5738
5809
|
const schedule = () => {
|
|
5739
5810
|
// The locale subscription below lives outside attach/detach, so this
|
|
5740
5811
|
// guard is what keeps a late dictionary registration from marking up
|
|
@@ -5770,7 +5841,7 @@ function installTurnFold(ctx) {
|
|
|
5770
5841
|
const clear = () => {
|
|
5771
5842
|
for (const chip of document.querySelectorAll(CHIP_SELECTOR))
|
|
5772
5843
|
chip.remove();
|
|
5773
|
-
for (const item of document.querySelectorAll(`[${FOLD}]`)) {
|
|
5844
|
+
for (const item of document.querySelectorAll(`[${FOLD}], [${exports.OPEN}]`)) {
|
|
5774
5845
|
item.removeAttribute(FOLD);
|
|
5775
5846
|
item.removeAttribute(exports.OPEN);
|
|
5776
5847
|
}
|
|
@@ -5779,7 +5850,10 @@ function installTurnFold(ctx) {
|
|
|
5779
5850
|
if (observer !== null)
|
|
5780
5851
|
return;
|
|
5781
5852
|
document.documentElement.setAttribute(ACTIVE_ATTR, '');
|
|
5782
|
-
observer = new MutationObserver(
|
|
5853
|
+
observer = new MutationObserver((records) => {
|
|
5854
|
+
markWholeRows(records);
|
|
5855
|
+
schedule();
|
|
5856
|
+
});
|
|
5783
5857
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
5784
5858
|
document.addEventListener('click', onClick);
|
|
5785
5859
|
scan();
|
|
@@ -5813,12 +5887,22 @@ function installTurnFold(ctx) {
|
|
|
5813
5887
|
if (narrow.matches)
|
|
5814
5888
|
attach();
|
|
5815
5889
|
narrow.addEventListener('change', onChange);
|
|
5816
|
-
|
|
5890
|
+
// Both switches are synchronous reads, so a returning reader folds before
|
|
5891
|
+
// first paint. Not awaited before phone attach either — folding must not
|
|
5892
|
+
// wait on a round-trip.
|
|
5893
|
+
if (desktopOptIn() || localStorage.getItem(DESKTOP_CACHE_KEY) === '1')
|
|
5817
5894
|
enableDesktop();
|
|
5818
|
-
// The row config arrives async
|
|
5819
|
-
//
|
|
5820
|
-
|
|
5821
|
-
|
|
5895
|
+
// The row config still arrives async, and remains the authority: a late
|
|
5896
|
+
// yes attaches then and is remembered, a no forgets. A late answer after
|
|
5897
|
+
// dispose is a no-op inside enableDesktop.
|
|
5898
|
+
void desktopConfigured().then((on) => {
|
|
5899
|
+
if (on) {
|
|
5900
|
+
localStorage.setItem(DESKTOP_CACHE_KEY, '1');
|
|
5901
|
+
enableDesktop();
|
|
5902
|
+
}
|
|
5903
|
+
else
|
|
5904
|
+
localStorage.removeItem(DESKTOP_CACHE_KEY);
|
|
5905
|
+
});
|
|
5822
5906
|
// A language switch changes the chip copy of every already-rendered turn.
|
|
5823
5907
|
const stopLocale = ctx.locale.subscribe(schedule);
|
|
5824
5908
|
return () => {
|
|
@@ -5855,6 +5939,8 @@ exports.TURN_FOLD_CSS = void 0;
|
|
|
5855
5939
|
const turn_fold_ts_1 = require("./effects/turn-fold.js");
|
|
5856
5940
|
/** Mirror of the root attribute effects/turn-fold.ts holds while attached. */
|
|
5857
5941
|
const ACTIVE = 'data-mnav-fold-on';
|
|
5942
|
+
/** One assistant-step row, inside an active fold. */
|
|
5943
|
+
const STEP = `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"]`;
|
|
5858
5944
|
/**
|
|
5859
5945
|
* Born folded — the reason a new tool call no longer blinks into view.
|
|
5860
5946
|
*
|
|
@@ -5871,21 +5957,43 @@ const ACTIVE = 'data-mnav-fold-on';
|
|
|
5871
5957
|
* block is live for exactly the widths the fold is, and hides nothing at all
|
|
5872
5958
|
* when the effect never ran.
|
|
5873
5959
|
*
|
|
5874
|
-
*
|
|
5875
|
-
*
|
|
5876
|
-
*
|
|
5877
|
-
*
|
|
5878
|
-
*
|
|
5960
|
+
* The Think rule reads {@link OPEN} off the ROW as well as off the block:
|
|
5961
|
+
* a row folded whole (see {@link WHOLE_STEP}) is the item the effect marks,
|
|
5962
|
+
* so an expanded turn carries the override on the row and its reasoning
|
|
5963
|
+
* would otherwise stay hidden behind this rule — the reader clicked the chip
|
|
5964
|
+
* and got an empty band (issue #3, 2026-08-25).
|
|
5879
5965
|
*/
|
|
5880
5966
|
const BORN_FOLDED = [
|
|
5881
5967
|
...turn_fold_ts_1.PROCESS_KINDS.map((kind) => `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="${kind}"]:not([${turn_fold_ts_1.OPEN}])`),
|
|
5882
|
-
|
|
5968
|
+
`${STEP}:not([${turn_fold_ts_1.OPEN}]) ${turn_fold_ts_1.THINK}:not([${turn_fold_ts_1.OPEN}])`,
|
|
5883
5969
|
].join(',\n');
|
|
5970
|
+
/*
|
|
5971
|
+
* The one folded item with no selector of its own: an assistant-step row that
|
|
5972
|
+
* holds reasoning and nothing else. It has to go as a ROW — the flow column is
|
|
5973
|
+
* `display: flex` with `gap: 16px`, so a row whose every child is hidden still
|
|
5974
|
+
* claims its gap and the reader sees a blank band — and "this row has nothing
|
|
5975
|
+
* but Think in it" cannot be written as a selector.
|
|
5976
|
+
*
|
|
5977
|
+
* `:has()` looks like it can: "holds reasoning, holds no content block that is
|
|
5978
|
+
* not reasoning". It cannot, safely. Every wrapper between the row and the
|
|
5979
|
+
* blocks is itself a non-reasoning element, so the guard has to name the exact
|
|
5980
|
+
* depth of the blocks — and the real tree is
|
|
5981
|
+
* flowItem > seat > AssistantMarkdown root > body > blocks, one level deeper
|
|
5982
|
+
* than the host's own component suggests, because the slot seat adds a wrapper
|
|
5983
|
+
* the component never sees. A selector pinned to that depth silently stops
|
|
5984
|
+
* matching the day a wrapper moves, with nothing to say why.
|
|
5985
|
+
*
|
|
5986
|
+
* So the row stays with effects/turn-fold.ts, which walks the tree instead of
|
|
5987
|
+
* assuming its shape — but marks it synchronously in the MutationObserver
|
|
5988
|
+
* callback (a microtask, before the frame paints) rather than a rAF later.
|
|
5989
|
+
*/
|
|
5884
5990
|
/** The fold + chip rules, with every selector prefixed by `scope`. */
|
|
5885
5991
|
const rules = (scope) => `
|
|
5886
|
-
/*
|
|
5887
|
-
|
|
5888
|
-
|
|
5992
|
+
/* The reasoning-only row (see above), which effects/turn-fold.ts marks
|
|
5993
|
+
itself because no selector can find it. Everything else is already hidden
|
|
5994
|
+
by BORN_FOLDED, by the host's own markers — this rule is what that one
|
|
5995
|
+
case rides on. !important because the official flow item and ReasoningRow
|
|
5996
|
+
both set their own display. */
|
|
5889
5997
|
${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
|
|
5890
5998
|
display: none !important;
|
|
5891
5999
|
}
|
|
@@ -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;
|
|
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;AAkD3E;;;;;;;sDAOsD;AACtD,eAAO,MAAM,aAAa,8CAA+C,CAAA;AAMzE;sEACsE;AACtE,eAAO,MAAM,KAAK,6BAA2B,CAAA;AAS7C,4DAA4D;AAC5D,eAAO,MAAM,IAAI,wBAAwB,CAAA;AA4FzC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAyNxD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-fold.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/turn-fold.css.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"turn-fold.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/turn-fold.css.ts"],"names":[],"mappings":"AAgKA,eAAO,MAAM,aAAa,QAyBzB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-zen-remote",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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",
|
|
@@ -14,6 +14,14 @@ const PHONE_QUERY = '(max-width: 767px)'
|
|
|
14
14
|
* `=0` opts back out). */
|
|
15
15
|
const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop'
|
|
16
16
|
const DESKTOP_PARAM = 'mobile-nav-turn-fold'
|
|
17
|
+
/** Last answer the row config gave. The fetch is a round-trip and the phone
|
|
18
|
+
* breakpoint is not, so a desktop reader used to watch a whole session render
|
|
19
|
+
* unfolded and then collapse at once, every page load (issue #3). Remembering
|
|
20
|
+
* the answer switches the fold on before first paint from the second load on.
|
|
21
|
+
* Enable-only: turning the row knob back off clears this, so that load still
|
|
22
|
+
* folds and the next one does not — a stale fold is a chip click away, a
|
|
23
|
+
* one-way `disableDesktop` is a state machine nobody asked for. */
|
|
24
|
+
const DESKTOP_CACHE_KEY = 'dsh-mobile-nav.turn-fold-desktop-seen'
|
|
17
25
|
/** Root attribute the stylesheet keys the width-independent rules on. */
|
|
18
26
|
const DESKTOP_ATTR = 'data-mnav-desktop-fold'
|
|
19
27
|
|
|
@@ -58,10 +66,12 @@ const TURN_HEAD_KINDS = new Set(['user', 'steering'])
|
|
|
58
66
|
* rendered inside an assistant-step row, beside that step's prose. */
|
|
59
67
|
export const THINK = '[data-variant="think"]'
|
|
60
68
|
|
|
61
|
-
/** Attribute the phone stylesheet turns into `display: none`.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
69
|
+
/** Attribute the phone stylesheet turns into `display: none`. Written for the
|
|
70
|
+
* one case the stylesheet cannot express on its own: an assistant-step row
|
|
71
|
+
* whose entire body is reasoning, which has to go as a whole row (see
|
|
72
|
+
* {@link foldsWholeRow}). Because it is the only thing hiding that row, it is
|
|
73
|
+
* written from {@link markWholeRows} in the observer callback rather than the
|
|
74
|
+
* scan a frame later — see that function for what the frame cost. */
|
|
65
75
|
const FOLD = 'data-mnav-fold'
|
|
66
76
|
/** Per-item override written while its turn is expanded. */
|
|
67
77
|
export const OPEN = 'data-mnav-fold-open'
|
|
@@ -73,6 +83,9 @@ export const OPEN = 'data-mnav-fold-open'
|
|
|
73
83
|
* stylesheet a no-op — content visible — if this effect never runs. */
|
|
74
84
|
const ACTIVE_ATTR = 'data-mnav-fold-on'
|
|
75
85
|
|
|
86
|
+
/** Selector of the one row kind that can fold whole — see {@link foldsWholeRow}. */
|
|
87
|
+
const STEP_SELECTOR = '[data-chat-flow-kind="assistant-step"]'
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* True when the reasoning rows are the ONLY thing this row renders, so the
|
|
78
91
|
* whole flow item has to go: the flow column is `display: flex` with
|
|
@@ -237,14 +250,67 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
237
250
|
// its row-level fold after prose started arriving in the same DOM node
|
|
238
251
|
// (React updates the row in place), hiding the streaming reply until a
|
|
239
252
|
// completion re-render rebuilt the row — the "reply only appears when
|
|
240
|
-
// the turn finishes" phone bug (2026-08-18).
|
|
241
|
-
|
|
253
|
+
// the turn finishes" phone bug (2026-08-18). {@link OPEN} needs the
|
|
254
|
+
// same sweep on its own account: it is what releases a row from the
|
|
255
|
+
// stylesheet's reasoning rules, so a stale one shows the reasoning of
|
|
256
|
+
// a turn the reader has since folded.
|
|
257
|
+
for (const stale of flow.querySelectorAll(`[${FOLD}], [${OPEN}]`)) {
|
|
242
258
|
if (liveItems.has(stale)) continue
|
|
243
259
|
stale.removeAttribute(FOLD)
|
|
244
260
|
stale.removeAttribute(OPEN)
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Fold (or release) the reasoning-only rows this mutation batch touched,
|
|
266
|
+
* synchronously.
|
|
267
|
+
*
|
|
268
|
+
* The full {@link scan} is rAF-coalesced, and React commits after that
|
|
269
|
+
* frame's rAF phase, so a scan scheduled from a mutation lands one PAINTED
|
|
270
|
+
* frame later. For everything else that is invisible — the stylesheet
|
|
271
|
+
* already hid it by the host's own markers before the effect looked. For
|
|
272
|
+
* the whole-row fold, which nothing but {@link FOLD} hides, the reader saw
|
|
273
|
+
* that frame: a blank 16px band on the way in (the flow column's gap,
|
|
274
|
+
* which an emptied flex item still claims), and — worse — the row still
|
|
275
|
+
* hidden for a frame after prose started arriving into it, so the reply
|
|
276
|
+
* appeared to stutter (issue #3).
|
|
277
|
+
*
|
|
278
|
+
* A MutationObserver callback is a microtask: it runs at the end of the
|
|
279
|
+
* task that mutated the DOM, before that frame's rendering update. Marking
|
|
280
|
+
* here is therefore never a frame late, in either direction.
|
|
281
|
+
*
|
|
282
|
+
* ponytail: O(rows touched by the batch), not O(rows). If streaming ever
|
|
283
|
+
* feels heavy, the cheap win is skipping batches whose records are all
|
|
284
|
+
* characterData — the fold verdict only changes when elements do.
|
|
285
|
+
*/
|
|
286
|
+
const markWholeRows = (records: readonly MutationRecord[]): void => {
|
|
287
|
+
const rows = new Set<Element>()
|
|
288
|
+
for (const record of records) {
|
|
289
|
+
const target = record.target
|
|
290
|
+
if (target instanceof Element) {
|
|
291
|
+
const row = target.closest(STEP_SELECTOR)
|
|
292
|
+
if (row !== null) rows.add(row)
|
|
293
|
+
}
|
|
294
|
+
for (const node of record.addedNodes) {
|
|
295
|
+
if (!(node instanceof Element)) continue
|
|
296
|
+
if (node.matches(STEP_SELECTOR)) rows.add(node)
|
|
297
|
+
for (const nested of node.querySelectorAll(STEP_SELECTOR)) rows.add(nested)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
// Exactly what {@link groupsOf} walks: a direct child of the chat flow
|
|
302
|
+
// column. This is the scoping that keeps the effect Chat-only — the
|
|
303
|
+
// mutation records reach the whole document, so without it a row in
|
|
304
|
+
// any other view that seats chat nodes would get hidden with no chip
|
|
305
|
+
// to bring it back. It also drops rows detached since the batch.
|
|
306
|
+
const parent = row.parentElement
|
|
307
|
+
if (parent === null || !parent.matches(FLOW)) continue
|
|
308
|
+
const thinks = [...row.querySelectorAll(THINK)]
|
|
309
|
+
if (thinks.length > 0 && foldsWholeRow(row, thinks)) row.setAttribute(FOLD, '')
|
|
310
|
+
else row.removeAttribute(FOLD)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
const schedule = (): void => {
|
|
249
315
|
// The locale subscription below lives outside attach/detach, so this
|
|
250
316
|
// guard is what keeps a late dictionary registration from marking up
|
|
@@ -275,7 +341,7 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
275
341
|
|
|
276
342
|
const clear = (): void => {
|
|
277
343
|
for (const chip of document.querySelectorAll(CHIP_SELECTOR)) chip.remove()
|
|
278
|
-
for (const item of document.querySelectorAll(`[${FOLD}]`)) {
|
|
344
|
+
for (const item of document.querySelectorAll(`[${FOLD}], [${OPEN}]`)) {
|
|
279
345
|
item.removeAttribute(FOLD)
|
|
280
346
|
item.removeAttribute(OPEN)
|
|
281
347
|
}
|
|
@@ -284,7 +350,10 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
284
350
|
const attach = (): void => {
|
|
285
351
|
if (observer !== null) return
|
|
286
352
|
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
287
|
-
observer = new MutationObserver(
|
|
353
|
+
observer = new MutationObserver((records) => {
|
|
354
|
+
markWholeRows(records)
|
|
355
|
+
schedule()
|
|
356
|
+
})
|
|
288
357
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
289
358
|
document.addEventListener('click', onClick)
|
|
290
359
|
scan()
|
|
@@ -317,10 +386,17 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
317
386
|
|
|
318
387
|
if (narrow.matches) attach()
|
|
319
388
|
narrow.addEventListener('change', onChange)
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
389
|
+
// Both switches are synchronous reads, so a returning reader folds before
|
|
390
|
+
// first paint. Not awaited before phone attach either — folding must not
|
|
391
|
+
// wait on a round-trip.
|
|
392
|
+
if (desktopOptIn() || localStorage.getItem(DESKTOP_CACHE_KEY) === '1') enableDesktop()
|
|
393
|
+
// The row config still arrives async, and remains the authority: a late
|
|
394
|
+
// yes attaches then and is remembered, a no forgets. A late answer after
|
|
395
|
+
// dispose is a no-op inside enableDesktop.
|
|
396
|
+
void desktopConfigured().then((on) => {
|
|
397
|
+
if (on) { localStorage.setItem(DESKTOP_CACHE_KEY, '1'); enableDesktop() }
|
|
398
|
+
else localStorage.removeItem(DESKTOP_CACHE_KEY)
|
|
399
|
+
})
|
|
324
400
|
// A language switch changes the chip copy of every already-rendered turn.
|
|
325
401
|
const stopLocale = ctx.locale.subscribe(schedule)
|
|
326
402
|
return () => {
|
|
@@ -21,6 +21,9 @@ import { OPEN, PROCESS_KINDS, THINK } from '../effects/turn-fold.ts'
|
|
|
21
21
|
/** Mirror of the root attribute effects/turn-fold.ts holds while attached. */
|
|
22
22
|
const ACTIVE = 'data-mnav-fold-on'
|
|
23
23
|
|
|
24
|
+
/** One assistant-step row, inside an active fold. */
|
|
25
|
+
const STEP = `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"]`
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
28
|
* Born folded — the reason a new tool call no longer blinks into view.
|
|
26
29
|
*
|
|
@@ -37,22 +40,45 @@ const ACTIVE = 'data-mnav-fold-on'
|
|
|
37
40
|
* block is live for exactly the widths the fold is, and hides nothing at all
|
|
38
41
|
* when the effect never ran.
|
|
39
42
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* The Think rule reads {@link OPEN} off the ROW as well as off the block:
|
|
44
|
+
* a row folded whole (see {@link WHOLE_STEP}) is the item the effect marks,
|
|
45
|
+
* so an expanded turn carries the override on the row and its reasoning
|
|
46
|
+
* would otherwise stay hidden behind this rule — the reader clicked the chip
|
|
47
|
+
* and got an empty band (issue #3, 2026-08-25).
|
|
45
48
|
*/
|
|
46
49
|
const BORN_FOLDED = [
|
|
47
50
|
...PROCESS_KINDS.map((kind) => `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="${kind}"]:not([${OPEN}])`),
|
|
48
|
-
|
|
51
|
+
`${STEP}:not([${OPEN}]) ${THINK}:not([${OPEN}])`,
|
|
49
52
|
].join(',\n')
|
|
50
53
|
|
|
54
|
+
/*
|
|
55
|
+
* The one folded item with no selector of its own: an assistant-step row that
|
|
56
|
+
* holds reasoning and nothing else. It has to go as a ROW — the flow column is
|
|
57
|
+
* `display: flex` with `gap: 16px`, so a row whose every child is hidden still
|
|
58
|
+
* claims its gap and the reader sees a blank band — and "this row has nothing
|
|
59
|
+
* but Think in it" cannot be written as a selector.
|
|
60
|
+
*
|
|
61
|
+
* `:has()` looks like it can: "holds reasoning, holds no content block that is
|
|
62
|
+
* not reasoning". It cannot, safely. Every wrapper between the row and the
|
|
63
|
+
* blocks is itself a non-reasoning element, so the guard has to name the exact
|
|
64
|
+
* depth of the blocks — and the real tree is
|
|
65
|
+
* flowItem > seat > AssistantMarkdown root > body > blocks, one level deeper
|
|
66
|
+
* than the host's own component suggests, because the slot seat adds a wrapper
|
|
67
|
+
* the component never sees. A selector pinned to that depth silently stops
|
|
68
|
+
* matching the day a wrapper moves, with nothing to say why.
|
|
69
|
+
*
|
|
70
|
+
* So the row stays with effects/turn-fold.ts, which walks the tree instead of
|
|
71
|
+
* assuming its shape — but marks it synchronously in the MutationObserver
|
|
72
|
+
* callback (a microtask, before the frame paints) rather than a rAF later.
|
|
73
|
+
*/
|
|
74
|
+
|
|
51
75
|
/** The fold + chip rules, with every selector prefixed by `scope`. */
|
|
52
76
|
const rules = (scope: string): string => `
|
|
53
|
-
/*
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
/* The reasoning-only row (see above), which effects/turn-fold.ts marks
|
|
78
|
+
itself because no selector can find it. Everything else is already hidden
|
|
79
|
+
by BORN_FOLDED, by the host's own markers — this rule is what that one
|
|
80
|
+
case rides on. !important because the official flow item and ReasoningRow
|
|
81
|
+
both set their own display. */
|
|
56
82
|
${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
|
|
57
83
|
display: none !important;
|
|
58
84
|
}
|