@zseven-w/dsh-openpencil 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/THIRD_PARTY_NOTICES.md +69 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/images/dsh-openpencil-overview.png +0 -0
  6. package/lib/client/details-compat.d.ts +28 -0
  7. package/lib/client/details-compat.js +10 -0
  8. package/lib/client/editor-bridge.d.ts +84 -0
  9. package/lib/client/editor-bridge.js +127 -0
  10. package/lib/client/editor-dock-layout.d.ts +15 -0
  11. package/lib/client/editor-dock-layout.js +43 -0
  12. package/lib/client/editor-recovery.d.ts +27 -0
  13. package/lib/client/editor-recovery.js +83 -0
  14. package/lib/client/editor-successor.d.ts +28 -0
  15. package/lib/client/editor-successor.js +109 -0
  16. package/lib/client/presentation-hydration.d.ts +36 -0
  17. package/lib/client/presentation-hydration.js +168 -0
  18. package/lib/client/selection-polling.d.ts +24 -0
  19. package/lib/client/selection-polling.js +79 -0
  20. package/lib/client/selection-store.d.ts +26 -0
  21. package/lib/client/selection-store.js +85 -0
  22. package/lib/client.js +4033 -0
  23. package/lib/design-tools.d.ts +21 -0
  24. package/lib/design-tools.js +214 -0
  25. package/lib/editor-host.d.ts +62 -0
  26. package/lib/editor-host.js +1147 -0
  27. package/lib/editor-recovery.d.ts +38 -0
  28. package/lib/editor-recovery.js +319 -0
  29. package/lib/index.d.ts +24 -0
  30. package/lib/index.js +132 -0
  31. package/lib/mcp-client.d.ts +47 -0
  32. package/lib/mcp-client.js +148 -0
  33. package/lib/new-tool.d.ts +23 -0
  34. package/lib/new-tool.js +136 -0
  35. package/lib/presentation-hydration.d.ts +55 -0
  36. package/lib/presentation-hydration.js +561 -0
  37. package/lib/renderer.d.ts +203 -0
  38. package/lib/renderer.js +772 -0
  39. package/lib/tool-names.d.ts +13 -0
  40. package/lib/tool-names.js +19 -0
  41. package/lib/tool.d.ts +27 -0
  42. package/lib/tool.js +221 -0
  43. package/lib/types/client/details-compat.d.ts +28 -0
  44. package/lib/types/client/editor-bridge.d.ts +84 -0
  45. package/lib/types/client/editor-dock-layout.d.ts +15 -0
  46. package/lib/types/client/editor-modal.d.ts +54 -0
  47. package/lib/types/client/editor-panel.d.ts +127 -0
  48. package/lib/types/client/editor-recovery.d.ts +27 -0
  49. package/lib/types/client/editor-successor.d.ts +28 -0
  50. package/lib/types/client/editor-workbench-host.d.ts +43 -0
  51. package/lib/types/client/frame-gallery.d.ts +98 -0
  52. package/lib/types/client/index.d.ts +176 -0
  53. package/lib/types/client/presentation-hydration.d.ts +36 -0
  54. package/lib/types/client/selection-dock.d.ts +18 -0
  55. package/lib/types/client/selection-polling.d.ts +24 -0
  56. package/lib/types/client/selection-store.d.ts +26 -0
  57. package/lib/types/tool-names.d.ts +13 -0
  58. package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
  59. package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
  60. package/lib/viewer-assets/manifest.json +22 -0
  61. package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
  62. package/lib/viewer-assets/sdk.js +225 -0
  63. package/lib/viewer-assets.d.ts +74 -0
  64. package/lib/viewer-assets.js +303 -0
  65. package/package.json +95 -0
  66. package/scripts/build-client.mjs +27 -0
  67. package/scripts/run-tool.mjs +63 -0
  68. package/scripts/sync-viewer-assets.mjs +304 -0
  69. package/scripts/test-host.mjs +311 -0
  70. package/scripts/test-viewer-assets.mjs +54 -0
  71. package/src/client/details-compat.ts +44 -0
  72. package/src/client/editor-bridge.ts +170 -0
  73. package/src/client/editor-dock-layout.ts +56 -0
  74. package/src/client/editor-modal.tsx +607 -0
  75. package/src/client/editor-panel.tsx +823 -0
  76. package/src/client/editor-recovery.ts +116 -0
  77. package/src/client/editor-successor.ts +136 -0
  78. package/src/client/editor-workbench-host.tsx +280 -0
  79. package/src/client/frame-gallery.tsx +546 -0
  80. package/src/client/index.tsx +987 -0
  81. package/src/client/presentation-hydration.ts +210 -0
  82. package/src/client/react-dom-compat.d.ts +22 -0
  83. package/src/client/selection-dock.tsx +89 -0
  84. package/src/client/selection-polling.ts +93 -0
  85. package/src/client/selection-store.ts +108 -0
  86. package/src/design-tools.ts +242 -0
  87. package/src/editor-host.ts +1238 -0
  88. package/src/editor-recovery.ts +374 -0
  89. package/src/index.ts +219 -0
  90. package/src/mcp-client.ts +193 -0
  91. package/src/new-tool.ts +160 -0
  92. package/src/presentation-hydration.ts +634 -0
  93. package/src/renderer.ts +884 -0
  94. package/src/tool-names.ts +21 -0
  95. package/src/tool.ts +258 -0
  96. package/src/viewer-assets.ts +353 -0
