@seed-ship/mcp-ui-solid 5.1.0 → 5.3.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +64 -13
  3. package/dist/components/ElicitationForm.cjs +51 -0
  4. package/dist/components/ElicitationForm.cjs.map +1 -0
  5. package/dist/components/ElicitationForm.d.ts +68 -0
  6. package/dist/components/ElicitationForm.d.ts.map +1 -0
  7. package/dist/components/ElicitationForm.js +51 -0
  8. package/dist/components/ElicitationForm.js.map +1 -0
  9. package/dist/components/FeedbackInline.cjs +57 -0
  10. package/dist/components/FeedbackInline.cjs.map +1 -0
  11. package/dist/components/FeedbackInline.d.ts +71 -0
  12. package/dist/components/FeedbackInline.d.ts.map +1 -0
  13. package/dist/components/FeedbackInline.js +57 -0
  14. package/dist/components/FeedbackInline.js.map +1 -0
  15. package/dist/components/index.d.ts +2 -0
  16. package/dist/components/index.d.ts.map +1 -1
  17. package/dist/components.cjs +2 -0
  18. package/dist/components.cjs.map +1 -1
  19. package/dist/components.d.cts +2 -0
  20. package/dist/components.d.ts +2 -0
  21. package/dist/components.js +2 -0
  22. package/dist/components.js.map +1 -1
  23. package/dist/index.cjs +17 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +12 -2
  26. package/dist/index.d.ts +12 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +19 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/services/chat-bus.cjs +71 -0
  31. package/dist/services/chat-bus.cjs.map +1 -1
  32. package/dist/services/chat-bus.d.ts +31 -1
  33. package/dist/services/chat-bus.d.ts.map +1 -1
  34. package/dist/services/chat-bus.js +71 -0
  35. package/dist/services/chat-bus.js.map +1 -1
  36. package/dist/services/chat-prompt-controller.cjs +83 -0
  37. package/dist/services/chat-prompt-controller.cjs.map +1 -0
  38. package/dist/services/chat-prompt-controller.d.ts +93 -0
  39. package/dist/services/chat-prompt-controller.d.ts.map +1 -0
  40. package/dist/services/chat-prompt-controller.js +83 -0
  41. package/dist/services/chat-prompt-controller.js.map +1 -0
  42. package/dist/stores/scratchpad-store.cjs +105 -77
  43. package/dist/stores/scratchpad-store.cjs.map +1 -1
  44. package/dist/stores/scratchpad-store.d.ts +88 -19
  45. package/dist/stores/scratchpad-store.d.ts.map +1 -1
  46. package/dist/stores/scratchpad-store.js +105 -77
  47. package/dist/stores/scratchpad-store.js.map +1 -1
  48. package/dist/stores/server-capabilities-store.cjs +61 -0
  49. package/dist/stores/server-capabilities-store.cjs.map +1 -0
  50. package/dist/stores/server-capabilities-store.d.ts +172 -0
  51. package/dist/stores/server-capabilities-store.d.ts.map +1 -0
  52. package/dist/stores/server-capabilities-store.js +61 -0
  53. package/dist/stores/server-capabilities-store.js.map +1 -0
  54. package/dist/types/chat-bus.d.ts +39 -0
  55. package/dist/types/chat-bus.d.ts.map +1 -1
  56. package/docs/recipes/elicitation-pseudo-spec-adapter.md +171 -0
  57. package/docs/recipes/feedback-inline-wiring.md +142 -0
  58. package/package.json +1 -1
  59. package/src/components/ElicitationForm.test.tsx +197 -0
  60. package/src/components/ElicitationForm.tsx +126 -0
  61. package/src/components/FeedbackInline.test.tsx +117 -0
  62. package/src/components/FeedbackInline.tsx +143 -0
  63. package/src/components/index.ts +4 -0
  64. package/src/index.ts +39 -1
  65. package/src/services/chat-bus.test.ts +154 -2
  66. package/src/services/chat-bus.ts +115 -0
  67. package/src/services/chat-prompt-controller.test.ts +144 -0
  68. package/src/services/chat-prompt-controller.ts +214 -0
  69. package/src/stores/scratchpad-store.test.tsx +140 -0
  70. package/src/stores/scratchpad-store.tsx +244 -0
  71. package/src/stores/server-capabilities-store.test.tsx +206 -0
  72. package/src/stores/server-capabilities-store.tsx +215 -0
  73. package/src/types/chat-bus.ts +40 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/src/stores/scratchpad-store.ts +0 -126
