dsh-zen-remote 2.3.4 → 2.3.5

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
@@ -66,7 +66,7 @@ dsh plugin --profile web add dsh-zen-remote
66
66
  // ~/.dsh/profiles/desktop/package.json
67
67
  {
68
68
  "dependencies": {
69
- "dsh-zen-remote": "^2.3.4" // for local development: "link:/path/to/dsh-zen-remote"
69
+ "dsh-zen-remote": "^2.3.5" // for local development: "link:/path/to/dsh-zen-remote"
70
70
  }
71
71
  }
72
72
  ```
package/README.zh-CN.md CHANGED
@@ -66,7 +66,7 @@ dsh plugin --profile web add dsh-zen-remote
66
66
  // ~/.dsh/profiles/desktop/package.json
67
67
  {
68
68
  "dependencies": {
69
- "dsh-zen-remote": "^2.3.4" // 本地开发换成 "link:/path/to/dsh-zen-remote"
69
+ "dsh-zen-remote": "^2.3.5" // 本地开发换成 "link:/path/to/dsh-zen-remote"
70
70
  }
71
71
  }
72
72
  ```
package/lib/client.js CHANGED
@@ -12363,8 +12363,11 @@ function clientConfig() {
12363
12363
  __modules["client/effects/turn-fold.js"] = function (require, module, exports) {
12364
12364
  "use strict";
12365
12365
  Object.defineProperty(exports, "__esModule", { value: true });
12366
- exports.OPEN = exports.THINK = exports.PROCESS_KINDS = void 0;
12366
+ exports.BLANK = exports.OPEN = exports.THINK = exports.PROCESS_KINDS = void 0;
12367
12367
  exports.foldSuppressionTransition = foldSuppressionTransition;
12368
+ exports.hasContentOutside = hasContentOutside;
12369
+ exports.foldsWholeRow = foldsWholeRow;
12370
+ exports.isBlankStep = isBlankStep;
12368
12371
  exports.installTurnFold = installTurnFold;
12369
12372
  const client_config_ts_1 = require("./client/client-config.js");
12370
12373
  const locales_ts_1 = require("./client/locales.js");
@@ -12464,46 +12467,93 @@ exports.OPEN = 'data-mnav-fold-open';
12464
12467
  const ACTIVE_ATTR = 'data-mnav-fold-on';
12465
12468
  /** Selector of the one row kind that can fold whole — see {@link foldsWholeRow}. */
12466
12469
  const STEP_SELECTOR = '[data-chat-flow-kind="assistant-step"]';
12470
+ /** Elements that show something without a text node of their own. Every other
12471
+ * element is a wrapper: it is content only if something inside it is. */
12472
+ const SELF_VISIBLE = new Set([
12473
+ 'AUDIO', 'CANVAS', 'EMBED', 'HR', 'IFRAME', 'IMG', 'INPUT', 'OBJECT',
12474
+ 'PICTURE', 'SELECT', 'SVG', 'TEXTAREA', 'VIDEO',
12475
+ ]);
12476
+ /**
12477
+ * True when `node` renders anything a reader could see outside the subtrees
12478
+ * that match `skip`: a non-blank text node, or a {@link SELF_VISIBLE} element.
12479
+ * Structural, no class names — same discipline as the rest of this effect.
12480
+ */
12481
+ function hasContentOutside(node, skip) {
12482
+ if (node.nodeType === 3)
12483
+ return (node.textContent ?? '').trim() !== '';
12484
+ if (node.nodeType !== 1)
12485
+ return false;
12486
+ if (skip !== null && node.matches?.(skip) === true)
12487
+ return false;
12488
+ if (SELF_VISIBLE.has((node.tagName ?? '').toUpperCase()))
12489
+ return true;
12490
+ const children = node.childNodes;
12491
+ for (let i = 0; i < children.length; i++) {
12492
+ const child = children[i];
12493
+ if (child !== undefined && hasContentOutside(child, skip))
12494
+ return true;
12495
+ }
12496
+ return false;
12497
+ }
12467
12498
  /**
12468
12499
  * True when the reasoning rows are the ONLY thing this row renders, so the
12469
12500
  * whole flow item has to go: the flow column is `display: flex` with
12470
12501
  * `gap: 16px`, and an emptied-out flex item still claims its gap — the
12471
12502
  * reader would see the folded steps as a column of blank bands.
12472
- * Structural test, no class names: every ancestor from the reasoning rows'
12473
- * shared parent up to the flow item must be an only child.
12503
+ * Structural test, no class names: outside the reasoning rows the row holds
12504
+ * nothing visible — only wrappers. Each reasoning block sits in its own
12505
+ * ProcessReasoning wrapper, and since DSH 0.2.1-rc.5 a block whose display
12506
+ * text is empty once a leading `<signature>…</signature>` is stripped renders
12507
+ * its ReasoningRow as null and leaves that wrapper empty; an empty wrapper
12508
+ * beside a Think must not keep the row unfolded.
12474
12509
  */
12475
12510
  function foldsWholeRow(row, thinks) {
12476
- const first = thinks[0];
12477
- if (first === undefined)
12478
- return false;
12479
- const body = first.parentElement;
12480
- if (body === null || body.childElementCount !== thinks.length)
12511
+ if (thinks.length === 0)
12481
12512
  return false;
12482
- let node = body;
12483
- while (node !== row) {
12484
- const parent = node.parentElement;
12485
- if (parent === null)
12486
- return false;
12487
- if (parent.childElementCount !== 1)
12488
- return false;
12489
- node = parent;
12490
- }
12491
- return true;
12492
- }
12493
- /** The process items one flow row contributes: the row itself, its reasoning
12494
- * rows, or nothing at all. */
12513
+ return !hasContentOutside(row, exports.THINK);
12514
+ }
12515
+ /**
12516
+ * True for an assistant-step row that renders nothing at all — no Think, no
12517
+ * prose, no image, no notice. DSH 0.2.1-rc.5 produces one for a step whose
12518
+ * only reasoning is a `<signature>…</signature>` (ReasoningRow hides itself,
12519
+ * the wrappers stay). It is not process to count in the chip, and it is not
12520
+ * content either: left alone it is a blank band in the flow (the gap again),
12521
+ * so it is hidden on its own marker ({@link BLANK}) that the chip's open
12522
+ * override never releases. On 0.2.1-rc.2, which renders such a block as an
12523
+ * ordinary Think, the row is not blank and folds exactly as before.
12524
+ */
12525
+ function isBlankStep(row) {
12526
+ return !hasContentOutside(row, null);
12527
+ }
12528
+ /** Marker of a {@link isBlankStep} row; the stylesheet hides it while the
12529
+ * fold is live. Never counted, never opened. */
12530
+ exports.BLANK = 'data-mnav-blank';
12531
+ /** Hold {@link BLANK} on an assistant-step row exactly while it is
12532
+ * {@link isBlankStep} — set the moment it empties, dropped the moment
12533
+ * anything (a Think, prose, an image) arrives in it. */
12534
+ function markBlank(row, thinks) {
12535
+ if (thinks.length === 0 && isBlankStep(row))
12536
+ row.setAttribute(exports.BLANK, '');
12537
+ else
12538
+ row.removeAttribute(exports.BLANK);
12539
+ }
12540
+ /** The process items one flow row contributes — the row itself, its reasoning
12541
+ * rows, or nothing at all — and how many steps they are on the chip: a row
12542
+ * folded whole still counts each reasoning block it holds, so the count does
12543
+ * not depend on how the row had to be hidden. */
12495
12544
  function processItems(row) {
12496
12545
  const kind = row.getAttribute('data-chat-flow-kind');
12497
12546
  if (kind === null)
12498
- return [];
12547
+ return { items: [], steps: 0 };
12499
12548
  if (PROCESS_KIND_SET.has(kind))
12500
- return [row];
12549
+ return { items: [row], steps: 1 };
12501
12550
  if (kind !== 'assistant-step')
12502
- return [];
12551
+ return { items: [], steps: 0 };
12503
12552
  const thinks = [...row.querySelectorAll(exports.THINK)];
12553
+ markBlank(row, thinks);
12504
12554
  if (thinks.length === 0)
12505
- return [];
12506
- return foldsWholeRow(row, thinks) ? [row] : thinks;
12555
+ return { items: [], steps: 0 };
12556
+ return { items: foldsWholeRow(row, thinks) ? [row] : thinks, steps: thinks.length };
12507
12557
  }
12508
12558
  /**
12509
12559
  * The summary row sitting immediately before `anchor`, created if absent.
@@ -12536,10 +12586,12 @@ function groupsOf(flow) {
12536
12586
  if (kind === null)
12537
12587
  continue;
12538
12588
  if (current === undefined || TURN_HEAD_KINDS.has(kind)) {
12539
- current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [] };
12589
+ current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [], steps: 0 };
12540
12590
  groups.push(current);
12541
12591
  }
12542
- current.items.push(...processItems(row));
12592
+ const { items, steps } = processItems(row);
12593
+ current.items.push(...items);
12594
+ current.steps += steps;
12543
12595
  }
12544
12596
  return groups;
12545
12597
  }
@@ -12636,7 +12688,7 @@ function installTurnFold(ctx) {
12636
12688
  chip.dataset['running'] = '';
12637
12689
  else
12638
12690
  delete chip.dataset['running'];
12639
- label(chip, group.items.length, groupRunning);
12691
+ label(chip, group.steps, groupRunning);
12640
12692
  live.add(group.key);
12641
12693
  });
12642
12694
  for (const chip of flow.querySelectorAll(CHIP_SELECTOR)) {
@@ -12659,6 +12711,12 @@ function installTurnFold(ctx) {
12659
12711
  stale.removeAttribute(FOLD);
12660
12712
  stale.removeAttribute(exports.OPEN);
12661
12713
  }
12714
+ // {@link BLANK} is held by groupsOf on every assistant-step row of this
12715
+ // column; anything else carrying it left the column (or changed kind).
12716
+ for (const stale of flow.querySelectorAll(`[${exports.BLANK}]`)) {
12717
+ if (stale.parentElement !== flow || !stale.matches(STEP_SELECTOR))
12718
+ stale.removeAttribute(exports.BLANK);
12719
+ }
12662
12720
  };
12663
12721
  /**
12664
12722
  * Fold (or release) the reasoning-only rows this mutation batch touched,
@@ -12724,6 +12782,9 @@ function installTurnFold(ctx) {
12724
12782
  row.setAttribute(FOLD, '');
12725
12783
  else
12726
12784
  row.removeAttribute(FOLD);
12785
+ // Same frame for the blank row (see {@link isBlankStep}): hidden
12786
+ // before it paints its band, shown before its first content paints.
12787
+ markBlank(row, thinks);
12727
12788
  }
12728
12789
  };
12729
12790
  const schedule = () => {
@@ -12765,6 +12826,8 @@ function installTurnFold(ctx) {
12765
12826
  item.removeAttribute(FOLD);
12766
12827
  item.removeAttribute(exports.OPEN);
12767
12828
  }
12829
+ for (const item of document.querySelectorAll(`[${exports.BLANK}]`))
12830
+ item.removeAttribute(exports.BLANK);
12768
12831
  };
12769
12832
  const attach = () => {
12770
12833
  if (observer !== null)
@@ -12919,6 +12982,15 @@ const rules = (scope) => `
12919
12982
  display: none !important;
12920
12983
  }
12921
12984
 
12985
+ /* An assistant-step row that renders nothing at all (effects/turn-fold.ts
12986
+ isBlankStep — DSH 0.2.1-rc.5 leaves one behind for a step whose only
12987
+ reasoning is a <signature>…</signature>). Not a step: never counted on
12988
+ the chip and never released by the open override, it would only ever
12989
+ paint the flow column's 16px gap as a blank band. Literal mirror of BLANK in effects/turn-fold.ts. */
12990
+ ${scope} [data-chat-flow] > [data-mnav-blank] {
12991
+ display: none !important;
12992
+ }
12993
+
12922
12994
  /* Compact chip, sized like the composer's own pills rather than a button:
12923
12995
  it is a reading affordance in the middle of the message flow, so it has
12924
12996
  to stay quieter than the content around it. The flow column is a flex
@@ -22,6 +22,48 @@ export declare const PROCESS_KINDS: readonly ["context", "tool-call", "command"]
22
22
  export declare const THINK = "[data-variant=\"think\"]";
23
23
  /** Per-item override written while its turn is expanded. */
24
24
  export declare const OPEN = "data-mnav-fold-open";
25
+ /** The DOM surface {@link hasContentOutside} reads — kept this narrow so the
26
+ * verdict is testable off-DOM (test/turn-fold-blank-step.test.cjs). */
27
+ export interface ContentNode {
28
+ readonly nodeType: number;
29
+ readonly textContent: string | null;
30
+ readonly childNodes: ArrayLike<ContentNode>;
31
+ readonly tagName?: string;
32
+ matches?(selector: string): boolean;
33
+ }
34
+ /**
35
+ * True when `node` renders anything a reader could see outside the subtrees
36
+ * that match `skip`: a non-blank text node, or a {@link SELF_VISIBLE} element.
37
+ * Structural, no class names — same discipline as the rest of this effect.
38
+ */
39
+ export declare function hasContentOutside(node: ContentNode, skip: string | null): boolean;
40
+ /**
41
+ * True when the reasoning rows are the ONLY thing this row renders, so the
42
+ * whole flow item has to go: the flow column is `display: flex` with
43
+ * `gap: 16px`, and an emptied-out flex item still claims its gap — the
44
+ * reader would see the folded steps as a column of blank bands.
45
+ * Structural test, no class names: outside the reasoning rows the row holds
46
+ * nothing visible — only wrappers. Each reasoning block sits in its own
47
+ * ProcessReasoning wrapper, and since DSH 0.2.1-rc.5 a block whose display
48
+ * text is empty once a leading `<signature>…</signature>` is stripped renders
49
+ * its ReasoningRow as null and leaves that wrapper empty; an empty wrapper
50
+ * beside a Think must not keep the row unfolded.
51
+ */
52
+ export declare function foldsWholeRow(row: ContentNode, thinks: readonly unknown[]): boolean;
53
+ /**
54
+ * True for an assistant-step row that renders nothing at all — no Think, no
55
+ * prose, no image, no notice. DSH 0.2.1-rc.5 produces one for a step whose
56
+ * only reasoning is a `<signature>…</signature>` (ReasoningRow hides itself,
57
+ * the wrappers stay). It is not process to count in the chip, and it is not
58
+ * content either: left alone it is a blank band in the flow (the gap again),
59
+ * so it is hidden on its own marker ({@link BLANK}) that the chip's open
60
+ * override never releases. On 0.2.1-rc.2, which renders such a block as an
61
+ * ordinary Think, the row is not blank and folds exactly as before.
62
+ */
63
+ export declare function isBlankStep(row: ContentNode): boolean;
64
+ /** Marker of a {@link isBlankStep} row; the stylesheet hides it while the
65
+ * fold is live. Never counted, never opened. */
66
+ export declare const BLANK = "data-mnav-blank";
25
67
  /**
26
68
  * S8 — collapse a turn's process into one summary row (< 768px by default;
27
69
  * every width when the plugin row sets `config.turnFoldDesktop` or a browser
@@ -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,oBAAoB,CAAA;AAuDvD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,oBAAoB,EAAE,OAAO,EAC7B,UAAU,EAAE,OAAO,GAClB,OAAO,GAAG,MAAM,GAAG,MAAM,CAG3B;AAMD;;;;;;;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,CAoPxD"}
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,oBAAoB,CAAA;AAuDvD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,oBAAoB,EAAE,OAAO,EAC7B,UAAU,EAAE,OAAO,GAClB,OAAO,GAAG,MAAM,GAAG,MAAM,CAG3B;AAMD;;;;;;;sDAOsD;AACtD,eAAO,MAAM,aAAa,8CAA+C,CAAA;AAMzE;sEACsE;AACtE,eAAO,MAAM,KAAK,6BAA2B,CAAA;AAS7C,4DAA4D;AAC5D,eAAO,MAAM,IAAI,wBAAwB,CAAA;AAmBzC;uEACuE;AACvE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC,WAAW,CAAC,CAAA;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,OAAO,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAA;CACpC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAWjF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAGnF;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAErD;AAED;gDACgD;AAChD,eAAO,MAAM,KAAK,oBAAoB,CAAA;AA0EtC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA6PxD"}
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"turn-fold.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/turn-fold.css.ts"],"names":[],"mappings":"AAyKA,eAAO,MAAM,aAAa,QAyBzB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "2.3.4",
3
+ "version": "2.3.5",
4
4
  "description": "一个插件、两种角色:主服务端把 DeepSeek Harness 变成手机能安全访问的 PWA(移动端界面、配对码网关、Web Push),并把选定会话共享给已配对的桌面端;子客户端在另一台 DSH 桌面端的侧边栏里远程使用这些会话。One DSH plugin, two roles: the host turns DeepSeek Harness into a phone-reachable PWA (mobile UI, pairing-code gateway behind your own reverse proxy, Web Push) and shares chosen sessions to paired desktops; the client uses those sessions remotely from another DSH desktop app's sidebar.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -116,43 +116,43 @@
116
116
  },
117
117
  "devDependencies": {
118
118
  "@deepseek-ai/cordis": "4.0.5",
119
- "@deepseek-ai/cosmokit": "1.8.5",
120
- "@deepseek-ai/dsh-agent": "0.2.1-rc.4",
121
- "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.4",
122
- "@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.4",
123
- "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.4",
124
- "@deepseek-ai/dsh-brand": "0.2.1-rc.4",
125
- "@deepseek-ai/dsh-client-connection": "0.2.1-rc.4",
126
- "@deepseek-ai/dsh-client-locale": "0.2.1-rc.4",
127
- "@deepseek-ai/dsh-client-store": "0.2.1-rc.4",
128
- "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.4",
129
- "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.4",
130
- "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.4",
131
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.4",
132
- "@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.4",
133
- "@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.4",
134
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.4",
135
- "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.4",
136
- "@deepseek-ai/dsh-llm": "0.2.1-rc.4",
137
- "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.4",
138
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.4",
139
- "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.4",
140
- "@deepseek-ai/dsh-scope": "0.2.1-rc.4",
141
- "@deepseek-ai/dsh-session": "0.2.1-rc.4",
142
- "@deepseek-ai/dsh-session-log-export": "0.2.1-rc.4",
143
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.4",
144
- "@deepseek-ai/dsh-session-query": "0.2.1-rc.4",
145
- "@deepseek-ai/dsh-session-stats": "0.2.1-rc.4",
146
- "@deepseek-ai/dsh-subagent": "0.2.1-rc.4",
147
- "@deepseek-ai/dsh-timeout": "0.2.1-rc.4",
148
- "@deepseek-ai/dsh-token-meter": "0.2.1-rc.4",
149
- "@deepseek-ai/dsh-tools": "0.2.1-rc.4",
150
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.4",
151
- "@deepseek-ai/dsh-util-crypto": "0.2.1-rc.4",
152
- "@deepseek-ai/dsh-util-values": "0.2.1-rc.4",
153
- "@deepseek-ai/dsh-workspace": "0.2.1-rc.4",
119
+ "@deepseek-ai/cosmokit": "1.8.6",
120
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.5",
121
+ "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.5",
122
+ "@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.5",
123
+ "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.5",
124
+ "@deepseek-ai/dsh-brand": "0.2.1-rc.5",
125
+ "@deepseek-ai/dsh-client-connection": "0.2.1-rc.5",
126
+ "@deepseek-ai/dsh-client-locale": "0.2.1-rc.5",
127
+ "@deepseek-ai/dsh-client-store": "0.2.1-rc.5",
128
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.5",
129
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.5",
130
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.5",
131
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.5",
132
+ "@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.5",
133
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.5",
134
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.5",
135
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.5",
136
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.5",
137
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.5",
138
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.5",
139
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.5",
140
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.5",
141
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
142
+ "@deepseek-ai/dsh-session-log-export": "0.2.1-rc.5",
143
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
144
+ "@deepseek-ai/dsh-session-query": "0.2.1-rc.5",
145
+ "@deepseek-ai/dsh-session-stats": "0.2.1-rc.5",
146
+ "@deepseek-ai/dsh-subagent": "0.2.1-rc.5",
147
+ "@deepseek-ai/dsh-timeout": "0.2.1-rc.5",
148
+ "@deepseek-ai/dsh-token-meter": "0.2.1-rc.5",
149
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
150
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.5",
151
+ "@deepseek-ai/dsh-util-crypto": "0.2.1-rc.5",
152
+ "@deepseek-ai/dsh-util-values": "0.2.1-rc.5",
153
+ "@deepseek-ai/dsh-workspace": "0.2.1-rc.5",
154
154
  "@deepseek-ai/schemastery": "3.18.5",
155
- "@types/node": "^26.2.0",
155
+ "@types/node": "^26.6.5",
156
156
  "@types/react": "~18.3.1",
157
157
  "@types/react-dom": "~18.3.0",
158
158
  "typescript": "^6.0.3"
@@ -111,39 +111,97 @@ const ACTIVE_ATTR = 'data-mnav-fold-on'
111
111
  /** Selector of the one row kind that can fold whole — see {@link foldsWholeRow}. */
112
112
  const STEP_SELECTOR = '[data-chat-flow-kind="assistant-step"]'
113
113
 
114
+ /** Elements that show something without a text node of their own. Every other
115
+ * element is a wrapper: it is content only if something inside it is. */
116
+ const SELF_VISIBLE = new Set([
117
+ 'AUDIO', 'CANVAS', 'EMBED', 'HR', 'IFRAME', 'IMG', 'INPUT', 'OBJECT',
118
+ 'PICTURE', 'SELECT', 'SVG', 'TEXTAREA', 'VIDEO',
119
+ ])
120
+
121
+ /** The DOM surface {@link hasContentOutside} reads — kept this narrow so the
122
+ * verdict is testable off-DOM (test/turn-fold-blank-step.test.cjs). */
123
+ export interface ContentNode {
124
+ readonly nodeType: number
125
+ readonly textContent: string | null
126
+ readonly childNodes: ArrayLike<ContentNode>
127
+ readonly tagName?: string
128
+ matches?(selector: string): boolean
129
+ }
130
+
131
+ /**
132
+ * True when `node` renders anything a reader could see outside the subtrees
133
+ * that match `skip`: a non-blank text node, or a {@link SELF_VISIBLE} element.
134
+ * Structural, no class names — same discipline as the rest of this effect.
135
+ */
136
+ export function hasContentOutside(node: ContentNode, skip: string | null): boolean {
137
+ if (node.nodeType === 3) return (node.textContent ?? '').trim() !== ''
138
+ if (node.nodeType !== 1) return false
139
+ if (skip !== null && node.matches?.(skip) === true) return false
140
+ if (SELF_VISIBLE.has((node.tagName ?? '').toUpperCase())) return true
141
+ const children = node.childNodes
142
+ for (let i = 0; i < children.length; i++) {
143
+ const child = children[i]
144
+ if (child !== undefined && hasContentOutside(child, skip)) return true
145
+ }
146
+ return false
147
+ }
148
+
114
149
  /**
115
150
  * True when the reasoning rows are the ONLY thing this row renders, so the
116
151
  * whole flow item has to go: the flow column is `display: flex` with
117
152
  * `gap: 16px`, and an emptied-out flex item still claims its gap — the
118
153
  * reader would see the folded steps as a column of blank bands.
119
- * Structural test, no class names: every ancestor from the reasoning rows'
120
- * shared parent up to the flow item must be an only child.
154
+ * Structural test, no class names: outside the reasoning rows the row holds
155
+ * nothing visible — only wrappers. Each reasoning block sits in its own
156
+ * ProcessReasoning wrapper, and since DSH 0.2.1-rc.5 a block whose display
157
+ * text is empty once a leading `<signature>…</signature>` is stripped renders
158
+ * its ReasoningRow as null and leaves that wrapper empty; an empty wrapper
159
+ * beside a Think must not keep the row unfolded.
121
160
  */
122
- function foldsWholeRow(row: Element, thinks: readonly Element[]): boolean {
123
- const first = thinks[0]
124
- if (first === undefined) return false
125
- const body = first.parentElement
126
- if (body === null || body.childElementCount !== thinks.length) return false
127
- let node: Element = body
128
- while (node !== row) {
129
- const parent = node.parentElement
130
- if (parent === null) return false
131
- if (parent.childElementCount !== 1) return false
132
- node = parent
133
- }
134
- return true
161
+ export function foldsWholeRow(row: ContentNode, thinks: readonly unknown[]): boolean {
162
+ if (thinks.length === 0) return false
163
+ return !hasContentOutside(row, THINK)
135
164
  }
136
165
 
137
- /** The process items one flow row contributes: the row itself, its reasoning
138
- * rows, or nothing at all. */
139
- function processItems(row: Element): readonly Element[] {
166
+ /**
167
+ * True for an assistant-step row that renders nothing at all — no Think, no
168
+ * prose, no image, no notice. DSH 0.2.1-rc.5 produces one for a step whose
169
+ * only reasoning is a `<signature>…</signature>` (ReasoningRow hides itself,
170
+ * the wrappers stay). It is not process to count in the chip, and it is not
171
+ * content either: left alone it is a blank band in the flow (the gap again),
172
+ * so it is hidden on its own marker ({@link BLANK}) that the chip's open
173
+ * override never releases. On 0.2.1-rc.2, which renders such a block as an
174
+ * ordinary Think, the row is not blank and folds exactly as before.
175
+ */
176
+ export function isBlankStep(row: ContentNode): boolean {
177
+ return !hasContentOutside(row, null)
178
+ }
179
+
180
+ /** Marker of a {@link isBlankStep} row; the stylesheet hides it while the
181
+ * fold is live. Never counted, never opened. */
182
+ export const BLANK = 'data-mnav-blank'
183
+
184
+ /** Hold {@link BLANK} on an assistant-step row exactly while it is
185
+ * {@link isBlankStep} — set the moment it empties, dropped the moment
186
+ * anything (a Think, prose, an image) arrives in it. */
187
+ function markBlank(row: Element, thinks: readonly Element[]): void {
188
+ if (thinks.length === 0 && isBlankStep(row)) row.setAttribute(BLANK, '')
189
+ else row.removeAttribute(BLANK)
190
+ }
191
+
192
+ /** The process items one flow row contributes — the row itself, its reasoning
193
+ * rows, or nothing at all — and how many steps they are on the chip: a row
194
+ * folded whole still counts each reasoning block it holds, so the count does
195
+ * not depend on how the row had to be hidden. */
196
+ function processItems(row: Element): { readonly items: readonly Element[], readonly steps: number } {
140
197
  const kind = row.getAttribute('data-chat-flow-kind')
141
- if (kind === null) return []
142
- if (PROCESS_KIND_SET.has(kind)) return [row]
143
- if (kind !== 'assistant-step') return []
198
+ if (kind === null) return { items: [], steps: 0 }
199
+ if (PROCESS_KIND_SET.has(kind)) return { items: [row], steps: 1 }
200
+ if (kind !== 'assistant-step') return { items: [], steps: 0 }
144
201
  const thinks = [...row.querySelectorAll(THINK)]
145
- if (thinks.length === 0) return []
146
- return foldsWholeRow(row, thinks) ? [row] : thinks
202
+ markBlank(row, thinks)
203
+ if (thinks.length === 0) return { items: [], steps: 0 }
204
+ return { items: foldsWholeRow(row, thinks) ? [row] : thinks, steps: thinks.length }
147
205
  }
148
206
 
149
207
  /**
@@ -170,6 +228,8 @@ interface Group {
170
228
  readonly key: string
171
229
  /** Every process element of this turn, in flow order. */
172
230
  readonly items: Element[]
231
+ /** Steps the chip announces (see {@link processItems}). */
232
+ steps: number
173
233
  }
174
234
 
175
235
  /** Split the flow column into turns: a group runs from one user/steering row
@@ -183,10 +243,12 @@ function groupsOf(flow: Element): readonly Group[] {
183
243
  const kind = row.getAttribute('data-chat-flow-kind')
184
244
  if (kind === null) continue
185
245
  if (current === undefined || TURN_HEAD_KINDS.has(kind)) {
186
- current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [] }
246
+ current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [], steps: 0 }
187
247
  groups.push(current)
188
248
  }
189
- current.items.push(...processItems(row))
249
+ const { items, steps } = processItems(row)
250
+ current.items.push(...items)
251
+ current.steps += steps
190
252
  }
191
253
  return groups
192
254
  }
@@ -279,7 +341,7 @@ export function installTurnFold(ctx: ClientContext): void {
279
341
  const groupRunning = running && index === groups.length - 1
280
342
  if (groupRunning) chip.dataset['running'] = ''
281
343
  else delete chip.dataset['running']
282
- label(chip, group.items.length, groupRunning)
344
+ label(chip, group.steps, groupRunning)
283
345
  live.add(group.key)
284
346
  })
285
347
 
@@ -302,6 +364,11 @@ export function installTurnFold(ctx: ClientContext): void {
302
364
  stale.removeAttribute(FOLD)
303
365
  stale.removeAttribute(OPEN)
304
366
  }
367
+ // {@link BLANK} is held by groupsOf on every assistant-step row of this
368
+ // column; anything else carrying it left the column (or changed kind).
369
+ for (const stale of flow.querySelectorAll(`[${BLANK}]`)) {
370
+ if (stale.parentElement !== flow || !stale.matches(STEP_SELECTOR)) stale.removeAttribute(BLANK)
371
+ }
305
372
  }
306
373
 
307
374
  /**
@@ -359,6 +426,9 @@ export function installTurnFold(ctx: ClientContext): void {
359
426
  const thinks = [...row.querySelectorAll(THINK)]
360
427
  if (thinks.length > 0 && foldsWholeRow(row, thinks)) row.setAttribute(FOLD, '')
361
428
  else row.removeAttribute(FOLD)
429
+ // Same frame for the blank row (see {@link isBlankStep}): hidden
430
+ // before it paints its band, shown before its first content paints.
431
+ markBlank(row, thinks)
362
432
  }
363
433
  }
364
434
 
@@ -396,6 +466,7 @@ export function installTurnFold(ctx: ClientContext): void {
396
466
  item.removeAttribute(FOLD)
397
467
  item.removeAttribute(OPEN)
398
468
  }
469
+ for (const item of document.querySelectorAll(`[${BLANK}]`)) item.removeAttribute(BLANK)
399
470
  }
400
471
 
401
472
  const attach = (): void => {
@@ -83,6 +83,15 @@ const rules = (scope: string): string => `
83
83
  display: none !important;
84
84
  }
85
85
 
86
+ /* An assistant-step row that renders nothing at all (effects/turn-fold.ts
87
+ isBlankStep — DSH 0.2.1-rc.5 leaves one behind for a step whose only
88
+ reasoning is a <signature>…</signature>). Not a step: never counted on
89
+ the chip and never released by the open override, it would only ever
90
+ paint the flow column's 16px gap as a blank band. Literal mirror of BLANK in effects/turn-fold.ts. */
91
+ ${scope} [data-chat-flow] > [data-mnav-blank] {
92
+ display: none !important;
93
+ }
94
+
86
95
  /* Compact chip, sized like the composer's own pills rather than a button:
87
96
  it is a reading affordance in the middle of the message flow, so it has
88
97
  to stay quieter than the content around it. The flow column is a flex