@@ -0,0 +1,987 @@
1
+ /**
2
+ * Browser presentation for `openpencil_render` and historical
3
+ * `design_render` conversation cards.
4
+ *
5
+ * PNG remains the replay-safe default. When the host also grants access to
6
+ * the source `.op`, the user can opt into one shared, read-only Web SDK
7
+ * canvas. The SDK and document are fetched only after that explicit action.
8
+ */
9
+
10
+ import {
11
+ useCallback,
12
+ useEffect,
13
+ useRef,
14
+ useState,
15
+ useSyncExternalStore,
16
+ } from 'react'
17
+ import type { PointerEvent as ReactPointerEvent } from 'react'
18
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
19
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
20
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
21
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
22
+ import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
23
+ import { editorPanelCopy, ManagedOpenPencilEditor } from './editor-panel.js'
24
+ import { editorLocaleFromDsh, type EditorColorScheme, type EditorLocale } from './editor-bridge.js'
25
+ import {
26
+ requestOpenPencilEditor,
27
+ type CompatibleToolCallViewProps,
28
+ type CompatibleToolDetailsViewProps,
29
+ } from './details-compat.js'
30
+ import type { EditorWorkbenchRequest } from './editor-workbench-host.js'
31
+ import { mountEditorWorkbenchHost } from './editor-workbench-host.js'
32
+ import { editorWorkbenchEditorKey } from './editor-modal.js'
33
+ import { FrameGallery, normalizeFrameIndex as normalizedFrameIndex } from './frame-gallery.js'
34
+ import type { GalleryFrame, GalleryLocale } from './frame-gallery.js'
35
+ import { OpenPencilSelectionDock } from './selection-dock.js'
36
+ import {
37
+ presentationHydrationRequestOf,
38
+ requestPresentationGrant,
39
+ } from './presentation-hydration.js'
40
+ import {
41
+ LEGACY_DESIGN_RENDER_TOOL_NAME,
42
+ OPENPENCIL_RENDER_TOOL_NAME,
43
+ } from '../tool-names.js'
44
+
45
+ export {
46
+ LEGACY_DESIGN_RENDER_TOOL_NAME,
47
+ OPENPENCIL_RENDER_TOOL_NAME,
48
+ } from '../tool-names.js'
49
+
50
+ export {
51
+ calculateGalleryFitViewZoom,
52
+ clampGalleryZoom,
53
+ frameLabel,
54
+ frameGalleryCopy,
55
+ galleryZoomCommandTarget,
56
+ galleryViewportMaxHeight,
57
+ galleryZoomPercent,
58
+ galleryZoomShortcut,
59
+ GALLERY_COMPACT_MAX_HEIGHT,
60
+ GALLERY_TOOLBAR_CONTROL_CONTENT_LAYOUT,
61
+ GALLERY_TOOLBAR_CONTROL_HEIGHT,
62
+ GALLERY_TOOLBAR_CONTROL_LAYOUT,
63
+ GALLERY_ZOOM_MAX,
64
+ GALLERY_ZOOM_MIN,
65
+ GALLERY_ZOOM_STEP,
66
+ nextGalleryZoom,
67
+ normalizeFrameIndex,
68
+ } from './frame-gallery.js'
69
+ export {
70
+ applyManagedEditorUnmountPolicy,
71
+ beginEditorInitRetry,
72
+ closeManagedEditorLaunch,
73
+ editorPanelCopy,
74
+ launchManagedEditor,
75
+ prepareManagedEditor,
76
+ prepareManagedEditorForMount,
77
+ } from './editor-panel.js'
78
+ export {
79
+ requestOpenPencilEditor,
80
+ } from './details-compat.js'
81
+ export {
82
+ clampEditorWorkbenchWidth,
83
+ confirmEditorModalClose,
84
+ editorModalCopy,
85
+ editorWorkbenchEditorKey,
86
+ editorWorkbenchFocusTargetIndex,
87
+ editorWorkbenchShouldHandleEscape,
88
+ editorWorkbenchUsesFullscreen,
89
+ editorWorkbenchWidthBounds,
90
+ EDITOR_WORKBENCH_FULLSCREEN_BREAKPOINT,
91
+ EDITOR_WORKBENCH_LEFT_CLEARANCE,
92
+ EDITOR_WORKBENCH_MAX_WIDTH,
93
+ EDITOR_WORKBENCH_MIN_WIDTH,
94
+ EDITOR_WORKBENCH_RESIZE_STEP,
95
+ resizedEditorWorkbenchWidth,
96
+ } from './editor-modal.js'
97
+ export {
98
+ claimEditorWorkbenchDock,
99
+ OPENPENCIL_WORKBENCH_DOCK_ATTRIBUTE,
100
+ } from './editor-dock-layout.js'
101
+ export {
102
+ createEditorWorkbenchStore,
103
+ mountEditorWorkbenchHost,
104
+ preserveEditorBeforeWorkbenchDispose,
105
+ } from './editor-workbench-host.js'
106
+ export {
107
+ editorGrantForBoot,
108
+ editorSuccessorFromSave,
109
+ editorSuccessorStorageKey,
110
+ rememberEditorSuccessor,
111
+ } from './editor-successor.js'
112
+ export {
113
+ captureManagedEditorRecovery,
114
+ discardManagedEditorRecovery,
115
+ editorRecoveryCopy,
116
+ editorRecoveryItemUrl,
117
+ editorRecoverySummaryOf,
118
+ restoreManagedEditorRecovery,
119
+ } from './editor-recovery.js'
120
+
121
+ export {
122
+ claimEditor,
123
+ confirmEditorClose,
124
+ editorControlUrl,
125
+ editorIframeUrlWithLocale,
126
+ editorIframeUrlWithTheme,
127
+ editorLocaleFromDsh,
128
+ editorMessageFrom,
129
+ editorOrigin,
130
+ encodeEditorOutbound,
131
+ parseEditorInbound,
132
+ } from './editor-bridge.js'
133
+ export {
134
+ clearOpenPencilSelection,
135
+ getOpenPencilSelectionSnapshot,
136
+ liveSelectionOf,
137
+ publishOpenPencilSelection,
138
+ subscribeOpenPencilSelection,
139
+ } from './selection-store.js'
140
+ export {
141
+ isTerminalEditorSelectionStatus,
142
+ startEditorSelectionPolling,
143
+ } from './selection-polling.js'
144
+ export {
145
+ hasOpenPencilSelection,
146
+ OPENPENCIL_SELECTION_DOCK_LAYOUT,
147
+ selectionNodeDetail,
148
+ selectionNodeLabel,
149
+ } from './selection-dock.js'
150
+ export {
151
+ documentSha256FromCanonicalResult,
152
+ PRESENTATION_HYDRATION_ENDPOINT,
153
+ presentationHydrationRequestOf,
154
+ requestPresentationGrant,
155
+ } from './presentation-hydration.js'
156
+
157
+ /** Presentation metadata key the host half projects into `block.meta`. */
158
+ export const PRESENTATION_META_KEY = '$dshOpenPencil'
159
+
160
+ const LIVE_AUTO_OPEN_TTL_MS = 15 * 60 * 1000
161
+ const LIVE_AUTO_OPEN_MAX = 256
162
+ const liveAutoOpenActivatedAt = Date.now()
163
+ const liveAutoOpenCalls = new Map<string, number>()
164
+
165
+ function liveAutoOpenKey(sessionId: string, callId: string): string {
166
+ return `${sessionId.length}:${sessionId}${callId}`
167
+ }
168
+
169
+ function pruneLiveAutoOpenCalls(now = Date.now()): void {
170
+ for (const [key, expiresAt] of liveAutoOpenCalls) {
171
+ if (expiresAt <= now) liveAutoOpenCalls.delete(key)
172
+ }
173
+ while (liveAutoOpenCalls.size > LIVE_AUTO_OPEN_MAX) {
174
+ const oldest = liveAutoOpenCalls.keys().next().value as string | undefined
175
+ if (oldest === undefined) break
176
+ liveAutoOpenCalls.delete(oldest)
177
+ }
178
+ }
179
+
180
+ function rememberLiveAutoOpenCall(key: string): void {
181
+ liveAutoOpenCalls.delete(key)
182
+ liveAutoOpenCalls.set(key, Date.now() + LIVE_AUTO_OPEN_TTL_MS)
183
+ pruneLiveAutoOpenCalls()
184
+ }
185
+
186
+ function takeLiveAutoOpenCall(key: string): boolean {
187
+ pruneLiveAutoOpenCalls()
188
+ if (!liveAutoOpenCalls.has(key)) return false
189
+ liveAutoOpenCalls.delete(key)
190
+ return true
191
+ }
192
+
193
+ function forgetLiveAutoOpenCall(key: string): void {
194
+ liveAutoOpenCalls.delete(key)
195
+ }
196
+
197
+ export type PresentationLocale = GalleryLocale
198
+
199
+ const DESIGN_RENDER_COPY = {
200
+ en: {
201
+ designRender: 'OpenPencil render',
202
+ error: 'error',
203
+ rendering: 'rendering…',
204
+ done: 'done',
205
+ renderingDocument: 'Rendering the design document…',
206
+ renderFailed: 'The render failed.',
207
+ frames: 'frames',
208
+ openInteractiveCanvas: 'Open interactive canvas',
209
+ editCanvas: 'Edit canvas',
210
+ editInSidebar: 'Edit in sidebar',
211
+ openRenderedPng: 'Open rendered PNG',
212
+ downloadPng: 'Download PNG',
213
+ editSource: 'Edit source .op',
214
+ downloadSource: 'Download source .op',
215
+ inspectToolCall: 'Inspect tool call',
216
+ recoveringPreview: 'Recovering the OpenPencil preview…',
217
+ noPreview: 'No preview channel available in this host.',
218
+ canvas: 'OpenPencil canvas',
219
+ zoomOut: 'Zoom out',
220
+ zoomIn: 'Zoom in',
221
+ fit: 'Fit',
222
+ close: 'Close',
223
+ readonlyCanvas: 'Read-only OpenPencil design canvas',
224
+ loadingCanvas: 'Loading interactive canvas…',
225
+ pngRemains: 'PNG preview remains available underneath the dialog.',
226
+ canvasUnavailable: 'Interactive canvas unavailable',
227
+ openPngFallback: 'Open PNG fallback',
228
+ panHint: 'Drag to pan · scroll to pan · Ctrl/⌘ + scroll to zoom',
229
+ snapshot: 'snapshot',
230
+ editorUnavailable: 'Editable OpenPencil canvas is not available for this result.',
231
+ },
232
+ zh: {
233
+ designRender: 'OpenPencil 渲染',
234
+ error: '错误',
235
+ rendering: '渲染中…',
236
+ done: '完成',
237
+ renderingDocument: '正在渲染设计文档…',
238
+ renderFailed: '渲染失败。',
239
+ frames: '页',
240
+ openInteractiveCanvas: '打开交互画布',
241
+ editCanvas: '编辑画布',
242
+ editInSidebar: '在侧边栏编辑',
243
+ openRenderedPng: '打开渲染 PNG',
244
+ downloadPng: '下载 PNG',
245
+ editSource: '编辑源文件 .op',
246
+ downloadSource: '下载源文件 .op',
247
+ inspectToolCall: '检查工具调用',
248
+ recoveringPreview: '正在恢复 OpenPencil 预览…',
249
+ noPreview: '当前宿主没有可用的预览通道。',
250
+ canvas: 'OpenPencil 画布',
251
+ zoomOut: '缩小',
252
+ zoomIn: '放大',
253
+ fit: '适应窗口',
254
+ close: '关闭',
255
+ readonlyCanvas: '只读 OpenPencil 设计画布',
256
+ loadingCanvas: '正在加载交互画布…',
257
+ pngRemains: '对话框下方仍保留 PNG 预览。',
258
+ canvasUnavailable: '交互画布不可用',
259
+ openPngFallback: '打开 PNG 预览',
260
+ panHint: '拖动平移 · 滚动平移 · Ctrl/⌘ + 滚动缩放',
261
+ snapshot: '快照',
262
+ editorUnavailable: '此渲染结果没有可用的 OpenPencil 编辑画布。',
263
+ },
264
+ } as const
265
+
266
+ export function designRenderCopy(locale: PresentationLocale) {
267
+ return DESIGN_RENDER_COPY[locale]
268
+ }
269
+
270
+ function isRecord(value: unknown): value is Record<string, unknown> {
271
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
272
+ }
273
+
274
+ export interface ImageGrant extends GalleryFrame {}
275
+
276
+ export interface DocumentGrant {
277
+ path?: string
278
+ url: string
279
+ downloadUrl?: string
280
+ bytes?: number
281
+ sha256?: string
282
+ mimeType?: string
283
+ }
284
+
285
+ export interface ViewerGrant {
286
+ sdkUrl: string
287
+ wasmUrl: string
288
+ canvasKitBaseUrl: string
289
+ }
290
+
291
+ export interface EditorGrant {
292
+ enabled: true
293
+ launchUrl: string
294
+ refreshUrl?: string
295
+ }
296
+
297
+ export interface PresentationGrant {
298
+ schemaVersion: 1 | 2
299
+ image?: ImageGrant
300
+ frames?: ImageGrant[]
301
+ document?: DocumentGrant
302
+ viewer?: ViewerGrant
303
+ editor?: EditorGrant
304
+ renderer?: string
305
+ rendererBinary?: string
306
+ fidelity?: string
307
+ warnings?: string[]
308
+ autoOpenEditor?: boolean
309
+ }
310
+
311
+ function optionalString(record: Record<string, unknown>, key: string): string | undefined {
312
+ const value = record[key]
313
+ return typeof value === 'string' && value.length > 0 ? value : undefined
314
+ }
315
+
316
+ function optionalFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
317
+ const value = record[key]
318
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
319
+ }
320
+
321
+ function optionalStrings(record: Record<string, unknown>, key: string): string[] | undefined {
322
+ const value = record[key]
323
+ if (!Array.isArray(value)) return undefined
324
+ const strings = value.filter((item): item is string => typeof item === 'string' && item.length > 0)
325
+ return strings.length === 0 ? undefined : strings
326
+ }
327
+
328
+ function imageGrantOf(value: unknown): ImageGrant | undefined {
329
+ if (!isRecord(value)) return undefined
330
+ const path = optionalString(value, 'path')
331
+ const previewUrl = optionalString(value, 'previewUrl')
332
+ const downloadUrl = optionalString(value, 'downloadUrl')
333
+ if (path === undefined || previewUrl === undefined || downloadUrl === undefined) return undefined
334
+ const id = optionalString(value, 'id')
335
+ const name = optionalString(value, 'name')
336
+ const index = optionalFiniteNumber(value, 'index')
337
+ return {
338
+ path,
339
+ previewUrl,
340
+ downloadUrl,
341
+ width: optionalFiniteNumber(value, 'width'),
342
+ height: optionalFiniteNumber(value, 'height'),
343
+ ...(id === undefined ? {} : { id }),
344
+ ...(name === undefined ? {} : { name }),
345
+ ...(index === undefined || !Number.isSafeInteger(index) || index < 0 ? {} : { index }),
346
+ }
347
+ }
348
+
349
+ function imageGrantsOf(value: unknown): ImageGrant[] | undefined {
350
+ if (!Array.isArray(value)) return undefined
351
+ const frames = value.map(imageGrantOf).filter((frame): frame is ImageGrant => frame !== undefined)
352
+ return frames.length === 0 ? undefined : frames
353
+ }
354
+
355
+ function documentGrantOf(envelope: Record<string, unknown>, image: unknown): DocumentGrant | undefined {
356
+ const raw = isRecord(envelope.document) ? envelope.document : undefined
357
+ const legacyImage = isRecord(image) ? image : undefined
358
+ const url = raw === undefined
359
+ ? optionalString(envelope, 'documentUrl')
360
+ ?? optionalString(envelope, 'sourceUrl')
361
+ ?? (legacyImage === undefined ? undefined : optionalString(legacyImage, 'documentUrl')
362
+ ?? optionalString(legacyImage, 'sourceUrl')
363
+ ?? optionalString(legacyImage, 'opUrl'))
364
+ : optionalString(raw, 'url') ?? optionalString(raw, 'documentUrl')
365
+ if (url === undefined) return undefined
366
+ return {
367
+ url,
368
+ path: raw === undefined ? optionalString(envelope, 'sourcePath') : optionalString(raw, 'path'),
369
+ downloadUrl: raw === undefined
370
+ ? optionalString(envelope, 'documentDownloadUrl')
371
+ : optionalString(raw, 'downloadUrl'),
372
+ bytes: raw === undefined ? undefined : optionalFiniteNumber(raw, 'bytes'),
373
+ sha256: raw === undefined ? undefined : optionalString(raw, 'sha256'),
374
+ mimeType: raw === undefined ? undefined : optionalString(raw, 'mimeType'),
375
+ }
376
+ }
377
+
378
+ function viewerGrantOf(value: unknown): ViewerGrant | undefined {
379
+ if (!isRecord(value)) return undefined
380
+ const sdkUrl = optionalString(value, 'sdkUrl')
381
+ const wasmUrl = optionalString(value, 'wasmUrl')
382
+ const canvasKitBaseUrl = optionalString(value, 'canvasKitBaseUrl')
383
+ ?? optionalString(value, 'assetBaseUrl')
384
+ if (sdkUrl === undefined || wasmUrl === undefined || canvasKitBaseUrl === undefined) return undefined
385
+ return { sdkUrl, wasmUrl, canvasKitBaseUrl }
386
+ }
387
+
388
+ function editorGrantOf(value: unknown): EditorGrant | undefined {
389
+ if (!isRecord(value) || value.enabled !== true) return undefined
390
+ const launchUrl = optionalString(value, 'launchUrl')
391
+ if (launchUrl === undefined) return undefined
392
+ const refreshUrl = optionalString(value, 'refreshUrl')
393
+ return { enabled: true, launchUrl, ...(refreshUrl === undefined ? {} : { refreshUrl }) }
394
+ }
395
+
396
+ /** Parse both the established v1 envelope and the additive v2 shape. */
397
+ export function presentationGrantOfMeta(metaValue: unknown): PresentationGrant | undefined {
398
+ const meta = isRecord(metaValue) ? metaValue : undefined
399
+ const envelope = meta?.[PRESENTATION_META_KEY]
400
+ if (!isRecord(envelope) || (envelope.schemaVersion !== 1 && envelope.schemaVersion !== 2)) return undefined
401
+ const frames = imageGrantsOf(envelope.frames)
402
+ const image = imageGrantOf(envelope.image) ?? frames?.[0]
403
+ const document = documentGrantOf(envelope, envelope.image)
404
+ if (image === undefined && document === undefined) return undefined
405
+ return {
406
+ schemaVersion: envelope.schemaVersion,
407
+ image,
408
+ frames: frames ?? (image === undefined ? undefined : [image]),
409
+ document,
410
+ viewer: viewerGrantOf(envelope.viewer),
411
+ editor: editorGrantOf(envelope.editor),
412
+ renderer: optionalString(envelope, 'renderer'),
413
+ rendererBinary: optionalString(envelope, 'rendererBinary'),
414
+ fidelity: optionalString(envelope, 'fidelity'),
415
+ warnings: optionalStrings(envelope, 'warnings'),
416
+ ...(envelope.autoOpenEditor === true ? { autoOpenEditor: true } : {}),
417
+ }
418
+ }
419
+
420
+ export function grantOf(block: ToolCallViewProps['block']): PresentationGrant | undefined {
421
+ if (!('kind' in block) || block.isError) return undefined
422
+ return presentationGrantOfMeta(block.meta)
423
+ }
424
+
425
+ /** Flatten the durable result text for the fallback disclosure. */
426
+ function resultText(block: ToolCallViewProps['block']): string | null {
427
+ if (!('kind' in block)) return null
428
+ const parts: string[] = []
429
+ for (const item of block.content) {
430
+ parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
431
+ }
432
+ if (parts.length === 0 && block.error !== undefined) {
433
+ parts.push(`${block.error.name}: ${block.error.code}`)
434
+ }
435
+ return parts.join('\n') || null
436
+ }
437
+
438
+ interface Viewport {
439
+ panX: number
440
+ panY: number
441
+ zoom: number
442
+ }
443
+
444
+ interface OpViewer {
445
+ readonly viewport: Viewport
446
+ setZoom(zoom: number): void
447
+ panTo(panX: number, panY: number): void
448
+ zoomToFit(width: number, height: number): void
449
+ on(event: 'viewportchange', callback: () => void): () => void
450
+ destroy(): void
451
+ }
452
+
453
+ interface OpenPencilSdk {
454
+ createViewer(options: {
455
+ canvas: HTMLCanvasElement
456
+ doc: string | Uint8Array
457
+ wasmUrl?: string
458
+ canvasKitBaseUrl?: string
459
+ }): Promise<OpViewer>
460
+ }
461
+
462
+ const sdkLoads = new Map<string, Promise<OpenPencilSdk>>()
463
+
464
+ /** Load the host-served ESM core SDK without coupling the client bundle to React 19. */
465
+ export function loadOpenPencilSdk(url: string): Promise<OpenPencilSdk> {
466
+ const absoluteUrl = new URL(url, window.location.href).href
467
+ let pending = sdkLoads.get(absoluteUrl)
468
+ if (pending === undefined) {
469
+ pending = import(/* @vite-ignore */ absoluteUrl).then((module: unknown) => {
470
+ if (!isRecord(module) || typeof module.createViewer !== 'function') {
471
+ throw new Error('OpenPencil viewer SDK did not export createViewer')
472
+ }
473
+ return module as unknown as OpenPencilSdk
474
+ })
475
+ sdkLoads.set(absoluteUrl, pending)
476
+ pending.catch(() => { sdkLoads.delete(absoluteUrl) })
477
+ }
478
+ return pending
479
+ }
480
+
481
+ interface ActiveCanvas {
482
+ token: symbol
483
+ close: () => void
484
+ }
485
+
486
+ let activeCanvas: ActiveCanvas | undefined
487
+
488
+ /** @internal Claim the page-wide SDK singleton; opening another canvas closes this one. */
489
+ export function claimCanvas(token: symbol, close: () => void): () => void {
490
+ const previous = activeCanvas
491
+ activeCanvas = { token, close }
492
+ if (previous !== undefined && previous.token !== token) previous.close()
493
+ return () => {
494
+ if (activeCanvas?.token === token) activeCanvas = undefined
495
+ }
496
+ }
497
+
498
+ const styles: Record<string, React.CSSProperties> = {
499
+ card: {
500
+ border: '1px solid var(--ui-border, rgba(128,128,128,0.35))',
501
+ borderRadius: 8,
502
+ overflow: 'hidden',
503
+ background: 'var(--ui-card-bg, transparent)',
504
+ fontFamily: 'inherit',
505
+ },
506
+ head: {
507
+ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
508
+ fontSize: 13, fontWeight: 600,
509
+ borderBottom: '1px solid var(--ui-border, rgba(128,128,128,0.2))',
510
+ },
511
+ badge: {
512
+ fontSize: 11, padding: '1px 8px', borderRadius: 99,
513
+ textTransform: 'uppercase', letterSpacing: 0.4,
514
+ },
515
+ badgeOk: { background: 'rgba(34,197,94,0.15)', color: '#16a34a' },
516
+ badgeError: { background: 'rgba(239,68,68,0.15)', color: '#dc2626' },
517
+ badgeRunning: { background: 'rgba(100,116,139,0.15)', color: '#64748b' },
518
+ body: { padding: 12 },
519
+ imageViewport: {
520
+ maxHeight: 560,
521
+ overflow: 'auto',
522
+ overscrollBehavior: 'contain',
523
+ borderRadius: 4,
524
+ border: '1px solid rgba(128,128,128,0.25)',
525
+ background: 'rgba(128,128,128,0.06)',
526
+ },
527
+ img: {
528
+ display: 'block', width: 'auto', maxWidth: '100%', height: 'auto', margin: '0 auto',
529
+ },
530
+ meta: {
531
+ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10,
532
+ marginTop: 10, fontSize: 12, color: 'var(--ui-text-muted, #888)',
533
+ },
534
+ link: { color: 'var(--ui-accent, #0ea5e9)', textDecoration: 'none' },
535
+ button: {
536
+ color: 'var(--ui-accent, #0ea5e9)', background: 'none', border: 'none',
537
+ cursor: 'pointer', padding: 0, font: 'inherit', fontSize: 12,
538
+ },
539
+ primaryButton: {
540
+ border: '1px solid var(--ui-accent, #0ea5e9)', borderRadius: 6,
541
+ color: 'var(--ui-accent, #0ea5e9)', background: 'transparent',
542
+ padding: '4px 9px', cursor: 'pointer', font: 'inherit', fontSize: 12,
543
+ },
544
+ pre: { whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, margin: 0, maxHeight: '24em', overflow: 'auto' },
545
+ muted: { fontSize: 12, color: 'var(--ui-text-muted, #888)' },
546
+ warning: {
547
+ margin: '10px 0 0', padding: '7px 9px', borderRadius: 6,
548
+ color: '#b45309', background: 'rgba(245,158,11,0.13)', fontSize: 12,
549
+ },
550
+ backdrop: {
551
+ position: 'fixed', inset: 0, zIndex: 2147483000,
552
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
553
+ padding: 20, background: 'rgba(0,0,0,0.72)',
554
+ },
555
+ dialog: {
556
+ width: 'min(1120px, 94vw)', height: 'min(820px, 92vh)',
557
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
558
+ border: '1px solid var(--ui-border, rgba(128,128,128,0.5))', borderRadius: 10,
559
+ background: 'var(--ui-card-bg, #17171a)', color: 'var(--ui-text, #eee)',
560
+ boxShadow: '0 24px 80px rgba(0,0,0,0.45)',
561
+ },
562
+ toolbar: {
563
+ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8,
564
+ minHeight: 44, padding: '7px 10px',
565
+ borderBottom: '1px solid var(--ui-border, rgba(128,128,128,0.3))',
566
+ },
567
+ canvasWrap: { position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', background: '#202124' },
568
+ canvas: { display: 'block', width: '100%', height: '100%', cursor: 'grab', touchAction: 'none' },
569
+ overlay: {
570
+ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
571
+ justifyContent: 'center', flexDirection: 'column', gap: 10,
572
+ padding: 24, textAlign: 'center', background: 'rgba(25,25,28,0.92)',
573
+ },
574
+ }
575
+
576
+ function baseName(path: string): string {
577
+ const normalized = path.replaceAll('\\', '/')
578
+ return normalized.slice(normalized.lastIndexOf('/') + 1) || path
579
+ }
580
+
581
+ /** Size the canvas backing store to its CSS box before CanvasKit attaches. */
582
+ export function sizeCanvasForDisplay(
583
+ canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'>,
584
+ devicePixelRatio = window.devicePixelRatio,
585
+ ): { cssWidth: number; cssHeight: number; dpr: number } {
586
+ const cssWidth = Math.max(1, Math.round(canvas.clientWidth))
587
+ const cssHeight = Math.max(1, Math.round(canvas.clientHeight))
588
+ const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1
589
+ canvas.width = Math.max(1, Math.round(cssWidth * dpr))
590
+ canvas.height = Math.max(1, Math.round(cssHeight * dpr))
591
+ return { cssWidth, cssHeight, dpr }
592
+ }
593
+
594
+ function CanvasModal({ grant, onClose, locale }: {
595
+ grant: PresentationGrant
596
+ onClose: () => void
597
+ locale: PresentationLocale
598
+ }) {
599
+ const canvasRef = useRef<HTMLCanvasElement>(null)
600
+ const viewerRef = useRef<OpViewer>()
601
+ const dragRef = useRef<{ id: number; x: number; y: number; panX: number; panY: number }>()
602
+ const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
603
+ const [failure, setFailure] = useState('')
604
+ const [viewport, setViewport] = useState<Viewport>({ panX: 0, panY: 0, zoom: 1 })
605
+ const documentGrant = grant.document
606
+ const viewerGrant = grant.viewer
607
+ const copy = designRenderCopy(locale)
608
+
609
+ const fit = useCallback(() => {
610
+ const viewer = viewerRef.current
611
+ const canvas = canvasRef.current
612
+ if (viewer === undefined || canvas === null) return
613
+ viewer.zoomToFit(Math.max(1, canvas.clientWidth), Math.max(1, canvas.clientHeight))
614
+ setViewport(viewer.viewport)
615
+ }, [])
616
+
617
+ const zoomBy = useCallback((factor: number) => {
618
+ const viewer = viewerRef.current
619
+ if (viewer === undefined) return
620
+ viewer.setZoom(Math.min(16, Math.max(0.05, viewer.viewport.zoom * factor)))
621
+ setViewport(viewer.viewport)
622
+ }, [])
623
+
624
+ useEffect(() => {
625
+ const onKeyDown = (event: KeyboardEvent): void => {
626
+ if (event.key === 'Escape') onClose()
627
+ }
628
+ window.addEventListener('keydown', onKeyDown)
629
+ return () => { window.removeEventListener('keydown', onKeyDown) }
630
+ }, [onClose])
631
+
632
+ useEffect(() => {
633
+ const canvas = canvasRef.current
634
+ if (canvas === null || documentGrant === undefined || viewerGrant === undefined) return
635
+ sizeCanvasForDisplay(canvas)
636
+ const abort = new AbortController()
637
+ let cancelled = false
638
+ let created: OpViewer | undefined
639
+ setPhase('loading')
640
+ setFailure('')
641
+
642
+ const load = async (): Promise<void> => {
643
+ try {
644
+ const [sdk, response] = await Promise.all([
645
+ loadOpenPencilSdk(viewerGrant.sdkUrl),
646
+ fetch(documentGrant.url, { signal: abort.signal, credentials: 'same-origin' }),
647
+ ])
648
+ if (!response.ok) throw new Error(`OpenPencil document request failed (${response.status})`)
649
+ const source = await response.text()
650
+ if (cancelled) return
651
+ created = await sdk.createViewer({
652
+ canvas,
653
+ doc: source,
654
+ wasmUrl: viewerGrant.wasmUrl,
655
+ canvasKitBaseUrl: viewerGrant.canvasKitBaseUrl,
656
+ })
657
+ if (cancelled) {
658
+ created.destroy()
659
+ return
660
+ }
661
+ viewerRef.current = created
662
+ const syncViewport = (): void => { if (!cancelled && created !== undefined) setViewport(created.viewport) }
663
+ created.on('viewportchange', syncViewport)
664
+ setPhase('ready')
665
+ requestAnimationFrame(() => { if (!cancelled) fit() })
666
+ } catch (error) {
667
+ if (cancelled || abort.signal.aborted) return
668
+ setFailure(error instanceof Error ? error.message : String(error))
669
+ setPhase('error')
670
+ }
671
+ }
672
+ void load()
673
+ return () => {
674
+ cancelled = true
675
+ abort.abort()
676
+ viewerRef.current = undefined
677
+ created?.destroy()
678
+ }
679
+ }, [documentGrant?.url, fit, viewerGrant?.canvasKitBaseUrl, viewerGrant?.sdkUrl, viewerGrant?.wasmUrl])
680
+
681
+ const pointerDown = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
682
+ const viewer = viewerRef.current
683
+ if (viewer === undefined) return
684
+ event.currentTarget.setPointerCapture(event.pointerId)
685
+ const current = viewer.viewport
686
+ dragRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, panX: current.panX, panY: current.panY }
687
+ }
688
+ const pointerMove = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
689
+ const drag = dragRef.current
690
+ const viewer = viewerRef.current
691
+ if (drag === undefined || drag.id !== event.pointerId || viewer === undefined) return
692
+ viewer.panTo(drag.panX + event.clientX - drag.x, drag.panY + event.clientY - drag.y)
693
+ setViewport(viewer.viewport)
694
+ }
695
+ const pointerUp = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
696
+ if (dragRef.current?.id === event.pointerId) dragRef.current = undefined
697
+ }
698
+
699
+ const title = documentGrant?.path === undefined ? copy.canvas : baseName(documentGrant.path)
700
+ return (
701
+ <div
702
+ style={styles.backdrop}
703
+ role="presentation"
704
+ data-openpencil-canvas-modal="true"
705
+ onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
706
+ >
707
+ <div style={styles.dialog} role="dialog" aria-modal="true" aria-label={`${copy.canvas}: ${title}`}>
708
+ <div style={styles.toolbar}>
709
+ <strong style={{ marginRight: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</strong>
710
+ <button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={() => { zoomBy(0.8) }} aria-label={copy.zoomOut}>−</button>
711
+ <span style={styles.muted}>{Math.round(viewport.zoom * 100)}%</span>
712
+ <button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={() => { zoomBy(1.25) }} aria-label={copy.zoomIn}>+</button>
713
+ <button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={fit}>{copy.fit}</button>
714
+ <button type="button" style={styles.primaryButton} onClick={onClose}>{copy.close}</button>
715
+ </div>
716
+ <div style={styles.canvasWrap}>
717
+ <canvas
718
+ ref={canvasRef}
719
+ style={styles.canvas}
720
+ onPointerDown={pointerDown}
721
+ onPointerMove={pointerMove}
722
+ onPointerUp={pointerUp}
723
+ onPointerCancel={pointerUp}
724
+ aria-label={copy.readonlyCanvas}
725
+ />
726
+ {phase === 'loading' ? (
727
+ <div style={styles.overlay} role="status"><strong>{copy.loadingCanvas}</strong><span style={styles.muted}>{copy.pngRemains}</span></div>
728
+ ) : null}
729
+ {phase === 'error' ? (
730
+ <div style={styles.overlay} role="alert">
731
+ <strong>{copy.canvasUnavailable}</strong>
732
+ <span style={styles.muted}>{failure}</span>
733
+ {grant.image !== undefined ? <a style={styles.link} href={grant.image.previewUrl} target="_blank" rel="noreferrer">{copy.openPngFallback}</a> : null}
734
+ </div>
735
+ ) : null}
736
+ </div>
737
+ <div style={{ ...styles.meta, margin: 0, padding: '7px 10px' }}>
738
+ <span>{copy.panHint}</span>
739
+ {documentGrant?.sha256 !== undefined ? <span title={documentGrant.sha256}>{copy.snapshot} {documentGrant.sha256.slice(0, 10)}</span> : null}
740
+ </div>
741
+ </div>
742
+ </div>
743
+ )
744
+ }
745
+
746
+ /** Render one OpenPencil render tool call as a PNG-first card. */
747
+ export function DesignRenderView({
748
+ block,
749
+ callId,
750
+ toolName,
751
+ openDetails,
752
+ openFile,
753
+ inspect,
754
+ locale = 'en',
755
+ sessionId,
756
+ openEditorWorkbench,
757
+ autoOpenEditorWorkbench,
758
+ }: CompatibleToolCallViewProps & {
759
+ locale?: PresentationLocale
760
+ openEditorWorkbench?: (request: EditorWorkbenchRequest) => boolean | Promise<boolean>
761
+ autoOpenEditorWorkbench?: (request: EditorWorkbenchRequest) => boolean | Promise<boolean>
762
+ }) {
763
+ const settled = 'kind' in block
764
+ const error = settled && block.isError
765
+ const running = !settled
766
+ const embeddedGrant = grantOf(block)
767
+ const hydrationRequest = !running && !error
768
+ ? presentationHydrationRequestOf({
769
+ block,
770
+ toolName,
771
+ sessionId: String(sessionId),
772
+ callId,
773
+ embeddedGrant,
774
+ })
775
+ : undefined
776
+ const hydrationKey = hydrationRequest === undefined
777
+ ? undefined
778
+ : `${hydrationRequest.sessionId}\n${hydrationRequest.callId}\n${hydrationRequest.documentSha256}`
779
+ const [hydrated, setHydrated] = useState<{ key: string; grant: PresentationGrant }>()
780
+ const [hydrationFailedKey, setHydrationFailedKey] = useState<string>()
781
+ const grant = embeddedGrant
782
+ ?? (hydrated !== undefined && hydrated.key === hydrationKey ? hydrated.grant : undefined)
783
+ const hydrationPending = hydrationKey !== undefined && hydrationFailedKey !== hydrationKey
784
+ const copy = designRenderCopy(locale)
785
+ const text = resultText(block)
786
+ const frames = grant?.frames ?? []
787
+ const [selectedFrameIndex, setSelectedFrameIndex] = useState(0)
788
+ const currentFrameIndex = normalizedFrameIndex(selectedFrameIndex, frames.length)
789
+ const selectedFrame = frames[currentFrameIndex] ?? grant?.image
790
+ const [modalToken, setModalToken] = useState<symbol>()
791
+ const releaseRef = useRef<() => void>()
792
+ const liveAutoOpenCallKey = liveAutoOpenKey(String(sessionId), callId)
793
+
794
+ useEffect(() => {
795
+ if (hydrationKey === undefined || hydrationRequest === undefined) return
796
+ const controller = new AbortController()
797
+ void requestPresentationGrant(hydrationRequest, presentationGrantOfMeta, { signal: controller.signal }).then(nextGrant => {
798
+ if (nextGrant !== undefined && !controller.signal.aborted) {
799
+ setHydrated({ key: hydrationKey, grant: nextGrant })
800
+ } else if (!controller.signal.aborted) {
801
+ setHydrationFailedKey(hydrationKey)
802
+ }
803
+ })
804
+ return () => { controller.abort() }
805
+ // The semantic key contains every request field. Depending on `block`
806
+ // itself would restart a local exchange whenever DSH reprojects a snapshot.
807
+ }, [hydrationKey])
808
+
809
+ const closeCanvas = useCallback(() => {
810
+ releaseRef.current?.()
811
+ releaseRef.current = undefined
812
+ setModalToken(undefined)
813
+ }, [])
814
+
815
+ const openCanvas = useCallback(() => {
816
+ const token = Symbol('openpencil-canvas')
817
+ releaseRef.current?.()
818
+ releaseRef.current = claimCanvas(token, () => {
819
+ setModalToken((current) => current === token ? undefined : current)
820
+ })
821
+ setModalToken(token)
822
+ }, [])
823
+
824
+ const openEditor = useCallback(() => {
825
+ // A nested Code-mode result has no durable `block.meta`, so DSH's native
826
+ // details owner would receive the unhydrated block. Keep that recovered
827
+ // grant in our page-owned workbench instead. Embedded metadata continues
828
+ // to prefer the native details surface.
829
+ requestOpenPencilEditor(embeddedGrant === undefined ? undefined : openDetails, () => {
830
+ if (grant === undefined) return
831
+ void openEditorWorkbench?.({ grant, sessionId: String(sessionId) })
832
+ })
833
+ }, [embeddedGrant, grant, openDetails, openEditorWorkbench, sessionId])
834
+
835
+ useEffect(() => {
836
+ if (running && block.time >= liveAutoOpenActivatedAt) rememberLiveAutoOpenCall(liveAutoOpenCallKey)
837
+ else if (error) forgetLiveAutoOpenCall(liveAutoOpenCallKey)
838
+ }, [error, liveAutoOpenCallKey, running])
839
+
840
+ useEffect(() => {
841
+ if (
842
+ running
843
+ || error
844
+ || grant?.autoOpenEditor !== true
845
+ || grant.editor?.enabled !== true
846
+ || autoOpenEditorWorkbench === undefined
847
+ ) return
848
+ if (!takeLiveAutoOpenCall(liveAutoOpenCallKey)) return
849
+ void autoOpenEditorWorkbench({ grant, sessionId: String(sessionId) })
850
+ }, [autoOpenEditorWorkbench, error, grant, liveAutoOpenCallKey, running, sessionId])
851
+
852
+ useEffect(() => () => { releaseRef.current?.() }, [])
853
+ useEffect(() => { setSelectedFrameIndex(0) }, [frames.map(frame => frame.previewUrl).join('\n')])
854
+
855
+ const badge = error
856
+ ? <span style={{ ...styles.badge, ...styles.badgeError }}>{copy.error}</span>
857
+ : running
858
+ ? <span style={{ ...styles.badge, ...styles.badgeRunning }}>{copy.rendering}</span>
859
+ : <span style={{ ...styles.badge, ...styles.badgeOk }}>{copy.done}</span>
860
+
861
+ return (
862
+ <section style={styles.card} data-tool={OPENPENCIL_RENDER_TOOL_NAME} data-state={error ? 'error' : running ? 'running' : 'success'}>
863
+ <div style={styles.head}><span>{copy.designRender}</span>{badge}</div>
864
+ <div style={styles.body}>
865
+ {running ? <p style={styles.muted}>{copy.renderingDocument}</p> : null}
866
+ {error ? <p style={styles.muted}>{text ?? copy.renderFailed}</p> : null}
867
+ {!running && !error && frames.length > 0 ? (
868
+ <FrameGallery frames={frames} selectedIndex={currentFrameIndex} onSelect={setSelectedFrameIndex} locale={locale} />
869
+ ) : null}
870
+ {!running && !error && grant?.warnings !== undefined ? (
871
+ <div style={styles.warning} role="status">{grant.warnings.join(' ')}</div>
872
+ ) : null}
873
+ {!running && !error && grant !== undefined ? (
874
+ <div style={styles.meta}>
875
+ {selectedFrame !== undefined ? <span>{selectedFrame.name ?? baseName(selectedFrame.path)}</span> : null}
876
+ {frames.length > 1 ? <span>{frames.length} {copy.frames}</span> : null}
877
+ {grant.renderer !== undefined ? (
878
+ <span title={grant.rendererBinary}>{grant.renderer}{grant.fidelity === undefined ? '' : ` · ${grant.fidelity}`}</span>
879
+ ) : null}
880
+ {grant.document !== undefined && grant.viewer !== undefined ? <button type="button" style={styles.primaryButton} onClick={openCanvas}>{copy.openInteractiveCanvas}</button> : null}
881
+ {grant.document !== undefined && grant.editor?.enabled === true ? (
882
+ <button type="button" style={styles.primaryButton} onClick={openEditor}>
883
+ {openDetails === undefined || embeddedGrant === undefined ? copy.editCanvas : copy.editInSidebar}
884
+ </button>
885
+ ) : null}
886
+ {selectedFrame !== undefined && openFile !== undefined ? (
887
+ <button type="button" style={styles.button} onClick={() => { openFile(selectedFrame.path) }}>{copy.openRenderedPng}</button>
888
+ ) : null}
889
+ {selectedFrame !== undefined ? <a style={styles.link} href={selectedFrame.downloadUrl} download>{copy.downloadPng}</a> : null}
890
+ {grant.document?.path !== undefined && openFile !== undefined ? (
891
+ <button type="button" style={styles.button} onClick={() => { openFile(grant.document?.path ?? '') }}>{copy.editSource}</button>
892
+ ) : null}
893
+ {grant.document?.downloadUrl !== undefined ? <a style={styles.link} href={grant.document.downloadUrl} download>{copy.downloadSource}</a> : null}
894
+ {inspect !== undefined ? <button type="button" style={styles.button} onClick={inspect}>{copy.inspectToolCall}</button> : null}
895
+ </div>
896
+ ) : null}
897
+ {!running && !error && grant === undefined && hydrationPending ? (
898
+ <p style={styles.muted} role="status">{copy.recoveringPreview}</p>
899
+ ) : null}
900
+ {!running && !error && grant === undefined && !hydrationPending ? (
901
+ <><p style={styles.muted}>{copy.noPreview}</p>{text !== null ? <pre style={{ ...styles.pre, marginTop: 8 }}>{text}</pre> : null}</>
902
+ ) : null}
903
+ </div>
904
+ {modalToken !== undefined && grant?.document !== undefined && grant.viewer !== undefined ? <CanvasModal grant={grant} onClose={closeCanvas} locale={locale} /> : null}
905
+ </section>
906
+ )
907
+ }
908
+
909
+ /** Render the selected editable design inside DSH's resident details column. */
910
+ export function OpenPencilEditorPanel({ block, colorScheme, locale, sessionId }: CompatibleToolDetailsViewProps & {
911
+ colorScheme: EditorColorScheme
912
+ locale: EditorLocale
913
+ }) {
914
+ const grant = grantOf(block)
915
+ if (grant?.editor === undefined || grant.document === undefined) {
916
+ return <div style={styles.overlay}>{editorPanelCopy(locale).unavailable}</div>
917
+ }
918
+ return <ManagedOpenPencilEditor
919
+ key={editorWorkbenchEditorKey(grant, String(sessionId))}
920
+ grant={grant}
921
+ colorScheme={colorScheme}
922
+ locale={locale}
923
+ sessionId={String(sessionId)}
924
+ />
925
+ }
926
+
927
+ /** Required client services. */
928
+ export const inject = ['slots', 'theme', 'locale']
929
+
930
+ /** Register canonical views plus a presentation-only alias for replaying historical cards. */
931
+ export function apply(ctx: ClientContext): void {
932
+ const subscribeTheme = (notify: () => void): (() => boolean) => ctx.on('theme/change', notify)
933
+ const getColorScheme = (): EditorColorScheme => ctx.theme.getTheme().active.colorScheme
934
+ const subscribeLocale = (notify: () => void): (() => boolean) => ctx.on('locale/change', notify)
935
+ const getLocale = (): PresentationLocale => ctx.locale.getLocale().active
936
+ const getEditorLocale = (): EditorLocale => editorLocaleFromDsh(getLocale())
937
+ let editorWorkbenchHost: ReturnType<typeof mountEditorWorkbenchHost> | undefined
938
+ if (typeof document !== 'undefined') {
939
+ ctx.effect(() => {
940
+ const host = mountEditorWorkbenchHost({
941
+ subscribeTheme,
942
+ getColorScheme,
943
+ subscribeLocale,
944
+ getLocale,
945
+ })
946
+ editorWorkbenchHost = host
947
+ return () => {
948
+ if (editorWorkbenchHost === host) editorWorkbenchHost = undefined
949
+ return host.dispose()
950
+ }
951
+ }, 'dsh-openpencil: fallback editor workbench host')
952
+ }
953
+ const HostSyncedDesignRenderView = (props: ToolCallViewProps): React.JSX.Element => {
954
+ const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale)
955
+ return (
956
+ <DesignRenderView
957
+ {...props}
958
+ locale={locale}
959
+ openEditorWorkbench={request => editorWorkbenchHost?.open(request) ?? false}
960
+ autoOpenEditorWorkbench={request => editorWorkbenchHost?.openIfIdle(request) ?? false}
961
+ />
962
+ )
963
+ }
964
+ const HostSyncedOpenPencilEditorPanel = (props: CompatibleToolDetailsViewProps): React.JSX.Element => {
965
+ const colorScheme = useSyncExternalStore(subscribeTheme, getColorScheme, getColorScheme)
966
+ const locale = useSyncExternalStore(subscribeLocale, getEditorLocale, getEditorLocale)
967
+ return <OpenPencilEditorPanel {...props} colorScheme={colorScheme} locale={locale} />
968
+ }
969
+ const HostSyncedOpenPencilSelectionDock = (props: Omit<React.ComponentProps<typeof OpenPencilSelectionDock>, 'locale'>): React.JSX.Element => {
970
+ const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale)
971
+ return <OpenPencilSelectionDock {...props} locale={locale} />
972
+ }
973
+ for (const toolName of [OPENPENCIL_RENDER_TOOL_NAME, LEGACY_DESIGN_RENDER_TOOL_NAME]) {
974
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
975
+ { name: 'tool.call.toolview', key: toolName },
976
+ HostSyncedDesignRenderView,
977
+ ))
978
+ ctx.slots.inject('tool.details.toolview', () => ctx.slots.register(
979
+ { name: 'tool.details.toolview', key: toolName },
980
+ HostSyncedOpenPencilEditorPanel,
981
+ ))
982
+ }
983
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
984
+ { name: 'conversation.input.dock', id: 'openpencil-selection', order: 30 },
985
+ HostSyncedOpenPencilSelectionDock,
986
+ ))
987
+ }