@kitn.ai/ui 0.16.0 → 0.17.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 (194) hide show
  1. package/README.md +28 -13
  2. package/dist/components/chat-thread.d.ts +2 -4
  3. package/dist/components/choice-card.d.ts +1 -0
  4. package/dist/components/dismissed-stub.d.ts +22 -0
  5. package/dist/components/message.d.ts +59 -2
  6. package/dist/components/response-compare-types.d.ts +66 -0
  7. package/dist/components/response-compare.d.ts +51 -0
  8. package/dist/components/tasks-card.d.ts +1 -0
  9. package/dist/components/toast.d.ts +48 -0
  10. package/dist/components/use-card-resolution.d.ts +10 -6
  11. package/dist/custom-elements.json +260 -11
  12. package/dist/elements/artifact.js +1 -0
  13. package/dist/elements/attachments.js +1 -0
  14. package/dist/elements/autoloader.d.ts +2 -0
  15. package/dist/elements/autoloader.js +1 -0
  16. package/dist/elements/card.js +1 -0
  17. package/dist/elements/cards.d.ts +1 -0
  18. package/dist/elements/cards.js +1 -0
  19. package/dist/elements/chain-of-thought.d.ts +0 -12
  20. package/dist/elements/chain-of-thought.js +1 -0
  21. package/dist/elements/chat-scope-picker.js +1 -0
  22. package/dist/elements/chat-workspace.js +1 -0
  23. package/dist/elements/chat.js +1 -0
  24. package/dist/elements/checkpoint.js +1 -0
  25. package/dist/elements/choice.js +1 -0
  26. package/dist/elements/chunks/Icon-D_Zmwmwh.js +1 -0
  27. package/dist/elements/chunks/action-icons-PVfIvcuG.js +1 -0
  28. package/dist/elements/chunks/attachments-Dp6vqtrc.js +1 -0
  29. package/dist/elements/chunks/bash-InADTalH.js +1 -0
  30. package/dist/elements/chunks/button-CKyYD9G4.js +1 -0
  31. package/dist/elements/chunks/card-Cjho-PYk.js +1 -0
  32. package/dist/elements/chunks/card-routing-C986KuUA.js +1 -0
  33. package/dist/elements/chunks/chat-thread-RT808iPf.js +1 -0
  34. package/dist/elements/chunks/check-CDYaMYrM.js +1 -0
  35. package/dist/elements/chunks/chevron-down-3SBiq8HF.js +1 -0
  36. package/dist/elements/chunks/chevron-right-k2UFlIVK.js +1 -0
  37. package/dist/elements/chunks/cn-Dogu6PbV.js +1 -0
  38. package/dist/elements/chunks/code-block-BYUmfFSd.js +1 -0
  39. package/dist/elements/chunks/collapsible-BiNwdOn6.js +1 -0
  40. package/dist/elements/chunks/context-18g6oi4J.js +1 -0
  41. package/dist/elements/chunks/conversation-list-D9Y0NVdo.js +1 -0
  42. package/dist/elements/chunks/core-AYMC6_lb.js +12 -0
  43. package/dist/elements/chunks/css-M7EaDHN_.js +1 -0
  44. package/dist/elements/chunks/default-input-CSAoYYMd.js +1 -0
  45. package/dist/elements/chunks/download-D8K0Je8r.js +1 -0
  46. package/dist/elements/chunks/dropdown-bUs2iTia.js +1 -0
  47. package/dist/elements/chunks/engine-javascript-vq0WuIJl.js +141 -0
  48. package/dist/elements/chunks/external-link-6mA0wxab.js +1 -0
  49. package/dist/elements/chunks/file-text-DnaYk3Dm.js +1 -0
  50. package/dist/elements/chunks/file-tree-CgtRLlxK.js +1 -0
  51. package/dist/elements/chunks/github-dark-dimmed-DUshB20C.js +1 -0
  52. package/dist/elements/chunks/github-light-JYsPkUQd.js +1 -0
  53. package/dist/elements/chunks/hover-card-CIGw62gP.js +1 -0
  54. package/dist/elements/chunks/html-CPZ3oZQ7.js +1 -0
  55. package/dist/elements/chunks/index-CTBEJeo6.js +1 -0
  56. package/dist/elements/chunks/javascript-C25yR2R2.js +1 -0
  57. package/dist/elements/chunks/json-DxJze_jm.js +1 -0
  58. package/dist/elements/chunks/link-BlGSeoPy.js +1 -0
  59. package/dist/elements/chunks/loader-DvhR4qa0.js +1 -0
  60. package/dist/elements/chunks/markdown-CMTGx9r8.js +64 -0
  61. package/dist/elements/chunks/message-CDZwXjhL.js +1 -0
  62. package/dist/elements/chunks/message-feedback-CYx81QiA.js +1 -0
  63. package/dist/elements/chunks/model-switcher-O_tUhoOT.js +1 -0
  64. package/dist/elements/chunks/overlay-DydXYMQW.js +1 -0
  65. package/dist/elements/chunks/prompt-suggestion-ldlWV5kd.js +1 -0
  66. package/dist/elements/chunks/reasoning-2PUnHlmr.js +1 -0
  67. package/dist/elements/chunks/resizable-B5WNbSeb.js +1 -0
  68. package/dist/elements/chunks/star-t9tI-P0q.js +1 -0
  69. package/dist/elements/chunks/store-C6rIooZz.js +1 -0
  70. package/dist/elements/chunks/svelte--5p79yCD.js +1 -0
  71. package/dist/elements/chunks/text-shimmer-uh9V_KUh.js +1 -0
  72. package/dist/elements/chunks/textarea-3nm6Ia9U.js +1 -0
  73. package/dist/elements/chunks/thumbs-up-BBtguGld.js +1 -0
  74. package/dist/elements/chunks/toast-store-D8cL3fWl.js +1 -0
  75. package/dist/elements/chunks/tool-GJ3vwp1U.js +1 -0
  76. package/dist/elements/chunks/tooltip-DwKAz4XR.js +1 -0
  77. package/dist/elements/chunks/triangle-alert-Ct1zt2JL.js +1 -0
  78. package/dist/elements/chunks/tsx-B8rCNbgL.js +1 -0
  79. package/dist/elements/chunks/typescript-RycA9KXf.js +1 -0
  80. package/dist/elements/chunks/use-card-resolution-D-_0Alzy.js +1 -0
  81. package/dist/elements/chunks/video-mVYiM99-.js +1 -0
  82. package/dist/elements/chunks/vue-BmIZj4XD.js +1 -0
  83. package/dist/elements/chunks/x-CzxL27Ch.js +1 -0
  84. package/dist/elements/code-block.js +1 -0
  85. package/dist/elements/compare.js +1 -0
  86. package/dist/elements/confirm-card.js +1 -0
  87. package/dist/elements/context-meter.js +1 -0
  88. package/dist/elements/conversation-list.d.ts +1 -11
  89. package/dist/elements/conversation-list.js +1 -0
  90. package/dist/elements/embed.js +1 -0
  91. package/dist/elements/empty.js +1 -0
  92. package/dist/elements/feedback-bar.js +1 -0
  93. package/dist/elements/file-tree.js +1 -0
  94. package/dist/elements/file-upload.js +1 -0
  95. package/dist/elements/form.js +1 -0
  96. package/dist/elements/image.js +1 -0
  97. package/dist/elements/link-preview.js +1 -0
  98. package/dist/elements/loader.js +1 -0
  99. package/dist/elements/markdown.js +1 -0
  100. package/dist/elements/message-skills.d.ts +0 -16
  101. package/dist/elements/message-skills.js +1 -0
  102. package/dist/elements/message.js +1 -0
  103. package/dist/elements/model-switcher.d.ts +1 -11
  104. package/dist/elements/model-switcher.js +1 -0
  105. package/dist/elements/popover.js +1 -0
  106. package/dist/elements/prompt-input.d.ts +1 -9
  107. package/dist/elements/prompt-input.js +1 -0
  108. package/dist/elements/prompt-suggestions.d.ts +0 -6
  109. package/dist/elements/prompt-suggestions.js +1 -0
  110. package/dist/elements/reasoning.js +1 -0
  111. package/dist/elements/resizable.d.ts +1 -17
  112. package/dist/elements/resizable.js +1 -0
  113. package/dist/elements/response-stream.js +14 -0
  114. package/dist/elements/scroll-button.js +1 -0
  115. package/dist/elements/source.d.ts +0 -17
  116. package/dist/elements/source.js +1 -0
  117. package/dist/elements/switch.js +1 -0
  118. package/dist/elements/tasks.js +1 -0
  119. package/dist/elements/text-shimmer.js +1 -0
  120. package/dist/elements/thinking-bar.js +1 -0
  121. package/dist/elements/toast.d.ts +1 -0
  122. package/dist/elements/toast.js +1 -0
  123. package/dist/elements/tool.js +1 -0
  124. package/dist/elements/voice-input.js +1 -0
  125. package/dist/elements.d.ts +41 -5
  126. package/dist/index.d.ts +12 -0
  127. package/dist/index.js +11832 -9790
  128. package/dist/kitn-chat.es.js +1 -1
  129. package/dist/llms/llms-full.txt +57 -12
  130. package/dist/llms/llms.txt +3 -3
  131. package/dist/mcp.es.js +157 -1
  132. package/dist/primitives/card-contract.d.ts +20 -4
  133. package/dist/primitives/card-recovery.d.ts +55 -0
  134. package/dist/primitives/message-feedback.d.ts +35 -0
  135. package/dist/primitives/toast-store.d.ts +88 -0
  136. package/dist/react/index.d.ts +83 -3
  137. package/dist/react.js +90 -80
  138. package/dist/register-JyTglR6m.js +1 -0
  139. package/dist/register-impl-C6wlSsts.js +77 -0
  140. package/dist/schemas/choice.schema.json +5 -0
  141. package/dist/schemas/tasks.schema.json +5 -0
  142. package/dist/ui/tooltip.d.ts +1 -0
  143. package/frameworks/react/index.tsx +63 -11
  144. package/llms-full.txt +57 -12
  145. package/llms.txt +3 -3
  146. package/package.json +15 -6
  147. package/src/agent-tooling/mcp/tools/debug.ts +184 -0
  148. package/src/agent-tooling/mcp/tools/scaffold.ts +133 -1
  149. package/src/components/chat-thread.tsx +27 -35
  150. package/src/components/choice-card.tsx +33 -4
  151. package/src/components/component-meta.json +250 -26
  152. package/src/components/confirm-card.tsx +15 -1
  153. package/src/components/dismissed-stub.tsx +80 -0
  154. package/src/components/form.tsx +21 -3
  155. package/src/components/message.tsx +151 -10
  156. package/src/components/response-compare-types.ts +127 -0
  157. package/src/components/response-compare.tsx +431 -0
  158. package/src/components/tasks-card.tsx +33 -3
  159. package/src/components/toast.tsx +398 -0
  160. package/src/components/use-card-resolution.ts +24 -8
  161. package/src/elements/autoloader.ts +89 -0
  162. package/src/elements/chat-types.ts +7 -0
  163. package/src/elements/chat-workspace.tsx +4 -2
  164. package/src/elements/chat.tsx +4 -2
  165. package/src/elements/compare.tsx +88 -0
  166. package/src/elements/compiled.css +1 -1
  167. package/src/elements/element-manifest.json +182 -0
  168. package/src/elements/element-meta.json +191 -68
  169. package/src/elements/element-types.d.ts +41 -5
  170. package/src/elements/framework-usage.json +25 -0
  171. package/src/elements/message.tsx +27 -45
  172. package/src/elements/register-impl.ts +3 -0
  173. package/src/elements/register.ts +9 -0
  174. package/src/elements/remote.tsx +4 -0
  175. package/src/elements/styles.css +19 -0
  176. package/src/elements/toast.tsx +74 -0
  177. package/src/index.ts +30 -0
  178. package/src/primitives/card-contract.ts +14 -5
  179. package/src/primitives/card-recovery.ts +149 -0
  180. package/src/primitives/card-routing.ts +3 -0
  181. package/src/primitives/card-schemas/choice.schema.json +5 -0
  182. package/src/primitives/card-schemas/tasks.schema.json +5 -0
  183. package/src/primitives/message-feedback.ts +123 -0
  184. package/src/primitives/toast-store.ts +235 -0
  185. package/src/ui/tooltip.tsx +8 -2
  186. package/dist/elements/chat-types.d.ts +0 -41
  187. package/dist/elements/css.d.ts +0 -1
  188. package/dist/elements/default-input.d.ts +0 -40
  189. package/dist/elements/define.d.ts +0 -42
  190. package/dist/elements/register-impl.d.ts +0 -0
  191. package/dist/elements/register.d.ts +0 -3
  192. package/dist/register-impl-wCsFPoh9.js +0 -77
  193. package/dist/register-ujhr1nBS.js +0 -1
  194. /package/dist/elements/{remote.d.ts → compare.d.ts} +0 -0
