@jini-ai/chat 0.3.7 → 0.3.9

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 (266) hide show
  1. package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
  2. package/dist/react/features/chat-pane/agent-tools.js +3 -3
  3. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +1 -1
  4. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  5. package/dist/react/features/chat-pane/components/ChatPane.js +3 -1
  6. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +15 -1
  7. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -2
  8. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +1 -0
  9. package/dist/react/features/chat-pane/types.d.ts +21 -2
  10. package/dist/react/hooks/composer-draft-cache.d.ts +104 -41
  11. package/dist/react/hooks/composer-draft-cache.js +369 -51
  12. package/dist/react/hooks/useComposer.d.ts +17 -0
  13. package/dist/react/hooks/useComposer.js +93 -12
  14. package/package.json +5 -4
  15. package/dist/core/agentic/chat-capabilities.d.ts.map +0 -1
  16. package/dist/core/agentic/chat-capabilities.js.map +0 -1
  17. package/dist/core/agentic/index.d.ts.map +0 -1
  18. package/dist/core/agentic/index.js.map +0 -1
  19. package/dist/core/events.d.ts.map +0 -1
  20. package/dist/core/events.js.map +0 -1
  21. package/dist/core/index.d.ts.map +0 -1
  22. package/dist/core/index.js.map +0 -1
  23. package/dist/core/messages.d.ts.map +0 -1
  24. package/dist/core/messages.js.map +0 -1
  25. package/dist/core/partial-json.d.ts.map +0 -1
  26. package/dist/core/partial-json.js.map +0 -1
  27. package/dist/core/persistence/index.d.ts.map +0 -1
  28. package/dist/core/persistence/index.js.map +0 -1
  29. package/dist/core/persistence/ports.d.ts.map +0 -1
  30. package/dist/core/persistence/ports.js.map +0 -1
  31. package/dist/core/persistence/title.d.ts.map +0 -1
  32. package/dist/core/persistence/title.js.map +0 -1
  33. package/dist/core/question-form/format.d.ts.map +0 -1
  34. package/dist/core/question-form/format.js.map +0 -1
  35. package/dist/core/question-form/normalize.d.ts.map +0 -1
  36. package/dist/core/question-form/normalize.js.map +0 -1
  37. package/dist/core/question-form/scan.d.ts.map +0 -1
  38. package/dist/core/question-form/scan.js.map +0 -1
  39. package/dist/core/question-form/types.d.ts.map +0 -1
  40. package/dist/core/question-form/types.js.map +0 -1
  41. package/dist/core/question-form.d.ts.map +0 -1
  42. package/dist/core/question-form.js.map +0 -1
  43. package/dist/core/todos.d.ts.map +0 -1
  44. package/dist/core/todos.js.map +0 -1
  45. package/dist/core/tool-events.d.ts.map +0 -1
  46. package/dist/core/tool-events.js.map +0 -1
  47. package/dist/core/tools.d.ts.map +0 -1
  48. package/dist/core/tools.js.map +0 -1
  49. package/dist/core/transcript.d.ts.map +0 -1
  50. package/dist/core/transcript.js.map +0 -1
  51. package/dist/core/transport.d.ts.map +0 -1
  52. package/dist/core/transport.js.map +0 -1
  53. package/dist/core/util/index.d.ts.map +0 -1
  54. package/dist/core/util/index.js.map +0 -1
  55. package/dist/core/util/manifest.d.ts.map +0 -1
  56. package/dist/core/util/manifest.js.map +0 -1
  57. package/dist/core/util/markdown-context.d.ts.map +0 -1
  58. package/dist/core/util/markdown-context.js.map +0 -1
  59. package/dist/core/util/markup-attributes.d.ts.map +0 -1
  60. package/dist/core/util/markup-attributes.js.map +0 -1
  61. package/dist/core/util/parser.d.ts.map +0 -1
  62. package/dist/core/util/parser.js.map +0 -1
  63. package/dist/core/util/pointer.d.ts.map +0 -1
  64. package/dist/core/util/pointer.js.map +0 -1
  65. package/dist/core/util/recover.d.ts.map +0 -1
  66. package/dist/core/util/recover.js.map +0 -1
  67. package/dist/core/util/strip.d.ts.map +0 -1
  68. package/dist/core/util/strip.js.map +0 -1
  69. package/dist/core/util/types.d.ts.map +0 -1
  70. package/dist/core/util/types.js.map +0 -1
  71. package/dist/core/util/validate.d.ts.map +0 -1
  72. package/dist/core/util/validate.js.map +0 -1
  73. package/dist/react/agent-bridge/frontend-session-bridge.d.ts.map +0 -1
  74. package/dist/react/agent-bridge/frontend-session-bridge.js.map +0 -1
  75. package/dist/react/artifact-types.d.ts.map +0 -1
  76. package/dist/react/artifact-types.js.map +0 -1
  77. package/dist/react/chat-pane.d.ts.map +0 -1
  78. package/dist/react/chat-pane.js.map +0 -1
  79. package/dist/react/components/A2uiSurfaceCard.d.ts.map +0 -1
  80. package/dist/react/components/A2uiSurfaceCard.js.map +0 -1
  81. package/dist/react/components/AttachmentPreviewModal.d.ts.map +0 -1
  82. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts.map +0 -1
  83. package/dist/react/components/AttachmentPreviewModal.hooks.js.map +0 -1
  84. package/dist/react/components/AttachmentPreviewModal.js.map +0 -1
  85. package/dist/react/components/AttachmentTray.d.ts.map +0 -1
  86. package/dist/react/components/AttachmentTray.js.map +0 -1
  87. package/dist/react/components/ChatFab.d.ts.map +0 -1
  88. package/dist/react/components/ChatFab.js.map +0 -1
  89. package/dist/react/components/Composer.d.ts.map +0 -1
  90. package/dist/react/components/Composer.js.map +0 -1
  91. package/dist/react/components/ComposerDiscovery.d.ts.map +0 -1
  92. package/dist/react/components/ComposerDiscovery.js.map +0 -1
  93. package/dist/react/components/ConversationList.d.ts.map +0 -1
  94. package/dist/react/components/ConversationList.js.map +0 -1
  95. package/dist/react/components/ExtEventErrorBoundary.d.ts.map +0 -1
  96. package/dist/react/components/ExtEventErrorBoundary.js.map +0 -1
  97. package/dist/react/components/Icon.d.ts.map +0 -1
  98. package/dist/react/components/Icon.js.map +0 -1
  99. package/dist/react/components/JiniChatProvider.d.ts.map +0 -1
  100. package/dist/react/components/JiniChatProvider.js.map +0 -1
  101. package/dist/react/components/Markdown.d.ts.map +0 -1
  102. package/dist/react/components/Markdown.js.map +0 -1
  103. package/dist/react/components/McpUiSurfaceCard.d.ts.map +0 -1
  104. package/dist/react/components/McpUiSurfaceCard.js.map +0 -1
  105. package/dist/react/components/MessageList.d.ts.map +0 -1
  106. package/dist/react/components/MessageList.js.map +0 -1
  107. package/dist/react/components/MessageRow.d.ts.map +0 -1
  108. package/dist/react/components/MessageRow.js.map +0 -1
  109. package/dist/react/components/NextStepActions.d.ts.map +0 -1
  110. package/dist/react/components/NextStepActions.js.map +0 -1
  111. package/dist/react/components/QuestionForm.d.ts.map +0 -1
  112. package/dist/react/components/QuestionForm.js.map +0 -1
  113. package/dist/react/components/QuestionsPanel.d.ts.map +0 -1
  114. package/dist/react/components/QuestionsPanel.js.map +0 -1
  115. package/dist/react/components/TodoCard.d.ts.map +0 -1
  116. package/dist/react/components/TodoCard.js.map +0 -1
  117. package/dist/react/components/ToolCard.d.ts.map +0 -1
  118. package/dist/react/components/ToolCard.js.map +0 -1
  119. package/dist/react/components/composer-discovery.d.ts.map +0 -1
  120. package/dist/react/components/composer-discovery.js.map +0 -1
  121. package/dist/react/ext-event-renderer-registry.d.ts.map +0 -1
  122. package/dist/react/ext-event-renderer-registry.js.map +0 -1
  123. package/dist/react/features/chat-pane/agent-tools.d.ts.map +0 -1
  124. package/dist/react/features/chat-pane/agent-tools.js.map +0 -1
  125. package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts.map +0 -1
  126. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js.map +0 -1
  127. package/dist/react/features/chat-pane/components/ChatPane.d.ts.map +0 -1
  128. package/dist/react/features/chat-pane/components/ChatPane.js.map +0 -1
  129. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.d.ts.map +0 -1
  130. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js.map +0 -1
  131. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts.map +0 -1
  132. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js.map +0 -1
  133. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts.map +0 -1
  134. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js.map +0 -1
  135. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts.map +0 -1
  136. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js.map +0 -1
  137. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.d.ts.map +0 -1
  138. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js.map +0 -1
  139. package/dist/react/features/chat-pane/hooks/useChatPaneComposerPlaceholder.hooks.d.ts.map +0 -1
  140. package/dist/react/features/chat-pane/hooks/useChatPaneComposerPlaceholder.hooks.js.map +0 -1
  141. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.d.ts.map +0 -1
  142. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js.map +0 -1
  143. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.d.ts.map +0 -1
  144. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js.map +0 -1
  145. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts.map +0 -1
  146. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js.map +0 -1
  147. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.d.ts.map +0 -1
  148. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js.map +0 -1
  149. package/dist/react/features/chat-pane/index.d.ts.map +0 -1
  150. package/dist/react/features/chat-pane/index.js.map +0 -1
  151. package/dist/react/features/chat-pane/react/components/AgentRuntimePicker.d.ts +0 -12
  152. package/dist/react/features/chat-pane/react/components/AgentRuntimePicker.d.ts.map +0 -1
  153. package/dist/react/features/chat-pane/react/components/AgentRuntimePicker.js +0 -102
  154. package/dist/react/features/chat-pane/react/components/AgentRuntimePicker.js.map +0 -1
  155. package/dist/react/features/chat-pane/react/components/ChatPane.d.ts +0 -10
  156. package/dist/react/features/chat-pane/react/components/ChatPane.d.ts.map +0 -1
  157. package/dist/react/features/chat-pane/react/components/ChatPane.js +0 -137
  158. package/dist/react/features/chat-pane/react/components/ChatPane.js.map +0 -1
  159. package/dist/react/features/chat-pane/react/hooks/useAgentRuntimePicker.hooks.d.ts +0 -133
  160. package/dist/react/features/chat-pane/react/hooks/useAgentRuntimePicker.hooks.d.ts.map +0 -1
  161. package/dist/react/features/chat-pane/react/hooks/useAgentRuntimePicker.hooks.js +0 -258
  162. package/dist/react/features/chat-pane/react/hooks/useAgentRuntimePicker.hooks.js.map +0 -1
  163. package/dist/react/features/chat-pane/react/hooks/useChatPane.hooks.d.ts +0 -66
  164. package/dist/react/features/chat-pane/react/hooks/useChatPane.hooks.d.ts.map +0 -1
  165. package/dist/react/features/chat-pane/react/hooks/useChatPane.hooks.js +0 -223
  166. package/dist/react/features/chat-pane/react/hooks/useChatPane.hooks.js.map +0 -1
  167. package/dist/react/features/chat-pane/react/hooks/useChatPaneAgentControl.hooks.d.ts +0 -38
  168. package/dist/react/features/chat-pane/react/hooks/useChatPaneAgentControl.hooks.d.ts.map +0 -1
  169. package/dist/react/features/chat-pane/react/hooks/useChatPaneAgentControl.hooks.js +0 -252
  170. package/dist/react/features/chat-pane/react/hooks/useChatPaneAgentControl.hooks.js.map +0 -1
  171. package/dist/react/features/chat-pane/react/hooks/useChatPaneFileDrop.hooks.d.ts +0 -26
  172. package/dist/react/features/chat-pane/react/hooks/useChatPaneFileDrop.hooks.d.ts.map +0 -1
  173. package/dist/react/features/chat-pane/react/hooks/useChatPaneFileDrop.hooks.js +0 -60
  174. package/dist/react/features/chat-pane/react/hooks/useChatPaneFileDrop.hooks.js.map +0 -1
  175. package/dist/react/features/chat-pane/react/hooks/useChatPaneRuntimeInventory.hooks.d.ts +0 -25
  176. package/dist/react/features/chat-pane/react/hooks/useChatPaneRuntimeInventory.hooks.d.ts.map +0 -1
  177. package/dist/react/features/chat-pane/react/hooks/useChatPaneRuntimeInventory.hooks.js +0 -83
  178. package/dist/react/features/chat-pane/react/hooks/useChatPaneRuntimeInventory.hooks.js.map +0 -1
  179. package/dist/react/features/chat-pane/react/hooks/useChatPaneWorkingDirectory.hooks.d.ts +0 -38
  180. package/dist/react/features/chat-pane/react/hooks/useChatPaneWorkingDirectory.hooks.d.ts.map +0 -1
  181. package/dist/react/features/chat-pane/react/hooks/useChatPaneWorkingDirectory.hooks.js +0 -184
  182. package/dist/react/features/chat-pane/react/hooks/useChatPaneWorkingDirectory.hooks.js.map +0 -1
  183. package/dist/react/features/chat-pane/react/styles.d.ts +0 -2
  184. package/dist/react/features/chat-pane/react/styles.d.ts.map +0 -1
  185. package/dist/react/features/chat-pane/react/styles.js +0 -863
  186. package/dist/react/features/chat-pane/react/styles.js.map +0 -1
  187. package/dist/react/features/chat-pane/rules.d.ts.map +0 -1
  188. package/dist/react/features/chat-pane/rules.js.map +0 -1
  189. package/dist/react/features/chat-pane/styles.d.ts.map +0 -1
  190. package/dist/react/features/chat-pane/styles.js.map +0 -1
  191. package/dist/react/features/chat-pane/types.d.ts.map +0 -1
  192. package/dist/react/features/chat-pane/types.js.map +0 -1
  193. package/dist/react/features/model-picker/components/CredentialStatusBadge.d.ts.map +0 -1
  194. package/dist/react/features/model-picker/components/CredentialStatusBadge.js.map +0 -1
  195. package/dist/react/features/model-picker/components/ModelPicker.d.ts.map +0 -1
  196. package/dist/react/features/model-picker/components/ModelPicker.js.map +0 -1
  197. package/dist/react/features/model-picker/constants.d.ts.map +0 -1
  198. package/dist/react/features/model-picker/constants.js.map +0 -1
  199. package/dist/react/features/model-picker/dependencies.d.ts.map +0 -1
  200. package/dist/react/features/model-picker/dependencies.js.map +0 -1
  201. package/dist/react/features/model-picker/hooks/useModelPicker.hooks.d.ts.map +0 -1
  202. package/dist/react/features/model-picker/hooks/useModelPicker.hooks.js.map +0 -1
  203. package/dist/react/features/model-picker/index.d.ts.map +0 -1
  204. package/dist/react/features/model-picker/index.js.map +0 -1
  205. package/dist/react/features/model-picker/ports.d.ts.map +0 -1
  206. package/dist/react/features/model-picker/ports.js.map +0 -1
  207. package/dist/react/features/model-picker/react/components/CredentialStatusBadge.d.ts +0 -7
  208. package/dist/react/features/model-picker/react/components/CredentialStatusBadge.d.ts.map +0 -1
  209. package/dist/react/features/model-picker/react/components/CredentialStatusBadge.js +0 -20
  210. package/dist/react/features/model-picker/react/components/CredentialStatusBadge.js.map +0 -1
  211. package/dist/react/features/model-picker/react/components/ModelPicker.d.ts +0 -9
  212. package/dist/react/features/model-picker/react/components/ModelPicker.d.ts.map +0 -1
  213. package/dist/react/features/model-picker/react/components/ModelPicker.js +0 -34
  214. package/dist/react/features/model-picker/react/components/ModelPicker.js.map +0 -1
  215. package/dist/react/features/model-picker/react/hooks/useModelPicker.hooks.d.ts +0 -27
  216. package/dist/react/features/model-picker/react/hooks/useModelPicker.hooks.d.ts.map +0 -1
  217. package/dist/react/features/model-picker/react/hooks/useModelPicker.hooks.js +0 -62
  218. package/dist/react/features/model-picker/react/hooks/useModelPicker.hooks.js.map +0 -1
  219. package/dist/react/features/model-picker/rules.d.ts.map +0 -1
  220. package/dist/react/features/model-picker/rules.js.map +0 -1
  221. package/dist/react/features/model-picker/types.d.ts.map +0 -1
  222. package/dist/react/features/model-picker/types.js.map +0 -1
  223. package/dist/react/hooks/attachment-preview-cache.d.ts.map +0 -1
  224. package/dist/react/hooks/attachment-preview-cache.js.map +0 -1
  225. package/dist/react/hooks/composer-draft-cache.d.ts.map +0 -1
  226. package/dist/react/hooks/composer-draft-cache.js.map +0 -1
  227. package/dist/react/hooks/context.d.ts.map +0 -1
  228. package/dist/react/hooks/context.js.map +0 -1
  229. package/dist/react/hooks/testing/fake-transport.d.ts.map +0 -1
  230. package/dist/react/hooks/testing/fake-transport.js.map +0 -1
  231. package/dist/react/hooks/useArtifactStream.d.ts.map +0 -1
  232. package/dist/react/hooks/useArtifactStream.js.map +0 -1
  233. package/dist/react/hooks/useChatFabDrag.d.ts.map +0 -1
  234. package/dist/react/hooks/useChatFabDrag.js.map +0 -1
  235. package/dist/react/hooks/useComposer.d.ts.map +0 -1
  236. package/dist/react/hooks/useComposer.js.map +0 -1
  237. package/dist/react/hooks/useConversation.d.ts.map +0 -1
  238. package/dist/react/hooks/useConversation.js.map +0 -1
  239. package/dist/react/hooks/useCopyToClipboard.d.ts.map +0 -1
  240. package/dist/react/hooks/useCopyToClipboard.js.map +0 -1
  241. package/dist/react/hooks/useExtEventGroups.d.ts.map +0 -1
  242. package/dist/react/hooks/useExtEventGroups.js.map +0 -1
  243. package/dist/react/hooks/useLatestOperation.d.ts.map +0 -1
  244. package/dist/react/hooks/useLatestOperation.js.map +0 -1
  245. package/dist/react/hooks/usePinnedTodos.d.ts.map +0 -1
  246. package/dist/react/hooks/usePinnedTodos.js.map +0 -1
  247. package/dist/react/hooks/useQuestionForms.d.ts.map +0 -1
  248. package/dist/react/hooks/useQuestionForms.js.map +0 -1
  249. package/dist/react/hooks/useRunStream.d.ts.map +0 -1
  250. package/dist/react/hooks/useRunStream.js.map +0 -1
  251. package/dist/react/hooks/useToolTimeline.d.ts.map +0 -1
  252. package/dist/react/hooks/useToolTimeline.js.map +0 -1
  253. package/dist/react/index.d.ts.map +0 -1
  254. package/dist/react/index.js.map +0 -1
  255. package/dist/react/message-blocks.d.ts.map +0 -1
  256. package/dist/react/message-blocks.js.map +0 -1
  257. package/dist/react/poc-mcp-ui-official/PocMcpUiCard.d.ts.map +0 -1
  258. package/dist/react/poc-mcp-ui-official/PocMcpUiCard.js.map +0 -1
  259. package/dist/react/poc-mcp-ui-official/create-poc-resource.d.ts.map +0 -1
  260. package/dist/react/poc-mcp-ui-official/create-poc-resource.js.map +0 -1
  261. package/dist/react/slots.d.ts.map +0 -1
  262. package/dist/react/slots.js.map +0 -1
  263. package/dist/react/tool-renderer-registry.d.ts.map +0 -1
  264. package/dist/react/tool-renderer-registry.js.map +0 -1
  265. package/dist/react/util/defined-props.d.ts.map +0 -1
  266. package/dist/react/util/defined-props.js.map +0 -1
