@seed-ship/mcp-ui-solid 5.0.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +160 -6
  3. package/dist/components/ChatPrompt.cjs +71 -53
  4. package/dist/components/ChatPrompt.cjs.map +1 -1
  5. package/dist/components/ChatPrompt.d.ts +37 -2
  6. package/dist/components/ChatPrompt.d.ts.map +1 -1
  7. package/dist/components/ChatPrompt.js +72 -54
  8. package/dist/components/ChatPrompt.js.map +1 -1
  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/index.cjs +9 -0
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +8 -2
  18. package/dist/index.d.ts +8 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/services/chat-bus.cjs +71 -0
  23. package/dist/services/chat-bus.cjs.map +1 -1
  24. package/dist/services/chat-bus.d.ts +31 -1
  25. package/dist/services/chat-bus.d.ts.map +1 -1
  26. package/dist/services/chat-bus.js +71 -0
  27. package/dist/services/chat-bus.js.map +1 -1
  28. package/dist/services/chat-prompt-controller.cjs +83 -0
  29. package/dist/services/chat-prompt-controller.cjs.map +1 -0
  30. package/dist/services/chat-prompt-controller.d.ts +93 -0
  31. package/dist/services/chat-prompt-controller.d.ts.map +1 -0
  32. package/dist/services/chat-prompt-controller.js +83 -0
  33. package/dist/services/chat-prompt-controller.js.map +1 -0
  34. package/dist/stores/scratchpad-store.cjs +105 -77
  35. package/dist/stores/scratchpad-store.cjs.map +1 -1
  36. package/dist/stores/scratchpad-store.d.ts +88 -19
  37. package/dist/stores/scratchpad-store.d.ts.map +1 -1
  38. package/dist/stores/scratchpad-store.js +105 -77
  39. package/dist/stores/scratchpad-store.js.map +1 -1
  40. package/dist/types/chat-bus.d.ts +164 -22
  41. package/dist/types/chat-bus.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/ChatPrompt.test.tsx +122 -0
  44. package/src/components/ChatPrompt.tsx +70 -15
  45. package/src/components/FeedbackInline.test.tsx +117 -0
  46. package/src/components/FeedbackInline.tsx +143 -0
  47. package/src/index.ts +24 -1
  48. package/src/services/chat-bus.test.ts +154 -2
  49. package/src/services/chat-bus.ts +115 -0
  50. package/src/services/chat-prompt-controller.test.ts +144 -0
  51. package/src/services/chat-prompt-controller.ts +214 -0
  52. package/src/stores/scratchpad-store.test.tsx +140 -0
  53. package/src/stores/scratchpad-store.tsx +244 -0
  54. package/src/types/chat-bus.ts +166 -22
  55. package/tsconfig.tsbuildinfo +1 -1
  56. package/src/stores/scratchpad-store.ts +0 -126
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6
- import { createEventEmitter, createCommandHandler, createChatBus, clarificationToPromptConfig } from './chat-bus'
7
- import type { ChatEvents, ChatCommands, ClarificationEvent } from '../types/chat-bus'
6
+ import { createEventEmitter, createCommandHandler, createChatBus, clarificationToPromptConfig, elicitationToPromptConfig } from './chat-bus'
7
+ import type { ChatEvents, ChatCommands, ClarificationEvent, ElicitationEvent } from '../types/chat-bus'
8
8
 
