dsh-ptc-plus 0.3.2 → 0.4.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 (199) hide show
  1. package/README.en.md +65 -78
  2. package/README.md +65 -78
  3. package/assets/ptc-plus-bindings-en.png +0 -0
  4. package/assets/ptc-plus-bindings-zh.png +0 -0
  5. package/assets/ptc-plus-repair-en.png +0 -0
  6. package/assets/ptc-plus-repair-zh.png +0 -0
  7. package/assets/ptc-plus-repl-workspace-en.png +0 -0
  8. package/assets/ptc-plus-repl-workspace-zh.png +0 -0
  9. package/assets/ptc-plus-settings-en.png +0 -0
  10. package/assets/ptc-plus-settings-zh.png +0 -0
  11. package/client.js +30596 -742
  12. package/compiler-amaro.cjs +5 -0
  13. package/compiler-amaro.cjs.map +6 -0
  14. package/compiler-asset-124f80c1bba92ff5fe79cfec6e8ad518f476de4a42514cae3919538887f510e8.txt +1 -0
  15. package/compiler-assets.json +3 -0
  16. package/compiler-core.cjs +554 -0
  17. package/compiler-core.cjs.map +6 -0
  18. package/compiler-platform.cjs +2 -0
  19. package/compiler-platform.cjs.map +6 -0
  20. package/compiler-typescript.cjs +388 -0
  21. package/compiler-typescript.cjs.map +6 -0
  22. package/docs/README.md +6 -0
  23. package/docs/adr/0004-declare-program-bindings-at-the-host.md +1 -1
  24. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +5 -3
  25. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +1 -1
  26. package/docs/adr/0009-bound-expensive-acceptance-default.md +2 -0
  27. package/docs/adr/0010-session-log-derived-recovery-tips.md +59 -5
  28. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +1 -1
  29. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +3 -1
  30. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +3 -1
  31. package/docs/adr/0014-persist-cell-rewrite-policy.md +6 -2
  32. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +1 -1
  33. package/docs/adr/0016-archive-resolved-review-findings.md +1 -1
  34. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +49 -0
  35. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +3 -3
  36. package/docs/adr/0019-plugin-settings-and-kill-switch.md +5 -3
  37. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
  38. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +12 -0
  39. package/docs/adr/0023-global-user-bindings.md +104 -0
  40. package/docs/adr/0024-repl-console-observation.md +33 -0
  41. package/docs/adr/0025-use-versioned-logical-binding-identities.md +119 -0
  42. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +21 -0
  43. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +37 -0
  44. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +103 -0
  45. package/docs/adr/0029-keep-plugin-unavailability-out-of-the-host-agent-lifecycle.md +27 -0
  46. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +27 -0
  47. package/docs/architecture.md +73 -21
  48. package/docs/binding-review-placement-design.md +200 -0
  49. package/docs/capability-projection.md +1 -1
  50. package/docs/client-ui.md +95 -19
  51. package/docs/durability-design.md +25 -10
  52. package/docs/evaluation.md +78 -6
  53. package/docs/installation.md +41 -6
  54. package/docs/publishing.md +5 -3
  55. package/docs/runtime-reference.md +72 -25
  56. package/docs/semantic-validation.md +29 -0
  57. package/docs/stateful-binding-updates-proposal.md +755 -0
  58. package/docs/user-bindings.en.md +64 -0
  59. package/docs/user-bindings.md +64 -0
  60. package/docs/value-wire.md +3 -3
  61. package/docs/verification.md +57 -0
  62. package/index.js +369 -92
  63. package/internal/ast-traversal.js +5 -2
  64. package/internal/binding-descriptors.js +1 -0
  65. package/internal/binding-pattern-traversal.js +25 -0
  66. package/internal/binding-pattern.js +26 -29
  67. package/internal/binding-source-analysis.js +391 -0
  68. package/internal/binding-update-policy.js +66 -0
  69. package/internal/bounded-worker-round.js +56 -0
  70. package/internal/callable-definition-environment.js +42 -0
  71. package/internal/callable-source-catalog.js +129 -0
  72. package/internal/callable-source-encoding.js +47 -0
  73. package/internal/callable-source-facts.js +459 -0
  74. package/internal/callable-source-format.js +2 -0
  75. package/internal/callable-source-lowering.js +123 -0
  76. package/internal/cell-analysis-contract.js +25 -0
  77. package/internal/cell-analysis.js +240 -290
  78. package/internal/cell-completion.js +15 -0
  79. package/internal/cell-error.js +7 -0
  80. package/internal/cell-parser.js +66 -0
  81. package/internal/cell-rewriter.js +3 -677
  82. package/internal/commonjs-export-evidence.js +63 -0
  83. package/internal/commonjs-source-evidence.js +72 -0
  84. package/internal/compiler-amaro.cjs +2 -0
  85. package/internal/compiler-compression.js +12 -0
  86. package/internal/compiler-data.js +58 -0
  87. package/internal/compiler-descriptors.js +54 -0
  88. package/internal/compiler-dynamic-cache.js +48 -0
  89. package/internal/compiler-entry.js +35 -0
  90. package/internal/compiler-generated-names.js +17 -0
  91. package/internal/compiler-intrinsics.js +84 -0
  92. package/internal/compiler-module-links.js +34 -0
  93. package/internal/compiler-operations.js +61 -0
  94. package/internal/compiler-platform-factory.js +84 -0
  95. package/internal/compiler-platform.js +136 -0
  96. package/internal/compiler-record-roles.js +53 -0
  97. package/internal/compiler-region-output.js +259 -0
  98. package/internal/compiler-resource-lowering.js +103 -0
  99. package/internal/compiler-scope-facts.js +115 -0
  100. package/internal/compiler-service.js +109 -0
  101. package/internal/compiler-source-cache.js +32 -0
  102. package/internal/compiler-source-pieces.js +93 -0
  103. package/internal/compiler-source-regions.js +132 -0
  104. package/internal/compiler-storage-source.js +54 -0
  105. package/internal/compiler-text.js +15 -0
  106. package/internal/compiler-typescript.cjs +2 -0
  107. package/internal/config-spec.js +83 -27
  108. package/internal/cordis-tools-owner.js +130 -21
  109. package/internal/diagnostic.js +27 -1
  110. package/internal/direct-surface-owner.js +76 -23
  111. package/internal/dynamic-binding-evidence.js +16 -0
  112. package/internal/dynamic-environment-compiler.js +779 -0
  113. package/internal/dynamic-environment-integration.js +87 -0
  114. package/internal/dynamic-environment-runtime.js +362 -0
  115. package/internal/dynamic-native-calls.js +150 -0
  116. package/internal/dynamic-native-runtime.js +248 -0
  117. package/internal/dynamic-scope-analysis.js +90 -0
  118. package/internal/edit-transport-owner.js +27 -2
  119. package/internal/execution-seam-compat.js +193 -0
  120. package/internal/failure-reporting.js +118 -18
  121. package/internal/host-rpc.js +66 -0
  122. package/internal/isolated-worker.js +548 -0
  123. package/internal/kernel-child.js +139 -0
  124. package/internal/kernel-worker.js +904 -120
  125. package/internal/language-semantics.js +14 -0
  126. package/internal/legacy-binding-catalog.js +48 -0
  127. package/internal/legacy-cell-analysis.js +600 -0
  128. package/internal/legacy-cell-parser.js +50 -0
  129. package/internal/legacy-cell-rewriter.js +631 -0
  130. package/internal/legacy-repl-convenience.js +291 -0
  131. package/internal/managed-module-operations.js +170 -0
  132. package/internal/module-compilation.js +321 -0
  133. package/internal/module-runtime-platform.js +19 -0
  134. package/internal/module-transform-contract.js +12 -0
  135. package/internal/native-await-declarations.js +68 -0
  136. package/internal/native-root-compilation.js +54 -0
  137. package/internal/native-root-dynamic.js +67 -0
  138. package/internal/recovery-tips.js +27 -15
  139. package/internal/rejected-cell-editor.js +13 -8
  140. package/internal/repl-convenience.js +2 -289
  141. package/internal/repl-memory-projection.js +81 -12
  142. package/internal/repl-observation-interest.js +70 -0
  143. package/internal/repl-rewrite-contract.js +6 -0
  144. package/internal/repl-scope-normalizer.js +2091 -0
  145. package/internal/repl-value-observer.js +114 -0
  146. package/internal/rpc-contract.js +52 -0
  147. package/internal/runtime-bridge-owner.js +172 -28
  148. package/internal/runtime-config.js +48 -52
  149. package/internal/runtime-contexts.js +82 -10
  150. package/internal/runtime-messages.js +111 -0
  151. package/internal/session-cell-executor.js +401 -87
  152. package/internal/session-journal-recovery.js +684 -0
  153. package/internal/session-journal-schema.js +106 -0
  154. package/internal/session-journal.js +184 -654
  155. package/internal/session-log-view.js +42 -9
  156. package/internal/session-runtime.js +256 -95
  157. package/internal/session-state.js +247 -71
  158. package/internal/source-position-map.js +211 -71
  159. package/internal/stateful-module-compiler.js +154 -0
  160. package/internal/stateful-module-runtime.js +303 -0
  161. package/internal/stateful-root-compiler.js +598 -0
  162. package/internal/stateful-root-runtime.js +340 -0
  163. package/internal/typert-codec-compat.js +28 -0
  164. package/internal/typescript-transform.js +29 -0
  165. package/internal/user-binding-authoring.js +70 -0
  166. package/internal/user-binding-console-worker.js +184 -0
  167. package/internal/user-binding-console.js +148 -0
  168. package/internal/user-binding-draft-projection.js +335 -0
  169. package/internal/user-binding-model-context.js +31 -0
  170. package/internal/user-binding-runner.js +56 -0
  171. package/internal/user-bindings-owner.js +1065 -0
  172. package/internal/user-bindings-store.js +293 -0
  173. package/internal/user-bindings.js +353 -0
  174. package/internal/value-wire-schema.js +28 -0
  175. package/internal/value-wire.js +29 -20
  176. package/internal/worker-client.js +57 -67
  177. package/internal/worker-environment.js +45 -0
  178. package/internal/worker-output-relay.js +29 -0
  179. package/internal/worker-shutdown.js +14 -0
  180. package/package.json +75 -7
  181. package/src/client-activity.js +88 -53
  182. package/src/client-authoring-view.js +580 -0
  183. package/src/client-binding-review.js +230 -0
  184. package/src/client-bindings-data.js +29 -0
  185. package/src/client-catalog.js +127 -0
  186. package/src/client-code-editor.js +84 -0
  187. package/src/client-console.js +112 -0
  188. package/src/client-copy.js +370 -0
  189. package/src/client-feature-gates.js +62 -0
  190. package/src/client-host-compat.js +150 -0
  191. package/src/client-indicator-view.js +267 -0
  192. package/src/client-repl-view.js +292 -0
  193. package/src/client-rpc.js +20 -0
  194. package/src/client-settings-view.js +221 -0
  195. package/src/client-styles.js +106 -0
  196. package/src/client-tool-view.js +108 -0
  197. package/src/client-transport.js +159 -0
  198. package/src/client-workbench.js +521 -0
  199. package/src/client.js +233 -615