@@ -80,11 +80,11 @@ export interface KaiCardsElement extends HTMLElement {
80
80
  /** Color mode (`auto` follows prefers-color-scheme). */
81
81
  theme?: 'light' | 'dark' | 'auto';
82
82
  /** The stream of card envelopes to render. Set as a JS PROPERTY: `el.cards = [...]`. */
83
- cards?: { type: string; id: string; data: unknown; title?: string; resolution?: { kind: "action"; action: string; payload?: unknown; at?: string } | { kind: "submit"; data: unknown; at?: string } }[];
83
+ cards?: { type: string; id: string; data: unknown; title?: string; resolution?: { kind: "action"; action: string; payload?: unknown; at?: string } | { kind: "submit"; data: unknown; at?: string } | { kind: "dismissed"; at?: string } | { kind: "expired"; reason?: string; at?: string } }[];
84
84
  /** Optional type→tag overrides/additions (merged over the built-ins). Property: `el.types`. Typed as a plain string map (not the `CardTagMap` alias) so the generated React wrapper inlines it instead of emitting an unresolved named type. */
85
85
  types?: Record<string, string>;
86
86
  /** Optional CardPolicy handling child events. Property: `el.policy`. */
87
- policy?: { onSubmit?: (cardId: string, data: unknown) => void; onAction?: (cardId: string, action: string, payload?: unknown) => void; onSendPrompt?: (text: string, opts: { mode: "compose" | "send"; context?: unknown; }) => void; onOpen?: (url: string, target: "tab" | "artifact") => void; onState?: (cardId: string, patch: unknown) => void; onDismiss?: (cardId: string) => void; onError?: (cardId: string, message: string) => void; maxSendPromptMode?: "compose" | "send" };
87
+ policy?: { onSubmit?: (cardId: string, data: unknown) => void; onAction?: (cardId: string, action: string, payload?: unknown) => void; onSendPrompt?: (text: string, opts: { mode: "compose" | "send"; context?: unknown; }) => void; onOpen?: (url: string, target: "tab" | "artifact") => void; onState?: (cardId: string, patch: unknown) => void; onDismiss?: (cardId: string) => void; onReopen?: (cardId: string) => void; onError?: (cardId: string, message: string) => void; maxSendPromptMode?: "compose" | "send" };
88
88
  }
