dsh-zen-remote 2.3.4 → 2.3.6

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 (53) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/interface.md +2 -2
  4. package/docs/plugin-bridge.md +2 -2
  5. package/docs/remote-access.en.md +3 -3
  6. package/docs/remote-access.md +3 -3
  7. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +185 -48
  10. package/lib/index.js +13 -0
  11. package/lib/index.js.map +1 -1
  12. package/lib/intercept.js +43 -1
  13. package/lib/intercept.js.map +1 -1
  14. package/lib/plugin-adapter-core.js +30 -1
  15. package/lib/plugin-adapter-core.js.map +1 -1
  16. package/lib/plugin-route-intercept.js +506 -11
  17. package/lib/plugin-route-intercept.js.map +1 -1
  18. package/lib/plugin-tunnel.js +210 -18
  19. package/lib/plugin-tunnel.js.map +1 -1
  20. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  21. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  22. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  23. package/lib/types/client/effects/turn-fold.d.ts +42 -0
  24. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  25. package/lib/types/client/locales.d.ts +2 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/styles/content.css.d.ts +1 -1
  28. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  29. package/lib/types/client/styles/layout.css.d.ts +1 -1
  30. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  31. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/intercept.d.ts +6 -0
  34. package/lib/types/intercept.d.ts.map +1 -1
  35. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  36. package/lib/types/plugin-route-intercept.d.ts +8 -0
  37. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  38. package/lib/types/plugin-tunnel.d.ts +37 -2
  39. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  40. package/package.json +37 -37
  41. package/scripts/check-info-sheet-stacking.mjs +85 -0
  42. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  43. package/src/client/MobileSessionInfo.tsx +29 -2
  44. package/src/client/effects/turn-fold.ts +97 -26
  45. package/src/client/locales.ts +4 -0
  46. package/src/client/styles/content.css.ts +44 -14
  47. package/src/client/styles/layout.css.ts +9 -3
  48. package/src/client/styles/turn-fold.css.ts +9 -0
  49. package/src/index.ts +13 -0
  50. package/src/intercept.ts +46 -0
  51. package/src/plugin-adapter-core.ts +29 -2
  52. package/src/plugin-route-intercept.ts +516 -12
  53. package/src/plugin-tunnel.ts +230 -20
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.6",
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"
@@ -14,6 +14,9 @@
14
14
  // Run: node scripts/check-info-sheet-stacking.mjs (needs Node >= 23.6 type stripping)
15
15
  import assert from 'node:assert/strict'
16
16
  import { INFO_CSS } from '../src/client/styles/info.css.ts'
17
+ import { CONTENT_CSS } from '../src/client/styles/content.css.ts'
18
+ import { LAYOUT_CSS } from '../src/client/styles/layout.css.ts'
19
+ import { en, zh } from '../src/client/locales.ts'
17
20
 
18
21
  /** Host z-index values this sheet has to out-rank (dsh-client-ui-conversation). */
19
22
  const HOST_COMPOSER_SEAT = 7
@@ -52,4 +55,86 @@ assert.ok(
52
55
  'the header promotion escaped the (max-width: 767px) block — it would change the desktop layout',
53
56
  )
54
57
 
