@seed-ship/mcp-ui-solid 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +64 -13
- package/dist/components/FeedbackInline.cjs +57 -0
- package/dist/components/FeedbackInline.cjs.map +1 -0
- package/dist/components/FeedbackInline.d.ts +71 -0
- package/dist/components/FeedbackInline.d.ts.map +1 -0
- package/dist/components/FeedbackInline.js +57 -0
- package/dist/components/FeedbackInline.js.map +1 -0
- package/dist/index.cjs +9 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/services/chat-bus.cjs +71 -0
- package/dist/services/chat-bus.cjs.map +1 -1
- package/dist/services/chat-bus.d.ts +31 -1
- package/dist/services/chat-bus.d.ts.map +1 -1
- package/dist/services/chat-bus.js +71 -0
- package/dist/services/chat-bus.js.map +1 -1
- package/dist/services/chat-prompt-controller.cjs +83 -0
- package/dist/services/chat-prompt-controller.cjs.map +1 -0
- package/dist/services/chat-prompt-controller.d.ts +93 -0
- package/dist/services/chat-prompt-controller.d.ts.map +1 -0
- package/dist/services/chat-prompt-controller.js +83 -0
- package/dist/services/chat-prompt-controller.js.map +1 -0
- package/dist/stores/scratchpad-store.cjs +105 -77
- package/dist/stores/scratchpad-store.cjs.map +1 -1
- package/dist/stores/scratchpad-store.d.ts +88 -19
- package/dist/stores/scratchpad-store.d.ts.map +1 -1
- package/dist/stores/scratchpad-store.js +105 -77
- package/dist/stores/scratchpad-store.js.map +1 -1
- package/dist/types/chat-bus.d.ts +39 -0
- package/dist/types/chat-bus.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/FeedbackInline.test.tsx +117 -0
- package/src/components/FeedbackInline.tsx +143 -0
- package/src/index.ts +23 -1
- package/src/services/chat-bus.test.ts +154 -2
- package/src/services/chat-bus.ts +115 -0
- package/src/services/chat-prompt-controller.test.ts +144 -0
- package/src/services/chat-prompt-controller.ts +214 -0
- package/src/stores/scratchpad-store.test.tsx +140 -0
- package/src/stores/scratchpad-store.tsx +244 -0
- package/src/types/chat-bus.ts +40 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/stores/scratchpad-store.ts +0 -126
package/src/services/chat-bus.ts
CHANGED
|
@@ -14,7 +14,10 @@ import type {
|
|
|
14
14
|
EventSubscribeOptions,
|
|
15
15
|
ScratchpadSection,
|
|
16
16
|
ClarificationEvent,
|
|
17
|
+
ElicitationEvent,
|
|
18
|
+
ElicitationPropertySchema,
|
|
17
19
|
ChatPromptConfig,
|
|
20
|
+
FormPromptConfig,
|
|
18
21
|
} from '../types/chat-bus'
|
|
19
22
|
|
|
20
23
|
// ─── Event Emitter ───────────────────────────────────────────
|
|
@@ -245,6 +248,118 @@ export function mergeScratchpadSections(
|
|
|
245
248
|
* bus.commands.exec('showChatPrompt', clarificationToPromptConfig(clarification))
|
|
246
249
|
* })
|
|
247
250
|
*/
|
|
251
|
+
// ─── Elicitation → Prompt Helper (v5.2.0) ───────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Convert an MCP `elicitation/create` payload into a `ChatPromptConfig`.
|
|
255
|
+
*
|
|
256
|
+
* Mapping rules :
|
|
257
|
+
* - Single `boolean` property → `type: 'confirm'`
|
|
258
|
+
* - Single property with `enum` of ≤4 values → `type: 'choice'` (one option per enum value)
|
|
259
|
+
* - Anything else → `type: 'form'` with one field per schema property
|
|
260
|
+
*
|
|
261
|
+
* JSON Schema primitive types map to mcp-ui form field types :
|
|
262
|
+
*
|
|
263
|
+
* | JSON Schema | mcp-ui FormFieldType |
|
|
264
|
+
* |---|---|
|
|
265
|
+
* | `type: 'string'` | `'text'` |
|
|
266
|
+
* | `type: 'string', format: 'email'` | `'email'` |
|
|
267
|
+
* | `type: 'string', format: 'date'` or `'date-time'` | `'date'` |
|
|
268
|
+
* | `type: 'string', enum: [...]` | `'select'` |
|
|
269
|
+
* | `type: 'number' \| 'integer'` | `'number'` |
|
|
270
|
+
* | `type: 'boolean'` | `'checkbox'` |
|
|
271
|
+
*
|
|
272
|
+
* Unknown shapes fall through to plain text with a `helpText` warning.
|
|
273
|
+
*
|
|
274
|
+
* @experimental
|
|
275
|
+
* @since v5.2.0
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* bus.events.on('onElicitation', ({ elicitation }) => {
|
|
279
|
+
* bus.commands.exec('showChatPrompt', elicitationToPromptConfig(elicitation))
|
|
280
|
+
* })
|
|
281
|
+
*/
|
|
282
|
+
export function elicitationToPromptConfig(event: ElicitationEvent): ChatPromptConfig {
|
|
283
|
+
const propEntries = Object.entries(event.requestedSchema.properties)
|
|
284
|
+
|
|
285
|
+
// Shortcut 1 : single boolean → confirm
|
|
286
|
+
if (propEntries.length === 1 && propEntries[0][1].type === 'boolean') {
|
|
287
|
+
return {
|
|
288
|
+
type: 'confirm',
|
|
289
|
+
title: event.message,
|
|
290
|
+
config: {
|
|
291
|
+
message: propEntries[0][1].description,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Shortcut 2 : single enum property with ≤4 values → choice
|
|
297
|
+
if (propEntries.length === 1) {
|
|
298
|
+
const [, schema] = propEntries[0]
|
|
299
|
+
if (schema.enum && schema.enum.length > 0 && schema.enum.length <= 4) {
|
|
300
|
+
return {
|
|
301
|
+
type: 'choice',
|
|
302
|
+
title: event.message,
|
|
303
|
+
config: {
|
|
304
|
+
options: schema.enum.map((val, idx) => ({
|
|
305
|
+
value: String(val),
|
|
306
|
+
label: schema.enumNames?.[idx] ?? String(val),
|
|
307
|
+
})),
|
|
308
|
+
layout: 'vertical',
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Default : full form
|
|
315
|
+
const required = new Set(event.requestedSchema.required ?? [])
|
|
316
|
+
const fields: FormPromptConfig['fields'] = propEntries.map(([name, schema]) => ({
|
|
317
|
+
name,
|
|
318
|
+
label: schema.title ?? name,
|
|
319
|
+
...schemaToFieldType(schema),
|
|
320
|
+
required: required.has(name),
|
|
321
|
+
helpText: schema.description,
|
|
322
|
+
...(schema.default !== undefined ? { placeholder: String(schema.default) } : {}),
|
|
323
|
+
}))
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
type: 'form',
|
|
327
|
+
title: event.message,
|
|
328
|
+
config: { fields },
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function schemaToFieldType(
|
|
333
|
+
schema: ElicitationPropertySchema
|
|
334
|
+
):
|
|
335
|
+
| { type: FormPromptConfig['fields'][number]['type']; options?: Array<{ label: string; value: string }> }
|
|
336
|
+
| { type: FormPromptConfig['fields'][number]['type']; helpText?: string } {
|
|
337
|
+
// Enum → select
|
|
338
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
339
|
+
return {
|
|
340
|
+
type: 'select',
|
|
341
|
+
options: schema.enum.map((val, idx) => ({
|
|
342
|
+
label: schema.enumNames?.[idx] ?? String(val),
|
|
343
|
+
value: String(val),
|
|
344
|
+
})),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (schema.type === 'boolean') return { type: 'checkbox' }
|
|
349
|
+
if (schema.type === 'number' || schema.type === 'integer') return { type: 'number' }
|
|
350
|
+
if (schema.type === 'string') {
|
|
351
|
+
if (schema.format === 'email') return { type: 'email' }
|
|
352
|
+
if (schema.format === 'date' || schema.format === 'date-time') return { type: 'date' }
|
|
353
|
+
return { type: 'text' }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Unknown primitive — fall back to text with a warning
|
|
357
|
+
console.warn(
|
|
358
|
+
`[MCP-UI] elicitationToPromptConfig: unsupported schema type "${(schema as { type?: string }).type}", falling back to text.`
|
|
359
|
+
)
|
|
360
|
+
return { type: 'text' }
|
|
361
|
+
}
|
|
362
|
+
|
|
248
363
|
export function clarificationToPromptConfig(
|
|
249
364
|
event: ClarificationEvent
|
|
250
365
|
): ChatPromptConfig {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for createChatPromptController — v5.2.0
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { createRoot } from 'solid-js'
|
|
7
|
+
import {
|
|
8
|
+
createChatPromptController,
|
|
9
|
+
PromptReplacedError,
|
|
10
|
+
} from './chat-prompt-controller'
|
|
11
|
+
import type { ChatPromptConfig, ChatPromptResponse } from '../types/chat-bus'
|
|
12
|
+
|
|
13
|
+
const choiceConfig = (title = 'Pick one'): ChatPromptConfig => ({
|
|
14
|
+
type: 'choice',
|
|
15
|
+
title,
|
|
16
|
+
config: { options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const confirmConfig = (title = 'Confirm?'): ChatPromptConfig => ({
|
|
20
|
+
type: 'confirm',
|
|
21
|
+
title,
|
|
22
|
+
config: { message: 'Sure?' },
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const choiceResponse = (value: string): ChatPromptResponse => ({
|
|
26
|
+
type: 'choice',
|
|
27
|
+
value,
|
|
28
|
+
label: value,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('createChatPromptController — v5.2.0', () => {
|
|
32
|
+
it('sequential prompts both resolve', async () => {
|
|
33
|
+
await createRoot(async (dispose) => {
|
|
34
|
+
const ctrl = createChatPromptController()
|
|
35
|
+
|
|
36
|
+
const p1 = ctrl.handle(choiceConfig('First'))
|
|
37
|
+
expect(ctrl.activePrompt()?.title).toBe('First')
|
|
38
|
+
ctrl.resolveActive(choiceResponse('a'))
|
|
39
|
+
expect(ctrl.activePrompt()).toBeNull()
|
|
40
|
+
const r1 = await p1
|
|
41
|
+
expect(r1.value).toBe('a')
|
|
42
|
+
|
|
43
|
+
const p2 = ctrl.handle(choiceConfig('Second'))
|
|
44
|
+
expect(ctrl.activePrompt()?.title).toBe('Second')
|
|
45
|
+
ctrl.resolveActive(choiceResponse('b'))
|
|
46
|
+
const r2 = await p2
|
|
47
|
+
expect(r2.value).toBe('b')
|
|
48
|
+
|
|
49
|
+
dispose()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('re-entrant call rejects previous Promise with PromptReplacedError', async () => {
|
|
54
|
+
await createRoot(async (dispose) => {
|
|
55
|
+
const ctrl = createChatPromptController()
|
|
56
|
+
|
|
57
|
+
const p1 = ctrl.handle(choiceConfig('First'))
|
|
58
|
+
// Don't resolve — fire a second one
|
|
59
|
+
const p2 = ctrl.handle(choiceConfig('Second'))
|
|
60
|
+
|
|
61
|
+
await expect(p1).rejects.toBeInstanceOf(PromptReplacedError)
|
|
62
|
+
|
|
63
|
+
// The second prompt is now active and can still resolve
|
|
64
|
+
expect(ctrl.activePrompt()?.title).toBe('Second')
|
|
65
|
+
ctrl.resolveActive(choiceResponse('b'))
|
|
66
|
+
await expect(p2).resolves.toMatchObject({ value: 'b' })
|
|
67
|
+
|
|
68
|
+
dispose()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('AbortSignal already aborted on entry rejects with DOMException AbortError', async () => {
|
|
73
|
+
await createRoot(async (dispose) => {
|
|
74
|
+
const ctrl = createChatPromptController()
|
|
75
|
+
const ac = new AbortController()
|
|
76
|
+
ac.abort()
|
|
77
|
+
|
|
78
|
+
const p = ctrl.handle(choiceConfig('Never shown'), ac.signal)
|
|
79
|
+
expect(ctrl.activePrompt()).toBeNull() // UI never installed
|
|
80
|
+
|
|
81
|
+
await expect(p).rejects.toMatchObject({ name: 'AbortError' })
|
|
82
|
+
dispose()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('AbortSignal aborted during prompt rejects + clears activePrompt', async () => {
|
|
87
|
+
await createRoot(async (dispose) => {
|
|
88
|
+
const ctrl = createChatPromptController()
|
|
89
|
+
const ac = new AbortController()
|
|
90
|
+
|
|
91
|
+
const p = ctrl.handle(choiceConfig('Pending'), ac.signal)
|
|
92
|
+
expect(ctrl.activePrompt()?.title).toBe('Pending')
|
|
93
|
+
|
|
94
|
+
ac.abort()
|
|
95
|
+
await expect(p).rejects.toMatchObject({ name: 'AbortError' })
|
|
96
|
+
expect(ctrl.activePrompt()).toBeNull()
|
|
97
|
+
|
|
98
|
+
dispose()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('abort() method rejects pending Promise with AbortError', async () => {
|
|
103
|
+
await createRoot(async (dispose) => {
|
|
104
|
+
const ctrl = createChatPromptController()
|
|
105
|
+
|
|
106
|
+
const p = ctrl.handle(choiceConfig('Will abort'))
|
|
107
|
+
ctrl.abort('Navigated away')
|
|
108
|
+
|
|
109
|
+
await expect(p).rejects.toMatchObject({ name: 'AbortError' })
|
|
110
|
+
expect(ctrl.activePrompt()).toBeNull()
|
|
111
|
+
|
|
112
|
+
dispose()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('dismissActive resolves with dismissed: true and preserves the prompt type', async () => {
|
|
117
|
+
await createRoot(async (dispose) => {
|
|
118
|
+
const ctrl = createChatPromptController()
|
|
119
|
+
const p = ctrl.handle(confirmConfig('Really?'))
|
|
120
|
+
|
|
121
|
+
ctrl.dismissActive()
|
|
122
|
+
const r = await p
|
|
123
|
+
expect(r).toMatchObject({ type: 'confirm', dismissed: true })
|
|
124
|
+
expect(ctrl.activePrompt()).toBeNull()
|
|
125
|
+
|
|
126
|
+
dispose()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('resolve/dismiss after abort is a no-op (double-settle protection)', async () => {
|
|
131
|
+
await createRoot(async (dispose) => {
|
|
132
|
+
const ctrl = createChatPromptController()
|
|
133
|
+
const p = ctrl.handle(choiceConfig('x'))
|
|
134
|
+
|
|
135
|
+
ctrl.abort()
|
|
136
|
+
// These should not throw or re-settle
|
|
137
|
+
ctrl.resolveActive(choiceResponse('ignored'))
|
|
138
|
+
ctrl.dismissActive()
|
|
139
|
+
|
|
140
|
+
await expect(p).rejects.toMatchObject({ name: 'AbortError' })
|
|
141
|
+
dispose()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -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
|
+
})
|