@@ -1,89 +1,407 @@
1
1
  /**
2
2
  * @module composer-draft-cache
3
3
  *
4
- * Per-conversation composer draft cache, keyed by conversation id. Exists so switching away from a
5
- * conversation and back round-trips whatever the operator was typing — even when the host remounts
6
- * `ChatPane` on switch (a React `key` keyed to the selected conversation, so a user-initiated switch
7
- * re-seeds the transcript from `initialMessages` — a documented pattern for this component: the pane
8
- * owns its own transcript and takes no `conversationId`-change effect of its own) — with zero host
9
- * wiring required. `useComposer.ts` reads/writes this on every
10
- * `conversationId` it is given; a host that also wants the draft to survive a page *reload* layers
11
- * its own `persistence` (`ComposerDraftPersistence`) on top, unkeyed, exactly as documented there.
12
- *
13
- * This is intentionally NOT the same seam as `ComposerDraftPersistence`: that one is a single opaque
14
- * host-owned slot (e.g. localStorage) that `useComposer` must never touch directly (see that
15
- * module's doc). This cache lives in module scope instead, purely in memory, so it survives a
16
- * `ChatPane` remount within the same page session — it does not, and is not meant to, survive a
17
- * reload.
4
+ * Per-conversation composer draft cache, keyed by conversation id. Exists so whatever the operator
5
+ * was typing comes back — across a `ChatPane` remount (a host re-keying the pane on a conversation
6
+ * switch, so a user-initiated switch re-seeds the transcript from `initialMessages` — a documented
7
+ * pattern for this component: the pane owns its own transcript and takes no `conversationId`-change
8
+ * effect of its own), across a navigation away and back, and across a full page reload or a server
9
+ * restart that takes the page down with it. `useComposer.ts` reads/writes this on every
10
+ * `conversationId` it is given, with zero host wiring required.
11
+ *
12
+ * Two tiers, same key:
13
+ * - a module-level `Map`, which answers instantly and is the only tier that works when storage is
14
+ * unavailable;
15
+ * - `localStorage` under {@link COMPOSER_DRAFT_STORAGE_PREFIX}, which is what actually survives a
16
+ * reload. Every access is wrapped: a private window, blocked site data, a quota rejection, or a
17
+ * non-browser runtime degrades this module to memory-only. It never throws at a caller and it
18
+ * never returns a wiped draft in place of a live one — a failed read falls back to the in-memory
19
+ * tier rather than reporting "no draft".
20
+ *
21
+ * Durability is deliberately NOT gated behind `ComposerDraftPersistence` (`useComposer`'s
22
+ * host-supplied port). That port is a single opaque host-owned slot with no conversation key, and
23
+ * grep across every known host finds nothing passing it — an opt-in seam nobody opted into would
24
+ * not have fixed the reported bug (an operator lost a half-written message to a site-server
25
+ * restart, 2026-09-18). The port still works and still takes precedence at mount for a host that
26
+ * wires one.
27
+ *
28
+ * Scope limits worth knowing: `localStorage` is per-browser and per-origin, so a draft never
29
+ * reaches the server, never follows the operator to another machine, and is visible to anything
30
+ * else running on that origin. Two tabs open on the same conversation last-write-wins. None of
31
+ * that is a regression — before this, the draft simply did not survive at all.
18
32
  *
19
33
  * @tradeoffs A module-level singleton (rather than a per-instance store a host constructs and
20
- * passes down) was chosen so the fix works for every host with no new prop plumbing — known hosts of
21
- * this package do not wire a `persistence` port today, so a purely opt-in fix would not have fixed
22
- * the reported bug. The risk a singleton usually carries — two unrelated call sites sharing
23
- * state that should be isolated (see `create-daemon-attachment-uploader.ts`'s per-uploader
24
- * `batchUsage` map, which exists for exactly that reason) — does not apply the same way here: the
25
- * partition key (`conversationId`) is already globally unique per backend, so two `ChatPane`
26
- * instances showing two different conversations can never collide on it. Tests must call
27
- * {@link __resetComposerDraftCacheForTests} between cases to avoid cross-test leakage; that is the
28
- * real cost of this choice.
29
- *
30
- * Bounded to `MAX_CACHED_CONVERSATION_DRAFTS` distinct conversations so a long session visiting many
31
- * conversations cannot grow this map without limit — the oldest-inserted entry is evicted once the
32
- * cap is exceeded. A cleared/empty draft is deleted outright rather than kept as `''`, so the common
33
- * case (drafted, then sent) does not linger in the cache at all.
34
- */
35
- /** Cap on distinct conversations tracked at once; see the module doc's `@tradeoffs`. */
34
+ * passes down) was chosen so the fix works for every host with no new prop plumbing. The risk a
35
+ * singleton usually carries — two unrelated call sites sharing state that should be isolated (see
36
+ * `create-daemon-attachment-uploader.ts`'s per-uploader `batchUsage` map, which exists for exactly
37
+ * that reason) — does not apply the same way here: the partition key (`conversationId`) is already
38
+ * globally unique per backend, so two `ChatPane` instances showing two different conversations can
39
+ * never collide on it. Tests must call {@link __resetComposerDraftCacheForTests} between cases to
40
+ * avoid cross-test leakage; that is the real cost of this choice.
41
+ *
42
+ * Both tiers are bounded to `MAX_CACHED_CONVERSATION_DRAFTS` distinct conversations so a long
43
+ * session visiting many conversations cannot grow without limit — the oldest entry is evicted once
44
+ * the cap is exceeded (insertion order in memory, stored timestamp in storage, since storage
45
+ * outlives insertion order). A cleared/empty draft is deleted from both tiers outright rather than
46
+ * kept as `''`, so the common case (drafted, then sent) leaves nothing behind to resurrect on the
47
+ * next load.
48
+ */
49
+ /** Cap on distinct conversations tracked at once, per tier; see the module doc's `@tradeoffs`. */
36
50
  export const MAX_CACHED_CONVERSATION_DRAFTS = 50;
