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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/interface.md +2 -2
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +3 -3
- package/docs/remote-access.md +3 -3
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +185 -48
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/turn-fold.d.ts +42 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +37 -37
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/turn-fold.ts +97 -26
- package/src/client/locales.ts +4 -0
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/client/styles/turn-fold.css.ts +9 -0
- package/src/index.ts +13 -0
- package/src/intercept.ts +46 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- 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.
|
|
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.
|
|
120
|
-
"@deepseek-ai/dsh-agent": "0.2.1-rc.
|
|
121
|
-
"@deepseek-ai/dsh-api-remotes": "0.2.1-rc.
|
|
122
|
-
"@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.
|
|
123
|
-
"@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.
|
|
124
|
-
"@deepseek-ai/dsh-brand": "0.2.1-rc.
|
|
125
|
-
"@deepseek-ai/dsh-client-connection": "0.2.1-rc.
|
|
126
|
-
"@deepseek-ai/dsh-client-locale": "0.2.1-rc.
|
|
127
|
-
"@deepseek-ai/dsh-client-store": "0.2.1-rc.
|
|
128
|
-
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.
|
|
129
|
-
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.
|
|
130
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.
|
|
131
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.
|
|
132
|
-
"@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.
|
|
133
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.
|
|
134
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.
|
|
135
|
-
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.
|
|
136
|
-
"@deepseek-ai/dsh-llm": "0.2.1-rc.
|
|
137
|
-
"@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.
|
|
138
|
-
"@deepseek-ai/dsh-sandbox": "0.2.1-rc.
|
|
139
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.
|
|
140
|
-
"@deepseek-ai/dsh-scope": "0.2.1-rc.
|
|
141
|
-
"@deepseek-ai/dsh-session": "0.2.1-rc.
|
|
142
|
-
"@deepseek-ai/dsh-session-log-export": "0.2.1-rc.
|
|
143
|
-
"@deepseek-ai/dsh-session-projection": "0.2.1-rc.
|
|
144
|
-
"@deepseek-ai/dsh-session-query": "0.2.1-rc.
|
|
145
|
-
"@deepseek-ai/dsh-session-stats": "0.2.1-rc.
|
|
146
|
-
"@deepseek-ai/dsh-subagent": "0.2.1-rc.
|
|
147
|
-
"@deepseek-ai/dsh-timeout": "0.2.1-rc.
|
|
148
|
-
"@deepseek-ai/dsh-token-meter": "0.2.1-rc.
|
|
149
|
-
"@deepseek-ai/dsh-tools": "0.2.1-rc.
|
|
150
|
-
"@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.
|
|
151
|
-
"@deepseek-ai/dsh-util-crypto": "0.2.1-rc.
|
|
152
|
-
"@deepseek-ai/dsh-util-values": "0.2.1-rc.
|
|
153
|
-
"@deepseek-ai/dsh-workspace": "0.2.1-rc.
|
|
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.
|
|
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
|
|
19
|
-
* `/sidebar/ws/sidechat`
|
|
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
|
-
|
|
452
|
-
|
|
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:
|
|
120
|
-
*
|
|
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:
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
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 => {
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
|
88
|
-
|
|
89
|
-
[data-time-hover-root] gate holds
|
|
90
|
-
:focus-within) — a phone never hovers, so
|
|
91
|
-
|
|
92
|
-
plugin's own
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
119
|
-
white-space:
|
|
120
|
-
overflow:
|
|
121
|
-
|
|
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:
|
|
144
|
-
|
|
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
|