9
9
  describe('createEventEmitter', () => {
10
10
  it('emits events to subscribed listeners', () => {
@@ -384,3 +384,155 @@ describe('clarificationToPromptConfig', () => {
384
384
  expect(opts[0]).not.toHaveProperty('metadata')
385
385
  })
386
386
  })
387
+
388
+ describe('elicitationToPromptConfig — v5.2.0', () => {
389
+ it('single boolean property maps to confirm prompt', () => {
390
+ const event: ElicitationEvent = {
391
+ message: 'Proceed with the deployment?',
392
+ requestedSchema: {
393
+ type: 'object',
394
+ properties: { confirmed: { type: 'boolean', description: 'Ship it?' } },
395
+ required: ['confirmed'],
396
+ },
397
+ }
398
+ const config = elicitationToPromptConfig(event)
399
+ expect(config.type).toBe('confirm')
400
+ expect(config.title).toBe('Proceed with the deployment?')
401
+ expect((config.config as any).message).toBe('Ship it?')
402
+ })
403
+
404
+ it('single enum string property (≤4 values) maps to choice prompt', () => {
405
+ const event: ElicitationEvent = {
406
+ message: 'Select severity',
407
+ requestedSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ level: {
411
+ type: 'string',
412
+ enum: ['low', 'medium', 'high'],
413
+ enumNames: ['Low', 'Medium', 'High'],
414
+ },
415
+ },
416
+ },
417
+ }
418
+ const config = elicitationToPromptConfig(event)
419
+ expect(config.type).toBe('choice')
420
+ const opts = (config.config as any).options
421
+ expect(opts).toEqual([
422
+ { value: 'low', label: 'Low' },
423
+ { value: 'medium', label: 'Medium' },
424
+ { value: 'high', label: 'High' },
425
+ ])
426
+ })
427
+
428
+ it('enum with >4 values maps to form with select field (not choice)', () => {
429
+ const event: ElicitationEvent = {
430
+ message: 'Pick a country',
431
+ requestedSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ country: {
435
+ type: 'string',
436
+ enum: ['FR', 'DE', 'IT', 'ES', 'PT'],
437
+ },
438
+ },
439
+ },
440
+ }
441
+ const config = elicitationToPromptConfig(event)
442
+ expect(config.type).toBe('form')
443
+ const fields = (config.config as any).fields
444
+ expect(fields[0].type).toBe('select')
445
+ expect(fields[0].options).toHaveLength(5)
446
+ })
447
+
448
+ it('multi-property object maps to form with one field per property', () => {
449
+ const event: ElicitationEvent = {
450
+ message: 'Fill in contact info',
451
+ requestedSchema: {
452
+ type: 'object',
453
+ properties: {
454
+ name: { type: 'string', title: 'Full name' },
455
+ age: { type: 'integer' },
456
+ newsletter: { type: 'boolean', description: 'Opt-in' },
457
+ },
458
+ required: ['name'],
459
+ },
460
+ }
461
+ const config = elicitationToPromptConfig(event)
462
+ expect(config.type).toBe('form')
463
+ const fields = (config.config as any).fields
464
+ expect(fields).toHaveLength(3)
465
+ expect(fields.map((f: any) => f.name)).toEqual(['name', 'age', 'newsletter'])
466
+ expect(fields[0]).toMatchObject({ type: 'text', label: 'Full name', required: true })
467
+ expect(fields[1]).toMatchObject({ type: 'number', required: false })
468
+ expect(fields[2]).toMatchObject({ type: 'checkbox', helpText: 'Opt-in' })
469
+ })
470
+
471
+ it('string format email maps to email field', () => {
472
+ const event: ElicitationEvent = {
473
+ message: 'Contact',
474
+ requestedSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ email: { type: 'string', format: 'email' },
478
+ reply: { type: 'string' }, // force form (not single-property shortcut)
479
+ },
480
+ },
481
+ }
482
+ const config = elicitationToPromptConfig(event)
483
+ const fields = (config.config as any).fields
484
+ expect(fields[0]).toMatchObject({ name: 'email', type: 'email' })
485
+ })
486
+
487
+ it('string format date / date-time maps to date field', () => {
488
+ const event: ElicitationEvent = {
489
+ message: 'Schedule',
490
+ requestedSchema: {
491
+ type: 'object',
492
+ properties: {
493
+ start: { type: 'string', format: 'date' },
494
+ end: { type: 'string', format: 'date-time' },
495
+ },
496
+ },
497
+ }
498
+ const config = elicitationToPromptConfig(event)
499
+ const fields = (config.config as any).fields
500
+ expect(fields[0].type).toBe('date')
501
+ expect(fields[1].type).toBe('date')
502
+ })
503
+
504
+ it('default value maps to placeholder', () => {
505
+ const event: ElicitationEvent = {
506
+ message: 'Settings',
507
+ requestedSchema: {
508
+ type: 'object',
509
+ properties: {
510
+ retries: { type: 'integer', default: 3 },
511
+ timeout: { type: 'integer' }, // second property to force form
512
+ },
513
+ },
514
+ }
515
+ const config = elicitationToPromptConfig(event)
516
+ const fields = (config.config as any).fields
517
+ expect(fields[0]).toMatchObject({ placeholder: '3' })
518
+ })
519
+
520
+ it('unknown schema type falls through to text with console.warn', () => {
521
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
522
+ const event = {
523
+ message: 'Edge case',
524
+ requestedSchema: {
525
+ type: 'object',
526
+ properties: {
527
+ weird: { type: 'array' }, // not a primitive
528
+ other: { type: 'string' },
529
+ },
530
+ },
531
+ } as unknown as ElicitationEvent
532
+ const config = elicitationToPromptConfig(event)
533
+ const fields = (config.config as any).fields
534
+ expect(fields[0].type).toBe('text')
535
+ expect(warnSpy).toHaveBeenCalled()
536
+ warnSpy.mockRestore()
537
+ })
538
+ })
@@ -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
+ }