51
+ /**
52
+ * Key prefix for the `localStorage` tier. Versioned (`.v1.`) so a future change to the stored shape
53
+ * can be introduced without having to interpret, or mis-interpret, entries written by this one.
54
+ */
55
+ export const COMPOSER_DRAFT_STORAGE_PREFIX = 'jini.chat.composer-draft.v1.';
56
+ /**
57
+ * Key prefix for a conversation's staged attachment REFERENCES. Deliberately a separate entry from
58
+ * the draft text rather than one combined record: text and attachments must degrade independently,
59
+ * so a corrupt or unwritable attachment entry can never cost the operator their words, and vice
60
+ * versa. See {@link readCachedAttachments} for what these references are and are not.
61
+ */
62
+ export const COMPOSER_ATTACHMENTS_STORAGE_PREFIX = 'jini.chat.composer-attachments.v1.';
37
63
  const drafts = new Map();
64
+ const stagedAttachments = new Map();
38
65
  /**
39
- * Reads the cached draft for `conversationId`.
66
+ * The origin's `localStorage`, or `null` when it cannot be used.
40
67
  *
41
- * @returns The cached draft, or `null` when there is none — including when `conversationId` itself
42
- * is absent (an untitled/new conversation has nothing to key on yet).
68
+ * @returns `null` in a non-browser runtime (SSR, a node-environment test) and in a browser that
69
+ * throws on the property access itself — Safari and Chrome both do that with site data blocked,
70
+ * which is why this is a try/catch rather than a `typeof` check alone.
43
71
  * @complexity Time/space: O(1).
44
72
  */