89
89
 
90
90
  export interface KaiChainOfThoughtElement extends HTMLElement {
@@ -98,7 +98,7 @@ export interface KaiChatElement extends HTMLElement {
98
98
  /** Color mode (`auto` follows prefers-color-scheme). */
99
99
  theme?: 'light' | 'dark' | 'auto';
100
100
  /** The full message thread to render, newest last. Each entry carries its role, content, and optional reasoning/tools/attachments/actions. Set as a JS property (`el.messages = [...]`). */
101
- messages: { id: string; role: "user" | "assistant"; content: string; reasoning?: undefined | { text: string; label?: undefined | string }; tools?: undefined | { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: undefined | Record<string, unknown>; output?: undefined | Record<string, unknown>; toolCallId?: undefined | string; errorText?: undefined | string }[]; attachments?: undefined | { id: string; type: "file" | "source-document"; filename?: undefined | string; mediaType?: undefined | string; url?: undefined | string; title?: undefined | string }[]; actions?: undefined | ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: undefined | string; tooltip?: undefined | string })[]; avatar?: undefined | { src?: undefined | string; fallback?: undefined | string; alt?: undefined | string } }[];
101
+ messages: { id: string; role: "user" | "assistant"; content: string; reasoning?: undefined | { text: string; label?: undefined | string }; tools?: undefined | { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: undefined | Record<string, unknown>; output?: undefined | Record<string, unknown>; toolCallId?: undefined | string; errorText?: undefined | string }[]; attachments?: undefined | { id: string; type: "file" | "source-document"; filename?: undefined | string; mediaType?: undefined | string; url?: undefined | string; title?: undefined | string }[]; actions?: undefined | ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: undefined | string; tooltip?: undefined | string })[]; avatar?: undefined | { src?: undefined | string; fallback?: undefined | string; alt?: undefined | string }; feedback?: undefined | "like" | "dislike" }[];
102
102
  /** Controlled value of the input. When set, the host owns the input text and must update it on `kai-value-change`; leave unset for uncontrolled behavior. */
103
103
  value?: string;
104
104
  /** Placeholder text shown in the empty input. */
@@ -186,6 +186,25 @@ export interface KaiCodeBlockElement extends HTMLElement {
186
186
  proseSize?: "xs" | "sm" | "base" | "lg";
187
187
  }
188
188
 
189
+ export interface KaiCompareElement extends HTMLElement {
190
+ /** Color mode (`auto` follows prefers-color-scheme). */
191
+ theme?: 'light' | 'dark' | 'auto';
192
+ /** The compare definition (prompt + the two candidates). Set as a JS PROPERTY: `el.data = { prompt, candidates: [A, B], collapse? }`. Import `ResponseCompareData` from `@kitn.ai/ui` for the full shape. */
193
+ data?: Record<string, unknown>;
194
+ /** Stable id correlating every emitted event. Attribute: `compare-id`. */
195
+ compareId?: string;
196
+ /** Re-hydrate / control the user's pick. Set as a JS PROPERTY: `el.selection = { chosenId, rejectedIds }`. Renders the collapsed winner. */
197
+ selection?: Record<string, unknown>;
198
+ /** Layout: `'auto'` (default — columns when wide, tabs when narrow, by CONTAINER width) | `'columns'` (side-by-side) | `'tabs'` (pills to switch). Attribute: `layout`. */
199
+ layout?: "auto" | "columns" | "tabs";
200
+ /** Prose/text size for the rendered candidates. Attribute: `prose-size`. */
201
+ proseSize?: "xs" | "sm" | "base" | "lg";
202
+ /** Shiki theme for code blocks in the candidates. Attribute: `code-theme`. */
203
+ codeTheme?: string;
204
+ /** Whether code blocks are syntax-highlighted. Attribute: `code-highlight`. */
205
+ codeHighlight?: boolean;
206
+ }
207
+
189
208
  export interface KaiConfirmElement extends HTMLElement {
190
209
  /** Color mode (`auto` follows prefers-color-scheme). */
191
210
  theme?: 'light' | 'dark' | 'auto';
@@ -347,7 +366,7 @@ export interface KaiMessageElement extends HTMLElement {
347
366
  /** Color mode (`auto` follows prefers-color-scheme). */
348
367
  theme?: 'light' | 'dark' | 'auto';
349
368
  /** The full message object. Set as a JS property. */
350
- message?: { id: string; role: "user" | "assistant"; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string; errorText?: string }[]; attachments?: { id: string; type: "file" | "source-document"; filename?: string; mediaType?: string; url?: string; title?: string }[]; actions?: ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: string; tooltip?: string })[]; avatar?: { src?: string; fallback?: string; alt?: string } };
369
+ message?: { id: string; role: "user" | "assistant"; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string; errorText?: string }[]; attachments?: { id: string; type: "file" | "source-document"; filename?: string; mediaType?: string; url?: string; title?: string }[]; actions?: ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: string; tooltip?: string })[]; avatar?: { src?: string; fallback?: string; alt?: string }; feedback?: "like" | "dislike" };
351
370
  /** Convenience for simple cases when not passing a `message` object. */
352
371
  role?: "user" | "assistant";
353
372
  /** Convenience content (used when `message` is not set). */