58
+ // ---- UI-1 (2026-10-30): turn-tail clock + info-card stat cells -------------
59
+ // Lives here rather than in its own script because package.json's test line
60
+ // is an explicit list, and the info-card half of UI-1 is this sheet's own
61
+ // content. Both halves regress silently (CSS still parses, the card still
62
+ // opens), so they get the same loud-failure treatment as the stacking rule.
63
+
64
+ /** Body of the single rule whose selector line matches `selector` exactly. */
65
+ const ruleBody = (css, selector, label) => {
66
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
+ const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`).exec(css)
68
+ assert.ok(match, `${label}: rule \`${selector}\` is gone`)
69
+ return match[1]
70
+ }
71
+
72
+ // DSH 0.2.x wraps the usage button and the 24h clock in one non-wrapping
73
+ // inline-flex _endInfo. The 0.1.x-era rule (flex-basis: 100% + white-space:
74
+ // normal on the clock) squeezed "18:06" onto two lines inside it.
75
+ const tailTime = ruleBody(
76
+ CONTENT_CSS,
77
+ '[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"]',
78
+ 'content.css.ts',
79
+ )
80
+ assert.ok(
81
+ /white-space:\s*nowrap\s*!important/.test(tailTime),
82
+ 'turn-tail _timeEnd must declare white-space: nowrap !important — the clock may never break mid-"18:06"',
83
+ )
84
+ assert.ok(
85
+ !/flex-basis:\s*100%/.test(tailTime),
86
+ 'turn-tail _timeEnd still carries flex-basis: 100% — the 0.1.x "own line" clamp that squeezes the clock inside _endInfo',
87
+ )
88
+ assert.ok(
89
+ !/white-space:\s*normal/.test(tailTime),
90
+ 'turn-tail _timeEnd still carries white-space: normal — this is what let the clock wrap',
91
+ )
92
+ assert.ok(
93
+ /flex:\s*none\s*!important/.test(tailTime),
94
+ 'turn-tail _timeEnd must be flex: none — a shrinkable clock is the squeeze itself',
95
+ )
96
+
97
+ // The other half of the root cause: layout.css.ts used to make the clock a
98
+ // shrinkable flex item (flex: 0 1 auto + min-width: 0) for every message row.
99
+ const rowTime = ruleBody(LAYOUT_CSS, '[data-phase] [class$="_actions"] [class$="_timeEnd"]', 'layout.css.ts')
100
+ assert.ok(/flex:\s*none\s*!important/.test(rowTime), 'layout.css.ts _timeEnd must be flex: none !important')
101
+ assert.ok(
102
+ !/min-width:\s*0/.test(rowTime) && !/flex:\s*0 1 auto/.test(rowTime),
103
+ 'layout.css.ts _timeEnd is shrinkable again (min-width: 0 / flex: 0 1 auto) — the clock will be squeezed inside _endInfo',
104
+ )
105
+
106
+ // _endInfo wraps as a unit, and the row may grow instead of clipping.
107
+ const endInfo = ruleBody(
108
+ CONTENT_CSS,
109
+ '[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"]',
110
+ 'content.css.ts',
111
+ )
112
+ assert.ok(/flex:\s*none/.test(endInfo), 'turn-tail _endInfo must be flex: none so the usage + clock group wraps whole')
113
+ const tailRows = [...CONTENT_CSS.matchAll(/\n\s*\[data-chat-flow-kind="turn-tail"\] \[class\$="_actions"\]\s*\{([^}]*)\}/g)]
114
+ const tailRow = tailRows.map((m) => m[1]).find((body) => /flex-wrap/.test(body))
115
+ assert.ok(tailRow, 'content.css.ts lost the wrapping turn-tail _actions rule')
116
+ assert.ok(
117
+ /height:\s*auto\s*!important/.test(tailRow) && /overflow:\s*visible\s*!important/.test(tailRow),
118
+ 'turn-tail _actions must release the host fixed height (height: auto) and layout.css.ts overflow: hidden (overflow: visible), or a wrapped second line is cut off',
119
+ )
120
+
121
+ // Desktop no-op: every turn-tail rule above has to sit inside the phone block.
122
+ const contentPhone = /@media \(max-width: 767px\) \{/.exec(CONTENT_CSS)
123
+ assert.ok(contentPhone, 'content.css.ts lost its phone media query')
124
+ assert.ok(
125
+ CONTENT_CSS.indexOf('[data-chat-flow-kind="turn-tail"]') > contentPhone.index,
126
+ 'a turn-tail rule escaped the (max-width: 767px) block — it would change the desktop layout',
127
+ )
128
+
129
+ // Info-card stat cells: turns + steps merged into one cell, output speed in
130
+ // the freed slot. Both dictionaries must carry the keys or t() prints the key.
131
+ for (const [name, dict] of [['zh', zh], ['en', en]]) {
132
+ for (const key of ['infoStatTurnsSteps', 'infoStatSpeed']) {
133
+ assert.ok(
134
+ typeof dict[key] === 'string' && dict[key].length > 0,
135
+ `locales.ts ${name} is missing '${key}' — the info card would render the raw key as a label`,
136
+ )
137
+ }
138
+ }
139
+
55
140
  console.log('check-info-sheet-stacking: ok')
@@ -15,8 +15,11 @@
15
15
  * model spend (commit-message generation), documented as such; NO path
16
16
  * fencing; `open.external` refuses for a remote session; `settings.*` and
17
17
  * `/sidebar/bundle` stay local; the adapter registers as whole-machine
18
- * data (consent per plugin, default off). PB-15: the 0.26.0 WebSocket
19
- * `/sidebar/ws/sidechat` is deliberately NOT declared (not bridged).
18
+ * data (consent per plugin, default off). PB-15 left the 0.26.0 WebSocket
19
+ * `/sidebar/ws/sidechat` undeclared; PB-26b bridges it — its ids ride the
20
+ * post-handshake JSON frames (`{"op":"watch","threadIds":[...]}`), which
21
+ * the json-body upgrade gate locates, classifies and rewrites frame by
22
+ * frame on both halves.
20
23
  *
21
24
  * The `cwd` handling is the one subtle part: better-sidebar resolves a
22
25
  * request's working directory as the session's own `header.cwd` first and
@@ -147,6 +150,16 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
147
150
  // file paths, not session ids).
148
151
  { path: '/sidebar/ws/agent-opens', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
149
152
  { path: '/sidebar/ws/fs-watch', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
153
+ // The side-chat push feed (PB-26b; lib/index.js's third registerUpgrade):
154
+ // the upgrade URL carries NO query — the browser's first frame after the
155
+ // handshake is `{"op":"watch","threadIds":[...]}` and the plugin pushes
156
+ // `{"threadId":"..."}` per event. The json-body locator rides the FRAME
157
+ // gate: the client half classifies and rewrites every watch frame (an
158
+ // empty list — unsubscribe-all — crosses untouched), the server's send
159
+ // gate share-checks each frame before the plugin sees it, and the
160
+ // client.response hook below virtualizes the pushed threadId so the
161
+ // browser's `wantedRef.includes(frame.threadId)` comparison fires.
162
+ { path: '/sidebar/ws/sidechat', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'json-body', fields: ['threadIds'] } },
150
163
  ],
151
164
  client: {
152
165
  request: (ctx) => {
@@ -160,6 +173,22 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
160
173
  return { allow: true }
161
174
  },
162
175
  response: (ctx) => {
176
+ // PB-26b: the sidechat ws PUSH frames name their thread on the TOP
177
+ // level — `{"threadId":"<real id>"}`. The browser compares
178
+ // `wantedRef.current.includes(frame.threadId)` against the VIRTUAL ids
179
+ // it watched, so a real id here would silently miss every push; the
180
+ // virtualized form rides in its place. A frame with no usable tid (a
181
+ // non-object, or an id that is already virtual) crosses untouched.
182
+ if (ctx.pathname === '/sidebar/ws/sidechat') {
183
+ if (ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body)) {
184
+ const record = ctx.body as Record<string, unknown>
185
+ const tid = record.threadId
186
+ if (typeof tid === 'string' && tid !== '' && !ctx.isVirtual(tid)) {
187
+ return { ...record, threadId: ctx.virtualize(tid) }
188
+ }
189
+ }
190
+ return ctx.body
191
+ }
163
192
  const name = apiMethodOf(ctx.pathname)
164
193
  if (name === '') return ctx.body
165
194
  const spec = SIDEBAR_METHODS[name]
@@ -100,6 +100,17 @@ function formatDuration(ms: number): string {
100
100
  return `${Math.floor(whole / 60)}m${whole % 60}s`
101
101
  }
102
102
 
103
+ /**
104
+ * Decode-throughput figure: whole tokens from ten up, one decimal below.
105
+ * Ported digit-for-digit from dsh-client-ui-chat lib/client.js
106
+ * formatTokensPerSecond (DSH 0.2.x TurnUsagePanel; verified 2026-10-30),
107
+ * which also owns the "{tps} tok/s" unit the cell appends.
108
+ */
109
+ function formatTokensPerSecond(tps: number): string {
110
+ const clamped = Math.max(0, tps)
111
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
112
+ }
113
+
103
114
  /** Sum of the three disjoint prompt-side billing buckets. */
104
115
  function billedInputTokens(usage: { uncachedInputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }): number {
105
116
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
@@ -448,8 +459,24 @@ export function MobileSessionInfo({
448
459
  }
449
460
 
450
461
  const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
451
- { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
452
- { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
462
+ /* Turns and steps share one cell ("3 / 12") so the grid stays 2×3 with
463
+ output speed taking the freed slot (UI-1, 2026-10-30). */
464
+ {
465
+ label: t('infoStatTurnsSteps'),
466
+ value: stats === undefined ? NA : `${stats.turns} / ${stats.steps}`,
467
+ sub: undefined,
468
+ },
469
+ /* Same guard and formula as the official TurnUsagePanel: decodeMs > 0
470
+ is its "we have a throughput reading" test, so a session with no
471
+ usage-reporting step shows the placeholder, never "0 tok/s". */
472
+ {
473
+ label: t('infoStatSpeed'),
474
+ value:
475
+ stats === undefined || stats.decodeMs <= 0
476
+ ? NA
477
+ : `${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`,
478
+ sub: undefined,
479
+ },
453
480
  {
454
481
  label: t('infoStatTtft'),
455
482
  value: stats === undefined || stats.ttftSteps === 0 ? NA : formatDuration(stats.ttftMs / stats.ttftSteps),
@@ -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 => {
@@ -35,6 +35,8 @@ export const zh = {
35
35
  'activityOpen': '查看子代理与后台任务',
36
36
  'infoStatTurns': '轮次',
37
37
  'infoStatSteps': '步骤',
38
+ 'infoStatTurnsSteps': '轮次 / 步数',
39
+ 'infoStatSpeed': '输出速度',
38
40
  'infoStatTtft': '首字延迟',
39
41
  'infoStatLlm': '模型耗时',
40
42
  'infoStatTool': '工具耗时',
@@ -549,6 +551,8 @@ export const en: Record<MobileNavKey, string> = {
549
551
  'activityOpen': 'View subagents and background tasks',
550
552
  'infoStatTurns': 'Turns',
551
553
  'infoStatSteps': 'Steps',
554
+ 'infoStatTurnsSteps': 'Turns / Steps',
555
+ 'infoStatSpeed': 'Output speed',
552
556
  'infoStatTtft': 'TTFT',
553
557
  'infoStatLlm': 'LLM time',
554
558
  'infoStatTool': 'Tool time',
@@ -84,15 +84,25 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
84
84
  max-width: none !important;
85
85
  }
86
86
 
87
- /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
88
- 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
89
- [data-time-hover-root] gate holds them at opacity 0 until :hover /
90
- :focus-within) — a phone never hovers, so they were simply invisible;
91
- and even ignoring that, the row is nowrap + overflow:hidden (plus this
92
- plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
93
- text. On phones: always visible, own full-width line under the action
94
- icons, wrapping freely, one type size down. !important + the extra
95
- [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
87
+ /* Turn-tail metadata (2026-08-18; reshaped 2026-10-30 for DSH 0.2.x): the
88
+ tail's 24h clock is desktop-hover-revealed (official
89
+ [data-time-hover-root] gate holds it at opacity 0 until :hover /
90
+ :focus-within) — a phone never hovers, so it was simply invisible; and
91
+ the row is a fixed-height nowrap + overflow:hidden flex line (plus this
92
+ plugin's own clamp in layout.css.ts), which clipped it.
93
+
94
+ DSH 0.2.x (MessageIconActions, dsh-client-ui-chat) groups the usage
95
+ button (TurnUsagePanel) and the clock (_timeEnd) inside ONE inline-flex
96
+ _endInfo (min-width: 0; align-items: center; gap: 8px; margin-left: 8px)
97
+ that never wraps internally. The 0.1.x-era rule here gave _timeEnd
98
+ flex-basis: 100% + white-space: normal — inside _endInfo that no longer
99
+ reads as "own full-width line", it reads as "shrink to nothing and
100
+ break", and with the transcript-wide overflow-wrap: anywhere above,
101
+ "18:06" rendered as two lines. On phones now: always visible; the row
102
+ may grow in height and wrap; _endInfo drops to the next line as one
103
+ unit when icons + usage + clock do not fit; the clock itself never
104
+ shrinks or breaks. !important + the extra [class$="_actions"] hop
105
+ out-specifies both upstream and layout.css.ts. */
96
106
  /* Pin the tail row's width chain before allowing wrap: with wrap enabled
97
107
  the row's intrinsic (shrink-to-fit) width collapses to its widest
98
108
  single icon, taking the whole tail down to ~16px. */
@@ -100,9 +110,26 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
100
110
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
101
111
  width: 100%;
102
112
  }
113
+ /* height: auto releases the upstream fixed height: calc(28px + …);
114
+ overflow: visible beats layout.css.ts's overflow: hidden (same
115
+ specificity — this file is concatenated last, so it wins the tie). A
116
+ wrapped second line must not be cut off vertically; min-height keeps
117
+ the one-line case at the upstream height so nothing shifts when it
118
+ fits. */
103
119
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
104
120
  flex-wrap: wrap;
105
121
  row-gap: 2px;
122
+ height: auto !important;
123
+ min-height: 28px;
124
+ overflow: visible !important;
125
+ }
126
+ /* _endInfo moves as a whole: flex: none so the usage + clock group is
127
+ never asked to shrink — when the row is too narrow the entire group
128
+ wraps to the next line instead of compressing the clock inside it.
129
+ max-width keeps an unusually long usage label inside the column. */
130
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"] {
131
+ flex: none;
132
+ max-width: 100%;
106
133
  }
107
134
  /* Wide companion to the rule below: mirrors the upstream hover-gate
108
135
  selector shape directly, with no assumptions about the tail's DOM
@@ -113,13 +140,16 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
113
140
  opacity: 1 !important;
114
141
  transition: none !important;
115
142
  }
143
+ /* overflow-wrap / word-break: normal undo the [data-chat-flow]
144
+ overflow-wrap: anywhere inherited from the top of this block — that is
145
+ the rule that let "18:06" break between digits once its box was
146
+ squeezed. */
116
147
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
117
148
  opacity: 1 !important;
118
- flex-basis: 100% !important;
119
- white-space: normal !important;
120
- overflow: visible !important;
121
- text-overflow: clip !important;
122
- padding-left: 0 !important;
149
+ flex: none !important;
150
+ white-space: nowrap !important;
151
+ overflow-wrap: normal !important;
152
+ word-break: normal !important;
123
153
  font-size: 12px;
124
154
  line-height: 18px;
125
155
  }
@@ -139,12 +139,18 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
139
139
  [data-phase] [class$="_actions"] {
140
140
  overflow: hidden !important;
141
141
  }
142
+ /* The 24h clock ("18:06" / "MM-DD 18:06") is a fixed-size item: it must
143
+ never be the thing that shrinks. DSH 0.2.x wraps it with the usage
144
+ button in an inline-flex _endInfo (min-width: 0), so a shrinkable
145
+ time (the old flex: 0 1 auto + min-width: 0) got squeezed inside that
146
+ non-wrapping box and "18:06" broke into two lines. flex: none pins its
147
+ width; the turn-tail rules in content.css.ts let _endInfo wrap as a
148
+ whole instead. */
142
149
  [data-phase] [class$="_actions"] [class$="_timeEnd"] {
143
- flex: 0 1 auto !important;
144
- min-width: 0 !important;
150
+ flex: none !important;
151
+ white-space: nowrap !important;
145
152
  overflow: hidden !important;
146
153
  text-overflow: ellipsis !important;
147
- white-space: nowrap !important;
148
154
  }
149
155
 
150
156
  [data-phase] [class$="_scroll"]:has(p) {
@@ -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
package/src/index.ts CHANGED
@@ -743,6 +743,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
743
743
  // best-effort bookkeeping — it must never be able to fail the
744
744
  // creation that triggered it.
745
745
  try {
746
+ // PB-26a: record the subagent→parent link the moment the child
747
+ // exists — for EVERY source. A sidechat child (better-sidebar's
748
+ // sidechat.start → agents.create) rides no workspace and writes
749
+ // no catalog; until its first session/event the index knew
750
+ // nothing of it, so the relay answered not-shared for a session
751
+ // whose share it inherits through its parent, and the forwarded
752
+ // status frames the client needs to announce the row never left.
753
+ parentIndex.observe(agent.session)
746
754
  if (source !== 'startup') return
747
755
  onSessionCreated(agent.session.header, {
748
756
  store,
@@ -1578,6 +1586,11 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1578
1586
  // answer — the rewriteIds deep scan rewrites an id that already sits
1579
1587
  // in the browser's view even when this request never carried it.
1580
1588
  getKnownRealIds: () => knownRealIds,
1589
+ // PB-26a: a response rewrite that minted a virtual id (a sidechat
1590
+ // child from `rewriteIds.fields` or an adapter's `virtualize`) is the
1591
+ // client's only early notice of a row that rides no workspace/follow
1592
+ // frame — hand it to the summary sync so the sidebar row appears.
1593
+ onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids) },
1581
1594
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1582
1595
  })
1583
1596
  pluginRouteHandle = handle