@@ -0,0 +1,214 @@
1
+ /**
2
+ * createChatPromptController — centralised lifecycle for `showChatPrompt`
3
+ *
4
+ * @experimental
5
+ * @since v5.2.0
6
+ *
7
+ * The controller owns the resolver closure, AbortSignal wiring, and
8
+ * re-entrance policy in one primitive. Consumers go from ~20 LOC of manual
9
+ * wiring per app to :
10
+ *
11
+ * ```ts
12
+ * const ctrl = createChatPromptController()
13
+ * bus.commands.handle('showChatPrompt', ctrl.handle)
14
+ * // ...
15
+ * <Show when={ctrl.activePrompt()}>
16
+ * {(cfg) => (
17
+ * <ChatPrompt
18
+ * config={cfg()}
19
+ * onSubmit={ctrl.resolveActive}
20
+ * onDismiss={ctrl.dismissActive}
21
+ * />
22
+ * )}
23
+ * </Show>
24
+ * ```
25
+ *
26
+ * ## Re-entrance policy
27
+ *
28
+ * If a new `showChatPrompt` arrives while a previous Promise is still
29
+ * pending, the previous Promise rejects **synchronously** with a
30
+ * `PromptReplacedError` before the new prompt is installed. Callers that
31
+ * care can branch on `err instanceof PromptReplacedError` or `err.name ===
32
+ * 'PromptReplacedError'`.
33
+ *
34
+ * ## Abort semantics
35
+ *
36
+ * `handle(config, signal?)` honours `AbortSignal` :
37
+ *
38
+ * - If `signal.aborted === true` on entry → returns a rejected Promise with
39
+ * `new DOMException('Prompt aborted', 'AbortError')`, does NOT set
40
+ * `activePrompt`.
41
+ * - Otherwise registers a once-only listener that rejects with the same
42
+ * `DOMException` on abort, clearing the active state.
43
+ *
44
+ * `AbortError` is the Web Platform convention (matches `fetch()`,
45
+ * `Response.body.cancel()`, etc.) — callers can branch on `err.name ===
46
+ * 'AbortError'` without importing any mcp-ui type.
47
+ */
48
+
49
+ import { createSignal, type Accessor } from 'solid-js'
50
+ import type { ChatPromptConfig, ChatPromptResponse } from '../types/chat-bus'
51
+
52
+ // ─── Error class ─────────────────────────────────────────────
53
+
54
+ /**
55
+ * Thrown when an active `showChatPrompt` Promise is rejected because a new
56
+ * prompt arrived before the previous one resolved. Consumers can use
57
+ * `instanceof PromptReplacedError` or `err.name === 'PromptReplacedError'` to
58
+ * branch (retry, bail, log).
59
+ *
60
+ * @experimental
61
+ * @since v5.2.0
62
+ */
63
+ export class PromptReplacedError extends Error {
64
+ readonly name = 'PromptReplacedError' as const
65
+ constructor(message = 'Prompt replaced by a newer one') {
66
+ super(message)
67
+ }
68
+ }
69
+
70
+ // ─── Controller shape ────────────────────────────────────────
71
+
72
+ export interface ChatPromptController {
73
+ /**
74
+ * Register as the bus handler :
75
+ * `bus.commands.handle('showChatPrompt', ctrl.handle)`
76
+ */
77
+ handle: (config: ChatPromptConfig, signal?: AbortSignal) => Promise<ChatPromptResponse>
78
+
79
+ /**
80
+ * Reactive accessor for the currently active prompt config (null when no
81
+ * prompt is pending). Use in JSX to drive `<ChatPrompt>` rendering.
82
+ */
83
+ activePrompt: Accessor<ChatPromptConfig | null>
84
+
85
+ /** Call this from `<ChatPrompt>`'s `onSubmit` prop. */
86
+ resolveActive: (response: ChatPromptResponse) => void
87
+
88
+ /** Call this from `<ChatPrompt>`'s `onDismiss` prop. */
89
+ dismissActive: () => void
90
+
91
+ /**
92
+ * Cancel the active prompt programmatically (e.g. on route change). Rejects
93
+ * the pending Promise with the supplied reason or an `AbortError`.
94
+ */
95
+ abort: (reason?: string) => void
96
+ }
97
+
98
+ // ─── Factory ─────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Create a stateful controller that owns the active prompt Promise, the
102
+ * AbortSignal listener, and the re-entrance policy. See module JSDoc for
103
+ * full usage.
104
+ *
105
+ * @experimental
106
+ * @since v5.2.0
107
+ */
108
+ export function createChatPromptController(): ChatPromptController {
109
+ const [activePrompt, setActivePrompt] = createSignal<ChatPromptConfig | null>(null)
110
+
111
+ interface PendingEntry {
112
+ type: ChatPromptConfig['type']
113
+ resolve: (r: ChatPromptResponse) => void
114
+ reject: (err: unknown) => void
115
+ signal?: AbortSignal
116
+ onAbort?: () => void
117
+ }
118
+
119
+ let pending: PendingEntry | null = null
120
+
121
+ function cleanupAbort(entry: PendingEntry): void {
122
+ if (entry.signal && entry.onAbort) {
123
+ entry.signal.removeEventListener('abort', entry.onAbort)
124
+ }
125
+ }
126
+
127
+ function clearPending(): void {
128
+ if (pending) {
129
+ cleanupAbort(pending)
130
+ pending = null
131
+ }
132
+ setActivePrompt(null)
133
+ }
134
+
135
+ function handle(
136
+ config: ChatPromptConfig,
137
+ signal?: AbortSignal
138
+ ): Promise<ChatPromptResponse> {
139
+ // Re-entrance : synchronously reject the previous Promise before
140
+ // installing the new prompt. The caller's .catch sees the rejection
141
+ // on the microtask boundary regardless.
142
+ if (pending) {
143
+ const previous = pending
144
+ pending = null
145
+ cleanupAbort(previous)
146
+ previous.reject(new PromptReplacedError())
147
+ }
148
+
149
+ // Abort already tripped on entry : return a rejected Promise without
150
+ // ever showing the UI.
151
+ if (signal?.aborted) {
152
+ setActivePrompt(null)
153
+ return Promise.reject(new DOMException('Prompt aborted', 'AbortError'))
154
+ }
155
+
156
+ return new Promise<ChatPromptResponse>((resolve, reject) => {
157
+ const entry: PendingEntry = { type: config.type, resolve, reject, signal }
158
+
159
+ if (signal) {
160
+ entry.onAbort = () => {
161
+ // If this entry is still active, reject + clear. If a newer prompt
162
+ // has since replaced it, the cleanup already ran — no-op.
163
+ if (pending === entry) {
164
+ pending = null
165
+ cleanupAbort(entry)
166
+ setActivePrompt(null)
167
+ reject(new DOMException('Prompt aborted', 'AbortError'))
168
+ }
169
+ }
170
+ signal.addEventListener('abort', entry.onAbort, { once: true })
171
+ }
172
+
173
+ pending = entry
174
+ setActivePrompt(config)
175
+ })
176
+ }
177
+
178
+ function resolveActive(response: ChatPromptResponse): void {
179
+ if (!pending) return
180
+ const entry = pending
181
+ pending = null
182
+ cleanupAbort(entry)
183
+ setActivePrompt(null)
184
+ entry.resolve(response)
185
+ }
186
+
187
+ function dismissActive(): void {
188
+ if (!pending) return
189
+ const entry = pending
190
+ pending = null
191
+ cleanupAbort(entry)
192
+ setActivePrompt(null)
193
+ // Surface as a resolved Promise with dismissed: true — matches existing
194
+ // ChatPrompt onDismiss contract from v4.x.
195
+ entry.resolve({ type: entry.type, value: '', label: '', dismissed: true })
196
+ }
197
+
198
+ function abort(reason = 'Prompt aborted'): void {
199
+ if (!pending) return
200
+ const entry = pending
201
+ pending = null
202
+ cleanupAbort(entry)
203
+ setActivePrompt(null)
204
+ entry.reject(new DOMException(reason, 'AbortError'))
205
+ }
206
+
207
+ return {
208
+ handle,
209
+ activePrompt,
210
+ resolveActive,
211
+ dismissActive,
212
+ abort,
213
+ }
214
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Tests for scratchpad-store — v5.2.0 createScratchpadStore factory + provider
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
6
+ import { render, cleanup } from '@solidjs/testing-library'
7
+ import {
8
+ createScratchpadStore,
9
+ dispatchScratchpad,
10
+ useScratchpadState,
11
+ ScratchpadStoreProvider,
12
+ } from './scratchpad-store'
13
+ import type { ScratchpadEvent } from '../types/chat-bus'
14
+
15
+ const createEvent = (id: string, title: string): ScratchpadEvent => ({
16
+ id,
17
+ action: 'create',
18
+ title,
19
+ sections: [],
20
+ status: 'ready',
21
+ })
22
+
23
+ describe('createScratchpadStore — v5.2.0', () => {
24
+ beforeEach(() => {
25
+ cleanup()
26
+ // Silence the info/warn logs that dispatch emits
27
+ vi.spyOn(console, 'info').mockImplementation(() => {})
28
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
29
+ })
30
+
31
+ it('two stores do not share state', () => {
32
+ const storeA = createScratchpadStore()
33
+ const storeB = createScratchpadStore()
34
+
35
+ storeA.dispatch(createEvent('a', 'Store A'))
36
+
37
+ expect(storeA.state()?.id).toBe('a')
38
+ expect(storeA.state()?.title).toBe('Store A')
39
+ expect(storeB.state()).toBeNull()
40
+ })
41
+
42
+ it('close() resets state independently per store', () => {
43
+ const storeA = createScratchpadStore()
44
+ const storeB = createScratchpadStore()
45
+
46
+ storeA.dispatch(createEvent('a', 'Store A'))
47
+ storeB.dispatch(createEvent('b', 'Store B'))
48
+
49
+ storeA.close()
50
+
51
+ expect(storeA.state()).toBeNull()
52
+ expect(storeB.state()?.id).toBe('b')
53
+ })
54
+
55
+ it('pinned flag is per-store', () => {
56
+ const storeA = createScratchpadStore()
57
+ const storeB = createScratchpadStore()
58
+
59
+ storeA.dispatch({ ...createEvent('a', 'A'), pinned: true })
60
+ storeB.dispatch({ ...createEvent('b', 'B'), pinned: false })
61
+
62
+ expect(storeA.pinned()).toBe(true)
63
+ expect(storeB.pinned()).toBe(false)
64
+ })
65
+
66
+ it('ScratchpadStoreProvider without store prop creates a fresh store', () => {
67
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
68
+
69
+ const Child = () => {
70
+ capturedState = useScratchpadState()
71
+ return <div>child</div>
72
+ }
73
+
74
+ render(() => (
75
+ <ScratchpadStoreProvider>
76
+ <Child />
77
+ </ScratchpadStoreProvider>
78
+ ))
79
+
80
+ expect(capturedState).not.toBeNull()
81
+ expect(capturedState!.state()).toBeNull()
82
+ expect(capturedState!.pinned()).toBe(false)
83
+ })
84
+
85
+ it('ScratchpadStoreProvider with explicit store prop binds children to it', () => {
86
+ const scoped = createScratchpadStore()
87
+ scoped.dispatch(createEvent('scoped', 'Scoped title'))
88
+
89
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
90
+ const Child = () => {
91
+ capturedState = useScratchpadState()
92
+ return <div>child</div>
93
+ }
94
+
95
+ render(() => (
96
+ <ScratchpadStoreProvider store={scoped}>
97
+ <Child />
98
+ </ScratchpadStoreProvider>
99
+ ))
100
+
101
+ expect(capturedState!.state()?.id).toBe('scoped')
102
+ expect(capturedState!.state()?.title).toBe('Scoped title')
103
+ })
104
+
105
+ it('useScratchpadState outside provider falls back to module singleton', () => {
106
+ // Reset singleton first by writing a unique id, then verifying dispatch lands on it
107
+ dispatchScratchpad(createEvent('singleton-test', 'Singleton'))
108
+
109
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
110
+ const Child = () => {
111
+ capturedState = useScratchpadState()
112
+ return <div>child</div>
113
+ }
114
+
115
+ render(() => <Child />)
116
+
117
+ expect(capturedState!.state()?.id).toBe('singleton-test')
118
+ // Cleanup: close singleton for test isolation
119
+ capturedState!.close()
120
+ })
121
+
122
+ it('dispatchScratchpad (module fn) only writes to singleton, not scoped stores', () => {
123
+ const scoped = createScratchpadStore()
124
+ dispatchScratchpad(createEvent('global', 'Global'))
125
+
126
+ // scoped store is still empty
127
+ expect(scoped.state()).toBeNull()
128
+
129
+ // and the singleton carries the dispatched event
130
+ let singletonSnap: ReturnType<typeof useScratchpadState> | null = null
131
+ const Child = () => {
132
+ singletonSnap = useScratchpadState()
133
+ return <div>child</div>
134
+ }
135
+ render(() => <Child />)
136
+ expect(singletonSnap!.state()?.id).toBe('global')
137
+ // Cleanup
138
+ singletonSnap!.close()
139
+ })
140
+ })
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Scratchpad Store — reactive state for HITL scratchpad
3
+ *
4
+ * @experimental
5
+ *
6
+ * **v5.2.0 :** the store is now a factory (`createScratchpadStore()`) with a
7
+ * module-level singleton kept as default. Two consumption modes :
8
+ *
9
+ * 1. **Singleton mode (default, zero-config)** — `dispatchScratchpad(event)` +
10
+ * `useScratchpadState()` read/write the module singleton. This is the v4.x
11
+ * path and keeps working unchanged.
12
+ *
13
+ * 2. **Multi-instance mode** — wrap a subtree in `<ScratchpadStoreProvider>`
14
+ * (it creates a scoped `ScratchpadStoreHandle` internally, or use your own
15
+ * via the `store` prop). `useScratchpadState()` auto-detects the context
16
+ * and reads from it; `ScratchpadPanel` mounted inside the provider reads
17
+ * the scoped store. Non-reactive callers (SSE parsers) should pass the
18
+ * handle explicitly — do NOT try to reach context from a non-reactive
19
+ * scope.
20
+ */
21
+
22
+ import { createContext, useContext, type ParentComponent, type JSX } from 'solid-js'
23
+ import { createStore, produce } from 'solid-js/store'
24
+ import type { ScratchpadState, ScratchpadEvent, ScratchpadSection } from '../types/chat-bus'
25
+
26
+ // ─── Handle shape ─────────────────────────────────────────────
27
+
28
+ export interface ScratchpadStoreHandle {
29
+ /** Mutate the store from an SSE/parser callback. */
30
+ dispatch: (event: ScratchpadEvent) => void
31
+ /** Reactive accessor for the current scratchpad state (null when closed). */
32
+ state: () => ScratchpadState | null
33
+ /** Reactive accessor for the pinned flag. */
34
+ pinned: () => boolean
35
+ /** Close the scratchpad (equivalent to dispatching an action='close'). */
36
+ close: () => void
37
+ }
38
+
39
+ // ─── Factory ──────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Create an isolated scratchpad store instance.
43
+ *
44
+ * Use this when you need two or more scratchpads live at the same time
45
+ * (e.g. chat scratchpad + admin dashboard scratchpad). Pair with
46
+ * `<ScratchpadStoreProvider store={...}>` to scope a SolidJS subtree.
47
+ *
48
+ * @experimental
49
+ * @since v5.2.0
50
+ */
51
+ export function createScratchpadStore(): ScratchpadStoreHandle {
52
+ const [scratchpadStore, setScratchpadStore] = createStore<{
53
+ current: ScratchpadState | null
54
+ pinned: boolean
55
+ }>({ current: null, pinned: false })
56
+
57
+ const dispatch = (event: ScratchpadEvent): void => {
58
+ if (event.action === 'create') {
59
+ console.info(
60
+ `%c[MCP-UI] dispatchScratchpad%c create id=${event.id} sections=${event.sections?.length || 0} status=${event.status || 'loading'}${event.pinned ? ' pinned' : ''}`,
61
+ 'color: #10b981; font-weight: bold',
62
+ 'color: inherit'
63
+ )
64
+ setScratchpadStore({
65
+ current: {
66
+ id: event.id,
67
+ title: event.title || '',
68
+ sections: event.sections || [],
69
+ filters: event.filters || {},
70
+ preview: event.preview,
71
+ agentMessages: event.agentMessages || [],
72
+ status: event.status || 'loading',
73
+ previewEndpoint: (event as any).previewEndpoint,
74
+ previewDebounce: (event as any).previewDebounce,
75
+ previewMethod: (event as any).previewMethod,
76
+ previewHeaders: (event as any).previewHeaders,
77
+ turn: (event as any).turn,
78
+ totalTurns: (event as any).totalTurns,
79
+ turnHistory: (event as any).turnHistory,
80
+ },
81
+ pinned: event.pinned || false,
82
+ })
83
+ } else if (event.action === 'update') {
84
+ console.info(
85
+ `%c[MCP-UI] dispatchScratchpad%c update id=${event.id} sectionMode=${event.sectionMode || 'replace'} sections=${event.sections?.length || 0} status=${event.status || '-'}`,
86
+ 'color: #3b82f6; font-weight: bold',
87
+ 'color: inherit'
88
+ )
89
+ setScratchpadStore(
90
+ produce((s) => {
91
+ if (!s.current || s.current.id !== event.id) {
92
+ console.warn(
93
+ `[MCP-UI] dispatchScratchpad: update for id=${event.id} but current is ${s.current?.id || 'null'}. Ignoring.`
94
+ )
95
+ return
96
+ }
97
+
98
+ if (event.sections) {
99
+ const mode = event.sectionMode || 'replace'
100
+ if (mode === 'replace') {
101
+ s.current.sections = event.sections
102
+ } else if (mode === 'append') {
103
+ s.current.sections = [...s.current.sections, ...event.sections]
104
+ } else if (mode === 'upsert') {
105
+ let matchCount = 0
106
+ for (const incoming of event.sections) {
107
+ const idx = s.current.sections.findIndex(
108
+ (sec: ScratchpadSection) => sec.id === incoming.id
109
+ )
110
+ if (idx >= 0) {
111
+ s.current.sections[idx] = incoming
112
+ matchCount++
113
+ } else {
114
+ s.current.sections.push(incoming)
115
+ }
116
+ }
117
+ if (matchCount === 0 && event.sections.length > 0) {
118
+ console.warn(
119
+ `[MCP-UI] dispatchScratchpad: sectionMode='upsert' but no IDs matched. ` +
120
+ `Incoming: [${event.sections.map((s: ScratchpadSection) => s.id).join(', ')}] ` +
121
+ `Existing: [${s.current.sections.map((s: ScratchpadSection) => s.id).join(', ')}]. All appended.`
122
+ )
123
+ }
124
+ }
125
+ }
126
+ if (event.agentMessages) s.current.agentMessages = event.agentMessages
127
+ if (event.status) s.current.status = event.status
128
+ if (event.filters) s.current.filters = event.filters
129
+ if (event.preview) s.current.preview = event.preview
130
+ if (event.pinned != null) s.pinned = event.pinned
131
+ if ((event as any).turnHistory) s.current.turnHistory = (event as any).turnHistory
132
+ if ((event as any).turn != null) s.current.turn = (event as any).turn
133
+ })
134
+ )
135
+ } else if (event.action === 'close') {
136
+ console.info(
137
+ `%c[MCP-UI] dispatchScratchpad%c close id=${event.id}`,
138
+ 'color: #6b7280; font-weight: bold',
139
+ 'color: inherit'
140
+ )
141
+ setScratchpadStore({ current: null, pinned: false })
142
+ }
143
+ }
144
+
145
+ return {
146
+ dispatch,
147
+ state: () => scratchpadStore.current,
148
+ pinned: () => scratchpadStore.pinned,
149
+ close: () => setScratchpadStore({ current: null, pinned: false }),
150
+ }
151
+ }
152
+
153
+ // ─── Module-level singleton (v4.x-compatible default) ─────────
154
+
155
+ const defaultStore: ScratchpadStoreHandle = createScratchpadStore()
156
+
157
+ /**
158
+ * Function for the PARSER/STORE — mutates the **module-level singleton**
159
+ * scratchpad state. Use this when you only need one scratchpad at a time
160
+ * (single-instance consumer, the v4.x pattern).
161
+ *
162
+ * For multi-instance scenarios, prefer `createScratchpadStore()` and pass the
163
+ * handle around explicitly.
164
+ *
165
+ * @example
166
+ * // In your SSE parser callback — ONE LINE
167
+ * onScratchpad: (data) => dispatchScratchpad(data as ScratchpadEvent)
168
+ */
169
+ export function dispatchScratchpad(event: ScratchpadEvent): void {
170
+ defaultStore.dispatch(event)
171
+ }
172
+
173
+ // ─── Context (v5.2.0) ─────────────────────────────────────────
174
+
175
+ /**
176
+ * Context for a scoped scratchpad store. Populated by
177
+ * `<ScratchpadStoreProvider>`. Read by `useScratchpadState()` with automatic
178
+ * fallback to the module-level singleton when the context is absent.
179
+ *
180
+ * @experimental
181
+ * @since v5.2.0
182
+ */
183
+ export const ScratchpadStoreContext = createContext<ScratchpadStoreHandle | undefined>(undefined)
184
+
185
+ /**
186
+ * Provide a scoped `ScratchpadStoreHandle` to a SolidJS subtree. Children
187
+ * reading via `useScratchpadState()` or rendering a `<ScratchpadPanel>` will
188
+ * bind to this store instead of the module singleton.
189
+ *
190
+ * If no `store` prop is passed, a fresh store is created for the provider's
191
+ * lifetime. Pass `store` explicitly when you need the handle outside the
192
+ * tree (e.g. in an SSE parser that lives at the app root).
193
+ *
194
+ * @experimental
195
+ * @since v5.2.0
196
+ *
197
+ * @example
198
+ * const chatStore = createScratchpadStore()
199
+ * const adminStore = createScratchpadStore()
200
+ *
201
+ * <ScratchpadStoreProvider store={chatStore}>
202
+ * <ChatInterface />
203
+ * </ScratchpadStoreProvider>
204
+ * <ScratchpadStoreProvider store={adminStore}>
205
+ * <AdminDashboard />
206
+ * </ScratchpadStoreProvider>
207
+ */
208
+ export const ScratchpadStoreProvider: ParentComponent<{
209
+ store?: ScratchpadStoreHandle
210
+ }> = (props): JSX.Element => {
211
+ const store = props.store ?? createScratchpadStore()
212
+ return (
213
+ <ScratchpadStoreContext.Provider value={store}>{props.children}</ScratchpadStoreContext.Provider>
214
+ )
215
+ }
216
+
217
+ // ─── Reactive hook (context-aware) ────────────────────────────
218
+
219
+ /**
220
+ * Hook for the COMPONENT — reads the scratchpad state reactively.
221
+ *
222
+ * **v5.2.0 :** if called inside a `<ScratchpadStoreProvider>`, reads the
223
+ * scoped handle; otherwise falls back to the module singleton. Old v4.x
224
+ * consumers keep working unchanged.
225
+ *
226
+ * @example
227
+ * const { state, pinned, close } = useScratchpadState()
228
+ * <Show when={state()}>
229
+ * <ScratchpadPanel state={state()!} pinned={pinned()} onClose={close} />
230
+ * </Show>
231
+ */
232
+ export function useScratchpadState(): {
233
+ state: () => ScratchpadState | null
234
+ pinned: () => boolean
235
+ close: () => void
236
+ } {
237
+ const scoped = useContext(ScratchpadStoreContext)
238
+ const handle = scoped ?? defaultStore
239
+ return {
240
+ state: handle.state,
241
+ pinned: handle.pinned,
242
+ close: handle.close,
243
+ }
244
+ }