@@ -602,6 +621,21 @@ export interface KaiThinkingBarElement extends HTMLElement {
602
621
  stopLabel?: string;
603
622
  }
604
623
 
624
+ export interface KaiToastRegionElement extends HTMLElement {
625
+ /** Color mode (`auto` follows prefers-color-scheme). */
626
+ theme?: 'light' | 'dark' | 'auto';
627
+ /** The toasts to render. Newest is shown on top. Set as a JS property (array); pass a new array reference to update. */
628
+ toasts: { id: string; message: string; variant?: undefined | "neutral" | "success"; action?: undefined | { label: string; onAction: () => void | false }; duration?: undefined | number; dismissible?: undefined | boolean; target?: undefined | HTMLElement }[];
629
+ /** Stack anchor: `'top-center'` (default), `'top-right'`, `'bottom-center'`, … */
630
+ position?: "top-center" | "top-right" | "top-left" | "bottom-center" | "bottom-right" | "bottom-left";
631
+ /** Max simultaneously-visible toasts; the rest queue. Defaults to `3`. */
632
+ max?: number;
633
+ /** Stacking: 'expanded' (default, full column) | 'collapsed' (Sonner-style pile that expands on hover/focus). Attribute: stack. */
634
+ stack?: "expanded" | "collapsed";
635
+ /** Container element to anchor this region to (JS property). Set by the store for a scoped region; unset = the global viewport region. */
636
+ target?: HTMLElement;
637
+ }
638
+
605
639
  export interface KaiToolElement extends HTMLElement {
606
640
  /** Color mode (`auto` follows prefers-color-scheme). */
607
641
  theme?: 'light' | 'dark' | 'auto';
@@ -630,7 +664,7 @@ export interface KaiWorkspaceElement extends HTMLElement {
630
664
  /** Id of the open conversation, highlighted in the sidebar. */
631
665
  activeId?: string;
632
666
  /** The active conversation's message thread, newest last. Set as a JS property. */
633
- messages: { id: string; role: "user" | "assistant"; content: string; reasoning?: undefined | { text: string; label?: undefined | string }; tools?: undefined | { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: undefined | Record<string, unknown>; output?: undefined | Record<string, unknown>; toolCallId?: undefined | string; errorText?: undefined | string }[]; attachments?: undefined | { id: string; type: "file" | "source-document"; filename?: undefined | string; mediaType?: undefined | string; url?: undefined | string; title?: undefined | string }[]; actions?: undefined | ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: undefined | string; tooltip?: undefined | string })[]; avatar?: undefined | { src?: undefined | string; fallback?: undefined | string; alt?: undefined | string } }[];
667
+ messages: { id: string; role: "user" | "assistant"; content: string; reasoning?: undefined | { text: string; label?: undefined | string }; tools?: undefined | { type: string; state: "input-streaming" | "input-available" | "output-available" | "output-error"; input?: undefined | Record<string, unknown>; output?: undefined | Record<string, unknown>; toolCallId?: undefined | string; errorText?: undefined | string }[]; attachments?: undefined | { id: string; type: "file" | "source-document"; filename?: undefined | string; mediaType?: undefined | string; url?: undefined | string; title?: undefined | string }[]; actions?: undefined | ("copy" | "like" | "dislike" | "regenerate" | "edit" | { id: string; label: string; icon?: undefined | string; tooltip?: undefined | string })[]; avatar?: undefined | { src?: undefined | string; fallback?: undefined | string; alt?: undefined | string }; feedback?: undefined | "like" | "dislike" }[];
634
668
  value?: string;
635
669
  placeholder?: string;
636
670
  loading?: boolean;
@@ -672,6 +706,7 @@ declare global {
672
706
  'kai-checkpoint': KaiCheckpointElement;
673
707
  'kai-choice': KaiChoiceElement;
674
708
  'kai-code-block': KaiCodeBlockElement;
709
+ 'kai-compare': KaiCompareElement;
675
710
  'kai-confirm': KaiConfirmElement;
676
711
  'kai-context': KaiContextElement;
677
712
  'kai-conversations': KaiConversationsElement;
@@ -704,6 +739,7 @@ declare global {
704
739
  'kai-tasks': KaiTasksElement;
705
740
  'kai-text-shimmer': KaiTextShimmerElement;
706
741
  'kai-thinking-bar': KaiThinkingBarElement;
742
+ 'kai-toast-region': KaiToastRegionElement;
707
743
  'kai-tool': KaiToolElement;
708
744
  'kai-voice-input': KaiVoiceInputElement;
709
745
  'kai-workspace': KaiWorkspaceElement;
@@ -113,6 +113,18 @@
113
113
  "solid": "import { CodeBlock } from '@kitn.ai/ui';\n\n<CodeBlock\n code={code}\n/>"
114
114
  }
115
115
  },
116
+ {
117
+ "tag": "kai-compare",
118
+ "displayName": "Compare",
119
+ "hasSolid": false,
120
+ "snippets": {
121
+ "html": "<script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@kitn.ai/ui/dist/kitn-chat.es.js\"></script>\n\n<kai-compare></kai-compare>\n<script type=\"module\">\n const el = document.querySelector('kai-compare');\n el.addEventListener('kai-compare-select', (e) => console.log(e.detail));\n el.addEventListener('kai-error', (e) => console.log(e.detail));\n el.addEventListener('kai-ready', (e) => console.log(e.detail));\n</script>",
122
+ "react": "import { Compare } from '@kitn.ai/ui/react';\n\n<Compare\n onCompareSelect={(e) => console.log(e.detail)}\n onError={(e) => console.log(e.detail)}\n onReady={(e) => console.log(e.detail)}\n/>",
123
+ "vue": "<kai-compare\n @kai-compare-select=\"onCompareSelect\"\n @kai-error=\"onError\"\n @kai-ready=\"onReady\"\n/>",
124
+ "svelte": "<script>\n import '@kitn.ai/ui/elements';\n</script>\n\n<kai-compare\n on:kai-compare-select={onCompareSelect}\n on:kai-error={onError}\n on:kai-ready={onReady}\n></kai-compare>",
125
+ "angular": "<kai-compare\n (kai-compare-select)=\"onCompareSelect($event)\"\n (kai-error)=\"onError($event)\"\n (kai-ready)=\"onReady($event)\"\n></kai-compare>"
126
+ }
127
+ },
116
128
  {
117
129
  "tag": "kai-confirm",
118
130
  "displayName": "Confirm",
@@ -522,6 +534,19 @@
522
534
  "solid": "import { ThinkingBar } from '@kitn.ai/ui';\n\n<ThinkingBar\n onStop={(e) => console.log(e.detail)}\n/>"
523
535
  }
524
536
  },
