dsh-model-everything 0.5.2 → 0.6.0

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/src/client.ts CHANGED
@@ -1,307 +1,1841 @@
1
- /**
2
- * The Client half: a lazy factory the page binds to this package's name.
3
- *
4
- * Everything that decides anything lives in `render/`; this file only turns shapes into React elements and
5
- * registers them where the page offers a seat. Two seats are used, and they fail independently:
6
- *
7
- * - `tool.call.toolview` replaces the card of a tool whose result this package can draw;
8
- * - `sidebar.right.pane.tab` is the whiteboard: a tab that renders whatever the reader pastes, with no tool
9
- * and no host data at all, so it keeps working when the card seat does not.
10
- *
11
- * This module is not a page script: it exports the factory, and `scripts/build-client.mjs` wraps the
12
- * compiled output in `window.__ModuleLoader__.load(...)`, which is the loader contract itself.
13
- */
14
-
15
- import { paintWith } from './render/paint.js'
16
- import { schemeNames, type Palette, type PaletteMode, type PaletteScheme } from './render/palette.js'
17
- import { render } from './render/index.js'
18
- import { toSvg } from './render/svg.js'
19
- import { viewModel } from './render/viewmodel.js'
20
-
21
- export interface ReactLike {
22
- createElement: (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
23
- useState?: <T>(initial: T) => [T, (next: T) => void]
24
- }
25
-
26
- export interface SlotContext {
27
- slots: {
28
- inject: (slot: string, callback: () => unknown) => unknown
29
- register: (options: Record<string, unknown>, component: unknown) => unknown
30
- }
31
- get?: (service: string) => unknown
32
- }
33
-
34
- export interface PluginShape {
35
- inject: string[]
36
- apply: (ctx: SlotContext) => void
37
- /** The whiteboard mount, with its scheduler injectable. `apply` calls this with the default. */
38
- mount: (ctx: SlotContext, options?: MountOptions) => unknown
39
- }
40
-
41
- /** A settled tool call, as the host hands it to a view. */
42
- export interface ToolCallProps {
43
- phase?: string
44
- toolName?: string
45
- block?: { args?: unknown; result?: unknown }
46
- }
47
-
48
- /** Tool names this package answers. A key that never dispatches simply never renders. */
49
- export const TOOL_NAMES: string[] = ['model_verify']
50
-
51
- /**
52
- * Which surface the page is painting the drawing on.
53
- *
54
- * The host publishes its palette as private CSS variables, so naming them here would break on the next theme.
55
- * The one fact that cannot drift is the rendered background: read it, and fall back to the media query, then
56
- * to light. Never throws - a view that cannot read the document still draws.
57
- */
58
- export function themeMode(): PaletteMode {
59
- try {
60
- // Reached through a loose view of the globals: this module is also loaded in Node by the bundle suite,
61
- // where `document` does not exist at all.
62
- const scope = globalThis as unknown as {
63
- document?: { body?: unknown }
64
- getComputedStyle?: (element: unknown) => { backgroundColor?: string }
65
- }
66
- const body = scope.document?.body
67
- const computed = typeof scope.getComputedStyle === 'function' && body !== undefined ? scope.getComputedStyle(body) : undefined
68
- const colour = computed?.backgroundColor ?? ''
69
- const rgb = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colour)
70
- if (rgb !== null) {
71
- const [r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
72
- // Rec. 601 luma: good enough to tell a light surface from a dark one, and cheap.
73
- return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 ? 'dark' : 'light'
74
- }
75
- } catch {
76
- // A document that refuses to be read is not a reason to fail a render.
77
- }
78
- return 'light'
79
- }
80
-
81
- /** The background a drawing sits on, so a card does not paint dark lines on a dark page. */
82
- function surfaceStyle(colours: Palette): Record<string, string> {
83
- return { background: colours.background, color: colours.text, padding: '4px 0', overflowX: 'auto' }
84
- }
85
-
86
-
87
- /** The whiteboard's seat key, in both the tab registry and the body slot. */
88
- export const WHITEBOARD_ID = 'model-everything-diagram'
89
-
90
- /** What the whiteboard opens with: a diagram the reader can edit rather than an empty box. */
91
- export const WHITEBOARD_SAMPLE = ['graph TD', ' a[Idea] --> b{Feasible?}', ' b -->|yes| c[Build]', ' b -->|no| d[Revise]', ' d --> a'].join('\n')
92
-
93
- /**
94
- * The view for one tool name.
95
- *
96
- * The drawing is emitted as markup, so React receives it through `dangerouslySetInnerHTML`. That is safe
97
- * only because the writer escapes every value that came from a report or a model, which the render suite
98
- * pins with an injection case.
99
- */
100
- export function viewFor(React: ReactLike, overrides: Partial<Palette> = {}): (props: ToolCallProps) => unknown {
101
- return (props: ToolCallProps): unknown => {
102
- // The whole block goes to the view model: the host shapes it differently per phase (a lazy arguments
103
- // view while running, content blocks once settled), and only that module knows how to read it.
104
- const diagram = viewModel({ phase: props.phase, toolName: props.toolName, block: props.block })
105
- const { canvas, colours } = paintWith(diagram, overrides, { mode: themeMode() })
106
- const written = toSvg(canvas, colours, diagram.kind === 'none' ? diagram.caption : diagram.title + ': ' + diagram.caption)
107
- return React.createElement('div', {
108
- style: surfaceStyle(colours),
109
- dangerouslySetInnerHTML: { __html: written.markup },
110
- })
111
- }
112
- }
113
-
114
- /**
115
- * The whiteboard: paste a diagram, get a drawing, zoom it.
116
- *
117
- * It holds no host data, which is the point: it is a place to think about a diagram while writing one, and
118
- * it cannot be broken by a change to any tool's result shape. The source it draws is whatever is in its own
119
- * box.
120
- */
121
- export function whiteboardFor(React: ReactLike, overrides: Partial<Palette> = {}): () => unknown {
122
- const h = (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]): unknown => React.createElement(type, props, ...children)
123
- return function Whiteboard(): unknown {
124
- const useState = React.useState as (<T>(initial: T) => [T, (next: T) => void]) | undefined
125
- if (useState === undefined) {
126
- // A host without hooks: the same board, read-only. It still draws, which is the part that matters.
127
- return h(
128
- 'div',
129
- { style: { padding: '8px' } },
130
- h('textarea', {
131
- value: WHITEBOARD_SAMPLE,
132
- readOnly: true,
133
- spellcheck: false,
134
- rows: WHITEBOARD_SAMPLE.split('\n').length,
135
- style: { width: '100%', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace' },
136
- }),
137
- // The picker is present but inert: a host without hooks cannot hold the choice, and hiding the
138
- // option would make the board look like it has none.
139
- h('select', { disabled: true, style: { margin: '4px 0', fontSize: '12px' } },
140
- ...schemeNames().map((name) => h('option', { value: name, selected: name === 'default' }, name))),
141
- whiteboardBody(React, overrides, WHITEBOARD_SAMPLE, 1),
142
- )
143
- }
144
- const [source, setSource] = useState(WHITEBOARD_SAMPLE)
145
- const [zoom, setZoom] = useState(1)
146
- const [scheme, setScheme] = useState<PaletteScheme>('default')
147
- const [fit, setFit] = useState(true)
148
- const [folded, setFolded] = useState<string[]>([])
149
- const drawn = drawSample(React, overrides, source, fit ? 0 : zoom, scheme, folded)
150
- const textarea = h('textarea', {
151
- value: source,
152
- spellcheck: false,
153
- rows: Math.min(12, Math.max(4, String(source).split('\n').length)),
154
- style: { width: '100%', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace', padding: '6px', resize: 'vertical' },
155
- onInput: (event: { target?: { value?: unknown } }) => setSource(String(event?.target?.value ?? '')),
156
- })
157
- const controls = h(
158
- 'div',
159
- { style: { display: 'flex', gap: '6px', alignItems: 'center', padding: '4px 0', flexWrap: 'wrap' } },
160
- h('button', { type: 'button', onClick: () => { setFit(false); setZoom(Math.max(0.5, Math.round((Number(zoom) - 0.25) * 100) / 100)) } }, '−'),
161
- h('span', { style: { fontSize: '12px', opacity: 0.7 } }, fit ? 'fit' : String(Math.round(Number(zoom) * 100)) + '%'),
162
- h('button', { type: 'button', onClick: () => { setFit(false); setZoom(Math.min(3, Math.round((Number(zoom) + 0.25) * 100) / 100)) } }, '+'),
163
- h('button', { type: 'button', onClick: () => { setFit(false); setZoom(1) } }, 'reset'),
164
- h('button', { type: 'button', title: 'Scale to the pane width', onClick: () => setFit(!fit) }, fit ? 'fit: on' : 'fit: off'),
165
- folded.length === 0
166
- ? null
167
- : h('button', { type: 'button', title: 'Unfold every group', onClick: () => setFolded([]) }, 'unfold ' + String(folded.length)),
168
- h(
169
- 'select',
170
- {
171
- value: scheme,
172
- title: 'Colour scheme',
173
- style: { marginLeft: 'auto', fontSize: '12px' },
174
- onChange: (event: { target?: { value?: unknown } }) => setScheme(String(event?.target?.value ?? 'default') as PaletteScheme),
175
- },
176
- ...schemeNames().map((name) => h('option', { value: name, selected: name === scheme }, name)),
177
- ),
178
- )
179
- return h(
180
- 'div',
181
- { style: { display: 'flex', flexDirection: 'column', gap: '4px', height: '100%', boxSizing: 'border-box', padding: '8px' } },
182
- textarea,
183
- controls,
184
- // The drawing takes the rest of the pane and scrolls inside it, so an expanded tab uses its room.
185
- h(
186
- 'div',
187
- {
188
- style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto' },
189
- // A boundary carries its id in the markup, so one listener on the container is enough: no per-rect
190
- // handler, and the handling survives a re-render that replaced the whole SVG.
191
- onClick: (event: { target?: { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null } }) => {
192
- const hit = event?.target?.closest?.('[data-group-id]')
193
- const id = hit?.getAttribute?.('data-group-id')
194
- if (typeof id !== 'string' || id === '') return
195
- setFolded(folded.includes(id) ? folded.filter((entry) => entry !== id) : [...folded, id])
196
- },
197
- },
198
- drawn,
199
- ),
200
- )
201
- }
202
- }
203
-
204
- function drawSample(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme, folded: string[] = []): unknown {
205
- return whiteboardBody(React, overrides, source, zoom, scheme, folded)
206
- }
207
-
208
- /** Render one source at one zoom, or say why it could not be drawn. */
209
- function whiteboardBody(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme = 'default', folded: string[] = []): unknown {
210
- if (String(source).trim() === '') {
211
- return React.createElement('p', { style: { fontSize: '12px', opacity: 0.7 } }, 'Paste Mermaid or PlantUML above.')
212
- }
213
- const drawn = render({ source: String(source), folded }, overrides, { mode: themeMode(), scheme })
214
- // Zoom 0 is "fill the pane": the SVG scales to the width it is given, which is what an expanded tab wants.
215
- const zoomed =
216
- zoom === 0
217
- ? drawn.markup.replace('<svg ', '<svg style="width: 100%; height: auto; display: block" ')
218
- : drawn.markup.replace('<svg ', '<svg style="zoom: ' + String(zoom) + '; display: block" ')
219
- const refused = drawn.refused.length === 0 ? null : React.createElement('pre', {
220
- style: { fontSize: '11px', color: '#b7791f', whiteSpace: 'pre-wrap', margin: '6px 0' },
221
- }, drawn.refused.join('\n'))
222
- return React.createElement(
223
- 'div',
224
- { style: { background: drawn.colours.background, color: drawn.colours.text, borderRadius: '6px' } },
225
- React.createElement('div', { dangerouslySetInnerHTML: { __html: zoomed } }),
226
- refused,
227
- )
228
- }
229
-
230
-
231
- /** How long to wait for the optional services, and how often. A page that never offers them registers nothing. */
232
- const SIDEBAR_POLL_MS = 250
233
- const SIDEBAR_POLL_ATTEMPTS = 40
234
-
235
- interface SidebarTabsService {
236
- register?: (options: Record<string, unknown>) => unknown
237
- }
238
-
239
- export interface MountOptions {
240
- /** Injected so the suite can drive the poll without waiting. Defaults to `setTimeout`. */
241
- schedule?: (run: () => void, delay: number) => unknown
242
- }
243
-
244
- /**
245
- * Register the whiteboard tab once the sidebar's optional services are ready.
246
- *
247
- * The slot can be declared before `sidebarRightTabs` exists - the host composes services and seats in an
248
- * order this plugin does not control - and a single `ctx.get` there is a race that loses the tab silently.
249
- * So it polls, like the reference implementation does, and stops after a bounded number of attempts.
250
- */
251
- export function mountWhiteboard(React: ReactLike, ctx: SlotContext, options: MountOptions = {}): unknown {
252
- const schedule =
253
- options.schedule ??
254
- ((run: () => void, delay: number) => {
255
- // A host (or a test sandbox) without timers simply gets no retry, rather than an exception.
256
- const timer = (globalThis as unknown as { setTimeout?: (f: () => void, ms: number) => unknown }).setTimeout
257
- return typeof timer === 'function' ? timer(run, delay) : undefined
258
- })
259
- let attempts = 0
260
-
261
- const attempt = (): unknown => {
262
- attempts += 1
263
- const tabs = typeof ctx.get === 'function' ? (ctx.get('sidebarRightTabs') as SidebarTabsService | undefined) : undefined
264
- if (tabs === undefined || typeof tabs.register !== 'function') {
265
- if (attempts < SIDEBAR_POLL_ATTEMPTS) schedule(attempt, SIDEBAR_POLL_MS)
266
- return undefined
267
- }
268
- tabs.register({
269
- id: WHITEBOARD_ID,
270
- kind: 'panel',
271
- keepMounted: true,
272
- title: () => 'Diagram',
273
- guide: [
274
- {
275
- id: 'diagram-whiteboard',
276
- order: 120,
277
- title: () => 'Diagram',
278
- description: () => 'Paste Mermaid or PlantUML and read the drawing',
279
- },
280
- ],
281
- })
282
- return ctx.slots.register({ name: 'sidebar.right.pane.tab', key: WHITEBOARD_ID }, whiteboardFor(React))
283
- }
284
-
285
- return attempt()
286
- }
287
-
288
- export default function factory(require: (name: string) => unknown): PluginShape {
289
- // Captured under a plain name: a static `require('react')` would be rewritten by the bundler and then
290
- // resolve against a scope the factory body does not have. A call through this alias stays a call.
291
- const hostRequire = require
292
- const React = hostRequire('react') as ReactLike
293
- return {
294
- inject: ['slots'],
295
- mount: (ctx: SlotContext, options: MountOptions = {}) => mountWhiteboard(React, ctx, options),
296
- apply(ctx: SlotContext) {
297
- for (const name of TOOL_NAMES) {
298
- // Only the options a keyed slot declares: `key`. An extra `id` is not part of this slot's
299
- // registration schema, and the working community reference (dsh-deepread) passes exactly this much.
300
- ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, viewFor(React)))
301
- }
302
- // The whiteboard seat only exists on a host with the official right sidebar. Presence of the slot is
303
- // the capability check: if the callback never runs, this host has no sidebar and nothing is lost.
304
- ctx.slots.inject('sidebar.right.pane.tab', () => mountWhiteboard(React, ctx))
305
- },
306
- }
307
- }
1
+ /**
2
+ * The Client half: a lazy factory the page binds to this package's name.
3
+ *
4
+ * Everything that decides anything lives in `render/`; this file only turns shapes into React elements and
5
+ * registers them where the page offers a seat. Two seats are used, and they fail independently:
6
+ *
7
+ * - `tool.call.toolview` replaces the card of a tool whose result this package can draw;
8
+ * - `sidebar.right.pane.tab` is the whiteboard: a tab that renders whatever the reader pastes, with no tool
9
+ * and no host data at all, so it keeps working when the card seat does not.
10
+ *
11
+ * This module is not a page script: it exports the factory, and `scripts/build-client.mjs` wraps the
12
+ * compiled output in `window.__ModuleLoader__.load(...)`, which is the loader contract itself.
13
+ */
14
+
15
+ import { paintWith } from './render/paint.js'
16
+ import { palette, schemeNames, type Palette, type PaletteMode, type PaletteScheme } from './render/palette.js'
17
+ import { render } from './render/index.js'
18
+ import { toSvg } from './render/svg.js'
19
+ import { viewModel } from './render/viewmodel.js'
20
+ import { annotationBlock, annotationMessage, composerRect, fillComposer, findComposer, pageCensus, prependBlock, pushBlock, pushNote, sessionFromLocation, submitComposer, withoutEmptyQuestion, type DocumentLike } from './annotation.js'
21
+ import { decorateTranscript, startTranscriptDecoration, type TranscriptDocument as TranscriptDocumentForClient } from './transcript.js'
22
+
23
+ export interface ReactLike {
24
+ createElement: (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
25
+ useState?: <T>(initial: T) => [T, (next: T | ((previous: T) => T)) => void]
26
+ useEffect?: (effect: () => void | (() => void), deps?: ReadonlyArray<unknown>) => void
27
+ }
28
+
29
+ export interface SlotContext {
30
+ slots: {
31
+ inject: (slot: string, callback: () => unknown) => unknown
32
+ register: (options: Record<string, unknown>, component: unknown) => unknown
33
+ }
34
+ get?: (service: string) => unknown
35
+ }
36
+
37
+ export interface PluginShape {
38
+ inject: string[]
39
+ apply: (ctx: SlotContext) => void
40
+ /** The whiteboard mount, with its scheduler injectable. `apply` calls this with the default. */
41
+ mount: (ctx: SlotContext, options?: MountOptions) => unknown
42
+ }
43
+
44
+ /** A settled tool call, as the host hands it to a view. */
45
+ /**
46
+ * The client context, kept from `apply`: a view has no other way to reach a client service.
47
+ *
48
+ * It is one module-level handle because there is one page. A null until `apply` runs, and every reader
49
+ * checks it, so a view that renders before the plugin mounts simply takes the composer path.
50
+ */
51
+ let services: SlotContext | undefined
52
+
53
+ export interface ToolCallProps {
54
+ phase?: string
55
+ toolName?: string
56
+ block?: { args?: unknown; result?: unknown }
57
+ }
58
+
59
+ /** Tool names this package answers. A key that never dispatches simply never renders. */
60
+ /**
61
+ * The drawing tool's wire name. A literal, not an import: the client bundle must not pull in the tool
62
+ * surface, which imports the host's tool SDK.
63
+ */
64
+ const DRAWING_TOOL = 'diagram_render'
65
+
66
+ /** Tool names this package answers. A key that never dispatches simply never renders. */
67
+ export const TOOL_NAMES: string[] = ['model_verify', DRAWING_TOOL]
68
+
69
+ /**
70
+ * Which surface the page is painting the drawing on.
71
+ *
72
+ * The host publishes its palette as private CSS variables, so naming them here would break on the next theme.
73
+ * The one fact that cannot drift is the rendered background: read it, and fall back to the media query, then
74
+ * to light. Never throws - a view that cannot read the document still draws.
75
+ */
76
+ export function themeMode(): PaletteMode {
77
+ try {
78
+ // Reached through a loose view of the globals: this module is also loaded in Node by the bundle suite,
79
+ // where `document` does not exist at all.
80
+ const scope = globalThis as unknown as {
81
+ document?: { body?: unknown }
82
+ getComputedStyle?: (element: unknown) => { backgroundColor?: string }
83
+ }
84
+ const body = scope.document?.body
85
+ const computed = typeof scope.getComputedStyle === 'function' && body !== undefined ? scope.getComputedStyle(body) : undefined
86
+ const colour = computed?.backgroundColor ?? ''
87
+ const rgb = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colour)
88
+ if (rgb !== null) {
89
+ const [r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
90
+ // Rec. 601 luma: good enough to tell a light surface from a dark one, and cheap.
91
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 ? 'dark' : 'light'
92
+ }
93
+ } catch {
94
+ // A document that refuses to be read is not a reason to fail a render.
95
+ }
96
+ return 'light'
97
+ }
98
+
99
+ /** The background a drawing sits on, so a card does not paint dark lines on a dark page. */
100
+ function surfaceStyle(colours: Palette): Record<string, string> {
101
+ return { background: colours.background, color: colours.text, padding: '4px 0', overflowX: 'auto' }
102
+ }
103
+
104
+
105
+ /** The whiteboard's seat key, in both the tab registry and the body slot. */
106
+ export const WHITEBOARD_ID = 'model-everything-diagram'
107
+
108
+ /** What the whiteboard opens with: a diagram the reader can edit rather than an empty box. */
109
+ export const WHITEBOARD_SAMPLE = ['graph TD', ' a[Idea] --> b{Feasible?}', ' b -->|yes| c[Build]', ' b -->|no| d[Revise]', ' d --> a'].join('\n')
110
+
111
+ /**
112
+ * One argument out of the host's lazy arguments view.
113
+ *
114
+ * A settled call exposes `value(key)`; a plain object (a test, or a replayed call) is read directly. The
115
+ * view answers `undefined` until the field closes, which is exactly when a drawing should be attempted.
116
+ */
117
+ function argOf(args: unknown, key: string): unknown {
118
+ if (args === null || typeof args !== 'object') return undefined
119
+ const view = args as { value?: (name: string) => unknown }
120
+ if (typeof view.value === 'function') return view.value(key)
121
+ return (args as Record<string, unknown>)[key]
122
+ }
123
+
124
+ /**
125
+ * The card for a drawing: the source the call carried, drawn by the renderer that wrote the file.
126
+ *
127
+ * Drawing it here rather than replaying the SVG means the card and the file are one picture from one
128
+ * engine, and it costs the result nothing. Lines the renderer refused are listed under the drawing: a
129
+ * card that hid them would show a picture that is missing something, which is the failure the tool
130
+ * exists to prevent.
131
+ */
132
+ /** A create-element function with this package's calling convention, so a shared builder fits both seats. */
133
+ type H = (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
134
+
135
+ /** The host's own colours, so the panel looks like the rest of the page instead of like a plugin. */
136
+ /**
137
+ * The two languages this surface speaks, and the one place a sentence lives.
138
+ *
139
+ * The page states its language; the plugin has no locale service of its own, and a shipped string that cannot
140
+ * be translated is a string somebody has to read in a language they did not choose. Keys are the English
141
+ * sentence's sense, not its text, so a wording change never breaks a caller.
142
+ */
143
+ const MESSAGES = {
144
+ note: { en: 'Note', zh: '批注' },
145
+ noteOn: { en: 'Note: on', zh: '批注: 开' },
146
+ noteHint: { en: 'Click a node to mark it', zh: '点一个节点标记它' },
147
+ node: { en: 'Node', zh: '节点' },
148
+ add: { en: 'Add', zh: '加入' },
149
+ addAndSend: { en: 'Add & send', zh: '加入并发送' },
150
+ close: { en: 'Close', zh: '关闭' },
151
+ send: { en: 'Send', zh: '发送' },
152
+ clear: { en: 'Clear', zh: '清空' },
153
+ remove: { en: 'Remove', zh: '删除' },
154
+ markedOnly: { en: '(marked only)', zh: '(只标记)' },
155
+ pending: { en: 'Notes', zh: '图注' },
156
+ fold: { en: 'hide', zh: '收起' },
157
+ carried: { en: 'Notes in this message', zh: '本消息携带图注' },
158
+ zoomOut: { en: 'Zoom out', zh: '缩小' },
159
+ zoomIn: { en: 'Zoom in', zh: '放大' },
160
+ actualSize: { en: 'Actual size', zh: '实际大小' },
161
+ full: { en: '⤢ Full', zh: '⤢ 全屏' },
162
+ exitFull: { en: '⤡ Exit', zh: '⤡ 退出' },
163
+ diagram: { en: 'Diagram', zh: '图' },
164
+ fitOn: { en: 'fit: on', zh: 'fit: on' },
165
+ fitOff: { en: 'fit: off', zh: 'fit: off' },
166
+ scheme: { en: 'Colours', zh: '配色' },
167
+ unfold: { en: 'unfold', zh: '展开' },
168
+ placeholder: { en: 'A note about this node… (Ctrl+Enter adds and sends)', zh: '批注…(Ctrl+Enter 加入并发送)' },
169
+ }
170
+
171
+ type MessageKey = keyof typeof MESSAGES
172
+
173
+ /** The page's language, as the two letter code this surface uses. */
174
+ function languageOf(): 'en' | 'zh' {
175
+ const scope = globalThis as { navigator?: { language?: string }; document?: { documentElement?: { lang?: string } } }
176
+ const asked = (scope.document?.documentElement?.lang ?? scope.navigator?.language ?? 'en').toLowerCase()
177
+ return asked.startsWith('zh') ? 'zh' : 'en'
178
+ }
179
+
180
+ function t(key: MessageKey): string {
181
+ return MESSAGES[key][languageOf()]
182
+ }
183
+ const TOKEN = {
184
+ surface: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1, Canvas))',
185
+ border: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.35))',
186
+ text: 'var(--dsw-alias-label-primary, CanvasText)',
187
+ dim: 'var(--dsw-alias-label-secondary, GrayText)',
188
+ accent: 'var(--dsw-alias-brand-primary, #2563eb)',
189
+ }
190
+
191
+ /** What an annotation editor shows, shared by the two surfaces that offer one. */
192
+ export interface AnnotationState {
193
+ picked: { id: string } | null
194
+ note: string
195
+ pending: Array<{ nodeId: string; note: string }>
196
+ status: string
197
+ /** Whether the pending list is expanded under the count. */
198
+ open?: boolean
199
+ /** Where the editor sits, in the drawing's own coordinates: beside the node the reader picked. */
200
+ anchor?: { left: number; top: number }
201
+ /** The correction the dock applied after measuring where the chip actually landed. */
202
+ dockFix?: { dx: number; dy: number }
203
+ }
204
+
205
+ export interface AnnotationActions {
206
+ onNote: (value: string) => void
207
+ onAdd: () => void
208
+ onAddAndSend: () => void
209
+ onSend: () => void
210
+ onClear: () => void
211
+ onCancel: () => void
212
+ onToggle: () => void
213
+ onRemove: (index: number) => void
214
+ }
215
+
216
+ /**
217
+ * The annotation surface: a floating editor over the drawing, and a count of what is waiting.
218
+ *
219
+ * Floating rather than inline, because the reader is looking at a picture and the editor must not move it.
220
+ * The notes stay in a list until one message is composed from them: the reader's input box is theirs, and N raw
221
+ * fragments in it would be exactly the mess this feature is meant to avoid. Colours come from the host's tokens,
222
+ * so light and dark themes need no separate code.
223
+ */
224
+ export function annotationPanel(h: H, state: AnnotationState, actions: AnnotationActions, options: { floating?: boolean; document?: DocumentLike; dockFix?: { dx: number; dy: number }; dockChip?: boolean } = {}): unknown {
225
+ const button = (label: string, onClick: () => void, accent = false): unknown =>
226
+ h('button', { type: 'button', onClick, style: { fontSize: '12px', padding: '2px 8px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: accent ? TOKEN.accent : TOKEN.text, cursor: 'pointer' } }, label)
227
+ const editor = state.picked === null
228
+ ? null
229
+ : h(
230
+ 'div',
231
+ {
232
+ style: {
233
+ ...(options.floating === false ? {} : { position: 'absolute', left: (state.anchor?.left ?? 10) + 'px', top: (state.anchor?.top ?? 10) + 'px', zIndex: 20 }),
234
+ width: 'min(260px, calc(100% - 16px))', boxSizing: 'border-box',
235
+ display: 'flex', flexDirection: 'column', gap: '6px', padding: '8px', borderRadius: '10px',
236
+ background: TOKEN.surface, border: '1px solid ' + TOKEN.border, color: TOKEN.text,
237
+ boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
238
+ },
239
+ },
240
+ h('div', { style: { fontSize: '12px', color: TOKEN.dim } }, t('node') + ' ' + state.picked.id),
241
+ h('textarea', {
242
+ 'data-dme-own': '1',
243
+ value: state.note,
244
+ rows: 3,
245
+ placeholder: t('placeholder'),
246
+ style: { width: '100%', boxSizing: 'border-box', font: '12px system-ui, sans-serif', padding: '6px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, resize: 'vertical' },
247
+ onInput: (event: { target?: { value?: unknown } }) => actions.onNote(String(event?.target?.value ?? '')),
248
+ onKeyDown: (event: { key?: string; ctrlKey?: boolean; metaKey?: boolean; preventDefault?: () => void }) => {
249
+ if (event?.key === 'Enter' && (event?.ctrlKey === true || event?.metaKey === true)) { event.preventDefault?.(); actions.onAdd() }
250
+ },
251
+ }),
252
+ h('div', { style: { display: 'flex', gap: '6px', justifyContent: 'flex-end' } }, button('关闭', actions.onCancel), button('加入', actions.onAdd)),
253
+ )
254
+ // The pending count docks above the conversation's own input box, where the message will be sent from: the
255
+ // reader is about to press Return there, and the count has to be in sight without covering the box.
256
+ const docked = options.floating !== false && options.document !== undefined ? composerRect(options.document) : undefined
257
+ // A docked count is a body-level layer now; rendering it here as well put a second, unpositioned copy
258
+ // inside the card - which is what "the chip's coordinates ran off" was.
259
+ const chip = state.pending.length === 0 || options.dockChip === false
260
+ ? null
261
+ : h(
262
+ 'div',
263
+ {
264
+ style: {
265
+ position: 'fixed', zIndex: 30,
266
+ left: (docked === undefined ? 16 : docked.left) + 'px',
267
+ transform: 'translate(' + String(state.dockFix?.dx ?? 0) + 'px, ' + String(state.dockFix?.dy ?? 0) + 'px)',
268
+ // Anchored to the composer's own top edge, in the gap above it: the count is in sight while the
269
+ // reader is typing, and never covers the box they are typing into.
270
+ top: (docked === undefined ? 16 : Math.max(8, docked.top - 46)) + 'px',
271
+ // Hugs its two buttons. A fixed width turned the control into a wide empty bar across the composer.
272
+ display: 'flex', flexDirection: 'column', gap: '6px', padding: '6px 10px', borderRadius: '12px', width: 'max-content',
273
+ // The composer's own surface and hairline: the control reads as part of that box, not as a dialog.
274
+ background: 'var(--dsw-alias-bg-layer-1, ' + TOKEN.surface + ')',
275
+ border: '1px solid var(--dsw-alias-border-l2, ' + TOKEN.border + ')', color: TOKEN.text,
276
+ boxShadow: '0 8px 24px rgba(0,0,0,0.22)', font: '12px system-ui, sans-serif',
277
+ },
278
+ },
279
+ h(
280
+ 'div',
281
+ { 'data-dme-dock': '1', style: { display: 'flex', alignItems: 'center', gap: '6px' } },
282
+ h('button', { type: 'button', title: '展开,逐个删除', onClick: () => actions.onToggle(), style: { fontSize: '12px', padding: '1px 6px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, t('pending') + ' ×' + String(state.pending.length)),
283
+ button(t('send'), actions.onSend, true),
284
+ ),
285
+ state.open
286
+ ? h(
287
+ 'div',
288
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
289
+ ...state.pending.map((item, index) =>
290
+ h(
291
+ 'div',
292
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
293
+ h('span', { style: { color: TOKEN.dim, fontSize: '11px' } }, item.nodeId),
294
+ h('span', { style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.note === '' ? t('markedOnly') : item.note),
295
+ button(t('remove'), () => actions.onRemove(index)),
296
+ ),
297
+ ),
298
+ )
299
+ : null,
300
+ )
301
+ const mode = state.picked === null && state.pending.length === 0
302
+ ? h('div', { style: { fontSize: '11px', color: TOKEN.dim, padding: '2px 4px' } }, t('noteHint'))
303
+ : null
304
+ return h(
305
+ 'div',
306
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px', color: TOKEN.text } },
307
+ mode,
308
+ chip,
309
+ editor,
310
+ state.status === '' ? null : h('div', { style: { fontSize: '11px', color: TOKEN.dim, padding: '0 4px' } }, state.status),
311
+ )
312
+ }
313
+
314
+ /**
315
+ * The card for a drawing: the source the call carried, drawn by the renderer that wrote the file.
316
+ *
317
+ * Drawing it here rather than replaying the SVG means the card and the file are one picture from one
318
+ * engine, and it costs the result nothing. Lines the renderer refused are listed under the drawing: a
319
+ * card that hid them would show a picture that is missing something, which is the failure the tool
320
+ * exists to prevent.
321
+ *
322
+ * With hooks the card also annotates: the picture is the subject, so the editor stays behind one button
323
+ * until the reader asks for it, and a click then marks a node instead of doing nothing.
324
+ */
325
+ /**
326
+ * The source of a drawing call, from whichever shape the host handed over.
327
+ *
328
+ * Measured, not assumed: a settled block carries `args`, `call`, `content` and `meta`, and which one holds the
329
+ * arguments differs by host version. Each is read in turn and the first non-empty text wins, so the card draws
330
+ * on a host that ships the lazy view, one that ships the call record, or one that ships a JSON string.
331
+ */
332
+ function sourceOf(block: unknown): string | undefined {
333
+ const record = (value: unknown): Record<string, unknown> | undefined =>
334
+ value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
335
+ const asText = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value : undefined)
336
+ const fromJson = (value: unknown): string | undefined => {
337
+ const raw = asText(value)
338
+ if (raw === undefined) return undefined
339
+ try {
340
+ return asText(record(JSON.parse(raw))?.source)
341
+ } catch {
342
+ return undefined
343
+ }
344
+ }
345
+ const frame = record(block)
346
+ if (frame === undefined) return undefined
347
+ // The payload the host persists from `presentationMeta`: the one field a settled call keeps on purpose.
348
+ const fromMeta = asText(record(frame.meta)?.source)
349
+ if (fromMeta !== undefined) return fromMeta
350
+ const args = record(frame.args)
351
+ if (args !== undefined && typeof args.value === 'function') {
352
+ const lazy = asText((args as { value: (key: string) => unknown }).value('source'))
353
+ if (lazy !== undefined) return lazy
354
+ }
355
+ const plain = asText(args?.source) ?? fromJson(frame.args)
356
+ if (plain !== undefined) return plain
357
+ const call = record(frame.call)
358
+ const fromCall = asText(call?.source) ?? fromJson(call?.input) ?? fromJson(call?.arguments)
359
+ if (fromCall !== undefined) return fromCall
360
+ return asText(record(frame.meta)?.source)
361
+ }
362
+
363
+ function drawingCard(React: ReactLike, props: ToolCallProps, overrides: Partial<Palette>): unknown {
364
+ const block = (props.block ?? {}) as { args?: unknown }
365
+ const source = sourceOf(props.block) ?? argOf(block.args, 'source')
366
+ const asked = argOf(block.args, 'scheme')
367
+ const scheme = schemeNames().includes(asked as PaletteScheme) ? (asked as PaletteScheme) : 'default'
368
+ // The arguments are the lazy view while the call runs; by the time it settles the host may hand back only
369
+ // the result. Both carry the drawing, so the card reads whichever one is there.
370
+ const payload = (props.block as { result?: unknown } | undefined)?.result
371
+ const returned = payload !== null && typeof payload === 'object' ? (payload as { svg?: unknown }).svg : undefined
372
+ const markup = typeof returned === 'string' && returned !== '' ? returned : null
373
+ // Nothing to draw yet, or nothing at all. The branch is taken after the hooks below, never before: a running
374
+ // call has no source and a settled one does, and a hook that disappears between those renders takes the whole
375
+ // conversation view down with it.
376
+ const hasDrawing = markup !== null || (typeof source === 'string' && source.trim() !== '')
377
+ const useState = React.useState
378
+ if (useState === undefined) return cardSurface(React, source, markup, scheme, overrides, null, null, null, null)
379
+ const [annotate, setAnnotate] = useState(false)
380
+ const [picked, setPicked] = useState<{ id: string } | null>(null)
381
+ const [note, setNote] = useState('')
382
+ const [status, setStatus] = useState('')
383
+ const document = (globalThis as { document?: DocumentLike }).document
384
+ const caption = typeof source === 'string' ? (source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? '') : ''
385
+ const [pending, setPending] = useState<Array<{ nodeId: string; note: string }>>([])
386
+ const [open, setOpen] = useState(false)
387
+ // A note that left: this plugin's own chrome says so, where no other plugin works.
388
+ const [sent, setSent] = useState('')
389
+ // A self-report the reader can copy in one click, so a UI question is answered with facts instead of screenshots.
390
+ const [report, setReport] = useState('')
391
+ // The composer grows when a picture is pasted, and the docked control has to follow it.
392
+ const [, setDockTick] = useState(0)
393
+ // `position: fixed` is relative to the nearest transformed ancestor, not to the viewport. The chip therefore
394
+ // measures where it actually landed and corrects itself once, instead of trusting the coordinates we computed.
395
+ const [dockFix, setDockFix] = useState({ dx: 0, dy: 0 })
396
+ React.useEffect?.(() => {
397
+ const doc = (globalThis as { document?: DocumentLike }).document
398
+ const composer = findComposer(doc)
399
+ const bump = (): void => setDockTick((n) => n + 1)
400
+ const scope = globalThis as {
401
+ addEventListener?: (type: string, handler: () => void) => void
402
+ removeEventListener?: (type: string, handler: () => void) => void
403
+ ResizeObserver?: new (callback: () => void) => { observe: (target: unknown) => void; disconnect: () => void }
404
+ }
405
+ scope.addEventListener?.('resize', bump)
406
+ let observer: { observe: (target: unknown) => void; disconnect: () => void } | undefined
407
+ if (composer !== undefined && composer !== null && scope.ResizeObserver !== undefined) {
408
+ try {
409
+ observer = new scope.ResizeObserver(bump)
410
+ observer.observe(composer)
411
+ } catch { observer = undefined }
412
+ }
413
+ return () => {
414
+ scope.removeEventListener?.('resize', bump)
415
+ observer?.disconnect()
416
+ }
417
+ }, [])
418
+ React.useEffect?.(() => {
419
+ const doc = (globalThis as { document?: DocumentLike }).document
420
+ const chip = doc?.querySelector?.('[data-dme-dock]') as { getBoundingClientRect?: () => { left: number; top: number; height: number } } | null | undefined
421
+ const surface = composerRect(doc)
422
+ const landed = chip?.getBoundingClientRect?.()
423
+ if (surface === undefined || landed === undefined) return
424
+ const dx = Math.round(surface.left - landed.left)
425
+ const dy = Math.round((surface.top - landed.height - 8) - landed.top)
426
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) setDockFix((was) => ({ dx: was.dx + dx, dy: was.dy + dy }))
427
+ })
428
+ const [anchor, setAnchor] = useState<{ left: number; top: number } | undefined>(undefined)
429
+ // What would leave with the next message: the chip shows it, and the draft stays empty until Enter.
430
+ setPendingBlock(pending.length === 0 ? '' : annotationBlock(pending.map((item) => ({ ...item, caption }))))
431
+ // The count has to clear when the message leaves, and the subscription must not be able to take the card
432
+ // down with it: a hook that throws here unmounts the drawing, which reads as "the card did not render".
433
+ React.useEffect?.(() => {
434
+ try { return onMessageSent(() => { setPending([]); setStatus('') }) } catch { return undefined }
435
+ }, [])
436
+ const [zoom, setZoom] = useState(1)
437
+ const [full, setFull] = useState(false)
438
+ if (!hasDrawing) {
439
+ const keys = props.block === null || typeof props.block !== 'object' ? 'none' : Object.keys(props.block as Record<string, unknown>).join(',')
440
+ return React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, 'no drawing in this call · block: ' + (keys === '' ? 'empty' : keys))
441
+ }
442
+ const sendAll = (list: Array<{ nodeId: string; note: string }>): void => {
443
+ if (list.length === 0) return
444
+ const text = annotationBlock(list.map((item) => ({ ...item, caption })))
445
+ void (async () => {
446
+ // The draft path runs FIRST and synchronously enough to answer the click. The command channel is a
447
+ // second attempt only after the note is already safe: a channel that hangs, refuses or silently does
448
+ // nothing must never be the thing a click waits on. Measured: it hung, and the button looked dead.
449
+ if (placeBlock(text)) {
450
+ const label = '图注 ×' + String(list.length)
451
+ // The button path is a send too: without this the count under the message only ever appeared when the
452
+ // reader pressed Enter, and a click left the message unmarked.
453
+ attachBelowBubble(label, '')
454
+ setSent('已发送 ' + label)
455
+ attachSentChip(label)
456
+ setStatus('已放入输入框并提交(' + String(list.length) + ' 条图注)')
457
+ setPending([])
458
+ submitDraft()
459
+ return
460
+ }
461
+ const said = prependBlock(document, list, caption)
462
+ setStatus(said)
463
+ if (!said.startsWith('已')) return
464
+ setPending([])
465
+ submitComposer(document)
466
+ })()
467
+ }
468
+ const onPick = (event: { target?: { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null } }): void => {
469
+ if (!annotate) return
470
+ const id = event?.target?.closest?.('[data-node-id]')?.getAttribute?.('data-node-id')
471
+ if (typeof id === 'string' && id !== '') {
472
+ // The editor goes beside the node the reader picked, not beside the pointer: the pointer moves, the node
473
+ // does not, and an editor that lands wherever the click happened reads as "it does not follow".
474
+ const pointer = event as {
475
+ target?: { closest?: (selector: string) => { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } } | null }
476
+ currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } }
477
+ }
478
+ const frame = pointer.currentTarget?.getBoundingClientRect?.()
479
+ const node = pointer.target?.closest?.('[data-node-id]')?.getBoundingClientRect?.()
480
+ if (frame !== undefined && node !== undefined) {
481
+ const width = 250
482
+ const left = Math.max(4, Math.min(node.left - frame.left + node.width + 10, Math.max(4, frame.width - width)))
483
+ const top = Math.max(4, Math.min(node.top - frame.top, Math.max(4, frame.height - 40)))
484
+ setAnchor({ left, top })
485
+ }
486
+ setPicked({ id })
487
+ setStatus('')
488
+ return
489
+ }
490
+ if (note.trim() === '') setPicked(null)
491
+ }
492
+ const toggle = React.createElement(
493
+ 'div',
494
+ { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
495
+ React.createElement('button', { type: 'button', title: 'Point at a node and send a note about it', onClick: () => { setAnnotate(!annotate); setStatus(''); if (annotate) { setPicked(null); setNote('') } }, style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, annotate ? t('noteOn') : t('note')),
496
+ annotate && picked === null ? React.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, '点一个节点') : null,
497
+ React.createElement('button', {
498
+ type: 'button',
499
+ onClick: () => {
500
+ try { setReport(domReport()) } catch (error) { setReport('report failed: ' + String((error as { message?: unknown })?.message ?? error)) }
501
+ },
502
+ style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.dim, cursor: 'pointer' },
503
+ }, '诊断'),
504
+ sent === '' ? null : React.createElement('span', {
505
+ style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, color: TOKEN.dim, whiteSpace: 'nowrap' },
506
+ }, sent),
507
+ )
508
+ // The dock is a body-level layer (see updateDockLayer); this panel keeps only the editor.
509
+ updateDockLayer(pending, open, {
510
+ onToggle: () => setOpen(!open),
511
+ onSend: () => sendAll(pending),
512
+ onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
513
+ })
514
+ const panel = annotate
515
+ ? annotationPanel(
516
+ (type, options, ...children) => React.createElement(type, options, ...children),
517
+ { picked, note, pending, status, anchor, open, dockFix },
518
+ {
519
+ onNote: setNote,
520
+ onAdd: () => {
521
+ if (picked === null) return
522
+ const merged = [...pending, { nodeId: picked.id, note }]
523
+ setPending(merged)
524
+ setNote('')
525
+ setPicked(null)
526
+ setStatus('已放入输入框,直接回车即可')
527
+ },
528
+ onAddAndSend: () => { if (picked === null) return; const merged = [...pending, { nodeId: picked.id, note }]; setNote(''); setPicked(null); sendAll(merged) },
529
+ onSend: () => sendAll(pending),
530
+ onClear: () => { setPending([]); setStatus('') },
531
+ onCancel: () => { setPicked(null); setNote(''); setStatus('') },
532
+ onToggle: () => setOpen(!open),
533
+ onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
534
+ },
535
+ // The count is the body-level layer; this panel keeps the editor, which still needs its anchor.
536
+ { document, dockChip: false },
537
+ )
538
+ : null
539
+ const reportPanel = report === '' ? null : React.createElement('pre', {
540
+ style: { margin: '6px 0 0', padding: '8px', borderRadius: '8px', border: '1px solid ' + TOKEN.border, background: 'rgba(127,127,127,0.08)', font: '11px ui-monospace, monospace', whiteSpace: 'pre-wrap', userSelect: 'text', maxHeight: '220px', overflow: 'auto' },
541
+ }, report)
542
+ return cardSurface(React, source, markup, scheme, overrides, onPick, toggle, panel, reportPanel, { zoom: Number(zoom), full: Boolean(full), onZoom: (next: number) => setZoom(next), onFull: (next: boolean) => setFull(next) })
543
+ }
544
+
545
+ /** The drawing itself, plus whatever the reader is doing to it. One surface, drawn once. */
546
+ function cardSurface(React: ReactLike, source: unknown, returned: string | null, scheme: PaletteScheme, overrides: Partial<Palette>, onClick: ((event: never) => void) | null, toggle: unknown, panel: unknown, report: unknown, view: { zoom: number; full: boolean; onZoom: (next: number) => void; onFull: (next: boolean) => void } = { zoom: 1, full: false, onZoom: () => {}, onFull: () => {} }): unknown {
547
+ if (returned === null && (typeof source !== 'string' || source.trim() === '')) {
548
+ return React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, 'no drawing in this call')
549
+ }
550
+ // A settled result already carries the drawing the file got; a running call has only its source. Either way
551
+ // it is the same picture from the same renderer.
552
+ const drawn = returned === null
553
+ ? render({ source: String(source), folded: [] }, overrides, { mode: themeMode(), scheme })
554
+ : { markup: returned, refused: [] as string[], colours: palette(overrides, { mode: themeMode(), scheme }) }
555
+ // The drawing keeps its own size and shrinks only when it has to: stretched to the card's width, a two-box
556
+ // diagram became a poster and the card stopped looking like the other cards. Zoom is the control for size.
557
+ const scaled = drawn.markup.replace('<svg ', '<svg style="max-width: 100%; height: auto; display: block; margin: 0 auto" ')
558
+ const refused =
559
+ drawn.refused.length === 0
560
+ ? null
561
+ : React.createElement('pre', { style: { fontSize: '11px', color: '#b7791f', whiteSpace: 'pre-wrap', margin: '6px 8px' } }, drawn.refused.join(String.fromCharCode(10)))
562
+ // Chrome in the host's own colours and type, so the card reads as another code card rather than a plugin:
563
+ // a header line with the drawing's name and its controls, then the picture on its own background.
564
+ const chrome = { border: '1px solid ' + TOKEN.border, borderRadius: '8px', overflow: 'hidden', background: TOKEN.surface, color: TOKEN.text }
565
+ const button = (label: string, onPress: () => void, title: string): unknown =>
566
+ React.createElement('button', { type: 'button', title, onClick: onPress, style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, label)
567
+ const header = React.createElement(
568
+ 'div',
569
+ { style: { display: 'flex', gap: '6px', alignItems: 'center', padding: '3px 8px', borderBottom: '1px solid ' + TOKEN.border, fontSize: '12px', color: TOKEN.dim } },
570
+ React.createElement('span', null, t('diagram')),
571
+ React.createElement('span', { style: { opacity: 0.7 } }, typeof source === 'string' ? (source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? '') : ''),
572
+ React.createElement(
573
+ 'span',
574
+ { style: { marginLeft: 'auto', display: 'flex', gap: '4px', alignItems: 'center' } },
575
+ button('−', () => view.onZoom(Math.max(0.5, Math.round((view.zoom - 0.25) * 100) / 100)), t('zoomOut')),
576
+ button(Math.round(view.zoom * 100) + '%', () => view.onZoom(1), t('actualSize')),
577
+ button('+', () => view.onZoom(Math.min(3, Math.round((view.zoom + 0.25) * 100) / 100)), t('zoomIn')),
578
+ button(view.full ? t('exitFull') : t('full'), () => view.onFull(!view.full), t('full')),
579
+ toggle,
580
+ ),
581
+ )
582
+ // Zoom scales the wrapper, not the markup: the SVG is written at one size and the card is what has to fit a
583
+ // column, a split pane, or a screen.
584
+ const drawing = React.createElement(
585
+ 'div',
586
+ {
587
+ // CSS zoom, not a transform: it reflows, so the scroll area grows with the picture and nothing is clipped.
588
+ style: { zoom: view.zoom as unknown as number, cursor: onClick === null ? undefined : 'crosshair' },
589
+ onClick: onClick === null ? undefined : (onClick as never),
590
+ dangerouslySetInnerHTML: { __html: scaled },
591
+ },
592
+ )
593
+ const body = React.createElement(
594
+ 'div',
595
+ { style: { position: 'relative', background: drawn.colours.background, padding: '6px', maxHeight: view.full ? 'none' : '260px', overflow: 'auto', flex: '1 1 auto', minHeight: '0' } },
596
+ drawing,
597
+ panel,
598
+ report,
599
+ )
600
+ const frame = view.full
601
+ ? { position: 'fixed' as const, inset: '16px', zIndex: 60, display: 'flex', flexDirection: 'column', ...chrome, boxShadow: '0 12px 40px rgba(0,0,0,0.35)' }
602
+ : { display: 'flex', flexDirection: 'column', ...chrome }
603
+ const children = [header, body, refused].filter((child) => child !== null)
604
+ return React.createElement('div', { style: frame }, ...children)
605
+ }
606
+
607
+ /**
608
+ * The view for one tool name.
609
+ *
610
+ * The drawing is emitted as markup, so React receives it through `dangerouslySetInnerHTML`. That is safe
611
+ * only because the writer escapes every value that came from a report or a model, which the render suite
612
+ * pins with an injection case.
613
+ */
614
+ /** Where the page thinks it is, short enough to read in a status line. */
615
+ /**
616
+ * The session the page is showing, asked of the client services in the order that actually answers.
617
+ *
618
+ * The address carries no id on this host; the store does. Fourth place is the host's own recorded selection,
619
+ * which is what the shipped annotation plugin falls back to when the first three are silent - a session id is
620
+ * the one thing every path here needs.
621
+ */
622
+ function currentSessionId(): string | undefined {
623
+ const sessions = services?.get?.('sessions') as { list?: { getSnapshot?: () => { current?: unknown } } } | undefined
624
+ const snapshot = sessions?.list?.getSnapshot?.()
625
+ if (typeof snapshot?.current === 'string' && snapshot.current !== '') return snapshot.current
626
+ const workspace = services?.get?.('uiWorkspace') as {
627
+ mainReference?: { sessionId?: unknown }
628
+ selection?: { getSnapshot?: () => { sessionId?: unknown } }
629
+ } | undefined
630
+ const main = workspace?.mainReference?.sessionId
631
+ if (typeof main === 'string' && main !== '') return main
632
+ const picked = workspace?.selection?.getSnapshot?.()?.sessionId
633
+ if (typeof picked === 'string' && picked !== '') return picked
634
+ const storage = (globalThis as { localStorage?: { getItem?: (key: string) => string | null } }).localStorage
635
+ const raw = storage?.getItem?.('dsh.sessions.current')
636
+ if (typeof raw === 'string' && raw !== '') {
637
+ try {
638
+ const parsed = JSON.parse(raw) as { sessionId?: unknown }
639
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId !== '') return parsed.sessionId
640
+ } catch {
641
+ // A key that is not the shape we expect is simply not an answer.
642
+ }
643
+ }
644
+ return undefined
645
+ }
646
+
647
+ /** The conversation's draft, when this host exposes it: the one writer that keeps line breaks intact. */
648
+ interface DraftShell {
649
+ state?: { getSnapshot?: () => { draft?: unknown }; subscribe?: (listener: () => void) => unknown }
650
+ setDraft?: (text: string) => void
651
+ submit?: (mode?: string) => void
652
+ }
653
+
654
+ function draftShell(): DraftShell | undefined {
655
+ const conversation = services?.get?.('conversation') as { input?: { for?: (scope: unknown) => DraftShell } } | undefined
656
+ const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
657
+ if (conversation?.input?.for === undefined || sessions?.scope === undefined) return undefined
658
+ const id = currentSessionId()
659
+ if (id === undefined) return undefined
660
+ try {
661
+ return conversation.input.for(sessions.scope(id))
662
+ } catch {
663
+ return undefined
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Put the block at the top of the draft through the host, and answer whether it landed.
669
+ *
670
+ * The composer's DOM is the wrong writer: a contenteditable keeps line breaks in its own model, and reaching into
671
+ * it produced one long line. The host's draft setter is the same writer the reader's keystrokes use.
672
+ */
673
+ /** What this plugin last put in the draft, so it can take it back out without touching the reader's words. */
674
+ let placedText = ''
675
+
676
+ /**
677
+ * Put the block in the draft, or take it out again, and keep the reader's own text exactly as it was.
678
+ *
679
+ * The block has to be in the draft BEFORE the message is sent, not at the moment this plugin decides to send.
680
+ * Any Enter handler on the composer - the host's, or another plugin's capture handler - composes the message
681
+ * from the draft it can see, so a block written after that point never reaches the model. Measured: with the
682
+ * annotation plugin active, a note committed at send time arrived as nothing at all.
683
+ *
684
+ * The write is a read-modify-write with the previous block removed first, so committing, adding another note
685
+ * and removing one all converge on one block instead of stacking.
686
+ */
687
+ function replaceBlock(text: string): boolean {
688
+ const shell = draftShell()
689
+ if (shell === undefined || typeof shell.setDraft !== 'function') return false
690
+ const snapshot = shell.state?.getSnapshot?.()
691
+ const current = typeof snapshot?.draft === 'string' ? String(snapshot.draft) : ''
692
+ let rest = current
693
+ if (placedText !== '' && rest.includes(placedText)) rest = rest.split(placedText).join('')
694
+ rest = rest.replace(/^[\r\n]+/, '').replace(/\s+$/, '')
695
+ // Appended, not prepended. The annotation plugin folds a message by deleting every text node BEFORE its own
696
+ // marker; a block placed in front of that marker is deleted with it, which is how our note vanished whenever
697
+ // both kinds were sent together. After it, the block survives their fold.
698
+ const next = text === '' ? rest : (rest === '' ? text : rest + String.fromCharCode(10, 10) + text)
699
+ try {
700
+ shell.setDraft(next)
701
+ placedText = text
702
+ return true
703
+ } catch {
704
+ return false
705
+ }
706
+ }
707
+
708
+ /** The block for a list of notes, in the draft. An empty list removes it. */
709
+ /** The block waiting to leave. Empty when there is nothing to send. */
710
+ let pendingBlock = ''
711
+ let enterWatch: unknown = null
712
+
713
+ /** Remember what would leave with the next message, and start watching for that message. */
714
+ export function setPendingBlock(text: string): void {
715
+ pendingBlock = text
716
+ installEnterCapture()
717
+ }
718
+
719
+ /**
720
+ * Put the block into the message at the instant it leaves.
721
+ *
722
+ * This is the only moment that satisfies both requirements at once: the composer shows nothing while a note
723
+ * is being written, and the message still carries the block because the draft is filled before the host
724
+ * composes it. A capture-phase listener runs before the page's own Enter handling, which is why the write
725
+ * lands in time - and why this is the same trick the shipped annotation plugin uses.
726
+ */
727
+ /**
728
+ * Fold the message that just left, once, at a moment we chose.
729
+ *
730
+ * The earlier attempt started an observer over the document and scanned on every change; a streaming
731
+ * conversation then spent all its time in the selector. This runs one pass, half a second after our own send,
732
+ * and nothing else - the cost is a known single scan per message instead of a continuous one.
733
+ */
734
+ function foldOnce(): void {
735
+ const doc = (globalThis as { document?: DocumentLike }).document
736
+ const timer = (globalThis as { setTimeout?: (handler: () => void, delay: number) => unknown }).setTimeout
737
+ if (doc === undefined || typeof timer !== 'function') return
738
+ // Four passes, not one. The host re-renders the message after the first pass - a fold that landed a moment
739
+ // ago can be gone a moment later, which reads as "the chip showed for an instant and then went away". Four
740
+ // single scans are still cheaper than one observer over a streaming transcript.
741
+ const fold = (): void => {
742
+ try { decorateTranscript(doc as unknown as TranscriptDocumentForClient, t('pending'), t('carried')) } catch { /* the message keeps its block */ }
743
+ }
744
+ for (const delay of [400, 1200, 2600, 5000]) timer(fold, delay)
745
+ }
746
+
747
+ /**
748
+ * Put the count beside the sent message, as an element - never by touching text.
749
+ *
750
+ * The annotation plugin folds a message by deleting the text nodes before its own marker, so anything this
751
+ * plugin wrote inside the message is gone by the time a scan could find it. An element inserted next to its
752
+ * chip is outside that surgery: text nodes are what it removes. When there is no anchor it inserts nothing -
753
+ * a missing chip is a smaller failure than a chip in the wrong place.
754
+ */
755
+ /**
756
+ * Put a count under the bubble that was just sent, outside it.
757
+ *
758
+ * Inside the bubble two other writers work: the host re-renders it, and the annotation plugin deletes the text
759
+ * before its own marker. An element inserted after the bubble is outside both - the row's children are
760
+ * reconciled by position, and a trailing extra child has nothing to disagree with.
761
+ *
762
+ * The bubble is found by the text the reader typed: after every rewrite, that string is still in the draft
763
+ * and in the message alike.
764
+ */
765
+ /** The deepest element whose text is the note block, last in document order. */
766
+ function findBlockElement(doc: any): any {
767
+ const all: any[] = Array.from(doc.querySelectorAll('div, p, span'))
768
+ const hits = all.filter((node) => String(node.textContent ?? '').indexOf('Diagram annotations -') !== -1)
769
+ // The deepest match is not enough: a transcript container also "contains" the marker and is thousands of pixels
770
+ // tall, which put the count at y=24375. The smallest box that still holds the text is the message.
771
+ let best: any
772
+ let bestArea = Number.POSITIVE_INFINITY
773
+ for (const node of hits) {
774
+ const box = node.getBoundingClientRect?.()
775
+ const area = box === undefined || box === null ? Number.POSITIVE_INFINITY : box.width * box.height
776
+ if (area > 0 && area < bestArea) { bestArea = area; best = node }
777
+ }
778
+ return best
779
+ }
780
+
781
+ /** Put the count inside the element that holds the message, after its text: that box already has the padding. */
782
+ function insertChipAfter(doc: any, element: any, label: string): void {
783
+ try {
784
+ const existing = Array.from(doc.querySelectorAll('[data-dme-chip]')) as Array<{ remove?: () => void; textContent?: string }>
785
+ if (existing.some((old) => String(old.textContent ?? '') === label)) return
786
+ for (const old of existing) old.remove?.()
787
+ const chip = doc.createElement('span')
788
+ chip.setAttribute('data-dme-own', '1')
789
+ chip.setAttribute('data-dme-chip', '1')
790
+ chip.textContent = label
791
+ chip.style.cssText = 'position:fixed;z-index:1140;display:inline-block;padding:1px 8px;border-radius:9px;font:11px system-ui;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35));color:var(--dsw-alias-label-secondary, #9aa0a6);background:var(--dsw-alias-bg-layer-1, rgba(30,30,32,0.92))'
792
+ // A body-level layer, the same construction that works for the dock. Appended into the message it lasted until
793
+ // the next render - the message is React's, and this is not.
794
+ doc.body.appendChild(chip)
795
+ chipAnchor = element
796
+ placeChip(doc)
797
+ // The message may not be laid out yet when this runs: a hidden chip is placed again a few times, then left alone.
798
+ const timer2 = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
799
+ if (typeof timer2 === 'function') { for (const delay of [300, 900, 2000, 3500]) timer2(() => { try { placeChip(doc) } catch { /* keeps the last placement */ } }, delay) }
800
+ // Scrolling moves the message, not the layer: keep the chip on the block it belongs to.
801
+ const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void }
802
+ if (!chipScrollWatched && typeof scope.addEventListener === 'function') {
803
+ chipScrollWatched = true
804
+ scope.addEventListener('scroll', () => {
805
+ try {
806
+ const live = doc.querySelector('[data-dme-chip]')
807
+ const anchor = chipAnchor
808
+ const box2 = anchor?.getBoundingClientRect?.()
809
+ if (live === null || box2 === undefined || box2 === null) return
810
+ live.style.setProperty('left', String(Math.round(box2.left)) + 'px')
811
+ live.style.setProperty('top', String(Math.round(box2.bottom) + 4) + 'px')
812
+ } catch { /* a chip that stops following is still a chip */ }
813
+ })
814
+ }
815
+ } catch { /* a chip not shown is not a broken message */ }
816
+ }
817
+
818
+ /** Put the count under the message it belongs to, or hide it while that message is outside the viewport. */
819
+ function placeChip(doc: any): void {
820
+ const chip = doc?.querySelector?.('[data-dme-chip]')
821
+ if (chip === null || chip === undefined) return
822
+ // The cached anchor can be detached by a re-render: a detached node still answers getBoundingClientRect with
823
+ // zeros, so the count would stay hidden forever. Re-find the message by its text when the cached one is gone.
824
+ if (chipAnchor === undefined || chipAnchor.isConnected === false) chipAnchor = findBlockElement(doc)
825
+ const anchor = chipAnchor
826
+ if (anchor === undefined) return
827
+ const box = anchor.getBoundingClientRect?.()
828
+ if (box === undefined || box === null) return
829
+ if (box.width === 0) {
830
+ // Laid out but not measurable yet: leave the count where it is rather than hiding it for good.
831
+ return
832
+ }
833
+ const view = globalThis as { innerHeight?: number }
834
+ const outside = box.bottom < 0 || box.top > Number(view.innerHeight ?? 0)
835
+ chip.style.setProperty('display', outside ? 'none' : 'inline-block')
836
+ if (outside) return
837
+ chip.style.setProperty('left', String(Math.max(8, Math.round(box.left))) + 'px')
838
+ chip.style.setProperty('top', String(Math.max(8, Math.round(box.bottom) + 4)) + 'px')
839
+ }
840
+
841
+ function attachBelowBubble(label: string, typed: string): void {
842
+ const doc = (globalThis as { document?: any }).document
843
+ const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
844
+ if (doc === undefined || typeof timer !== 'function') return
845
+ const wanted = typed.trim()
846
+ let tries = 0
847
+ const attempt = (): void => {
848
+ tries += 1
849
+ try {
850
+ // Two ways to find the message that just left: by the text the reader typed, or - with nothing typed,
851
+ // which is the usual case for a note - by the note block's own opening line.
852
+ const all: any[] = Array.from(doc.querySelectorAll('div, p, span'))
853
+ let hit: any
854
+ if (wanted !== '') {
855
+ const hits = all.filter((node) => String(node.textContent ?? '').trim().startsWith(wanted) && node.parentElement)
856
+ hit = hits[hits.length - 1]
857
+ } else {
858
+ hit = findBlockElement(doc)
859
+ }
860
+ chipAttempt = hit === undefined ? 'not found, try ' + String(tries) : 'found'
861
+ if (hit !== undefined) { insertChipAfter(doc, hit, label); return }
862
+ } catch { /* a chip not shown is not a broken message */ }
863
+ if (tries < 20) timer(attempt, 500)
864
+ }
865
+ timer(attempt, 300)
866
+ }
867
+
868
+ function attachSentChip(label: string): void {
869
+ const doc = (globalThis as { document?: DocumentLike }).document
870
+ const timer = (globalThis as { setTimeout?: (handler: () => void, delay: number) => unknown }).setTimeout
871
+ if (doc === undefined || typeof timer !== 'function') return
872
+ let tries = 0
873
+ const attempt = (): void => {
874
+ tries += 1
875
+ // Their chip is the anchor; the BUBBLE is its parent, and the chip goes after the bubble - not inside it.
876
+ // Inside, it lasted until the next render of a message two other writers are rebuilding.
877
+ const anchor = doc.querySelector?.('[data-annotation-chip]') as { parentElement?: any } | null | undefined
878
+ const bubble = anchor?.parentElement
879
+ const row = bubble?.parentElement
880
+ const made = (doc as unknown as { createElement?: (tag: string) => any }).createElement?.('span')
881
+ if (made !== undefined && bubble !== undefined && bubble !== null && row !== undefined && row !== null) {
882
+ made.textContent = label
883
+ made.setAttribute?.('data-dme-own', '1')
884
+ made.style.cssText = 'display:inline-block;margin:4px 8px 0;padding:1px 8px;border-radius:9px;font:11px system-ui;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35));color:var(--dsw-alias-label-secondary, #9aa0a6)'
885
+ try {
886
+ row.insertBefore(made, bubble.nextSibling)
887
+ return
888
+ } catch {
889
+ // A refused insert is a chip not shown, not a broken message.
890
+ }
891
+ }
892
+ if (tries < 8) timer(attempt, 250)
893
+ }
894
+ timer(attempt, 300)
895
+ }
896
+
897
+ /**
898
+ * The dock, as a layer on `document.body`.
899
+ *
900
+ * A `position: fixed` node still positions against the nearest transformed ancestor, and the composer sits
901
+ * inside one - which is why every coordinate this plugin computed landed inside the box. The shipped
902
+ * annotation plugin sidesteps it the same way: its chip is a body-level layer, so fixed means the viewport.
903
+ * Size and place come from the composer card's own rectangle, measured after every layout change.
904
+ */
905
+ interface DockHandlers { onToggle: () => void; onSend: () => void; onRemove: (index: number) => void }
906
+ let dockLayer: { remove?: () => void } | undefined
907
+ let dockReact: { count: number; open: boolean; items: Array<{ nodeId: string; note: string }>; handlers: DockHandlers } | undefined
908
+
909
+ let dockEl: any
910
+ let dockRetries = 0
911
+ // What the last attempt to place the per-message count saw, for the diagnostic report.
912
+ let chipAttempt = 'not attempted'
913
+ let chipAnchor: any
914
+ let chipScrollWatched = false
915
+ let dockReceipt = ''
916
+ let receiptTimer: unknown
917
+
918
+ /** Show a receipt for a few seconds in the dock layer, and take it down again. */
919
+ function publishDockReceipt(text: string): void {
920
+ dockReceipt = text
921
+ const scope = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (id: unknown) => void }
922
+ if (scope.clearTimeout !== undefined) scope.clearTimeout(receiptTimer)
923
+ receiptTimer = scope.setTimeout?.(() => { dockReceipt = ''; try { scheduleDock() } catch { /* gone */ } }, 8000)
924
+ }
925
+
926
+ let dockObserver: { disconnect: () => void } | undefined
927
+ let dockFrame: number | undefined
928
+
929
+ /** The layer, created once. `cssText` is the form the reference plugin uses and the browser applies. */
930
+ function ensureDock(): any {
931
+ // A cached node that is no longer in the document is a stale handle: writing to it updates nothing and the
932
+ // layer that IS in the document keeps its old position. That is how one control became two.
933
+ if (dockEl !== undefined && dockEl.isConnected !== false) return dockEl
934
+ const doc = (globalThis as { document?: any }).document
935
+ if (doc?.body === undefined || typeof doc.createElement !== 'function') return undefined
936
+ // Exactly one layer: a reload, a remount or a stale reference must not leave two counts floating.
937
+ for (const old of Array.from(doc.querySelectorAll('[data-dme-dock]')) as Array<{ remove?: () => void }>) old.remove?.()
938
+ const el = doc.createElement('div')
939
+ el.setAttribute('data-dme-dock', '1')
940
+ el.setAttribute('data-dme-own', '1')
941
+ el.style.cssText = 'position:fixed;z-index:1140;display:none;align-items:center;gap:6px;padding:4px 8px;border-radius:11px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35));background:var(--dsw-alias-bg-layer-1, #2c2c2e);box-shadow:0 8px 24px rgba(0,0,0,0.24);font:11px var(--dsw-font-family, system-ui);color:var(--dsw-alias-label-primary, #eee)'
942
+ doc.body.appendChild(el)
943
+ dockEl = el
944
+ return el
945
+ }
946
+
947
+ /** One update per frame at most, the way the reference plugin schedules it. */
948
+ function scheduleDock(): void {
949
+ const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame
950
+ if (dockFrame !== undefined || typeof raf !== 'function') { updateDockChip(); return }
951
+ dockFrame = raf(() => { dockFrame = undefined; updateDockChip() })
952
+ }
953
+
954
+ /** Anchors the layer on the composer card, and follows it when that card changes size. */
955
+ function observeDockAnchor(): void {
956
+ const doc = (globalThis as { document?: any }).document
957
+ const card = doc?.querySelector?.('[data-composer-card]')
958
+ if (card === null || card === undefined) return
959
+ const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void; disconnect: () => void } }).ResizeObserver
960
+ if (Observer === undefined) return
961
+ try {
962
+ dockObserver?.disconnect()
963
+ const fresh = new Observer(() => { scheduleDock() })
964
+ fresh.observe(card)
965
+ dockObserver = fresh
966
+ } catch { dockObserver = undefined }
967
+ }
968
+
969
+ function updateDockChip(): void {
970
+ const state = dockReact
971
+ const el = ensureDock()
972
+ if (state === undefined || el === undefined) return
973
+ const doc = (globalThis as { document?: any }).document
974
+ if (state.count === 0) {
975
+ if (dockReceipt === '') { el.style.display = 'none'; return }
976
+ // The receipt is text only: nothing to click, nothing to send.
977
+ el.textContent = ''
978
+ const note = doc.createElement('span')
979
+ note.textContent = dockReceipt
980
+ note.style.cssText = 'opacity:.75'
981
+ el.appendChild(note)
982
+ el.style.display = 'flex'
983
+ const cardRect = doc.querySelector('[data-composer-card]')?.getBoundingClientRect?.()
984
+ if (cardRect === undefined) { el.style.left = '16px'; el.style.top = '16px'; return }
985
+ const w = Number(el.offsetWidth) || 100
986
+ const h = Number(el.offsetHeight) || 24
987
+ el.style.left = Math.max(8, Math.min(cardRect.left, Number((globalThis as { innerWidth?: number }).innerWidth ?? 0) - w - 8)) + 'px'
988
+ el.style.top = Math.max(8, cardRect.top - h - 8) + 'px'
989
+ return
990
+ }
991
+ const create = (tag: string): any => doc.createElement(tag)
992
+ el.textContent = ''
993
+ const toggle = create('button')
994
+ toggle.textContent = t('pending') + ' ×' + String(state.count)
995
+ toggle.style.cssText = 'font:11px inherit;padding:1px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35));background:transparent;color:inherit;cursor:pointer'
996
+ toggle.addEventListener('click', () => state.handlers.onToggle())
997
+ el.appendChild(toggle)
998
+ const send = create('button')
999
+ send.textContent = t('send')
1000
+ send.style.cssText = 'font:11px inherit;padding:1px 8px;border-radius:6px;border:1px solid var(--dsw-alias-brand-primary,#2563eb);background:var(--dsw-alias-brand-primary,#2563eb);color:#fff;cursor:pointer'
1001
+ send.addEventListener('click', () => state.handlers.onSend())
1002
+ el.appendChild(send)
1003
+ if (state.open && state.items.length > 0) {
1004
+ for (const [index, item] of state.items.entries()) {
1005
+ const line = create('span')
1006
+ line.textContent = item.nodeId + ':' + (item.note === '' ? t('markedOnly') : item.note)
1007
+ line.title = '点击删除'
1008
+ line.style.cssText = 'cursor:pointer;opacity:.85'
1009
+ line.addEventListener('click', () => state.handlers.onRemove(index))
1010
+ el.appendChild(line)
1011
+ }
1012
+ }
1013
+ el.style.display = 'flex'
1014
+ observeDockAnchor()
1015
+ // Same seat as the annotation plugin, read from its source: the composer card, and the seat that holds it.
1016
+ // Above the card, right-aligned to it, 6px clear - which is where that plugin's own count sits.
1017
+ const surface = (findComposer(doc) ?? doc.querySelector('textarea, [contenteditable="true"]')) as any
1018
+ const rect = surface?.getBoundingClientRect?.()
1019
+ const cardEl = (doc.querySelector('[data-composer-card]') ?? null) as any
1020
+ const seatEl = (cardEl?.closest?.('[data-composer-seat]') ?? null) as any
1021
+ const cardRect = cardEl?.getBoundingClientRect?.() ?? null
1022
+ void surface
1023
+ const width = Number(el.offsetWidth) || 100
1024
+ const height = Number(el.offsetHeight) || 26
1025
+ if (rect === undefined && cardRect === null) { el.style.left = '16px'; el.style.top = '16px'; return }
1026
+ const view = globalThis as { innerWidth?: number }
1027
+ const anchor = cardRect ?? rect
1028
+ const seatTop = seatEl?.getBoundingClientRect?.()?.top
1029
+ const topEdge = Math.min(anchor.top, typeof seatTop === 'number' ? seatTop : anchor.top)
1030
+ // Left-aligned to the card, because the annotation plugin's own count sits on the right of the same strip and
1031
+ // two right-aligned controls would cover each other. Same row, opposite ends.
1032
+ const left = Math.max(8, Math.min(anchor.left, Number(view.innerWidth ?? 0) - width - 8))
1033
+ el.style.left = left + 'px'
1034
+ el.style.top = Math.max(8, topEdge - height - 6) + 'px'
1035
+ observeDivider(doc)
1036
+ observeSurface(doc, surface)
1037
+ // Before the composer has a box the rectangle is degenerate and the control lands at the edge. Retry until the
1038
+ // number stops looking like "not laid out yet" - bounded, so a host that never lays out does not spin.
1039
+ const scope2 = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }
1040
+ if (left <= 8 && dockRetries < 12 && typeof scope2.setTimeout === 'function') {
1041
+ dockRetries += 1
1042
+ scope2.setTimeout(() => { try { updateDockChip() } catch { /* keeps the last placement */ } }, 250)
1043
+ } else if (left > 8) {
1044
+ dockRetries = 0
1045
+ }
1046
+ }
1047
+
1048
+ /** Keep the docked count in step with the pending list. Called from a render path, so it cannot throw. */
1049
+ export function updateDockLayer(items: Array<{ nodeId: string; note: string }>, open: boolean, handlers: DockHandlers): void {
1050
+ dockReact = { count: items.length, open, items, handlers }
1051
+ bumpDock()
1052
+ if (dockSlotMounted) return
1053
+ try { scheduleDock() } catch { /* a lost badge never costs the drawing */ }
1054
+ }
1055
+
1056
+ /** Set by the slot component while it is mounted; the body layer stands down when it is. */
1057
+ let dockSlotMounted = false
1058
+ let dockVersion = 0
1059
+ const dockSubs = new Set<() => void>()
1060
+
1061
+ function bumpDock(): void {
1062
+ dockVersion += 1
1063
+ for (const listener of Array.from(dockSubs)) {
1064
+ try { listener() } catch { /* one listener must not stop the others */ }
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * The count, rendered into the host's dock slot.
1070
+ *
1071
+ * Same state as the body layer, so the card drives one source of truth and the host decides where it goes.
1072
+ */
1073
+ function dockSlotView(React: ReactLike): () => unknown {
1074
+ return function DockSlot(): unknown {
1075
+ const [, setVersion] = (React.useState ?? ((initial: number) => [initial, () => {}] as [number, (next: number) => void]))(0) as [number, (next: number) => void]
1076
+ React.useEffect?.(() => {
1077
+ dockSlotMounted = true
1078
+ const listener = (): void => setVersion(dockVersion)
1079
+ dockSubs.add(listener)
1080
+ return () => { dockSubs.delete(listener); dockSlotMounted = false }
1081
+ }, [])
1082
+ // The slot is full-width; the composer card is inset inside it. The count aligns with the card, so the
1083
+ // insets are measured from the card's own box rather than guessed - and re-measured when either side
1084
+ // changes size, which is what a dragged divider does.
1085
+ const [pad, setPad] = (React.useState ?? ((initial: { left: number; right: number }) => [initial, () => {}]))({ left: 0, right: 0 }) as [{ left: number; right: number }, (next: { left: number; right: number }) => void]
1086
+ const [diag, setDiag] = (React.useState ?? ((initial: string) => [initial, () => {}]))('') as [string, (next: string) => void]
1087
+ React.useEffect?.(() => {
1088
+ const doc = (globalThis as { document?: any }).document
1089
+ const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
1090
+ const measure = (): void => {
1091
+ try {
1092
+ const slot = doc?.querySelector?.('[data-dme-dock-slot]')
1093
+ // Same two rectangles the render uses, so a measurement taken here and a measurement taken there agree.
1094
+ const card = (findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')) as any
1095
+ if (slot === null || slot === undefined || card === null || card === undefined) return
1096
+ const s = slot.getBoundingClientRect()
1097
+ const c = card.getBoundingClientRect()
1098
+ const next = { left: Math.max(0, Math.round(c.left - s.left)), right: Math.max(0, Math.round(s.right - c.right)) }
1099
+ setPad(next)
1100
+ // The numbers are shown in the control itself: a screenshot then answers what a console command would,
1101
+ // and this loop stops being guesswork.
1102
+ setDiag(' L' + String(next.left) + ' R' + String(next.right) + ' s' + String(Math.round(s.left)) + ' c' + String(Math.round(c.left)) + ' w' + String(Math.round(c.width)))
1103
+ } catch { /* unmeasured insets are a cosmetic miss */ }
1104
+ }
1105
+ // The composer can be laid out after this mounts: the first measurements come back 0 and the control sits
1106
+ // at the column edge until some unrelated change forces a re-render (a pasted picture did). Retry until the
1107
+ // number settles instead of hoping one measurement lands.
1108
+ if (typeof timer === 'function') { for (const delay of [0, 100, 250, 500, 900, 1500, 2400]) timer(measure, delay) }
1109
+ const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void; removeEventListener?: (t: string, fn: () => void) => void }
1110
+ scope.addEventListener?.('resize', measure)
1111
+ const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void; disconnect: () => void } }).ResizeObserver
1112
+ let watcher: { observe: (t: unknown) => void; disconnect: () => void } | undefined
1113
+ if (Observer !== undefined) {
1114
+ try {
1115
+ watcher = new Observer(measure)
1116
+ watcher.observe(doc?.body)
1117
+ } catch { watcher = undefined }
1118
+ }
1119
+ return () => { watcher?.disconnect(); scope.removeEventListener?.('resize', measure) }
1120
+ }, [])
1121
+ const state = dockReact
1122
+ if (state === undefined || state.count === 0) return null
1123
+ // Measured at render time, not only in an effect: the effect can run before the host has laid the slot out,
1124
+ // and a zero measured at that moment is then the value the control keeps.
1125
+ let livePad = pad
1126
+ try {
1127
+ const doc = (globalThis as { document?: any }).document
1128
+ const slotEl = doc?.querySelector?.('[data-dme-dock-slot]')
1129
+ const surfaceEl = (findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')) as any
1130
+ const sr = slotEl?.getBoundingClientRect?.()
1131
+ const cr = surfaceEl?.getBoundingClientRect?.()
1132
+ if (sr !== undefined && sr !== null && cr !== undefined && cr !== null) {
1133
+ livePad = { left: Math.max(0, Math.round(cr.left - sr.left)), right: Math.max(0, Math.round(sr.right - cr.right)) }
1134
+ }
1135
+ } catch { /* the effect's value stands */ }
1136
+ return React.createElement(
1137
+ 'div',
1138
+ {
1139
+ 'data-dme-own': '1',
1140
+ 'data-dme-dock-slot': '1',
1141
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '6px', paddingTop: '2px', paddingBottom: '2px', paddingLeft: String(livePad.left) + 'px', paddingRight: String(livePad.right) + 'px', font: '11px system-ui, sans-serif', color: 'inherit' },
1142
+ },
1143
+ React.createElement('button', {
1144
+ type: 'button',
1145
+ onClick: () => state.handlers.onToggle(),
1146
+ style: { font: 'inherit', padding: '1px 8px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: 'inherit', cursor: 'pointer' },
1147
+ }, t('pending') + ' ×' + String(state.count) + ' L' + String(livePad.left) + diag),
1148
+ React.createElement('button', {
1149
+ type: 'button',
1150
+ onClick: () => state.handlers.onSend(),
1151
+ style: { font: 'inherit', padding: '1px 8px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: 'inherit', cursor: 'pointer' },
1152
+ }, t('send')),
1153
+ )
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * The composer's card: the nearest ancestor of the writing surface that looks like a box.
1159
+ *
1160
+ * Hosts mark it differently - one ships `[data-composer-card]`, another marks nothing - but every one of them
1161
+ * draws the input as a rounded card inside the column. Walking up for a border radius finds that box without
1162
+ * depending on an attribute name.
1163
+ */
1164
+ function composerBox(doc: any): any {
1165
+ try {
1166
+ const marked = doc?.querySelector?.('[data-composer-card]')
1167
+ if (marked !== null && marked !== undefined) return marked
1168
+ // The column is the full-width reference; the card is the widest ancestor of the writing surface that is
1169
+ // narrower than that column. Measured both ways round - no constant, and a dragged divider changes both.
1170
+ const column = doc?.querySelector?.('[data-conversation-scroll]')?.getBoundingClientRect?.()
1171
+ let node = findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')
1172
+ let card = node
1173
+ for (let depth = 0; depth < 8 && node !== null && node !== undefined; depth += 1) {
1174
+ const rect = node.getBoundingClientRect?.()
1175
+ if (rect !== undefined && rect.width > 0) {
1176
+ const inset = column === undefined ? false : rect.width < column.width - 4
1177
+ if (inset) card = node
1178
+ }
1179
+ node = node.parentElement
1180
+ }
1181
+ return card
1182
+ } catch {
1183
+ return undefined
1184
+ }
1185
+ }
1186
+
1187
+ let dividerObserved: unknown
1188
+ let surfaceObserved: unknown
1189
+ let surfaceInputWatched = false
1190
+
1191
+ /**
1192
+ * Re-place when the composer changes height.
1193
+ *
1194
+ * A pasted picture grows the box and the control has to move up with it. The surface alone is not enough:
1195
+ * some hosts grow a wrapper and let the inner element keep its size, so the wrapper is watched too.
1196
+ */
1197
+ function observeSurface(doc: any, surface: any): void {
1198
+ try {
1199
+ if (surface === undefined || surface === null || surfaceObserved === surface) return
1200
+ surfaceObserved = surface
1201
+ // A pasted picture grows whichever ancestor the host chose, and the surface itself may keep its size while
1202
+ // only moving down - which fires no resize at all. Watch a few levels up, and listen to the paste itself.
1203
+ // Two levels only: the surface and the box around it. Watching further up fired on unrelated layout changes
1204
+ // and moved the control to places the reader did not ask for.
1205
+ const targets: any[] = [surface, surface?.parentElement].filter((node) => node !== null && node !== undefined)
1206
+ const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void } }).ResizeObserver
1207
+ if (Observer !== undefined) {
1208
+ const watcher = new Observer(() => { scheduleDock() })
1209
+ for (const target of targets) { try { watcher.observe(target) } catch { /* one target is enough to try */ } }
1210
+ }
1211
+ const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void; setTimeout?: (fn: () => void, ms: number) => unknown }
1212
+ if (typeof scope.addEventListener === 'function' && !surfaceInputWatched) {
1213
+ surfaceInputWatched = true
1214
+ // `input` covers paste, typing and deletion; two delayed passes catch a layout that settles after it.
1215
+ scope.addEventListener('input', () => { scheduleDock(); scope.setTimeout?.(() => { scheduleDock() }, 120); scope.setTimeout?.(() => { scheduleDock() }, 400) })
1216
+ scope.addEventListener('paste', () => { scope.setTimeout?.(() => { scheduleDock() }, 60) })
1217
+ }
1218
+ } catch { /* no observer, no follow */ }
1219
+ }
1220
+
1221
+ /**
1222
+ * Re-place after a divider drag.
1223
+ *
1224
+ * The host's column divider is `[class*="widthHandle"]`. Watching its size catches the drag itself; a
1225
+ * pointerup catches the release, in case the layout settles after the last size change.
1226
+ */
1227
+ function observeDivider(doc: any): void {
1228
+ try {
1229
+ const handle = doc?.querySelector?.('[class*="widthHandle"]')
1230
+ if (handle === null || handle === undefined || dividerObserved === handle) return
1231
+ dividerObserved = handle
1232
+ const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void } }).ResizeObserver
1233
+ if (Observer !== undefined) {
1234
+ try { new Observer(() => { scheduleDock() }).observe(handle) } catch { /* no observer, no drag follow */ }
1235
+ }
1236
+ const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void }
1237
+ scope.addEventListener?.('pointerup', () => { scheduleDock() })
1238
+ } catch { /* a missing handle is a layout that never changes */ }
1239
+ }
1240
+
1241
+ /**
1242
+ * One click, one paste: what the page actually contains.
1243
+ *
1244
+ * Every layout question so far cost a screenshot and a guess. The facts that decide those questions are cheap to
1245
+ * collect - which anchors exist, where they are, whether our nodes are still in the document - so they are
1246
+ * collected here and shown as selectable text.
1247
+ */
1248
+ function domReport(): string {
1249
+ const doc = (globalThis as { document?: any }).document
1250
+ if (doc === undefined) return 'no document'
1251
+ const lines: string[] = []
1252
+ const rect = (el: any): string => {
1253
+ const r = el?.getBoundingClientRect?.()
1254
+ return r === undefined || r === null ? 'absent' : [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)].join(',')
1255
+ }
1256
+ const probe = (name: string, selector: string): void => {
1257
+ const el = doc.querySelector(selector)
1258
+ lines.push(name + ' ' + (el === null || el === undefined ? 'absent' : 'l,t,w,h=' + rect(el)))
1259
+ }
1260
+ probe('[data-composer-card]', '[data-composer-card]')
1261
+ probe('[data-composer-seat]', '[data-composer-seat]')
1262
+ probe('textarea/contenteditable', 'textarea, [contenteditable="true"]')
1263
+ probe('[data-annotation-chip]', '[data-annotation-chip]')
1264
+ probe('[data-conversation-scroll]', '[data-conversation-scroll]')
1265
+ probe('widthHandle', '[class*="widthHandle"]')
1266
+ const dock = doc.querySelector('[data-dme-dock]')
1267
+ lines.push('dock ' + (dock === null ? 'absent' : 'l,t,w,h=' + rect(dock) + ' connected=' + String(dock.isConnected)))
1268
+ const chips = doc.querySelectorAll('[data-dme-chip]')
1269
+ lines.push('chips ' + String(chips.length) + ' ' + Array.from(chips).map((chip) => rect(chip)).join(' | '))
1270
+ lines.push('pendingBlock ' + String(pendingBlock === '' ? 'empty' : pendingBlock.split(String.fromCharCode(10)).length + ' lines'))
1271
+ lines.push('chipAttempt ' + chipAttempt)
1272
+ lines.push('dockedCount ' + String(dockReact?.count ?? 0) + ' placedText ' + String(placedText.length))
1273
+ lines.push('viewport ' + String((globalThis as { innerWidth?: number }).innerWidth) + 'x' + String((globalThis as { innerHeight?: number }).innerHeight))
1274
+ const report = lines.join(String.fromCharCode(10))
1275
+ try { (globalThis as { console?: { log?: (value: string) => void } }).console?.log?.(report) } catch { /* console may be gone */ }
1276
+ return report
1277
+ }
1278
+
1279
+ function refreshDock(): void { scheduleDock() }
1280
+ function installEnterCapture(): void {
1281
+ if (pendingBlock === '') return
1282
+ const doc = (globalThis as { document?: DocumentLike }).document
1283
+ const composer = findComposer(doc)
1284
+ if (composer === undefined || composer === null) return
1285
+ if (enterWatch === composer) return
1286
+ enterWatch = composer
1287
+ const listen = (composer as { addEventListener?: (type: string, handler: (event: unknown) => void, capture?: boolean) => void }).addEventListener
1288
+ if (typeof listen !== 'function') return
1289
+ listen.call(composer, 'keydown', (event: unknown) => {
1290
+ const key = event as { key?: string; shiftKey?: boolean; isComposing?: boolean }
1291
+ if (key?.key !== 'Enter' || key.shiftKey === true || key.isComposing === true) return
1292
+ if (pendingBlock === '') return
1293
+ // Written, not submitted: the host's own Enter handling submits right after this returns.
1294
+ // With nothing typed, a bare "Question:" would be the message's last line and would invite an answer
1295
+ // nobody asked for - the same rule the click path applies.
1296
+ const shell = draftShell()
1297
+ const draft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
1298
+ const block = draft.trim() === '' ? withoutEmptyQuestion(pendingBlock) : pendingBlock
1299
+ replaceBlock(block)
1300
+ // The count has to appear for this message too: pressing Enter is the path most readers take, and a
1301
+ // marker that only answers the button is a marker half the messages never get.
1302
+ const count = (block.match(/^Diagram note \[/gm) ?? []).length
1303
+ const label = '图注 ×' + String(count === 0 ? 1 : count)
1304
+ // The below-bubble insert is the one that lines up with the message text: it lands inside the element that
1305
+ // already carries the text's padding. The sibling insert sat at the row's edge instead. One of them, and it
1306
+ // is this one.
1307
+ attachBelowBubble(label, draft)
1308
+ void attachSentChip
1309
+ notifySent()
1310
+ // foldOnce() is off: rewriting the message text made the bubble flicker back to the raw block on the next
1311
+ // host render, and every re-fold landed beside the chip. The chip is the feature; the text stays as sent.
1312
+ pendingBlock = ''
1313
+ }, true)
1314
+ }
1315
+
1316
+ /** Tell the surfaces that a note left, so their own chrome can say so. */
1317
+ function notifySent(): void {
1318
+ // Clear the dock here as well as through React: the card can unmount while the message is being sent (the
1319
+ // tool row collapses), and a listener that never runs leaves a count for a note that already left.
1320
+ if (dockReact !== undefined) {
1321
+ const before = dockReact.count
1322
+ dockReact = { count: 0, open: false, items: [], handlers: dockReact.handlers }
1323
+ // A note that left keeps a short-lived receipt in this plugin's own layer. The bubble is not the place for
1324
+ // it: the transcript is rebuilt by the host and by any other plugin folding the same message, and a chip
1325
+ // planted there survives only until the next re-render.
1326
+ if (before > 0) publishDockReceipt('图注 ×' + String(before) + ' 已发送')
1327
+ try { scheduleDock() } catch { /* the count is a convenience */ }
1328
+ }
1329
+ for (const listener of sentListeners.slice()) {
1330
+ try { listener() } catch { /* one listener must not keep the others from hearing it */ }
1331
+ }
1332
+ }
1333
+
1334
+ function placeListBlock(list: Array<{ nodeId: string; note: string }>, caption: string): boolean {
1335
+ return replaceBlock(list.length === 0 ? '' : annotationBlock(list.map((item) => ({ ...item, caption }))))
1336
+ }
1337
+
1338
+ function placeBlock(text: string): boolean {
1339
+ // With nothing typed, a trailing question line would be the only thing left to read: drop it before placing.
1340
+ const shell = draftShell()
1341
+ const snapshot = shell?.state?.getSnapshot?.()
1342
+ const current = typeof snapshot?.draft === 'string' ? String(snapshot.draft) : ''
1343
+ return replaceBlock(current.trim() === '' ? withoutEmptyQuestion(text) : text)
1344
+ }
1345
+
1346
+ /** Listeners waiting for the message to leave. */
1347
+ let sentListeners: Array<() => void> = []
1348
+ let watchState: 'idle' | 'watching' | 'unavailable' = 'idle'
1349
+ let lastSeenDraft = ''
1350
+
1351
+ /**
1352
+ * Tell this module when a message leaves, by watching the one signal the host gives: the draft empties.
1353
+ *
1354
+ * A note that was committed is in the draft, so after the send there is nothing left to keep - and a chip still
1355
+ * counting a note that has already gone out is the plugin lying about its own state.
1356
+ */
1357
+ export function onMessageSent(listener: () => void): () => void {
1358
+ sentListeners.push(listener)
1359
+ watchDraft()
1360
+ return () => { sentListeners = sentListeners.filter((item) => item !== listener) }
1361
+ }
1362
+
1363
+ function watchDraft(): void {
1364
+ if (watchState !== 'idle') return
1365
+ const shell = draftShell()
1366
+ const subscribe = shell?.state?.subscribe
1367
+ if (typeof subscribe !== 'function') {
1368
+ // A host that has not loaded its conversation yet is retried by the next caller, not by a timer.
1369
+ return
1370
+ }
1371
+ watchState = 'watching'
1372
+ try {
1373
+ lastSeenDraft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
1374
+ subscribe(() => {
1375
+ const draft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
1376
+ const before = lastSeenDraft
1377
+ lastSeenDraft = draft
1378
+ if (before.trim() === '' || draft.trim() !== '') return
1379
+ // Only a send clears the count. An empty box that Backspace or a deletion empties is not a send, and
1380
+ // treating it as one deleted the reader's note without sending anything.
1381
+ if (placedText === '') return
1382
+ placedText = ''
1383
+ for (const listener of sentListeners.slice()) {
1384
+ try { listener() } catch { /* one listener must not keep the others from hearing it */ }
1385
+ }
1386
+ })
1387
+ } catch {
1388
+ watchState = 'unavailable'
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Send the block through the session's command channel, which never touches the composer.
1394
+ *
1395
+ * The client cannot call a host service directly; the remote facade is the only route, and its signature is
1396
+ * `execute(sessionId, line, attachments)` - the session id first, because the facade resolves the agent itself.
1397
+ *
1398
+ * Returns the reason it worked, or undefined. Undefined means "use the composer": a command that silently did
1399
+ * nothing would lose the notes, and this function is the only thing standing between a note and the void.
1400
+ */
1401
+ let commandWhy = 'not tried'
1402
+
1403
+ async function sendViaCommand(text: string): Promise<string | undefined> {
1404
+ commandWhy = 'not tried'
1405
+ const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
1406
+ const id = currentSessionId()
1407
+ if (id === undefined) { commandWhy = 'no session id'; return undefined }
1408
+ if (typeof sessions?.scope !== 'function') { commandWhy = 'no sessions service'; return undefined }
1409
+ // The remote facade hangs off the CLIENT context, not off a session scope: page-side code writes
1410
+ // `ctx.remote.commands.execute(sessionId, line, attachments)` and the facade resolves the agent itself
1411
+ // (that is how ui-commands calls it). The scope route stays as a second door for a host that mounts it there.
1412
+ let commands: { execute?: unknown } | undefined
1413
+ commands = (services as unknown as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote?.commands
1414
+ if (commands === undefined || typeof commands.execute !== 'function') {
1415
+ try {
1416
+ commands = ((sessions.scope(id) as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote)?.commands
1417
+ } catch {
1418
+ return undefined
1419
+ }
1420
+ }
1421
+ if (commands === undefined || typeof commands.execute !== 'function') { commandWhy = 'no remote.commands.execute'; return undefined }
1422
+ try {
1423
+ // A remote call that never settles would take the button with it: the note would sit in no draft and no
1424
+ // message, and the reader would see a click that did nothing. The wait is bounded, and a timeout is just
1425
+ // another reason to fall back to the composer.
1426
+ const timeout = new Promise<string>((resolve) => {
1427
+ const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
1428
+ if (typeof timer === 'function') timer(() => resolve('timeout'), 1200)
1429
+ else resolve('timeout')
1430
+ })
1431
+ const raced = await Promise.race([
1432
+ (commands.execute as (session: string, line: string, attachments: unknown[]) => Promise<{ kind?: string } | undefined>)(id, '/annotate ' + text, []),
1433
+ timeout,
1434
+ ])
1435
+ if (raced === 'timeout') { commandWhy = 'execute timed out'; return undefined }
1436
+ const result = typeof raced === 'string' ? undefined : raced
1437
+ if (result?.kind === 'success') return '已通过会话命令发送(输入框未占用)'
1438
+ commandWhy = 'execute said ' + String(result?.kind ?? 'nothing')
1439
+ return undefined
1440
+ } catch (error) {
1441
+ commandWhy = 'execute threw: ' + String((error as { message?: unknown })?.message ?? error).slice(0, 60)
1442
+ return undefined
1443
+ }
1444
+ }
1445
+
1446
+ /** Ask the host to submit the draft it now holds. */
1447
+ function submitDraft(): boolean {
1448
+ const shell = draftShell()
1449
+ if (shell === undefined || typeof shell.submit !== 'function') return false
1450
+ try {
1451
+ shell.submit('queue')
1452
+ // foldOnce() is off: rewriting the message text made the bubble flicker back to the raw block on the next
1453
+ // host render, and every re-fold landed beside the chip. The chip is the feature; the text stays as sent.
1454
+ return true
1455
+ } catch {
1456
+ return false
1457
+ }
1458
+ }
1459
+
1460
+ function shortAddress(location: { pathname?: string; search?: string } | undefined): string {
1461
+ if (location === undefined) return 'no location'
1462
+ return ((location.pathname ?? '') + (location.search ?? '')).slice(0, 80)
1463
+ }
1464
+
1465
+ function reasonOf(error: unknown): string {
1466
+ const message = (error as { message?: unknown } | null)?.message
1467
+ return String(message ?? error).slice(0, 120)
1468
+ }
1469
+
1470
+ /**
1471
+ * Try the session-command channel, and answer either `'ok'` or the reason it stopped.
1472
+ *
1473
+ * The client half sees no host API directly: its only route is a client service, and the only session it can
1474
+ * name is the one in the page's own address. Every step here answers with its reason, and the reader sees
1475
+ * that reason - so one click says whether the channel exists at all, and where it broke.
1476
+ */
1477
+ async function probePush(text: string): Promise<string> {
1478
+ const location = (globalThis as { location?: { pathname?: string; search?: string; hash?: string } }).location
1479
+ const session = sessionFromLocation(location)
1480
+ if (session === undefined) return 'no session id in ' + shortAddress(location)
1481
+ const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
1482
+ if (sessions === undefined || typeof sessions.scope !== 'function') return 'no sessions service'
1483
+ let scope: unknown
1484
+ try { scope = sessions.scope(session) } catch (error) { return 'scope threw: ' + reasonOf(error) }
1485
+ const remote = (scope as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote
1486
+ if (remote === undefined) return 'the agent scope has no remote'
1487
+ const commands = remote.commands
1488
+ if (commands === undefined || typeof commands.execute !== 'function') return 'remote.commands.execute is missing'
1489
+ const Controller = (globalThis as { AbortController?: new () => { signal: unknown } }).AbortController
1490
+ try {
1491
+ await (commands.execute as (agent: unknown, line: string, attachments: unknown[], signal: unknown) => Promise<unknown>).call(commands, { id: session }, '/annotate ' + text, [], Controller === undefined ? undefined : new Controller().signal)
1492
+ return 'ok'
1493
+ } catch (error) {
1494
+ return 'execute threw: ' + reasonOf(error)
1495
+ }
1496
+ }
1497
+
1498
+ export function viewFor(React: ReactLike, overrides: Partial<Palette> = {}): (props: ToolCallProps) => unknown {
1499
+ return (props: ToolCallProps): unknown => {
1500
+ // A drawing is not a report: its card is the source, drawn by the renderer that wrote the file.
1501
+ if (props.toolName === DRAWING_TOOL) return drawingCard(React, props, overrides)
1502
+ // The whole block goes to the view model: the host shapes it differently per phase (a lazy arguments
1503
+ // view while running, content blocks once settled), and only that module knows how to read it.
1504
+ const diagram = viewModel({ phase: props.phase, toolName: props.toolName, block: props.block })
1505
+ const { canvas, colours } = paintWith(diagram, overrides, { mode: themeMode() })
1506
+ const written = toSvg(canvas, colours, diagram.kind === 'none' ? diagram.caption : diagram.title + ': ' + diagram.caption)
1507
+ return React.createElement('div', {
1508
+ style: surfaceStyle(colours),
1509
+ dangerouslySetInnerHTML: { __html: written.markup },
1510
+ })
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * The whiteboard: paste a diagram, get a drawing, zoom it.
1516
+ *
1517
+ * It holds no host data, which is the point: it is a place to think about a diagram while writing one, and
1518
+ * it cannot be broken by a change to any tool's result shape. The source it draws is whatever is in its own
1519
+ * box.
1520
+ */
1521
+ export function whiteboardFor(React: ReactLike, overrides: Partial<Palette> = {}): () => unknown {
1522
+ const h = (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]): unknown => React.createElement(type, props, ...children)
1523
+ return function Whiteboard(): unknown {
1524
+ const useState = React.useState as (<T>(initial: T) => [T, (next: T) => void]) | undefined
1525
+ if (useState === undefined) {
1526
+ // A host without hooks: the same board, read-only. It still draws, which is the part that matters.
1527
+ return h(
1528
+ 'div',
1529
+ { style: { padding: '8px' } },
1530
+ h('textarea', {
1531
+ value: WHITEBOARD_SAMPLE,
1532
+ readOnly: true,
1533
+ spellcheck: false,
1534
+ rows: WHITEBOARD_SAMPLE.split('\n').length,
1535
+ style: { width: '100%', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace' },
1536
+ }),
1537
+ // The picker is present but inert: a host without hooks cannot hold the choice, and hiding the
1538
+ // option would make the board look like it has none.
1539
+ h('select', { disabled: true, style: { margin: '4px 0', fontSize: '12px' } },
1540
+ ...schemeNames().map((name) => h('option', { value: name, selected: name === 'default' }, name))),
1541
+ whiteboardBody(React, overrides, WHITEBOARD_SAMPLE, 1),
1542
+ )
1543
+ }
1544
+ const [source, setSource] = useState(WHITEBOARD_SAMPLE)
1545
+ const [zoom, setZoom] = useState(1)
1546
+ const [scheme, setScheme] = useState<PaletteScheme>('default')
1547
+ const [fit, setFit] = useState(true)
1548
+ const [folded, setFolded] = useState<string[]>([])
1549
+ const [annotate, setAnnotate] = useState(false)
1550
+ const [picked, setPicked] = useState<{ id: string } | null>(null)
1551
+ const [anchor, setAnchor] = useState<{ left: number; top: number } | undefined>(undefined)
1552
+ const [note, setNote] = useState('')
1553
+ const [status, setStatus] = useState('')
1554
+ const [pending, setPending] = useState<Array<{ nodeId: string; note: string }>>([])
1555
+ const [open, setOpen] = useState(false)
1556
+ React.useEffect?.(() => { try { return onMessageSent(() => { setPending([]); setStatus('') }) } catch { return undefined } }, [])
1557
+ const drawn = drawSample(React, overrides, source, fit ? 0 : zoom, scheme, folded)
1558
+ const document = (globalThis as { document?: DocumentLike }).document
1559
+ const caption = source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? ''
1560
+ // The notes accumulate here; one structured message leaves for the session, not one raw fragment per note.
1561
+ const sendAll = (list: Array<{ nodeId: string; note: string }>, submit: boolean): void => {
1562
+ if (list.length === 0) return
1563
+ const text = annotationBlock(list.map((item) => ({ ...item, caption })))
1564
+ void (async () => {
1565
+ // The draft writer first: it is the writer the reader's keystrokes use, so line breaks survive.
1566
+ if (placeBlock(text)) {
1567
+ setStatus('已放入输入框并提交(' + String(list.length) + ' 条图注)')
1568
+ setPending([])
1569
+ if (submit) submitDraft()
1570
+ return
1571
+ }
1572
+ const said = prependBlock(document, list, caption)
1573
+ setStatus(said)
1574
+ if (!said.startsWith('已')) return
1575
+ setPending([])
1576
+ if (submit) submitComposer(document)
1577
+ })()
1578
+ }
1579
+ const addDraft = (): boolean => {
1580
+ if (picked === null) return false
1581
+ const merged = [...pending, { nodeId: picked.id, note }]
1582
+ setPending(merged)
1583
+ setNote('')
1584
+ setPicked(null)
1585
+ setStatus('已放入输入框,直接回车即可')
1586
+ return true
1587
+ }
1588
+ const addAndSend = (): void => {
1589
+ if (picked === null) return
1590
+ const merged = [...pending, { nodeId: picked.id, note }]
1591
+ setNote('')
1592
+ setPicked(null)
1593
+ sendAll(merged, true)
1594
+ }
1595
+ const textarea = h('textarea', {
1596
+ 'data-dme-own': '1',
1597
+ value: source,
1598
+ spellcheck: false,
1599
+ rows: Math.min(12, Math.max(4, String(source).split('\n').length)),
1600
+ style: { width: '100%', boxSizing: 'border-box', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace', padding: '6px', resize: 'vertical', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text },
1601
+ onInput: (event: { target?: { value?: unknown } }) => setSource(String(event?.target?.value ?? '')),
1602
+ })
1603
+ // Two rows: the tools that change the picture, then the mode and the scheme. One row folded on a narrow
1604
+ // pane and pushed the scheme out of reach, which is the one control a reader looks for by name.
1605
+ const controlStyle = { fontSize: '11px', lineHeight: '18px', padding: '1px 7px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' }
1606
+ const control = (label: string, onPress: () => void, title?: string): unknown => h('button', { type: 'button', title, onClick: onPress, style: controlStyle }, label)
1607
+ const controls = h(
1608
+ 'div',
1609
+ { style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '2px 0' } },
1610
+ h(
1611
+ 'div',
1612
+ { style: { display: 'flex', gap: '4px', alignItems: 'center', flexWrap: 'wrap' } },
1613
+ control('−', () => { setFit(false); setZoom(Math.max(0.5, Math.round((Number(zoom) - 0.25) * 100) / 100)) }, '缩小'),
1614
+ h('span', { style: { fontSize: '11px', color: TOKEN.dim, minWidth: '34px', textAlign: 'center' } }, fit ? 'fit' : String(Math.round(Number(zoom) * 100)) + '%'),
1615
+ control('+', () => { setFit(false); setZoom(Math.min(3, Math.round((Number(zoom) + 0.25) * 100) / 100)) }, '放大'),
1616
+ control('100%', () => { setFit(false); setZoom(1) }, '实际大小'),
1617
+ control(fit ? t('fitOn') : t('fitOff'), () => setFit(!fit), t('fitOff')),
1618
+ folded.length === 0 ? null : control(t('unfold') + ' ' + String(folded.length), () => setFolded([]), '展开所有分组'),
1619
+ h(
1620
+ 'select',
1621
+ {
1622
+ value: scheme,
1623
+ title: t('scheme'),
1624
+ style: { marginLeft: 'auto', fontSize: '11px', padding: '1px 4px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text },
1625
+ onChange: (event: { target?: { value?: unknown } }) => setScheme(String(event?.target?.value ?? 'default') as PaletteScheme),
1626
+ },
1627
+ ...schemeNames().map((name) => h('option', { value: name, selected: name === scheme }, name)),
1628
+ ),
1629
+ ),
1630
+ h(
1631
+ 'div',
1632
+ { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
1633
+ control(annotate ? t('noteOn') : t('note'), () => { setAnnotate(!annotate); setStatus(''); if (annotate) { setPicked(null); setNote('') } }, t('noteHint')),
1634
+ h('span', { style: { fontSize: '11px', color: TOKEN.dim } }, annotate ? (picked === null ? t('noteHint') : t('node') + ' ' + picked.id) : t('noteHint')),
1635
+ ),
1636
+ )
1637
+ return h(
1638
+ 'div',
1639
+ { style: { display: 'flex', flexDirection: 'column', gap: '4px', height: '100%', boxSizing: 'border-box', padding: '8px' } },
1640
+ textarea,
1641
+ controls,
1642
+ // The drawing takes the rest of the pane and scrolls inside it; the annotation surface floats above it,
1643
+ // so the picture keeps its size while the reader writes about it.
1644
+ h(
1645
+ 'div',
1646
+ {
1647
+ style: { position: 'relative', flex: '1 1 auto', minHeight: '0', display: 'flex', flexDirection: 'column', gap: '2px' },
1648
+ },
1649
+ h(
1650
+ 'div',
1651
+ {
1652
+ style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto' },
1653
+ // A boundary carries its id in the markup, so one listener on the container is enough: no per-rect
1654
+ // handler, and the handling survives a re-render that replaced the whole SVG.
1655
+ onClick: (event: { target?: { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null } }) => {
1656
+ // In annotation mode a click marks a node instead of folding a boundary: one gesture, one meaning,
1657
+ // chosen by the mode the reader just switched on.
1658
+ if (annotate) {
1659
+ const node = event?.target?.closest?.('[data-node-id]') as {
1660
+ getAttribute?: (name: string) => string | null
1661
+ getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }
1662
+ } | null
1663
+ const marked = node?.getAttribute?.('data-node-id')
1664
+ if (typeof marked === 'string' && marked !== '') {
1665
+ // Beside the node, not at a fixed corner: the note is about that box, and a window that jumps to
1666
+ // the corner makes the reader look for it. Coordinates are the drawing's own, because the editor
1667
+ // is positioned against the pane the drawing sits in.
1668
+ // At the pointer, not at the node's corner: the reader clicked there, and a window that appears
1669
+ // somewhere else makes them look for it. Clamped so it cannot leave the pane on the right.
1670
+ const pointer = event as { clientX?: number; clientY?: number; currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } } }
1671
+ const frame = pointer.currentTarget?.getBoundingClientRect?.()
1672
+ if (frame !== undefined && typeof pointer.clientX === 'number' && typeof pointer.clientY === 'number') {
1673
+ setAnchor({
1674
+ left: Math.max(4, Math.min(pointer.clientX - frame.left + 12, frame.width - 270)),
1675
+ top: Math.max(4, Math.min(pointer.clientY - frame.top + 12, Math.max(4, frame.height - 160))),
1676
+ })
1677
+ } else {
1678
+ setAnchor({ left: 10, top: 10 })
1679
+ }
1680
+ setPicked({ id: marked })
1681
+ setStatus('')
1682
+ return
1683
+ }
1684
+ // A click that lands on nothing closes the window only when it is empty: a miss is not a reason to
1685
+ // lose words, and an empty window left open is clutter over the picture.
1686
+ if (note.trim() === '') { setPicked(null); setAnchor(undefined) }
1687
+ return
1688
+ }
1689
+ const hit = event?.target?.closest?.('[data-group-id]')
1690
+ const id = hit?.getAttribute?.('data-group-id')
1691
+ if (typeof id !== 'string' || id === '') return
1692
+ setFolded(folded.includes(id) ? folded.filter((entry) => entry !== id) : [...folded, id])
1693
+ },
1694
+ },
1695
+ drawn,
1696
+ ),
1697
+ annotate
1698
+ ? annotationPanel(
1699
+ h,
1700
+ { picked, note, pending, status, anchor, open },
1701
+ {
1702
+ onNote: setNote,
1703
+ onAdd: () => { addDraft() },
1704
+ onAddAndSend: addAndSend,
1705
+ onSend: () => sendAll(pending, true),
1706
+ onClear: () => { setPending([]); setStatus('') },
1707
+ onCancel: () => { setPicked(null); setNote(''); setStatus('') },
1708
+ onToggle: () => setOpen(!open),
1709
+ onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
1710
+ },
1711
+ { document },
1712
+ )
1713
+ : null,
1714
+ ),
1715
+ )
1716
+ }
1717
+ }
1718
+
1719
+ function drawSample(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme, folded: string[] = []): unknown {
1720
+ return whiteboardBody(React, overrides, source, zoom, scheme, folded)
1721
+ }
1722
+
1723
+ /** Render one source at one zoom, or say why it could not be drawn. */
1724
+ function whiteboardBody(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme = 'default', folded: string[] = []): unknown {
1725
+ if (String(source).trim() === '') {
1726
+ return React.createElement('p', { style: { fontSize: '12px', opacity: 0.7 } }, 'Paste Mermaid or PlantUML above.')
1727
+ }
1728
+ const drawn = render({ source: String(source), folded }, overrides, { mode: themeMode(), scheme })
1729
+ // Zoom 0 is "fill the pane": the SVG scales to the width it is given, which is what an expanded tab wants.
1730
+ const zoomed =
1731
+ zoom === 0
1732
+ ? drawn.markup.replace('<svg ', '<svg style="width: 100%; height: auto; display: block" ')
1733
+ : drawn.markup.replace('<svg ', '<svg style="zoom: ' + String(zoom) + '; display: block" ')
1734
+ const refused = drawn.refused.length === 0 ? null : React.createElement('pre', {
1735
+ style: { fontSize: '11px', color: '#b7791f', whiteSpace: 'pre-wrap', margin: '6px 0' },
1736
+ }, drawn.refused.join('\n'))
1737
+ return React.createElement(
1738
+ 'div',
1739
+ { style: { background: drawn.colours.background, color: drawn.colours.text, borderRadius: '6px' } },
1740
+ React.createElement('div', { dangerouslySetInnerHTML: { __html: zoomed } }),
1741
+ refused,
1742
+ )
1743
+ }
1744
+
1745
+
1746
+ /** How long to wait for the optional services, and how often. A page that never offers them registers nothing. */
1747
+ const SIDEBAR_POLL_MS = 250
1748
+ const SIDEBAR_POLL_ATTEMPTS = 40
1749
+
1750
+ interface SidebarTabsService {
1751
+ register?: (options: Record<string, unknown>) => unknown
1752
+ }
1753
+
1754
+ export interface MountOptions {
1755
+ /** Injected so the suite can drive the poll without waiting. Defaults to `setTimeout`. */
1756
+ schedule?: (run: () => void, delay: number) => unknown
1757
+ }
1758
+
1759
+ /**
1760
+ * Register the whiteboard tab once the sidebar's optional services are ready.
1761
+ *
1762
+ * The slot can be declared before `sidebarRightTabs` exists - the host composes services and seats in an
1763
+ * order this plugin does not control - and a single `ctx.get` there is a race that loses the tab silently.
1764
+ * So it polls, like the reference implementation does, and stops after a bounded number of attempts.
1765
+ */
1766
+ export function mountWhiteboard(React: ReactLike, ctx: SlotContext, options: MountOptions = {}): unknown {
1767
+ const schedule =
1768
+ options.schedule ??
1769
+ ((run: () => void, delay: number) => {
1770
+ // A host (or a test sandbox) without timers simply gets no retry, rather than an exception.
1771
+ const timer = (globalThis as unknown as { setTimeout?: (f: () => void, ms: number) => unknown }).setTimeout
1772
+ return typeof timer === 'function' ? timer(run, delay) : undefined
1773
+ })
1774
+ let attempts = 0
1775
+
1776
+ const attempt = (): unknown => {
1777
+ attempts += 1
1778
+ const tabs = typeof ctx.get === 'function' ? (ctx.get('sidebarRightTabs') as SidebarTabsService | undefined) : undefined
1779
+ if (tabs === undefined || typeof tabs.register !== 'function') {
1780
+ if (attempts < SIDEBAR_POLL_ATTEMPTS) schedule(attempt, SIDEBAR_POLL_MS)
1781
+ return undefined
1782
+ }
1783
+ tabs.register({
1784
+ id: WHITEBOARD_ID,
1785
+ kind: 'panel',
1786
+ keepMounted: true,
1787
+ title: () => 'Diagram',
1788
+ guide: [
1789
+ {
1790
+ id: 'diagram-whiteboard',
1791
+ order: 120,
1792
+ title: () => 'Diagram',
1793
+ description: () => 'Paste Mermaid or PlantUML and read the drawing',
1794
+ },
1795
+ ],
1796
+ })
1797
+ return ctx.slots.register({ name: 'sidebar.right.pane.tab', key: WHITEBOARD_ID }, whiteboardFor(React))
1798
+ }
1799
+
1800
+ return attempt()
1801
+ }
1802
+
1803
+ export default function factory(require: (name: string) => unknown): PluginShape {
1804
+ // Captured under a plain name: a static `require('react')` would be rewritten by the bundler and then
1805
+ // resolve against a scope the factory body does not have. A call through this alias stays a call.
1806
+ const hostRequire = require
1807
+ const React = hostRequire('react') as ReactLike
1808
+ return {
1809
+ inject: ['slots'],
1810
+ mount: (ctx: SlotContext, options: MountOptions = {}) => mountWhiteboard(React, ctx, options),
1811
+ apply(ctx: SlotContext) {
1812
+ services = ctx
1813
+ // The transcript fold is parked, and this is the second time for a reason worth writing down.
1814
+ //
1815
+ // It worked as a static pass. Started from `apply` it watches the whole document and, per change, runs a
1816
+ // selector over every div/p/span and a subtree scan per match; a streaming conversation changes the DOM many
1817
+ // times a second, so the scan ran continuously and the conversation container stopped responding.
1818
+ //
1819
+ // To bring it back: scope the scan to a message container instead of the document, run it once per sent
1820
+ // message rather than per mutation, and never from an interval. Until then the block stays visible in the
1821
+ // bubble, which is what the host itself renders and costs nothing.
1822
+ void startTranscriptDecoration
1823
+ for (const name of TOOL_NAMES) {
1824
+ // Only the options a keyed slot declares: `key`. An extra `id` is not part of this slot's
1825
+ // registration schema, and the working community reference (dsh-deepread) passes exactly this much.
1826
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, viewFor(React)))
1827
+ }
1828
+ // The whiteboard seat only exists on a host with the official right sidebar. Presence of the slot is
1829
+ // the capability check: if the callback never runs, this host has no sidebar and nothing is lost.
1830
+ ctx.slots.inject('sidebar.right.pane.tab', () => mountWhiteboard(React, ctx))
1831
+ // The count belongs in the host's own dock above the composer card - "full-width entries above the
1832
+ // composer card" in the slot catalog. A slot is the supported way to sit there: the host owns the
1833
+ // position, the width and the surface, so this plugin stops computing coordinates in someone else's
1834
+ // layout. The body-level layer stays as the fallback for a host without this slot.
1835
+ // The composer dock slot is not used: on this host its component renders before the composer has a box, and
1836
+ // a hook-driven re-measure is not guaranteed - the count sat at the column edge until unrelated typing forced
1837
+ // a re-render. The body layer measures itself on a retry schedule, which converges without hooks.
1838
+ void dockSlotView
1839
+ },
1840
+ }
1841
+ }