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-5B4CF0?style=flat-square" alt="v1.1.6">
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.6" // 本地开发换成 "link:/path/to/dsh-zen-remote"
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`. Still written
5547
- * per element for the one case the stylesheet cannot express on its own: an
5548
- * assistant-step row whose entire body is reasoning, which has to go as a
5549
- * whole row (see {@link foldsWholeRow}). */
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
- for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
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(schedule);
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
- if (desktopOptIn())
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; a late enable just attaches then. Not
5819
- // awaited before phone attach folding must not wait on a round-trip.
5820
- else
5821
- void desktopConfigured().then((on) => (on ? enableDesktop() : undefined));
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
- * One case stays with the effect: an assistant-step row whose whole body is
5875
- * reasoning has to be hidden as a row (an emptied-out flex item still claims
5876
- * the flow column's 16px gap), and "this row has nothing but Think in it" is
5877
- * not a selector. Its rows therefore still fold a frame late as blank
5878
- * space, not as content, since the Think rows below are hidden here.
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
- `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"] ${turn_fold_ts_1.THINK}:not([${turn_fold_ts_1.OPEN}])`,
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
- /* Folded process: tool-call / context / command rows and the Think
5887
- disclosures inside an assistant step. !important because the official
5888
- flow item and ReasoningRow both set their own display. */
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;AA0C3E;;;;;;;sDAOsD;AACtD,eAAO,MAAM,aAAa,8CAA+C,CAAA;AAMzE;sEACsE;AACtE,eAAO,MAAM,KAAK,6BAA2B,CAAA;AAO7C,4DAA4D;AAC5D,eAAO,MAAM,IAAI,wBAAwB,CAAA;AAyFzC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA0JxD"}
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":"AAsIA,eAAO,MAAM,aAAa,QAyBzB,CAAA"}
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.6",
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`. Still written
62
- * per element for the one case the stylesheet cannot express on its own: an
63
- * assistant-step row whose entire body is reasoning, which has to go as a
64
- * whole row (see {@link foldsWholeRow}). */
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
- for (const stale of flow.querySelectorAll(`[${FOLD}]`)) {
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(schedule)
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
- if (desktopOptIn()) enableDesktop()
321
- // The row config arrives async; a late enable just attaches then. Not
322
- // awaited before phone attach — folding must not wait on a round-trip.
323
- else void desktopConfigured().then((on) => (on ? enableDesktop() : undefined))
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
- * One case stays with the effect: an assistant-step row whose whole body is
41
- * reasoning has to be hidden as a row (an emptied-out flex item still claims
42
- * the flow column's 16px gap), and "this row has nothing but Think in it" is
43
- * not a selector. Its rows therefore still fold a frame late as blank
44
- * space, not as content, since the Think rows below are hidden here.
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
- `html[${ACTIVE}] [data-chat-flow] > [data-chat-flow-kind="assistant-step"] ${THINK}:not([${OPEN}])`,
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
- /* Folded process: tool-call / context / command rows and the Think
54
- disclosures inside an assistant step. !important because the official
55
- flow item and ReasoningRow both set their own display. */
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
  }