537
+ {
538
+ "tag": "kai-toast-region",
539
+ "displayName": "ToastRegion",
540
+ "hasSolid": true,
541
+ "snippets": {
542
+ "html": "<script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@kitn.ai/ui/dist/kitn-chat.es.js\"></script>\n\n<kai-toast-region></kai-toast-region>\n<script type=\"module\">\n const el = document.querySelector('kai-toast-region');\n el.toasts = /* … */;\n el.addEventListener('kai-action', (e) => console.log(e.detail));\n el.addEventListener('kai-dismiss', (e) => console.log(e.detail));\n</script>",
543
+ "react": "import { ToastRegion } from '@kitn.ai/ui/react';\n\n<ToastRegion\n toasts={toasts}\n onAction={(e) => console.log(e.detail)}\n onDismiss={(e) => console.log(e.detail)}\n/>",
544
+ "vue": "<kai-toast-region\n :toasts.prop=\"toasts\"\n @kai-action=\"onAction\"\n @kai-dismiss=\"onDismiss\"\n/>",
545
+ "svelte": "<script>\n import '@kitn.ai/ui/elements';\n</script>\n\n<kai-toast-region\n {toasts}\n on:kai-action={onAction}\n on:kai-dismiss={onDismiss}\n></kai-toast-region>",
546
+ "angular": "<kai-toast-region\n [toasts]=\"toasts\"\n (kai-action)=\"onAction($event)\"\n (kai-dismiss)=\"onDismiss($event)\"\n></kai-toast-region>",
547
+ "solid": "import { ToastRegion } from '@kitn.ai/ui';\n\n<ToastRegion\n toasts={toasts}\n onAction={(e) => console.log(e.detail)}\n onDismiss={(e) => console.log(e.detail)}\n/>"
548
+ }
549
+ },
525
550
  {
526
551
  "tag": "kai-tool",
527
552
  "displayName": "Tool",
@@ -1,10 +1,8 @@
1
- import { For, Show, createSignal, onMount, onCleanup } from 'solid-js';
1
+ import { Show, createSignal, onMount, onCleanup } from 'solid-js';
2
2
  import { defineWebComponent } from './define';
3
3
  import { ChatConfig, useChatConfig, type ProseSize } from '../primitives/chat-config';
4
- import { Message, MessageAvatar, MessageContent, MessageActionBar } from '../components/message';
5
- import { Reasoning, ReasoningTrigger, ReasoningContent } from '../components/reasoning';
6
- import { Tool } from '../components/tool';
7
- import { Attachments, Attachment, AttachmentPreview, AttachmentInfo } from '../components/attachments';
4
+ import { Message, MessageAvatar, MessageBody } from '../components/message';
5
+ import { createMessageFeedback } from '../primitives/message-feedback';
8
6
  import type { ChatMessage } from './chat-types';
9
7
 
10
8
  interface Props extends Record<string, unknown> {
@@ -33,8 +31,10 @@ interface Props extends Record<string, unknown> {
33
31
 
34
32
  /** Events fired by `<kai-message>`. */
35
33
  interface Events {
36
- /** An action button was clicked. `action` is the built-in name or custom id. */
37
- 'kai-message-action': { messageId: string; action: string };
34
+ /** An action button was clicked. `action` is the built-in name or custom id.
35
+ * `state` is present only for the toggleable feedback votes: `'on'` when a
36
+ * like/dislike is set, `'off'` when re-tapped to clear. */
37
+ 'kai-message-action': { messageId: string; action: string; state?: 'on' | 'off' };
38
38
  }
39
39
 
40
40
  /**
@@ -58,6 +58,12 @@ defineWebComponent<Props, Events>('kai-message', {
58
58
  const outer = useChatConfig();
59
59
  const msg = (): ChatMessage =>
60
60
  props.message ?? { id: 'message', role: props.role ?? 'assistant', content: props.content ?? '' };
61
+ // Copy + vote state lives here (above the rendered body) so it survives a
62
+ // re-render when the host swaps in a fresh `message` object during streaming.
63
+ const feedback = createMessageFeedback({
64
+ emit: (detail) => dispatch('kai-message-action', detail),
65
+ target: () => element,
66
+ });
61
67
 
62
68
  // Read declarative <kai-action> children from light DOM.
63
69
  // Shadow DOM with no <slot> suppresses them visually — they're invisible data carriers.
@@ -89,45 +95,21 @@ defineWebComponent<Props, Events>('kai-message', {
89
95
  element.hasAttribute('markdown') || props.markdown === true || props.markdown === false;
90
96
  const useMarkdown = () => (markdownExplicit() ? flag('markdown') : !isUser());
91
97
 
98
+ const mergedActions = () => [...(msg().actions ?? []), ...slottedActions()];
92
99
  const body = () => (
93
- <>
94
- <Show when={msg().reasoning}>
95
- <Reasoning class="mb-2 w-full">
96
- <ReasoningTrigger>{msg().reasoning!.label ?? 'Reasoning'}</ReasoningTrigger>
97
- <ReasoningContent markdown>{msg().reasoning!.text}</ReasoningContent>
98
- </Reasoning>
99
- </Show>
100
- <For each={msg().tools ?? []}>
101
- {(tp) => <Tool toolPart={tp} class="mb-2 w-full" />}
102
- </For>
103
- <Show when={msg().attachments?.length}>
104
- <Attachments variant="inline" class={isUser() ? 'mb-2 justify-end' : 'mb-2'}>
105
- <For each={msg().attachments!}>
106
- {(att) => (
107
- <Attachment data={att}>
108
- <AttachmentPreview />
109
- <AttachmentInfo />
110
- </Attachment>
111
- )}
112
- </For>
113
- </Attachments>
114
- </Show>
115
- <MessageContent
116
- markdown={useMarkdown()}
117
- class={isUser()
118
- ? 'bg-muted text-primary max-w-[85%] rounded-2xl px-4 py-2'
119
- : 'bg-transparent p-0'}
120
- >
121
- {msg().content}
122
- </MessageContent>
123
- <Show when={(msg().actions?.length ?? 0) > 0 || slottedActions().length > 0}>
124
- <MessageActionBar
125
- actions={[...(msg().actions ?? []), ...slottedActions()]}
126
- reveal={reveal()}
127
- onAction={(action) => dispatch('kai-message-action', { messageId: msg().id, action })}
128
- />
129
- </Show>
130
- </>
100
+ <MessageBody
101
+ content={msg().content}
102
+ reasoning={msg().reasoning}
103
+ tools={msg().tools}
104
+ attachments={msg().attachments}
105
+ isUser={isUser()}
106
+ markdown={useMarkdown()}
107
+ actions={mergedActions()}
108
+ actionsReveal={reveal()}
109
+ activeFeedback={feedback.resolveFeedback(msg())}
110
+ copied={feedback.isCopied(msg().id)}
111
+ onAction={(action) => feedback.handleAction(msg(), action)}
112
+ />
131
113
  );
132
114
 
133
115
  // Row carries `group` so a hover-revealed action bar fades in on row hover.
@@ -44,6 +44,7 @@ import './artifact';
44
44
  import './scroll-button';
45
45
  import './popover';
46
46
  import './switch';
47
+ import './toast';
47
48
  // Generative-UI cards (Card Contract)
48
49
  import './card';
49
50
  import './form';
@@ -53,3 +54,5 @@ import './confirm-card';
53
54
  import './tasks';
54
55
  import './choice';
55
56
  import './cards';
57
+ // Dual-response comparison (preference capture)
58
+ import './compare';
@@ -20,3 +20,12 @@ if (typeof window !== 'undefined' && typeof customElements !== 'undefined') {
20
20
  export type { ChatMessage, ChatMessageAction } from './chat-types';
21
21
  export { configureCodeHighlighting, isCodeHighlightingEnabled } from '../primitives/highlighter';
22
22
  export type { CodeHighlightingOptions } from '../primitives/highlighter';
23
+
24
+ // Imperative toast API — usable directly from the elements entry so consumers
25
+ // who only import the web-components bundle still get `toast()`. The store is
26
+ // SSR-safe (no DOM touched until the first toast is raised on the client).
27
+ export { toast, configureToasts } from '../primitives/toast-store';
28
+ export type {
29
+ ToastConfig,
30
+ ToastItem, ToastVariant, ToastAction, ToastOptions, ToastHandle,
31
+ } from '../primitives/toast-store';
@@ -81,6 +81,10 @@ function buildWrappedPolicy(element: HTMLElement, userPolicy: CardPolicy | undef
81
81
  emit({ kind: 'dismiss', cardId });
82
82
  userPolicy?.onDismiss?.(cardId);
83
83
  },
84
+ onReopen(cardId) {
85
+ emit({ kind: 'reopen', cardId });
86
+ userPolicy?.onReopen?.(cardId);
87
+ },
84
88
  onError(cardId, message) {
85
89
  emit({ kind: 'error', cardId, message });
86
90
  userPolicy?.onError?.(cardId, message);
@@ -1,6 +1,12 @@
1
1
  /* Element-bundle stylesheet. Compiled to compiled.css and injected into each
2
2
  custom element's shadow root. Mirrors .storybook/styles.css but scans src/. */
3
3
  @import "tailwindcss";
4
+ /* tw-animate-css supplies the animate-in/animate-out + slide/fade/zoom utilities
5
+ the kit uses for every overlay enter/exit (toast, popover, tooltip, dropdown,
6
+ hover-card, the message action-row hide). Without this @import those classes
7
+ compile to nothing and all the animations are silently dead. Compiled into
8
+ compiled.css at build time, so it's a build-time dep only (no consumer import). */
9
+ @import "tw-animate-css";
4
10
  @import "../../theme.css";
5
11
  @plugin "@tailwindcss/typography";
6
12
 
@@ -65,6 +71,19 @@
65
71
  }
66
72
  *::-webkit-scrollbar-thumb:hover { background-color: var(--color-scrollbar-thumb-hover); }
67
73
  *::-webkit-scrollbar-corner { background: transparent; }
74
+
75
+ /* Respect a user's reduced-motion preference: disable the tw-animate-css
76
+ enter/exit keyframe animations (toasts, overlays, cards) so they appear and
77
+ disappear instantly instead of sliding/fading. Targets the utility classes
78
+ tw-animate-css emits (.animate-in / .animate-out) plus any arbitrary
79
+ `animate-[…]` keyframe utility. */
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .animate-in,
82
+ .animate-out,
83
+ [class*="animate-["] {
84
+ animation: none !important;
85
+ }
86
+ }
68
87
  }