73
+ function storage() {
74
+ try {
75
+ return typeof localStorage === 'undefined' ? null : localStorage;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /**
82
+ * Reads and validates the stored draft for `conversationId`.
83
+ *
84
+ * Anything that does not parse as this module's own envelope is treated as foreign or corrupt and
85
+ * deleted rather than returned — a stale format, another tool writing under a colliding key, or a
86
+ * truncated write must not surface as composer text.
87
+ *
88
+ * @returns The stored draft, or `null` when there is none, storage is unavailable, or the entry
89
+ * failed validation.
90
+ * @complexity Time/space: O(n) in the stored draft's length (the JSON parse).
91
+ */
92
+ function readStoredDraft(conversationId) {
93
+ const store = storage();
94
+ if (!store)
95
+ return null;
96
+ const key = `${COMPOSER_DRAFT_STORAGE_PREFIX}${conversationId}`;
97
+ try {
98
+ const raw = store.getItem(key);
99
+ if (raw === null)
100
+ return null;
101
+ const parsed = JSON.parse(raw);
102
+ const draft = parsed?.d;
103
+ if (typeof draft !== 'string' || draft === '') {
104
+ store.removeItem(key);
105
+ return null;
106
+ }
107
+ return draft;
108
+ }
109
+ catch {
110
+ // A parse failure leaves the bad entry behind on purpose when `removeItem` is what threw —
111
+ // there is nothing further this module can do, and memory-only is still a working composer.
112
+ try {
113
+ store.removeItem(key);
114
+ }
115
+ catch {
116
+ /* storage is unusable; the in-memory tier still serves this conversation */
117
+ }
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Deletes every stored draft past the cap, oldest write first, so an operator who abandons drafts
123
+ * in many conversations cannot fill the origin's quota and silently break persistence for the
124
+ * conversation they actually care about.
125
+ *
126
+ * @param keep How many entries may remain. Entries whose envelope does not parse are treated as
127
+ * oldest (timestamp `0`) so corrupt rows are the first to go.
128
+ * @complexity Time O(n log n) in the number of this module's stored keys (a sort), bounded in
129
+ * practice by `MAX_CACHED_CONVERSATION_DRAFTS` plus whatever a previous session left behind. Space
130
+ * O(n) in the same.
131
+ */
132
+ function pruneStored(prefix, keep) {
133
+ const store = storage();
134
+ if (!store)
135
+ return;
136
+ try {
137
+ const entries = [];
138
+ for (let i = 0; i < store.length; i += 1) {
139
+ const key = store.key(i);
140
+ if (key === null || !key.startsWith(prefix))
141
+ continue;
142
+ let at = 0;
143
+ try {
144
+ const parsed = JSON.parse(store.getItem(key) ?? '');
145
+ at = typeof parsed?.t === 'number' ? parsed.t : 0;
146
+ }
147
+ catch {
148
+ at = 0;
149
+ }
150
+ entries.push({ key, at });
151
+ }
152
+ if (entries.length <= keep)
153
+ return;
154
+ entries.sort((a, b) => a.at - b.at);
155
+ for (const entry of entries.slice(0, entries.length - keep))
156
+ store.removeItem(entry.key);
157
+ }
158
+ catch {
159
+ /* storage is unusable; nothing to prune and nothing to report */
160
+ }
161
+ }
162
+ /**
163
+ * Writes or removes the stored copy for `conversationId`.
164
+ *
165
+ * @param draft A blank draft removes the entry. A quota rejection is swallowed: the in-memory tier
166
+ * has already accepted the draft, so the composer keeps working and only reload-survival is lost.
167
+ * @complexity Time O(n) in the draft's length, plus a prune (see {@link pruneStored}) only
168
+ * when this introduces a new key at the cap. Space O(n) in the draft's length.
169
+ */
170
+ function writeStoredDraft(conversationId, draft) {
171
+ const store = storage();
172
+ if (!store)
173
+ return;
174
+ const key = `${COMPOSER_DRAFT_STORAGE_PREFIX}${conversationId}`;
175
+ try {
176
+ if (draft === '') {
177
+ store.removeItem(key);
178
+ return;
179
+ }
180
+ if (store.getItem(key) === null)
181
+ pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
182
+ const envelope = { v: 1, t: Date.now(), d: draft };
183
+ store.setItem(key, JSON.stringify(envelope));
184
+ }
185
+ catch {
186
+ /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
187
+ }
188
+ }
189
+ /**
190
+ * Records `draft` in the in-memory tier, evicting the oldest-inserted conversation first if this
191
+ * would introduce a new entry past {@link MAX_CACHED_CONVERSATION_DRAFTS}.
192
+ * @complexity Time/space: O(1) amortized.
193
+ */
194
+ function rememberInMemory(conversationId, draft) {
195
+ if (!drafts.has(conversationId) && drafts.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
196
+ const oldest = drafts.keys().next().value;
197
+ if (oldest !== undefined)
198
+ drafts.delete(oldest);
199
+ }
200
+ drafts.set(conversationId, draft);
201
+ }
202
+ /**
203
+ * Reads the cached draft for `conversationId`, in memory first and storage second.
204
+ *
205
+ * A storage hit is promoted into memory so the reload-survival path costs one read, not one per
206
+ * keystroke-triggered re-render.
207
+ *
208
+ * @returns The cached draft, or `null` when there is none — including when `conversationId` itself
209
+ * is absent (an untitled/new conversation has nothing to key on yet). Never returns `''`; callers
210
+ * treat `null` as "nothing to restore".
211
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored draft's length otherwise.
212
+ */
45
213
  export function readCachedDraft(conversationId) {
46
214
  if (!conversationId)
47
215
  return null;
48
- return drafts.get(conversationId) ?? null;
216
+ const remembered = drafts.get(conversationId);
217
+ if (remembered !== undefined)
218
+ return remembered;
219
+ const stored = readStoredDraft(conversationId);
220
+ if (stored === null)
221
+ return null;
222
+ rememberInMemory(conversationId, stored);
223
+ return stored;
49
224
  }
50
225
  /**
51
- * Stores `draft` for `conversationId`, evicting the oldest tracked conversation first if this would
52
- * introduce a new entry past {@link MAX_CACHED_CONVERSATION_DRAFTS}.
226
+ * Stores `draft` for `conversationId` in both tiers.
53
227
  *
54
228
  * @param conversationId No-ops when absent (`null`/`undefined`) — there is nothing to key on yet.
55
- * @param draft A blank (or whitespace-only) draft deletes the entry instead of storing `''`; see the
56
- * module doc's cleanup note.
57
- * @complexity Time/space: O(1) amortized.
229
+ * `useComposer` covers that window by adopting the typed text once an id arrives; see its own doc.
230
+ * @param draft A blank (or whitespace-only) draft deletes the entry from both tiers instead of
231
+ * storing `''`. This is what makes a successful send clear the draft for free: `useComposer.reset`
232
+ * writes `''` here.
233
+ * @complexity Time/space: O(n) in the draft's length; see {@link writeStoredDraft}.
58
234
  */
59
235
  export function writeCachedDraft(conversationId, draft) {
60
236
  if (!conversationId)
61
237
  return;
62
238
  if (draft.trim() === '') {
63
239
  drafts.delete(conversationId);
240
+ writeStoredDraft(conversationId, '');
64
241
  return;
65
242
  }
66
- if (!drafts.has(conversationId) && drafts.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
67
- const oldest = drafts.keys().next().value;
68
- if (oldest !== undefined)
69
- drafts.delete(oldest);
70
- }
71
- drafts.set(conversationId, draft);
243
+ rememberInMemory(conversationId, draft);
244
+ writeStoredDraft(conversationId, draft);
72
245
  }
73
246
  /**
74
- * Explicitly evicts `conversationId`'s cached draft, for a host that wants to free it immediately
75
- * (e.g. on conversation delete) rather than waiting for the cap-triggered eviction above. Not
76
- * currently called by any host — a host's own conversation-delete flow is a candidate follow-up
77
- * caller, out of scope here (that flow lives outside this package).
247
+ * Explicitly evicts `conversationId`'s draft from both tiers, for a host that wants to free it
248
+ * immediately (e.g. on conversation delete) rather than waiting for the cap-triggered eviction.
249
+ * Not currently called by any host — a host's own conversation-delete flow is a candidate
250
+ * follow-up caller, out of scope here (that flow lives outside this package).
78
251
  * @complexity Time/space: O(1).
79
252
  */
80
253
  export function clearCachedDraft(conversationId) {
81
254
  if (!conversationId)
82
255
  return;
83
256
  drafts.delete(conversationId);
257
+ writeStoredDraft(conversationId, '');
258
+ writeCachedAttachments(conversationId, []);
84
259
  }
85
- /** Test-only: empties the module-level cache so test cases cannot leak drafts into each other. */
86
- export function __resetComposerDraftCacheForTests() {
260
+ /**
261
+ * Narrows one parsed array element to a `ChatAttachment`.
262
+ *
263
+ * Validated field by field rather than trusted, for the same reason the draft envelope is: this
264
+ * data came back from storage, where anything on the origin could have written it. `size`/`order`
265
+ * are optional in the type, so they are checked only when present.
266
+ * @complexity Time/space: O(1).
267
+ */
268
+ function isChatAttachment(candidate) {
269
+ if (typeof candidate !== 'object' || candidate === null)
270
+ return false;
271
+ const { path, name, kind, size, order } = candidate;
272
+ if (typeof path !== 'string' || path === '')
273
+ return false;
274
+ if (typeof name !== 'string')
275
+ return false;
276
+ if (kind !== 'image' && kind !== 'file')
277
+ return false;
278
+ if (size !== undefined && typeof size !== 'number')
279
+ return false;
280
+ if (order !== undefined && typeof order !== 'number')
281
+ return false;
282
+ return true;
283
+ }
284
+ /**
285
+ * Reads the stored attachment references for `conversationId`.
286
+ *
287
+ * Elements that do not validate are dropped individually rather than failing the whole entry — one
288
+ * malformed row should cost the operator that row, not every attachment on the turn.
289
+ *
290
+ * @returns The stored references, or `null` when there are none, storage is unavailable, or nothing
291
+ * in the entry validated.
292
+ * @complexity Time/space: O(n) in the number of stored references.
293
+ */
294
+ function readStoredAttachments(conversationId) {
295
+ const store = storage();
296
+ if (!store)
297
+ return null;
298
+ const key = `${COMPOSER_ATTACHMENTS_STORAGE_PREFIX}${conversationId}`;
299
+ try {
300
+ const raw = store.getItem(key);
301
+ if (raw === null)
302
+ return null;
303
+ const parsed = JSON.parse(raw);
304
+ const list = parsed?.a;
305
+ if (!Array.isArray(list)) {
306
+ store.removeItem(key);
307
+ return null;
308
+ }
309
+ const valid = list.filter(isChatAttachment);
310
+ if (valid.length === 0) {
311
+ store.removeItem(key);
312
+ return null;
313
+ }
314
+ return valid;
315
+ }
316
+ catch {
317
+ try {
318
+ store.removeItem(key);
319
+ }
320
+ catch {
321
+ /* storage is unusable; the in-memory tier still serves this conversation */
322
+ }
323
+ return null;
324
+ }
325
+ }
326
+ /**
327
+ * Reads the cached attachment references for `conversationId`, in memory first, storage second.
328
+ *
329
+ * **These are references, never bytes.** By the time an attachment reaches the composer it has
330
+ * already been uploaded (`ChatPane` blocks sending while uploads are in flight), so what is cached
331
+ * here is the server-side record — a path, a name, a kind. The file itself lives wherever the host
332
+ * put it and is subject to that host's retention, which is why a caller must not treat a returned
333
+ * reference as proof the file still exists. `useComposer` restores these only when the host supplies
334
+ * a validator; see its `validateAttachments` option.
335
+ *
336
+ * @returns The cached references, or `null` when there are none.
337
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
338
+ */
339
+ export function readCachedAttachments(conversationId) {
340
+ if (!conversationId)
341
+ return null;
342
+ const remembered = stagedAttachments.get(conversationId);
343
+ if (remembered !== undefined)
344
+ return remembered;
345
+ const stored = readStoredAttachments(conversationId);
346
+ if (stored === null)
347
+ return null;
348
+ stagedAttachments.set(conversationId, stored);
349
+ return stored;
350
+ }
351
+ /**
352
+ * Stores `attachments` for `conversationId` in both tiers.
353
+ *
354
+ * @param attachments An empty list deletes the entry, so a sent or cleared turn leaves nothing to
355
+ * restore — the same rule the draft text follows.
356
+ * @complexity Time/space: O(n) in the number of references.
357
+ */
358
+ export function writeCachedAttachments(conversationId, attachments) {
359
+ if (!conversationId)
360
+ return;
361
+ const key = `${COMPOSER_ATTACHMENTS_STORAGE_PREFIX}${conversationId}`;
362
+ const store = storage();
363
+ if (attachments.length === 0) {
364
+ stagedAttachments.delete(conversationId);
365
+ try {
366
+ store?.removeItem(key);
367
+ }
368
+ catch {
369
+ /* blocked storage; the in-memory tier is already correct */
370
+ }
371
+ return;
372
+ }
373
+ if (!stagedAttachments.has(conversationId) && stagedAttachments.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
374
+ const oldest = stagedAttachments.keys().next().value;
375
+ if (oldest !== undefined)
376
+ stagedAttachments.delete(oldest);
377
+ }
378
+ stagedAttachments.set(conversationId, attachments);
379
+ if (!store)
380
+ return;
381
+ try {
382
+ if (store.getItem(key) === null)
383
+ pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
384
+ const envelope = { v: 1, t: Date.now(), a: attachments };
385
+ store.setItem(key, JSON.stringify(envelope));
386
+ }
387
+ catch {
388
+ /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
389
+ }
390
+ }
391
+ /**
392
+ * Test-only: empties the cache so test cases cannot leak drafts into each other.
393
+ *
394
+ * @param options `keepStorage: true` empties only the in-memory tier, leaving `localStorage`
395
+ * intact. That is the honest simulation of a page reload — fresh module scope, storage that
396
+ * outlived it — and is how the durability tests reproduce the bug this module exists to fix.
397
+ * @complexity Time O(n) in the number of this module's stored keys; space O(n) in the same.
398
+ */
399
+ export function __resetComposerDraftCacheForTests(options) {
87
400
  drafts.clear();
401
+ stagedAttachments.clear();
402
+ if (options?.keepStorage === true)
403
+ return;
404
+ pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, 0);
405
+ pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, 0);
88
406
  }
89
407
  //# sourceMappingURL=composer-draft-cache.js.map
@@ -17,6 +17,23 @@ export interface UseComposerOptions {
17
17
  * exists.
18
18
  */
19
19
  conversationId?: string | null;
20
+ /**
21
+ * Confirms which of a conversation's previously staged attachments still exist, so a restored
22
+ * draft never shows a chip for a file the host has since garbage-collected — that looks intact and
23
+ * then fails at send, which is worse than not restoring it at all.
24
+ *
25
+ * Receives the cached references and returns the subset still valid (order need not be preserved;
26
+ * an empty array means none survived). A rejection is treated as "none survived" — restoring
27
+ * unverified references is the failure mode this option exists to prevent.
28
+ *
29
+ * **Omitting it disables attachment persistence entirely** — nothing is written and nothing is
30
+ * restored, which is exactly the behavior before this option existed. Only the draft TEXT is
31
+ * persisted then, so a host that cannot answer the liveness question loses nothing and risks
32
+ * nothing. Text and attachments are stored under separate keys and degrade independently.
33
+ *
34
+ * Captured in a ref, so an inline arrow is safe and will not re-run the restore on every render.
35
+ */
36
+ validateAttachments?: (attachments: readonly ChatAttachment[]) => Promise<readonly ChatAttachment[]>;
20
37
  }
21
38
  export interface MentionPopoverState {
22
39
  open: boolean;
@@ -17,22 +17,22 @@
17
17
  * draft/attachment/mention/agent-selection *state shape* is kept.
18
18
  *
19
19
  * `conversationId` (when supplied) keys the draft — and, on a change, the staged attachments and
20
- * mention popover — against `composer-draft-cache.ts`'s module-level cache, so a switch between
21
- * conversations round-trips whatever was typed instead of losing it or leaking it into the wrong
22
- * conversation. That cache is a separate, always-on mechanism from `persistence` above: `persistence`
23
- * is a single opaque host-owned slot (e.g. localStorage, for surviving a page *reload*) that this
24
- * hook still never touches directly; the cache instead survives a `ChatPane` remount within the same
25
- * page session — see its own module doc for why a switch here so often means a full remount. Staged
26
- * attachments and the mention popover are deliberately NOT cached across a switch (unlike the draft
27
- * text): they reference an in-flight upload batch, and carrying them into a different conversation
28
- * risks attaching the wrong files to the wrong thread, so they are cleared instead.
20
+ * mention popover — against `composer-draft-cache.ts`, so whatever was typed comes back on a switch
21
+ * between conversations, on a `ChatPane` remount, and on a full page reload, without leaking into
22
+ * the wrong conversation. That cache is a separate, always-on mechanism from `persistence` above,
23
+ * which stays an opt-in host-owned slot this hook never touches directly and which still wins at
24
+ * mount when a host wires one; the cache needs no host wiring and is what actually fixes the
25
+ * reported data loss (see its module doc). Staged attachments and the mention popover are
26
+ * deliberately NOT cached across a switch (unlike the draft text): they reference an in-flight
27
+ * upload batch, and carrying them into a different conversation risks attaching the wrong files to
28
+ * the wrong thread, so they are cleared instead.
29
29
  */
30
30
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
31
- import { readCachedDraft, writeCachedDraft } from './composer-draft-cache.js';
31
+ import { readCachedAttachments, readCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
32
32
  import { cacheAttachmentPreviewSource } from './attachment-preview-cache.js';
33
33
  const EMPTY_MENTION = { open: false, query: '', results: [] };
34
34
  export function useComposer(options = {}) {
35
- const { project, composerSlots, persistence, conversationId } = options;
35
+ const { project, composerSlots, persistence, conversationId, validateAttachments } = options;
36
36
  const [draft, setDraftState] = useState(() => options.initialDraft ?? persistence?.read() ?? readCachedDraft(conversationId) ?? '');
37
37
  const [attachments, setAttachments] = useState([]);
38
38
  const [agent, setAgent] = useState(options.initialAgent);
@@ -42,6 +42,10 @@ export function useComposer(options = {}) {
42
42
  persistence?.write(next);
43
43
  writeCachedDraft(conversationId, next);
44
44
  }, [persistence, conversationId]);
45
+ // Lets the conversation-change effect below read the live draft without taking `draft` as a
46
+ // dependency, which would re-run it on every keystroke.
47
+ const draftRef = useRef(draft);
48
+ draftRef.current = draft;
45
49
  // Re-keys the draft (and drops attachments/the mention popover — see this module's doc) when
46
50
  // `conversationId` changes WITHOUT a remount — a host that keeps the composer mounted across
47
51
  // conversations, unlike a host that remounts `ChatPane` via a conversation-keyed `key` (the
@@ -49,13 +53,90 @@ export function useComposer(options = {}) {
49
53
  // render via the ref comparison so it never fights that initializer.
50
54
  const previousConversationIdRef = useRef(conversationId);
51
55
  useEffect(() => {
56
+ const previousConversationId = previousConversationIdRef.current;
52
57
  if (conversationId === previousConversationIdRef.current)
53
58
  return;
54
59
  previousConversationIdRef.current = conversationId;
55
- setDraftState(readCachedDraft(conversationId) ?? '');
60
+ const restored = readCachedDraft(conversationId);
61
+ // An id arriving where there was none is NOT a switch between two conversations — it is the one
62
+ // the operator is already looking at finally getting a key (a new chat created server-side
63
+ // mid-compose, or a host that resolves the active id asynchronously after mount). Adopt what
64
+ // they have typed instead of wiping it: writes no-op while the id is absent, so that text
65
+ // exists in this hook's state and nowhere else, and an empty restore must never clobber text
66
+ // the person can currently see. Guarded to `previousConversationId == null` so a real
67
+ // conversation-to-conversation switch still clears, and cannot leak one thread's half-written
68
+ // message into another. Attachments and the mention popover are left alone here for the same
69
+ // reason — nothing was switched away from.
70
+ if (previousConversationId == null && conversationId != null && restored === null && draftRef.current.trim() !== '') {
71
+ writeCachedDraft(conversationId, draftRef.current);
72
+ return;
73
+ }
74
+ setDraftState(restored ?? '');
56
75
  setAttachments([]);
57
76
  setMention(EMPTY_MENTION);
58
77
  }, [conversationId]);
78
+ // Held in a ref so an inline arrow from the host does not re-run the restore below on every
79
+ // render. Reassigned each render so a host that swaps implementations still gets the new one.
80
+ const validateAttachmentsRef = useRef(validateAttachments);
81
+ validateAttachmentsRef.current = validateAttachments;
82
+ // Which conversation the attachment restore has already settled for. Compared against the live
83
+ // `conversationId` before any persist, so the switch effect's own `setAttachments([])` can never
84
+ // race ahead and delete the conversation being switched TO. `undefined` means "not settled yet",
85
+ // which is why this is not a boolean.
86
+ const attachmentsHydratedForRef = useRef(undefined);
87
+ // Restores previously staged attachments, but only ones the host confirms still exist. Without a
88
+ // validator this does nothing at all: attachment persistence stays off and only the draft text
89
+ // survives, which is the documented default (see `validateAttachments`).
90
+ useEffect(() => {
91
+ const validate = validateAttachmentsRef.current;
92
+ if (!validate || !conversationId) {
93
+ attachmentsHydratedForRef.current = conversationId;
94
+ return;
95
+ }
96
+ const cached = readCachedAttachments(conversationId);
97
+ if (cached === null || cached.length === 0) {
98
+ attachmentsHydratedForRef.current = conversationId;
99
+ return;
100
+ }
101
+ let cancelled = false;
102
+ void (async () => {
103
+ let valid = [];
104
+ try {
105
+ valid = await validate(cached);
106
+ }
107
+ catch {
108
+ // A validator that throws tells us nothing about the files, so assume none survived rather
109
+ // than restoring references we cannot vouch for.
110
+ valid = [];
111
+ }
112
+ if (cancelled)
113
+ return;
114
+ if (valid.length > 0) {
115
+ // Only seeds an untouched composer: anything the operator staged while validation was in
116
+ // flight is theirs and outranks a restore.
117
+ setAttachments((prev) => (prev.length === 0 ? [...valid] : prev));
118
+ }
119
+ else {
120
+ // Every reference was dead. Purge them so the next load does not pay for this again.
121
+ writeCachedAttachments(conversationId, []);
122
+ }
123
+ attachmentsHydratedForRef.current = conversationId;
124
+ })();
125
+ return () => {
126
+ cancelled = true;
127
+ };
128
+ }, [conversationId]);
129
+ // Persists staged attachments once the restore for THIS conversation has settled. Gated on the
130
+ // settle marker so a mount's initial empty `attachments` cannot delete what is still being
131
+ // restored. An empty list deletes the entry, so a send (which clears attachments) also clears the
132
+ // cached references, matching the draft text's own rule.
133
+ useEffect(() => {
134
+ if (!validateAttachmentsRef.current || !conversationId)
135
+ return;
136
+ if (attachmentsHydratedForRef.current !== conversationId)
137
+ return;
138
+ writeCachedAttachments(conversationId, attachments);
139
+ }, [attachments, conversationId]);
59
140
  const addAttachment = useCallback((attachment) => {
60
141
  setAttachments((prev) => [...prev, attachment]);
61
142
  }, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -53,7 +53,8 @@
53
53
  "!dist/**/*.test.d.ts",
54
54
  "!dist/**/*.test.js.map",
55
55
  "!dist/**/*.test.d.ts.map",
56
- "!dist/**/__tests__/**"
56
+ "!dist/**/__tests__/**",
57
+ "!dist/**/*.map"
57
58
  ],
58
59
  "publishConfig": {
59
60
  "access": "public",
@@ -62,9 +63,9 @@
62
63
  "dependencies": {
63
64
  "@mcp-ui/client": "7.1.1",
64
65
  "@mcp-ui/server": "6.1.0",
65
- "@jini-ai/agentic": "0.3.6",
66
- "@jini-ai/ui": "0.3.7",
67
66
  "@jini-ai/protocol": "0.3.0",
67
+ "@jini-ai/agentic": "0.3.7",
68
+ "@jini-ai/ui": "0.3.12",
68
69
  "@jini-ai/platform": "0.3.0"
69
70
  },
70
71
  "peerDependencies": {