dsh-zen-remote 2.3.5 → 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 +84 -19
- 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/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/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 +1 -1
- 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/locales.ts +4 -0
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- 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
|
@@ -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),
|
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) {
|
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
|
package/src/intercept.ts
CHANGED
|
@@ -671,6 +671,12 @@ export interface InterceptHandle {
|
|
|
671
671
|
* re-judges NOW — the mergers dedupe no-change calls — instead of
|
|
672
672
|
* waiting for the next frame or the 1s fallback poll. */
|
|
673
673
|
currentSessionChanged(): void
|
|
674
|
+
/** PB-26a: trigger a summary-sync check for virtual session ids discovered
|
|
675
|
+
* OUTSIDE workspace/follow (a sidechat childId the plugin bridge just
|
|
676
|
+
* minted in a response rewrite, for instance). Empty and non-virtual
|
|
677
|
+
* entries are dropped; a batch with nothing left is a no-op. Reaches every
|
|
678
|
+
* live merged `$events` stream, and the pending slot when none is open. */
|
|
679
|
+
nudgeSummarySync(ids: readonly string[]): void
|
|
674
680
|
}
|
|
675
681
|
|
|
676
682
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -2013,6 +2019,15 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2013
2019
|
/** The ids the in-flight round asked (its triggering batches) — marked at
|
|
2014
2020
|
* completion, joined by nothing (mid-flight batches queue instead). */
|
|
2015
2021
|
let askingNow: string[] = []
|
|
2022
|
+
/** PB-26a: the newest REAL-TIME running flag this stream delivered per
|
|
2023
|
+
* VIRTUAL session id (the leg's forwarded `api-session/status` frames
|
|
2024
|
+
* only — never the synthesized status-sync frames). Read when a row is
|
|
2025
|
+
* first announced: a status frame that reached the UI BEFORE its row
|
|
2026
|
+
* existed was dropped by the host (handleSessionStatus only updates
|
|
2027
|
+
* sessions already in its summaries), and a flip during the list pull is
|
|
2028
|
+
* newer than the snapshot's `running` — either way the announce must be
|
|
2029
|
+
* followed by the live value. Cleared with a fresh serving period. */
|
|
2030
|
+
const latestLiveStatus = new Map<string, boolean>()
|
|
2016
2031
|
const scheduleRetry = (): void => {
|
|
2017
2032
|
if (retryTimer !== undefined || retryAt.size === 0) return
|
|
2018
2033
|
const earliest = Math.min(...retryAt.values())
|
|
@@ -2072,6 +2087,15 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2072
2087
|
if (announced.has(rowId)) continue
|
|
2073
2088
|
announced.add(rowId)
|
|
2074
2089
|
channel.push(frame)
|
|
2090
|
+
// PB-26a: the row is born in the UI carrying the snapshot's
|
|
2091
|
+
// `running`; when this stream delivered a live status for it the
|
|
2092
|
+
// host could not apply (no row yet) or that postdates the
|
|
2093
|
+
// snapshot, realign right behind the added frame — only when the
|
|
2094
|
+
// two disagree, a matching flag needs no second frame.
|
|
2095
|
+
const liveRunning = latestLiveStatus.get(rowId)
|
|
2096
|
+
if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
|
|
2097
|
+
channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] })
|
|
2098
|
+
}
|
|
2075
2099
|
}
|
|
2076
2100
|
// T65-fix ask bookkeeping: a returned id is announced and done; an
|
|
2077
2101
|
// id the server did NOT return either exhausts its attempts or
|
|
@@ -2206,6 +2230,10 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2206
2230
|
askedForGood.clear()
|
|
2207
2231
|
askAttempts.clear()
|
|
2208
2232
|
retryAt.clear()
|
|
2233
|
+
// PB-26a: the live flags belong to the serving period that delivered
|
|
2234
|
+
// them — the status re-sync below re-reads every row from a fresh
|
|
2235
|
+
// snapshot.
|
|
2236
|
+
latestLiveStatus.clear()
|
|
2209
2237
|
if (retryTimer !== undefined) {
|
|
2210
2238
|
clock.clearTimeout(retryTimer)
|
|
2211
2239
|
retryTimer = undefined
|
|
@@ -2370,6 +2398,20 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2370
2398
|
if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
2371
2399
|
const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
|
|
2372
2400
|
if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
|
|
2401
|
+
// PB-26a: the rewritten frame is the proved shape (`args` =
|
|
2402
|
+
// [virtualId, running]). Remember the live flag under the
|
|
2403
|
+
// VIRTUAL id, and — when this stream has not announced the row
|
|
2404
|
+
// — pull the list now: a sidechat / subagent child rides no
|
|
2405
|
+
// workspace/follow frame, so this status is the client's first
|
|
2406
|
+
// (and, until the next turn, only) notice that the row exists.
|
|
2407
|
+
// The frame itself still crosses at once, below.
|
|
2408
|
+
const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined
|
|
2409
|
+
const virtualId = args?.[0]
|
|
2410
|
+
const running = args?.[1]
|
|
2411
|
+
if (typeof virtualId === 'string' && typeof running === 'boolean') {
|
|
2412
|
+
latestLiveStatus.set(virtualId, running)
|
|
2413
|
+
if (!announced.has(virtualId)) handleForwardedBatch([virtualId])
|
|
2414
|
+
}
|
|
2373
2415
|
}
|
|
2374
2416
|
// The orphan-close bookkeeping rides the two id-carrying frame
|
|
2375
2417
|
// types: waterfalls enter the record (once per leg — a repeat
|
|
@@ -3498,6 +3540,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3498
3540
|
currentSessionChanged() {
|
|
3499
3541
|
for (const listener of [...currentSessionListeners]) listener()
|
|
3500
3542
|
},
|
|
3543
|
+
nudgeSummarySync(ids: readonly string[]) {
|
|
3544
|
+
const virtualIds = ids.filter((id) => typeof id === 'string' && id !== '' && isVirtual(id))
|
|
3545
|
+
if (virtualIds.length > 0) summarySync.noteForwardedSessionIds(virtualIds)
|
|
3546
|
+
},
|
|
3501
3547
|
}
|
|
3502
3548
|
}
|
|
3503
3549
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { createHash } from 'node:crypto'
|
|
26
26
|
import { isVirtual, fromVirtual } from './virtual-id.js'
|
|
27
27
|
import { MAX_UPLOAD_BYTES } from './upload-limits.js'
|
|
28
|
-
import type { NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
|
|
28
|
+
import type { BridgeLocator, NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
|
|
29
29
|
import { BRIDGE_METHODS, DEFAULT_ROUTE_BODY_BYTES } from './plugin-bridge.js'
|
|
30
30
|
|
|
31
31
|
export { fromVirtual }
|
|
@@ -327,6 +327,15 @@ export function normalizeAdapter(input: unknown, installedVersion: string, optio
|
|
|
327
327
|
methods: route.methods.length === 1 && route.methods[0] === 'GET' ? undefined : [...route.methods],
|
|
328
328
|
maxBody: route.maxBody === DEFAULT_ROUTE_BODY_BYTES ? undefined : route.maxBody,
|
|
329
329
|
upgrade: route.upgrade ? true : undefined,
|
|
330
|
+
// PB-26b: an upgrade route whose ids do NOT ride the query keeps its
|
|
331
|
+
// locator on the registry entry — the tunnel's validation and the
|
|
332
|
+
// client's upgrade surrogate read where the ids actually arrive (the
|
|
333
|
+
// sidechat ws watch carries them in the post-handshake JSON frames).
|
|
334
|
+
// A query route's declaration stays EXACTLY the 2.1 shape — no
|
|
335
|
+
// locator key — so every older consumer and digest is untouched
|
|
336
|
+
// (the adapter digest always covered route.locator and never
|
|
337
|
+
// covered the declaration routes' shape).
|
|
338
|
+
...(route.upgrade && route.locator.in !== 'query' ? { locator: route.locator as BridgeLocator } : {}),
|
|
330
339
|
})),
|
|
331
340
|
digest,
|
|
332
341
|
}
|
|
@@ -483,6 +492,16 @@ export function locateAdapterIds(route: NormalizedAdapterRoute, pathname: string
|
|
|
483
492
|
for (const field of locator.fields) {
|
|
484
493
|
const value = body[field]
|
|
485
494
|
if (typeof value === 'string' && value !== '') hits.push({ kind: 'json-body', key: field, value })
|
|
495
|
+
// PB-26b: a field may carry a LIST of ids — better-sidebar's sidechat
|
|
496
|
+
// ws watch frame sends `{"op":"watch","threadIds":[...]}`. Every
|
|
497
|
+
// non-empty string element is one hit under the same field key, so
|
|
498
|
+
// classify/rewrite treat the batch exactly like repeated query
|
|
499
|
+
// values: every id virtual → remote, one mixed in → refuse.
|
|
500
|
+
else if (Array.isArray(value)) {
|
|
501
|
+
for (const item of value) {
|
|
502
|
+
if (typeof item === 'string' && item !== '') hits.push({ kind: 'json-body', key: field, value: item })
|
|
503
|
+
}
|
|
504
|
+
}
|
|
486
505
|
}
|
|
487
506
|
}
|
|
488
507
|
}
|
|
@@ -549,7 +568,15 @@ export function rewriteAdapterRequest(
|
|
|
549
568
|
} else {
|
|
550
569
|
if (bodyCopy === undefined && isRecord(body)) bodyCopy = { ...body }
|
|
551
570
|
if (bodyCopy !== undefined) {
|
|
552
|
-
bodyCopy[hit.key!]
|
|
571
|
+
const current = bodyCopy[hit.key!]
|
|
572
|
+
// PB-26b: an array field rewrites IN PLACE, element for element —
|
|
573
|
+
// only the entries equal to this hit's located value swap for their
|
|
574
|
+
// real halves, so a mixed batch of ids keeps its shape and order.
|
|
575
|
+
if (Array.isArray(current)) {
|
|
576
|
+
bodyCopy[hit.key!] = current.map((item) => (item === hit.value ? real : item))
|
|
577
|
+
} else {
|
|
578
|
+
bodyCopy[hit.key!] = real
|
|
579
|
+
}
|
|
553
580
|
bodyText = JSON.stringify(bodyCopy)
|
|
554
581
|
}
|
|
555
582
|
}
|