69
88
 
70
89
  @layer components {
@@ -0,0 +1,74 @@
1
+ import { defineWebComponent } from './define';
2
+ import { ToastRegion, type ToastDismissReason, type ToastPosition } from '../components/toast';
3
+ import { toast as toastStore, type ToastItem } from '../primitives/toast-store';
4
+
5
+ interface Props extends Record<string, unknown> {
6
+ /** The toasts to render. Newest is shown on top. Set as a JS property (array);
7
+ * pass a new array reference to update. */
8
+ toasts: ToastItem[];
9
+ /** Stack anchor: `'top-center'` (default), `'top-right'`, `'bottom-center'`, … */
10
+ position?: ToastPosition;
11
+ /** Max simultaneously-visible toasts; the rest queue. Defaults to `3`. */
12
+ max?: number;
13
+ /** Stacking: 'expanded' (default, full column) | 'collapsed' (Sonner-style
14
+ * pile that expands on hover/focus). Attribute: stack. */
15
+ stack?: 'expanded' | 'collapsed';
16
+ /** Container element to anchor this region to (JS property). Set by the store
17
+ * for a scoped region; unset = the global viewport region. */
18
+ target?: HTMLElement;
19
+ }
20
+
21
+ interface Events {
22
+ /** A toast left the stack. `reason` is `'timeout' | 'close' | 'action'`. */
23
+ 'kai-dismiss': { id: string; reason: ToastDismissReason };
24
+ /** A toast's action button was pressed. */
25
+ 'kai-action': { id: string; label: string };
26
+ }
27
+
28
+ /**
29
+ * `<kai-toast-region>` — the viewport overlay that renders the toast stack.
30
+ *
31
+ * It is the substrate behind the imperative `toast()` API: the store lazily
32
+ * mounts ONE of these on `document.body` and binds the list to `toasts`. It is
33
+ * also usable declaratively — set `el.toasts = [...]` (a JS property, never an
34
+ * attribute) and listen for `kai-dismiss` / `kai-action`.
35
+ *
36
+ * Because it is a real `kai-*` element it carries its own shadow root + the
37
+ * shared kit stylesheet, so it is viewport-positioned AND kit-styled.
38
+ */
39
+ defineWebComponent<Props, Events>('kai-toast-region', {
40
+ toasts: [],
41
+ position: 'top-center',
42
+ max: 3,
43
+ stack: 'expanded',
44
+ target: undefined,
45
+ }, (props, { dispatch }) => {
46
+ // `max` may arrive as a string attribute (`<kai-toast-region max="2">`).
47
+ const max = () => {
48
+ const raw = props.max as unknown;
49
+ const n = typeof raw === 'string' ? parseInt(raw, 10) : raw;
50
+ return typeof n === 'number' && !Number.isNaN(n) ? n : 3;
51
+ };
52
+
53
+ const remove = (id: string) => {
54
+ // Drop from the shared store (imperative path). For purely-declarative
55
+ // consumers the store simply won't contain the id — the dispatched event
56
+ // is the signal for them to update their own `toasts` array.
57
+ toastStore.dismiss(id);
58
+ };
59
+
60
+ return (
61
+ <ToastRegion
62
+ toasts={props.toasts ?? []}
63
+ position={props.position}
64
+ max={max()}
65
+ stack={props.stack as 'expanded' | 'collapsed' | undefined}
66
+ target={props.target as HTMLElement | undefined}
67
+ onDismiss={(id, reason) => {
68
+ remove(id);
69
+ dispatch('kai-dismiss', { id, reason });
70
+ }}
71
+ onAction={(id, label) => dispatch('kai-action', { id, label })}
72
+ />
73
+ );
74
+ });
package/src/index.ts CHANGED
@@ -18,6 +18,16 @@ export type { CodeHighlightingOptions } from './primitives/highlighter';
18
18
  export { configurePdfPreview, isPdfPreviewEnabled } from './primitives/pdf-preview';
19
19
  export type { PdfPreviewOptions } from './primitives/pdf-preview';
20
20
 
21
+ // Toasts: imperative `toast()` API + the reactive store behind <kai-toast-region>
22
+ export { toast, configureToasts, ensureMounted as ensureToastRegion, getToasts } from './primitives/toast-store';
23
+ export type {
24
+ ToastItem, ToastVariant, ToastAction, ToastOptions, ToastHandle, ToastFn, ToastConfig,
25
+ } from './primitives/toast-store';
26
+ export { Toast, ToastRegion } from './components/toast';
27
+ export type {
28
+ ToastRegionProps, ToastDismissReason, ToastPosition,
29
+ } from './components/toast';
30
+
21
31
  // Card Contract (generative-UI foundation)
22
32
  export { CARD_CONTRACT_VERSION } from './primitives/card-contract';
23
33
  export type {
@@ -27,6 +37,10 @@ export { applyResolution, resolutionFromEvent } from './primitives/card-resoluti
27
37
  export { CardProvider, useCardHost } from './primitives/card-host';
28
38
  export type { CardProviderProps } from './primitives/card-host';
29
39
  export { CARD_EVENT_NAME, emitCardEvent, routeCardEvent, listenForCardEvents } from './primitives/card-routing';
40
+ export { dismissRecovery, defaultIsReopenable } from './primitives/card-recovery';
41
+ export type {
42
+ RecoveryToast, ReopenEnv, DismissRecoveryOptions,
43
+ } from './primitives/card-recovery';
30
44
  export { validateAgainstSchema } from './primitives/card-validate';
31
45
  export type { JsonSchema, ValidationResult } from './primitives/card-validate';
32
46
 
@@ -47,6 +61,8 @@ export type { CardComponent, CardComponentMap, CardTagMap } from './primitives/c
47
61
  // Card: kai-card (base shell) + kai-form (JSON-Schema form renderer)
48
62
  export { Card } from './components/card';
49
63
  export type { CardProps } from './components/card';
64
+ export { DismissedStub, stubIntent } from './components/dismissed-stub';
65
+ export type { DismissedStubProps, DismissedCardType } from './components/dismissed-stub';
50
66
  export { Form, validateForm, buildResult, widgetFor, orderedKeys, coerceValue } from './components/form';
51
67
  export type {
52
68
  FormProps,
@@ -168,6 +184,20 @@ export {
168
184
  } from './components/chat-container';
169
185
  export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction, MessageCopyButton } from './components/message';
170
186
  export type { MessageCopyButtonProps } from './components/message';
187
+ export { ResponseCompare, useResolved } from './components/response-compare';
188
+ export type { ResponseCompareProps, CompareLayout, ResolvedController } from './components/response-compare';
189
+ export {
190
+ normalizeCandidates,
191
+ buildSelection,
192
+ isAnyStreaming,
193
+ } from './components/response-compare';
194
+ export type {
195
+ CompareCandidate,
196
+ ComparePair,
197
+ CompareCollapse,
198
+ ResponseCompareData,
199
+ CompareSelection,
200
+ } from './components/response-compare';
171
201
  export { MessageSkills } from './components/message-skills';
172
202
  export type { Skill as MessageSkill } from './components/message-skills';
173
203
  export {
@@ -38,18 +38,24 @@ export type CardEvent =
38
38
  | { kind: 'resize'; cardId: string; height: number }
39
39
  | { kind: 'state'; cardId: string; patch: unknown }
40
40
  | { kind: 'dismiss'; cardId: string }
41
+ | { kind: 'reopen'; cardId: string }
41
42
  | { kind: 'error'; cardId: string; message: string };
42
43
 
43
44
  export type CardEventKind = CardEvent['kind'];
44
45
 
45
46
  /** How a card was resolved by the user — the re-hydration channel for the chromed
46
- * read-only state. Mirrors the two terminal CardEvents (minus `cardId`): the
47
- * resolution is just the event that resolved the card. `at` is optional ISO-8601
48
- * provenance (data only; never rendered). Additivedoes not bump the contract
49
- * version. */
47
+ * read-only state. The terminal kinds (`action`/`submit`) mirror the two terminal
48
+ * CardEvents (minus `cardId`): the resolution is just the event that resolved the
49
+ * card. `dismissed` is a DEFERRED (non-terminal) statethe user set the card
50
+ * aside; it can be re-opened. `expired` is terminal — a dismissed card the host
51
+ * decided can no longer be re-opened (e.g. the agent proceeded, or it went stale).
52
+ * `at` is optional ISO-8601 provenance (data only; never rendered). Additive —
53
+ * does not bump the contract version. */
50
54
  export type CardResolution =
51
55
  | { kind: 'action'; action: string; payload?: unknown; at?: string }
52
- | { kind: 'submit'; data: unknown; at?: string };
56
+ | { kind: 'submit'; data: unknown; at?: string }
57
+ | { kind: 'dismissed'; at?: string }
58
+ | { kind: 'expired'; reason?: string; at?: string };
53
59
 
54
60
  /** What every card is handed (via native context or the iframe bridge). */
55
61
  export interface CardHost {
@@ -65,6 +71,9 @@ export interface CardPolicy {
65
71
  onOpen?: (url: string, target: 'tab' | 'artifact') => void;
66
72
  onState?: (cardId: string, patch: unknown) => void;
67
73
  onDismiss?: (cardId: string) => void;
74
+ /** A dismissed card asked to be re-opened. The host decides whether it can come
75
+ * back (clear the resolution → live again) or has expired (stamp `expired`). */
76
+ onReopen?: (cardId: string) => void;
68
77
  onError?: (cardId: string, message: string) => void;
69
78
  /** Cap on send-prompt: 'compose' (default) forbids silent sends. 'send' to allow. */
70
79
  maxSendPromptMode?: 'compose' | 'send';
@@ -0,0 +1,149 @@
1
+ // src/primitives/card-recovery.ts
2
+ // Recovery glue for the card dismiss/reopen flow. `dismissRecovery` builds the
3
+ // `{ onDismiss, onReopen }` half of a CardPolicy for a host that keeps its cards in
4
+ // some store: it writes a `dismissed` resolution onto the matching envelope (a NEW
5
+ // array reference, never an in-place mutation, so a Solid/React host re-renders),
6
+ // optionally shows an injected "Dismissed · Undo" toast (Undo restores the prior
7
+ // resolution), and on reopen decides — via `isReopenable` — whether the card comes
8
+ // back live (resolution cleared) or `expired`.
9
+ //
10
+ // The toast is an INJECTED ADAPTER, never an import — cards (and this helper) stay
11
+ // decoupled from the toast module. The re-appear rule is the host's: "the agent
12
+ // already proceeded" is a host fact, surfaced through `isReopenable`.
13
+ import type { CardEnvelope, CardPolicy, CardResolution } from './card-contract';
14
+
15
+ /** The minimal toast surface this helper needs. Inject a real adapter (e.g. one
16
+ * backed by `kai-toast-region`) — this module never imports the toast itself. */
17
+ export interface RecoveryToast {
18
+ show(opts: {
19
+ message: string;
20
+ action?: { label: string; onClick: () => void };
21
+ durationMs?: number;
22
+ }): { dismiss(): void };
23
+ }
24
+
25
+ /** Context handed to a custom `isReopenable` predicate. */
26
+ export interface ReopenEnv {
27
+ cardId: string;
28
+ /** The card's current resolution at reopen time (should be `dismissed`). */
29
+ resolution: CardResolution | undefined;
30
+ /** `Date.now()` at reopen time (injected for testability). */
31
+ now: number;
32
+ }
33
+
34
+ export interface DismissRecoveryOptions {
35
+ /** Read the current cards array. */
36
+ get: () => CardEnvelope[];
37
+ /** Write the next cards array (a NEW reference). */
38
+ set: (next: CardEnvelope[]) => void;
39
+ /** Optional injected toast adapter for the "Dismissed · Undo" affordance. */
40
+ toast?: RecoveryToast;
41
+ /** Override the default re-openable rule. Return false to expire instead. */
42
+ isReopenable?: (env: ReopenEnv) => boolean;
43
+ /** A dismissed card older than this (ms since `dismissed.at`) is no longer
44
+ * re-openable (→ expired). Omit/Infinity = never stale. */
45
+ staleAfterMs?: number;
46
+ /** Auto-dismiss the Undo toast after this long. Default 6000ms. */
47
+ undoMs?: number;
48
+ /** Injected clock (defaults to Date.now); keeps the helper testable. */
49
+ now?: () => number;
50
+ }
51
+
52
+ /** The terminal resolution kinds that block a reopen (a finished card can't come back). */
53
+ const TERMINAL_KINDS = new Set<CardResolution['kind']>(['action', 'submit', 'expired']);
54
+
55
+ /** Default re-openable rule: a card can come back unless it carries a terminal
56
+ * resolution OR it has gone stale (`staleAfterMs` elapsed since `dismissed.at`).
57
+ * A `dismissed` card with no/invalid `at` is always re-openable. */
58
+ export function defaultIsReopenable(env: ReopenEnv, staleAfterMs?: number): boolean {
59
+ const r = env.resolution;
60
+ if (r && TERMINAL_KINDS.has(r.kind)) return false;
61
+ if (
62
+ staleAfterMs !== undefined &&
63
+ Number.isFinite(staleAfterMs) &&
64
+ r?.kind === 'dismissed' &&
65
+ typeof r.at === 'string'
66
+ ) {
67
+ const at = Date.parse(r.at);
68
+ if (!Number.isNaN(at) && env.now - at >= staleAfterMs) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ /** Return a new cards array with `cardId`'s envelope stamped with `resolution`
74
+ * (or cleared when `resolution` is undefined). Same reference if nothing changed. */
75
+ function withResolution(
76
+ cards: CardEnvelope[],
77
+ cardId: string,
78
+ resolution: CardResolution | undefined,
79
+ ): CardEnvelope[] {
80
+ let changed = false;
81
+ const next = cards.map((c) => {
82
+ if (c.id !== cardId) return c;
83
+ changed = true;
84
+ if (resolution === undefined) {
85
+ if (c.resolution === undefined) return c;
86
+ const { resolution: _drop, ...rest } = c;
87
+ return rest;
88
+ }
89
+ return { ...c, resolution };
90
+ });
91
+ return changed ? next : cards;
92
+ }
93
+
94
+ /** The current resolution on `cardId`, if any. */
95
+ function resolutionOf(cards: CardEnvelope[], cardId: string): CardResolution | undefined {
96
+ return cards.find((c) => c.id === cardId)?.resolution;
97
+ }
98
+
99
+ /**
100
+ * Build the `{ onDismiss, onReopen }` policy handlers for the card dismiss/recovery
101
+ * flow over a host store (`get`/`set`).
102
+ *
103
+ * - `onDismiss(cardId)` — stamps `{ kind:'dismissed', at }` immutably and, when a
104
+ * `toast` is injected, shows "Dismissed" with an Undo that restores the card's
105
+ * prior resolution (live again when there was none).
106
+ * - `onReopen(cardId)` — clears the resolution (live) when `isReopenable`, else
107
+ * stamps `{ kind:'expired', at }`.
108
+ */
109
+ export function dismissRecovery(
110
+ opts: DismissRecoveryOptions,
111
+ ): Pick<CardPolicy, 'onDismiss' | 'onReopen'> {
112
+ const now = opts.now ?? (() => Date.now());
113
+ const reopenable = opts.isReopenable ?? ((env: ReopenEnv) => defaultIsReopenable(env, opts.staleAfterMs));
114
+ const undoMs = opts.undoMs ?? 6000;
115
+
116
+ const onDismiss = (cardId: string): void => {
117
+ const before = opts.get();
118
+ const prior = resolutionOf(before, cardId);
119
+ const dismissed: CardResolution = { kind: 'dismissed', at: new Date(now()).toISOString() };
120
+ opts.set(withResolution(before, cardId, dismissed));
121
+
122
+ opts.toast?.show({
123
+ message: 'Dismissed',
124
+ durationMs: undoMs,
125
+ action: {
126
+ label: 'Undo',
127
+ onClick: () => {
128
+ // Restore the prior resolution (undefined → live again). Read fresh in
129
+ // case the store moved on; only touch this card.
130
+ opts.set(withResolution(opts.get(), cardId, prior));
131
+ },
132
+ },
133
+ });
134
+ };
135
+
136
+ const onReopen = (cardId: string): void => {
137
+ const before = opts.get();
138
+ const current = resolutionOf(before, cardId);
139
+ const env: ReopenEnv = { cardId, resolution: current, now: now() };
140
+ if (reopenable(env)) {
141
+ opts.set(withResolution(before, cardId, undefined));
142
+ } else {
143
+ const expired: CardResolution = { kind: 'expired', at: new Date(now()).toISOString() };
144
+ opts.set(withResolution(before, cardId, expired));
145
+ }
146
+ };
147
+
148
+ return { onDismiss, onReopen };
149
+ }