@@ -0,0 +1,580 @@
1
+ import { bindingDraftProjection, bindingReviewStatus } from './client-binding-review.js'
2
+ import { bindingModelPreferences } from '../internal/user-binding-model-context.js'
3
+ import { featureEnabled } from './client-feature-gates.js'
4
+
5
+ /**
6
+ * Hover dwell before the composer entry opens its menu. The entry sits inside the
7
+ * composer, so a pointer merely crossing it must not drop a menu over the input;
8
+ * click and keyboard paths stay immediate, and the published Menu owns the close
9
+ * grace for a pointer that leaves the trigger and its list.
10
+ */
11
+ const HOVER_DWELL_MS = 150
12
+
13
+ /**
14
+ * Authoring surfaces: the composer entry menu, the binding review dock and the
15
+ * command card. The review registry, catalog source and workbench controller
16
+ * stay owned by the entry and arrive as dependencies.
17
+ */
18
+ export function createAuthoringView(React, deps) {
19
+ const {
20
+ ActionButton, IconButton, Menu, Toast, Tooltip, CodeBlock, BindingsDialog, PTCPlusSettingsDialog,
21
+ useWorkbenchController, useBindingReview, catalogOwner, callUserBindings, subscribeReset,
22
+ settingsCardSeat, updateSetting,
23
+ icons: {
24
+ sparkle: IconSparkle16, chevron: IconChevronDownOutline14,
25
+ close: IconCloseOutline16, check: IconCheckOutline14,
26
+ },
27
+ } = deps
28
+ const h = React.createElement
29
+
30
+ /**
31
+ * Where the settings card lives on the installed generation.
32
+ *
33
+ * The compat module reports the seat the host declared rather than the
34
+ * generation, so this copy names the place a user can open now. Before either
35
+ * seat mounts it reports the current one, which is what an up-to-date install
36
+ * offers; the preceding generation's own seat replaces it once it registers.
37
+ */
38
+ const settingsPath = t => t(settingsCardSeat?.() === 'settings.plugin.item'
39
+ ? 'settings.pathSettings' : 'settings.pathPlugins')
40
+
41
+ function BindingAuthorButton({
42
+ sessionId, t, useInput, inputActions, usePtcSettings, useBindingCommand,
43
+ }) {
44
+ const input = useInput?.(snapshot => snapshot)
45
+ const available = useBindingCommand(snapshot => snapshot)
46
+ const settings = usePtcSettings(snapshot => snapshot)
47
+ const [review, view] = useBindingReview(sessionId)
48
+ const anchorRef = React.useRef(null)
49
+ const attachAnchor = React.useCallback(element => {
50
+ anchorRef.current = element
51
+ review.access = element
52
+ }, [review])
53
+ const firstItemRef = React.useRef(null)
54
+ const manageItemRef = React.useRef(null)
55
+ const reloadItemRef = React.useRef(null)
56
+ const settingsItemRef = React.useRef(null)
57
+ const focused = React.useRef(false)
58
+ const dialogReturnFocus = React.useRef(null)
59
+ const hoverTimer = React.useRef(undefined)
60
+ const [menu, setMenu] = React.useState(null)
61
+ const [managing, setManaging] = React.useState(false)
62
+ const [settingsOpen, setSettingsOpen] = React.useState(false)
63
+ // The menu catalog and the workbench catalog are independent sources: a
64
+ // read or write in one never invalidates the other's in-flight request.
65
+ const catalogSource = React.useMemo(() => catalogOwner.claim(), [catalogOwner])
66
+ const catalogState = React.useSyncExternalStore(catalogSource.subscribe, catalogSource.getSnapshot)
67
+ const catalog = catalogState.catalog
68
+ const catalogStatus = catalogState.status
69
+ const catalogError = catalogState.error
70
+ // Rows act on the catalog already on screen. A background read keeps them
71
+ // usable; only a write in flight blocks a second toggle on the same menu.
72
+ const catalogReady = catalog !== null && catalogStatus !== 'writing'
73
+ const catalogFocus = React.useRef(null)
74
+ const toastSequence = React.useRef(0)
75
+ const [toast, setToast] = React.useState(null)
76
+ const hasDraft = view.mounted && view.candidate !== null && view.action === null
77
+ const quickAccess = featureEnabled(settings, 'authorButton')
78
+ const canAuthor = quickAccess && available && typeof useInput === 'function' && typeof inputActions?.setDraft === 'function'
79
+ const menuOpen = (hasDraft || quickAccess) && view.reachable && !managing && !settingsOpen
80
+ && menu !== null && menu.key === view.candidateKey
81
+ // The dialog owns the management session; losing the input surface only
82
+ // suspends reads, so a draft survives a temporary Host takeover.
83
+ const workbench = useWorkbenchController({
84
+ enabled: quickAccess && managing, active: view.reachable, callUserBindings,
85
+ })
86
+ const refreshCatalog = React.useCallback(async (reload = false) => {
87
+ if (reload) captureCatalogFocus()
88
+ await catalogSource.read({ reload })
89
+ }, [catalogSource])
90
+ React.useEffect(() => {
91
+ const reset = () => {
92
+ catalogSource.reset()
93
+ setMenu(null)
94
+ setManaging(false)
95
+ setSettingsOpen(false)
96
+ }
97
+ const unsubscribe = subscribeReset(reset)
98
+ return () => {
99
+ unsubscribe()
100
+ catalogSource.release()
101
+ }
102
+ }, [catalogSource])
103
+ const toggleBinding = async entry => {
104
+ if (!catalogReady || !quickAccess) return
105
+ captureCatalogFocus()
106
+ await catalogSource.write(() => callUserBindings(entry.enabled ? 'disable' : 'enable', {
107
+ id: entry.id, expectedRevision: catalog.revision,
108
+ }))
109
+ }
110
+ const cancelHoverOpen = React.useCallback(() => {
111
+ if (hoverTimer.current === undefined) return
112
+ clearTimeout(hoverTimer.current)
113
+ hoverTimer.current = undefined
114
+ }, [])
115
+ React.useEffect(() => cancelHoverOpen, [cancelHoverOpen])
116
+ const showMenu = mode => {
117
+ cancelHoverOpen()
118
+ if (!menuOpen && quickAccess) void refreshCatalog()
119
+ setMenu({ key: view.candidateKey, mode, step: 'root' })
120
+ }
121
+ // Touch has no hover: its tap reaches the same menu through the click path.
122
+ const hoverMenu = event => {
123
+ if (event.pointerType === 'touch' || menuOpen) return
124
+ cancelHoverOpen()
125
+ hoverTimer.current = setTimeout(() => {
126
+ hoverTimer.current = undefined
127
+ showMenu('hover')
128
+ }, HOVER_DWELL_MS)
129
+ }
130
+ const menuElement = () => manageItemRef.current?.closest('[role=menu]')
131
+ ?? firstItemRef.current?.closest('[role=menu]')
132
+ const captureCatalogFocus = () => {
133
+ catalogFocus.current = menuElement()?.contains(document.activeElement) ? document.activeElement : null
134
+ }
135
+ // The list must sit next to the entry that opened it. The published Menu reads
136
+ // this rect on open and on every scroll/resize, and re-runs placement whenever
137
+ // the prop identity changes, so measuring the trigger each render also keeps an
138
+ // open list on a composer that grows without emitting a scroll.
139
+ const menuAnchorRect = () => {
140
+ const anchor = anchorRef.current
141
+ if (!anchor) return null
142
+ const rect = anchor.getBoundingClientRect()
143
+ menuElement()?.style.setProperty('--ptc-plus-menu-space', `${Math.max(0, rect.top - 16)}px`)
144
+ return rect
145
+ }
146
+ const hideMenu = () => {
147
+ const itemHasFocus = menuElement()?.contains(document.activeElement)
148
+ || (catalogFocus.current !== null && document.activeElement === document.body)
149
+ setMenu(null)
150
+ if (itemHasFocus && anchorRef.current?.getClientRects().length) {
151
+ anchorRef.current.querySelector('.ptcPlusAuthorButton')?.focus({ preventScroll: true })
152
+ }
153
+ }
154
+ React.useEffect(() => {
155
+ const observer = typeof IntersectionObserver === 'function'
156
+ ? new IntersectionObserver(entries => review.reachable(entries.some(entry => entry.isIntersecting))) : undefined
157
+ if (observer) observer.observe(anchorRef.current)
158
+ else review.reachable(true)
159
+ return () => { observer?.disconnect(); review.reachable(false) }
160
+ }, [review])
161
+ React.useEffect(() => {
162
+ // Losing the input surface hides the dialog; it does not end the management
163
+ // session. The draft lives in the controller above, so the same text returns
164
+ // with the composer. Explicit close and feature disablement still end it.
165
+ setMenu(null)
166
+ }, [hasDraft, view.reachable, view.candidateKey])
167
+ React.useLayoutEffect(() => {
168
+ if (focused.current && document.activeElement === document.body) {
169
+ focused.current = false
170
+ const anchor = anchorRef.current
171
+ const button = anchor?.querySelector('.ptcPlusAuthorButton')
172
+ if (button?.getClientRects().length) button.focus({ preventScroll: true })
173
+ else focusComposer(anchor)
174
+ }
175
+ }, [hasDraft, canAuthor, view.candidateKey])
176
+ React.useLayoutEffect(() => {
177
+ if (!menuOpen) { catalogFocus.current = null; return }
178
+ if (catalogStatus === 'writing' || catalogStatus === 'loading') return
179
+ const target = catalogFocus.current
180
+ catalogFocus.current = null
181
+ if (!target || document.activeElement !== document.body) return
182
+ const root = menuElement()
183
+ const next = root?.contains(target) && !target.disabled ? target
184
+ : reloadItemRef.current?.closest('button') ?? root?.querySelector('button:not(:disabled)')
185
+ next?.focus({ preventScroll: true })
186
+ }, [catalogStatus, menuOpen])
187
+ React.useLayoutEffect(() => {
188
+ if (!menuOpen || menu.mode === 'hover') return
189
+ // The public Menu commits its measured position before keyboard focus enters it.
190
+ let cancelled = false
191
+ queueMicrotask(() => {
192
+ if (!cancelled && anchorRef.current?.getClientRects().length) menuElement()?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true })
193
+ })
194
+ return () => { cancelled = true }
195
+ }, [menuOpen, menu?.mode, menu?.step])
196
+ React.useEffect(() => {
197
+ if (toast === null || typeof Toast === 'function') return undefined
198
+ const timer = setTimeout(() => setToast(null), 2_500)
199
+ return () => clearTimeout(timer)
200
+ }, [toast])
201
+ const label = hasDraft ? t('bindings.reviewMenuLabel', { count: 1 })
202
+ + (view.message ? ` · ${t('bindings.reviewAttention')}` : '') : t('bindings.open')
203
+ const hint = t(hasDraft ? 'bindings.draftHint' : 'bindings.openHint')
204
+ // Every authoring action goes through the same busy guard and input owner, so a
205
+ // new or revision request can never overwrite an unsent user draft.
206
+ const prefillAuthoring = value => {
207
+ hideMenu()
208
+ if (!canAuthor) return
209
+ if (typeof input?.draft === 'string' && input.draft.trim() !== '') {
210
+ toastSequence.current += 1
211
+ setToast({ sequence: toastSequence.current, text: t('bindings.composerBusy') })
212
+ return
213
+ }
214
+ inputActions.setDraft(value)
215
+ }
216
+ const openAuthoring = () => prefillAuthoring('/binding new ')
217
+ const openAuthoringEdit = entry => prefillAuthoring(`/binding edit ${entry.id} `)
218
+ const starButton = h('button', {
219
+ type: 'button', className: 'ptcPlusAuthorButton', 'aria-label': label,
220
+ // Older UI-kit lines ship no Tooltip primitive; the native title carries the hint there.
221
+ title: typeof Tooltip === 'function' ? undefined : hint,
222
+ 'aria-haspopup': 'menu', 'aria-expanded': menuOpen,
223
+ onPointerEnter: hoverMenu,
224
+ onPointerLeave: cancelHoverOpen,
225
+ onKeyDown: event => {
226
+ if (['ArrowUp', 'ArrowDown'].includes(event.key)) { event.preventDefault(); showMenu('keyboard') }
227
+ },
228
+ onClick: () => {
229
+ if (menuOpen && menu.mode !== 'hover') hideMenu()
230
+ else showMenu('click')
231
+ },
232
+ }, typeof IconSparkle16 === 'function'
233
+ ? h(IconSparkle16, { size: 16, 'aria-hidden': true })
234
+ : h('span', { className: 'ptcPlusAuthorButtonLabel', 'aria-hidden': true }, t('bindings.open')),
235
+ hasDraft ? h('span', { className: 'ptcPlusDraftBadge', 'aria-hidden': true,
236
+ 'data-attention': view.message !== null }, '1') : null)
237
+ const catalogItems = !quickAccess ? [] : [
238
+ { id: 'global-heading', type: 'label', text: t('bindings.quickHeading') },
239
+ ...(catalog?.entries ?? []).map(entry => ({ id: `global:${entry.id}`,
240
+ disabled: !catalogReady,
241
+ label: h('span', { className: 'ptcPlusBindingQuickRow', 'data-enabled': entry.enabled },
242
+ h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
243
+ h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
244
+ h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
245
+ })),
246
+ ...(catalog !== null && catalog.entries.length === 0
247
+ ? [{ id: 'empty', type: 'label', text: t('memory.globalEmpty') }] : []),
248
+ ...(catalogStatus === 'writing' || (catalog === null && catalogStatus === 'loading')
249
+ ? [{ id: 'pending', type: 'label', text: t(catalogStatus === 'writing' ? 'bindings.quickSaving' : 'bindings.quickLoading') }] : []),
250
+ ...(catalogError === null ? [] : [{ id: 'error', type: 'label', text: t('bindings.failed', { error: catalogError }) }]),
251
+ ]
252
+ // Revision selection is a second step of the same flat Menu. The published Menu
253
+ // disables its scroll viewport as soon as any top-level row has a submenu, so a
254
+ // submenu here would lose the bounded, scrollable catalog this step needs.
255
+ const editItems = [
256
+ { id: 'edit-heading', type: 'label', text: t('bindings.quickEditHeading') },
257
+ ...(catalog?.entries ?? []).map(entry => ({
258
+ id: `edit:${entry.id}`,
259
+ disabled: !catalogReady,
260
+ label: h('span', { className: 'ptcPlusBindingQuickRow' },
261
+ h('span', { className: 'ptcPlusBindingQuickName' }, h('strong', { title: entry.name }, entry.name),
262
+ h('span', { className: 'ptcPlusBindingQuickState' }, t(entry.enabled ? 'bindings.enabled' : 'bindings.disabledEntry'))),
263
+ h('span', { className: 'ptcPlusBindingQuickPurpose', title: entry.purpose }, entry.purpose)),
264
+ })),
265
+ // The back row also anchors menu lookup, so the step still owns its list
266
+ // when the catalog is momentarily empty.
267
+ { id: 'edit-back', label: h('span', { className: 'ptcPlusBindingMenuAction', ref: firstItemRef }, t('bindings.back')) },
268
+ ]
269
+ return h('span', {
270
+ className: 'ptcPlusComposerBindingAnchor', tabIndex: -1,
271
+ onFocusCapture: () => { focused.current = true }, onBlurCapture: () => { focused.current = false },
272
+ 'data-text': typeof IconSparkle16 === 'function' ? undefined : true,
273
+ ref: attachAnchor,
274
+ },
275
+ !hasDraft && !quickAccess ? null : h(Menu, {
276
+ className: 'ptcPlusAuthorButtonShell', open: menuOpen,
277
+ anchor: typeof Tooltip === 'function'
278
+ ? h(Tooltip, { label: hint, delayMs: 400 }, starButton) : starButton,
279
+ portal: true, side: 'top', dense: true,
280
+ // A hovered menu leaves with the pointer once it leaves the trigger and the
281
+ // list for the published grace, which re-entering either cancels; click and
282
+ // keyboard menus stay until dismissed.
283
+ closeOnPointerLeave: menu !== null && menu.mode === 'hover',
284
+ getAnchorRect: menuAnchorRect,
285
+ selectedIds: (catalog?.entries ?? []).filter(entry => entry.enabled).map(entry => `global:${entry.id}`),
286
+ onClose: hideMenu,
287
+ items: menu?.step === 'edit' ? editItems : [...(hasDraft ? [{ id: 'draft-heading', type: 'label', text: t('bindings.quickDrafts') }, { id: view.candidateKey,
288
+ label: h('span', { className: 'ptcPlusDraftMenuItem', ref: firstItemRef },
289
+ h('strong', null, view.candidate.entry.name),
290
+ h('span', null, t(bindingReviewStatus(view)))) },
291
+ ...(quickAccess ? [{ id: 'draft-separator', type: 'separator' }] : [])] : []), ...catalogItems,
292
+ ...(quickAccess ? [{ id: 'actions-separator', type: 'separator' }] : []),
293
+ ...(canAuthor ? [{ id: 'new', label: h('span', { className: 'ptcPlusBindingMenuAction' }, t('bindings.authorNewDraft')) }] : []),
294
+ ...(canAuthor && (catalog?.entries?.length ?? 0) > 0
295
+ ? [{ id: 'edit', label: h('span', { className: 'ptcPlusBindingMenuAction' }, t('bindings.authorEdit')) }] : []),
296
+ ...(quickAccess ? [
297
+ ...(catalogError === null ? [] : [{ id: 'reload', label: h('span', { ref: reloadItemRef }, t('bindings.reload')) }]),
298
+ { id: 'manage', label: h('span', { ref: manageItemRef, className: 'ptcPlusBindingMenuAction' }, t('bindings.manage')) },
299
+ ] : []),
300
+ // The entry is also the shortcut to this plugin's own settings, so the
301
+ // row names the seat the installed generation declared and its hint
302
+ // carries the full path that opens it.
303
+ { id: 'settings', label: h('span', {
304
+ ref: settingsItemRef, className: 'ptcPlusBindingMenuAction', title: settingsPath(t),
305
+ }, t('settings.menuEntry')) },
306
+ ],
307
+ onSelect: id => {
308
+ if (!anchorRef.current?.getClientRects().length) return
309
+ if (id === 'edit') {
310
+ setMenu(current => current === null ? current : { ...current, step: 'edit' })
311
+ return
312
+ }
313
+ if (id === 'edit-back') {
314
+ setMenu(current => current === null ? current : { ...current, step: 'root' })
315
+ return
316
+ }
317
+ if (id.startsWith('edit:')) {
318
+ const entry = catalog?.entries.find(entry => `edit:${entry.id}` === id)
319
+ if (entry) openAuthoringEdit(entry)
320
+ return
321
+ }
322
+ if (id.startsWith('global:')) {
323
+ const entry = catalog?.entries.find(entry => `global:${entry.id}` === id)
324
+ if (entry) void toggleBinding(entry)
325
+ return
326
+ }
327
+ if (id === 'reload') { void refreshCatalog(true); return }
328
+ if (id === 'settings') {
329
+ hideMenu()
330
+ setSettingsOpen(true)
331
+ return
332
+ }
333
+ hideMenu()
334
+ if (id === 'new') openAuthoring()
335
+ else if (id === 'manage') {
336
+ dialogReturnFocus.current = null
337
+ setManaging(true)
338
+ }
339
+ else if (id === view.candidateKey) openBindingReview(review, view.candidate)
340
+ },
341
+ }),
342
+ // Hiding the composer unmounts the dialog, not the controller: the draft survives.
343
+ managing && quickAccess && view.reachable
344
+ ? h(BindingsDialog, {
345
+ controller: workbench, t, onClose: () => setManaging(false), returnFocusRef: dialogReturnFocus,
346
+ getReturnFocus: () => anchorRef.current?.querySelector('.ptcPlusAuthorButton'),
347
+ }) : null,
348
+ settingsOpen && view.reachable
349
+ ? h(PTCPlusSettingsDialog, {
350
+ t, usePtcSettings, updateSetting, callUserBindings,
351
+ onClose: () => {
352
+ setSettingsOpen(false)
353
+ queueMicrotask(() => anchorRef.current?.querySelector('.ptcPlusAuthorButton')
354
+ ?.focus({ preventScroll: true }))
355
+ },
356
+ }) : null,
357
+ toast === null ? null : typeof Toast === 'function'
358
+ ? h(Toast, {
359
+ key: toast.sequence,
360
+ text: toast.text,
361
+ anchor: anchorRef.current,
362
+ onDone: () => setToast(null),
363
+ })
364
+ : h('span', {
365
+ key: toast.sequence, className: 'ptcPlusComposerNotice', role: 'status',
366
+ }, toast.text))
367
+ }
368
+
369
+ function BindingCandidateContent({ candidate, t, showName = true }) {
370
+ const preferences = bindingModelPreferences(candidate.entry.modelContext)
371
+ const contextTitle = React.useId()
372
+ return h('div', { className: 'ptcPlusAuthoringDraft' },
373
+ showName ? h('strong', null, candidate.entry.name) : null,
374
+ h('span', { className: 'ptcPlusBindingMeta' }, `${candidate.entry.scope} - ${candidate.entry.symbols.join(', ')}`),
375
+ candidate.entry.purpose ? h('p', { className: 'ptcPlusMessage' }, candidate.entry.purpose) : null,
376
+ typeof CodeBlock === 'function'
377
+ ? h(CodeBlock, { code: candidate.entry.source, lang: 'typescript', className: 'ptcPlusBindingCommandCode',
378
+ copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
379
+ : h('pre', { className: 'ptcPlusBindingCommandSource' }, candidate.entry.source),
380
+ h('section', { className: 'ptcPlusCandidateContext', 'aria-labelledby': contextTitle },
381
+ h('h4', { id: contextTitle }, t('bindings.modelContext')),
382
+ h('dl', null,
383
+ h('div', { className: 'ptcPlusCandidateDeclaration' },
384
+ h('dt', null, t('bindings.declaration')),
385
+ h('dd', { 'data-included': preferences.includeDeclaration },
386
+ preferences.includeDeclaration ? h(IconCheckOutline14, { size: 14, 'aria-hidden': true }) : null,
387
+ t(preferences.includeDeclaration ? 'bindings.declarationIncluded' : 'bindings.declarationExcluded'))),
388
+ h('div', { className: 'ptcPlusCandidatePrompt' },
389
+ h('dt', null, t('bindings.instructions')),
390
+ h('dd', { 'data-empty': preferences.instructions === '' },
391
+ preferences.instructions || t('bindings.noInstructions'))))))
392
+ }
393
+
394
+ // Native editable semantics are enough to restore focus; no Host class, store or editor internals are used.
395
+ function focusComposer(anchor) {
396
+ if (!anchor || anchor.getClientRects().length === 0) return
397
+ for (let parent = anchor.parentElement; parent; parent = parent.parentElement) {
398
+ const editable = [...parent.querySelectorAll('textarea, [contenteditable="true"]')]
399
+ .find(element => element.getClientRects().length > 0 && !element.disabled)
400
+ if (editable) { editable.focus({ preventScroll: true }); return }
401
+ }
402
+ anchor.focus({ preventScroll: true })
403
+ }
404
+
405
+ function openBindingReview(review, candidate = review.getSnapshot().candidate) {
406
+ if (!review.display('expanded', candidate)) return
407
+ requestAnimationFrame(() => {
408
+ if (review.getSnapshot().candidate === candidate && review.panel?.getClientRects().length) {
409
+ review.panel.focus({ preventScroll: true })
410
+ }
411
+ })
412
+ }
413
+
414
+ function focusBindingReviewAccess(review) {
415
+ const anchor = review.access
416
+ const button = anchor?.querySelector('button')
417
+ if (button?.getClientRects().length) button.focus({ preventScroll: true })
418
+ else focusComposer(anchor)
419
+ }
420
+
421
+ function fitBindingReview(panel) {
422
+ const body = panel?.querySelector('.ptcPlusBindingDockBody')
423
+ const anchor = panel?.parentElement
424
+ if (!anchor || typeof ResizeObserver !== 'function') return undefined
425
+ const ancestors = []
426
+ let seat = panel
427
+ let viewport
428
+ for (let parent = panel.parentElement; parent; parent = parent.parentElement) {
429
+ ancestors.push(parent)
430
+ if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) {
431
+ viewport = parent
432
+ break
433
+ }
434
+ seat = parent
435
+ }
436
+ if (!viewport) return undefined
437
+ let frame
438
+ const update = () => {
439
+ if (!panel.getClientRects().length) return
440
+ const top = Math.max(viewport.getBoundingClientRect().top + viewport.clientTop,
441
+ window.visualViewport?.offsetTop ?? 0)
442
+ // Float above the complete composer stack without changing its height or covering other docks.
443
+ const offset = anchor.getBoundingClientRect().bottom - seat.getBoundingClientRect().top + 8
444
+ panel.style.setProperty('--ptc-plus-review-offset', `${Math.max(8, Math.ceil(offset))}px`)
445
+ const room = Math.max(0, seat.getBoundingClientRect().top - top - 16)
446
+ panel.style.setProperty('--ptc-plus-review-height', `${Math.floor(room)}px`)
447
+ if (body) {
448
+ const chrome = panel.scrollHeight - body.offsetHeight + 2
449
+ const available = Math.max(0, room - chrome)
450
+ // In short viewports one scroller keeps source and actions reachable together.
451
+ panel.dataset.scroll = available < 80 ? 'panel' : 'body'
452
+ body.style.setProperty('--ptc-plus-review-space', `${Math.floor(available)}px`)
453
+ }
454
+ }
455
+ const schedule = () => {
456
+ cancelAnimationFrame(frame)
457
+ frame = requestAnimationFrame(update)
458
+ }
459
+ const observer = new ResizeObserver(schedule)
460
+ for (const element of [panel, ...panel.children, ...ancestors]) observer.observe(element)
461
+ window.visualViewport?.addEventListener('resize', schedule)
462
+ window.visualViewport?.addEventListener('scroll', schedule)
463
+ update()
464
+ return () => {
465
+ observer.disconnect()
466
+ cancelAnimationFrame(frame)
467
+ window.visualViewport?.removeEventListener('resize', schedule)
468
+ window.visualViewport?.removeEventListener('scroll', schedule)
469
+ panel.style.removeProperty('--ptc-plus-review-offset')
470
+ panel.style.removeProperty('--ptc-plus-review-height')
471
+ delete panel.dataset.scroll
472
+ body?.style.removeProperty('--ptc-plus-review-space')
473
+ }
474
+ }
475
+
476
+ function BindingReviewDock({ sessionId, useProjection, t }) {
477
+ const raw = useProjection('ptcPlusBindingDraft')
478
+ const projection = React.useMemo(() => bindingDraftProjection(raw), [raw])
479
+ const [review, view] = useBindingReview(sessionId)
480
+ const focused = React.useRef(false)
481
+ // The composer entry follows the dock; wait for its refs to attach in the same commit.
482
+ React.useEffect(() => {
483
+ if (view.visibility !== 'hidden') return
484
+ if (view.action !== null && focused.current && document.activeElement === document.body) {
485
+ focusBindingReviewAccess(review)
486
+ }
487
+ focused.current = false
488
+ }, [review, view.visibility, view.action])
489
+ React.useLayoutEffect(() => review.attach(), [review])
490
+ React.useLayoutEffect(() => { review.sync(projection) }, [review, projection])
491
+ const title = React.useId()
492
+ const content = React.useId()
493
+ const expanded = view.visibility === 'expanded'
494
+ const candidateKey = view.candidateKey
495
+ React.useLayoutEffect(() => fitBindingReview(review.panel),
496
+ [review, candidateKey, expanded, view.visibility, view.message])
497
+ const close = () => {
498
+ review.display('hidden')
499
+ requestAnimationFrame(() => focusBindingReviewAccess(review))
500
+ }
501
+ const act = (operation, activate = false) => {
502
+ // Keep focus in the review when pending state disables the clicked action.
503
+ if (review.panel?.contains(document.activeElement)) review.panel.focus({ preventScroll: true })
504
+ void review.act(operation, activate)
505
+ }
506
+ if (view.candidate === null || view.visibility === 'hidden') return null
507
+ return h('div', { className: 'ptcPlusBindingDockAnchor' },
508
+ h('section', { key: candidateKey, className: 'ptcPlusBindingDock', 'aria-labelledby': title, tabIndex: -1,
509
+ ref: element => { review.panel = element },
510
+ onFocusCapture: () => { focused.current = true },
511
+ onBlurCapture: event => {
512
+ // Blink blurs the removed panel before layout effects; completion still owns that lost focus.
513
+ if (event.relatedTarget || review.getSnapshot().action === null) focused.current = false
514
+ },
515
+ 'aria-busy': view.busy },
516
+ h('div', { className: 'ptcPlusBindingDockHead' },
517
+ h('button', { type: 'button', className: 'ptcPlusBindingDockToggle',
518
+ 'aria-label': t(expanded ? 'bindings.reviewCollapse' : 'bindings.reviewExpand'),
519
+ 'aria-expanded': expanded, 'aria-controls': expanded ? content : undefined,
520
+ onClick: () => review.display(expanded ? 'collapsed' : 'expanded') },
521
+ h('span', { className: 'ptcPlusBindingDockSymbol', 'aria-hidden': true }, '</>'),
522
+ h('span', { className: 'ptcPlusBindingDockHeading' },
523
+ h('strong', { id: title, title: view.candidate.entry.name }, view.candidate.entry.name),
524
+ h('span', { role: 'status', title: t(bindingReviewStatus(view)) },
525
+ `${t('bindings.reviewTitle')} · ${t(bindingReviewStatus(view))}`)),
526
+ h('span', { className: 'ptcPlusBindingDockChevron', 'aria-hidden': true },
527
+ h(IconChevronDownOutline14, { size: 16 }))),
528
+ h(IconButton, { icon: IconCloseOutline16, label: t('bindings.reviewClose'), onClick: close })),
529
+ !expanded ? null : h(React.Fragment, null,
530
+ h('div', { className: 'ptcPlusBindingDockBody', id: content, tabIndex: 0,
531
+ role: 'region', 'aria-label': t('bindings.source') },
532
+ h(BindingCandidateContent, { candidate: view.candidate, t, showName: false })),
533
+ view.message === null ? null : h('p', { className: 'ptcPlusMessage ptcPlusDanger', role: 'status' }, t(view.message)),
534
+ h('div', { className: 'ptcPlusBindingDockActions' },
535
+ h(ActionButton, { className: 'ptcPlusBindingDockDiscard', 'data-kind': 'ghost',
536
+ disabled: !view.writable || view.busy,
537
+ onClick: () => act('discard-draft') }, t('bindings.draftDiscard')),
538
+ h(ActionButton, { disabled: !view.writable || view.busy,
539
+ onClick: () => act('save-draft', false) }, t('bindings.draftSave')),
540
+ h(ActionButton, { 'data-kind': 'primary', disabled: !view.writable || view.busy,
541
+ onClick: () => act('save-draft', true) }, t('bindings.draftSaveEnable')),
542
+ view.message === null ? null : h(ActionButton, { disabled: view.busy,
543
+ onClick: () => review.reset() }, t('bindings.reviewRetry'))))))
544
+ }
545
+
546
+ function BindingCommandCard({ node, sessionId, t, useProjection }) {
547
+ const projection = bindingDraftProjection(useProjection?.('ptcPlusBindingDraft'))
548
+ const [review, current] = useBindingReview(sessionId)
549
+ const historical = projection?.history?.find(record => record.commandId === node.commandId)
550
+ const matches = projection?.commandId === node.commandId
551
+ const currentMatches = current.candidate?.commandId === node.commandId
552
+ const candidate = historical?.candidate ?? (currentMatches ? current.candidate : null)
553
+ const action = historical?.action ?? (currentMatches ? current.action : null)
554
+ const phase = action?.state ?? (candidate !== null ? 'ready'
555
+ : node.outcome?.kind === 'error' || (matches && projection.phase === 'failed') ? 'failed'
556
+ : matches && projection.phase === 'pending' ? 'pending' : 'idle')
557
+ const status = action != null ? bindingReviewStatus({ action })
558
+ : phase === 'ready' ? 'bindings.commandGenerated'
559
+ : phase === 'pending' ? 'bindings.commandPending'
560
+ : phase === 'failed' ? 'bindings.commandFailed'
561
+ : node.outcome === null ? 'bindings.commandAdmitting' : 'bindings.commandAccepted'
562
+ const canOpen = matches && currentMatches && action == null && current.mounted && current.reachable
563
+ return h('section', { className: 'ptcPlusBindingCommand', 'data-phase': phase,
564
+ 'aria-label': t('bindings.commandTitle') },
565
+ h('div', { className: 'ptcPlusBindingCommandHeader' },
566
+ h('strong', { className: 'ptcPlusBindingCommandTitle' }, t('bindings.commandTitle')),
567
+ h('span', { className: 'ptcPlusBindingCommandState' },
568
+ h('span', { className: 'ptcPlusBindingCommandStateDot', 'aria-hidden': true }), t(status))),
569
+ h('pre', { className: 'ptcPlusBindingCommandRequirement' }, `/binding${node.args ?? ''}`),
570
+ node.outcome?.kind !== 'error' ? null : h('p', { className: 'ptcPlusMessage ptcPlusDanger' },
571
+ node.outcome.text ?? t('bindings.commandFailed')),
572
+ canOpen ? h(ActionButton, { onClick: () => openBindingReview(review) }, t('bindings.reviewOpen')) : null,
573
+ !current.mounted && (candidate !== null || matches && projection.phase === 'ready')
574
+ ? h('p', { className: 'ptcPlusMessage' }, t('bindings.reviewUnavailable')) : null,
575
+ candidate === null ? null : h('details', { className: 'ptcPlusBindingSourceDetails' },
576
+ h('summary', null, t('tool.source')), h(BindingCandidateContent, { candidate, t })))
577
+ }
578
+
579
+ return { BindingAuthorButton, BindingReviewDock, BindingCommandCard }
580
+ }