collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,1458 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { ChatDrafts } from '../chat_drafts'
7
+
8
+ describe('ChatDrafts', () => {
9
+ let drafts
10
+
11
+ const storedValues = (prefix) => Array.from(
12
+ { length: window.localStorage.length },
13
+ (_, index) => window.localStorage.key(index),
14
+ )
15
+ .filter((key) => key.startsWith(prefix))
16
+ .map((key) => window.localStorage.getItem(key))
17
+
18
+ beforeEach(() => {
19
+ window.localStorage.clear()
20
+ window.sessionStorage.clear()
21
+ document.body.dataset.currentUserId = '9'
22
+ drafts = new ChatDrafts()
23
+ })
24
+
25
+ afterEach(() => {
26
+ jest.restoreAllMocks()
27
+ delete document.body.dataset.currentUserId
28
+ })
29
+
30
+ test('set + get roundtrip, keyed per chat', () => {
31
+ drafts.set('101', 'hello draft')
32
+ drafts.set(202, 'second draft')
33
+ expect(drafts.get('101')).toBe('hello draft')
34
+ expect(drafts.get('202')).toBe('second draft')
35
+ expect(drafts.get('999')).toBeNull()
36
+ })
37
+
38
+ test('stores concurrent chats under independent keys', () => {
39
+ const firstTab = new ChatDrafts()
40
+ const otherTab = new ChatDrafts()
41
+
42
+ firstTab.set('101', 'draft from first tab')
43
+ otherTab.set('202', 'draft from other tab')
44
+
45
+ expect(drafts.get('101')).toBe('draft from first tab')
46
+ expect(drafts.get('202')).toBe('draft from other tab')
47
+ expect(storedValues('collavre_chat_drafts_9:101:').join()).toContain('first tab')
48
+ expect(storedValues('collavre_chat_drafts_9:202:').join()).toContain('other tab')
49
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
50
+ })
51
+
52
+ test('uses a deterministic version tie-breaker for the same timestamp', () => {
53
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
54
+ const earlier = { text: 'earlier', updatedAt: 1000, version: '1000-a' }
55
+ const later = { text: 'later', updatedAt: 1000, version: '1000-b' }
56
+
57
+ expect(drafts._append('101', later)).toBe(true)
58
+ expect(drafts._append('101', earlier)).toBe(true)
59
+
60
+ expect(drafts.get('101')).toBe('later')
61
+ expect(drafts.revision('101')).toBe('1000-b')
62
+ })
63
+
64
+ test('snapshots text and revision from one resolved operation', () => {
65
+ const resolved = { text: 'submitted draft', updatedAt: 1000, version: 'v1' }
66
+ const newer = { text: 'concurrent draft', updatedAt: 1001, version: 'v2' }
67
+ const entry = jest.spyOn(drafts, '_entry')
68
+ .mockImplementationOnce(() => resolved)
69
+ .mockImplementation(() => newer)
70
+
71
+ expect(drafts.snapshot('101')).toEqual({
72
+ text: 'submitted draft',
73
+ revision: 'v1',
74
+ updatedAt: 1000,
75
+ })
76
+ expect(entry).toHaveBeenCalledTimes(1)
77
+ expect(drafts.snapshot(null)).toEqual({
78
+ text: null,
79
+ revision: null,
80
+ updatedAt: null,
81
+ })
82
+
83
+ entry.mockRestore()
84
+ expect(drafts.snapshot('missing')).toEqual({
85
+ text: null,
86
+ revision: null,
87
+ updatedAt: null,
88
+ })
89
+ drafts.set('blank', '', { preserveBlank: true })
90
+ expect(drafts.snapshot('blank')).toEqual({
91
+ text: null,
92
+ revision: drafts.revision('blank'),
93
+ updatedAt: drafts.updatedAt('blank'),
94
+ })
95
+ drafts.set('raw', 'draft to move')
96
+ drafts.move('raw', 'effective')
97
+ expect(drafts.snapshot('raw')).toEqual({
98
+ text: null,
99
+ revision: null,
100
+ updatedAt: null,
101
+ })
102
+
103
+ drafts.set('cleared', 'draft to clear')
104
+ drafts.clear('cleared')
105
+ expect(drafts.snapshot('cleared')).toEqual({
106
+ text: null,
107
+ revision: null,
108
+ updatedAt: drafts._entry('cleared').updatedAt,
109
+ })
110
+ })
111
+
112
+ test('compares resolved drafts across chat keys', () => {
113
+ drafts.set('raw', 'raw draft')
114
+ expect(drafts.isNewer('raw', 'effective')).toBe(true)
115
+ expect(drafts.isNewer('raw', null)).toBe(true)
116
+ expect(drafts.isNewer(null, 'effective')).toBe(false)
117
+ expect(drafts.isNewer('missing', 'effective')).toBe(false)
118
+
119
+ drafts.set('effective', 'canonical draft')
120
+ expect(drafts.isNewer('raw', 'effective')).toBe(false)
121
+ })
122
+
123
+ test('orders a submission backup after the regular draft it supersedes', () => {
124
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
125
+ drafts.set('101', 'older regular draft')
126
+
127
+ drafts.saveSubmissionBackup('101', 'newer failed submission')
128
+
129
+ expect(drafts.latestSubmissionBackup('101')?.updatedAt).toBe(1001)
130
+ })
131
+
132
+ test('uses a captured timestamp to preserve submission-time ordering', () => {
133
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
134
+ drafts.set('101', 'regular draft written after submission')
135
+
136
+ drafts.saveSubmissionBackup('101', 'failed submission', { updatedAt: 999 })
137
+
138
+ expect(drafts.latestSubmissionBackup('101')?.updatedAt).toBe(999)
139
+ })
140
+
141
+ test('does not replace a newer submission backup with a late older failure', () => {
142
+ jest.spyOn(Date, 'now').mockReturnValue(2000)
143
+ const olderTab = new ChatDrafts()
144
+ const newerTab = new ChatDrafts()
145
+ olderTab._writerId = 'a'
146
+ newerTab._writerId = 'b'
147
+
148
+ const newerKey = newerTab.saveSubmissionBackup(
149
+ '101',
150
+ 'later submission that failed first',
151
+ { updatedAt: 2000 },
152
+ )
153
+ const ignoredOlderKey = olderTab.saveSubmissionBackup(
154
+ '101',
155
+ 'earlier submission that failed later',
156
+ { updatedAt: 1000 },
157
+ )
158
+ const ignoredTiedKey = olderTab.saveSubmissionBackup(
159
+ '101',
160
+ 'same-time submission with an older version',
161
+ { updatedAt: 2000 },
162
+ )
163
+
164
+ expect(ignoredOlderKey).toBeNull()
165
+ expect(ignoredTiedKey).toBeNull()
166
+ expect(drafts.latestSubmissionBackup('101')).toMatchObject({
167
+ key: newerKey,
168
+ text: 'later submission that failed first',
169
+ updatedAt: 2000,
170
+ })
171
+ })
172
+
173
+ test('drops its candidate when a newer backup arrives during the write', () => {
174
+ jest.spyOn(Date, 'now').mockReturnValue(3000)
175
+ const values = new Map()
176
+ let interleaved = false
177
+ const storage = {
178
+ get length() { return values.size },
179
+ key: (index) => [...values.keys()][index] ?? null,
180
+ getItem: (key) => values.get(key) ?? null,
181
+ setItem: (key, value) => {
182
+ values.set(key, value)
183
+ if (interleaved || !key.includes('_pending:101:')) return
184
+
185
+ interleaved = true
186
+ values.set(
187
+ 'collavre_chat_drafts_9_pending:101:3000-z-1',
188
+ JSON.stringify({ text: 'concurrent newer failure', updatedAt: 3000 }),
189
+ )
190
+ },
191
+ removeItem: (key) => values.delete(key),
192
+ }
193
+ const interleavedDrafts = new ChatDrafts(null, storage)
194
+ interleavedDrafts._writerId = 'a'
195
+
196
+ const candidateKey = interleavedDrafts.saveSubmissionBackup(
197
+ '101',
198
+ 'older failure being persisted',
199
+ { updatedAt: 2000 },
200
+ )
201
+
202
+ expect(candidateKey).toBeNull()
203
+ expect(interleavedDrafts.latestSubmissionBackup('101')?.text)
204
+ .toBe('concurrent newer failure')
205
+ expect([...values.keys()]).toHaveLength(1)
206
+ })
207
+
208
+ test('rolls back a new backup when an older backup cannot be removed', () => {
209
+ jest.spyOn(Date, 'now').mockReturnValue(2000)
210
+ const values = new Map()
211
+ let blockedKey = null
212
+ const storage = {
213
+ get length() { return values.size },
214
+ key: (index) => [...values.keys()][index] ?? null,
215
+ getItem: (key) => values.get(key) ?? null,
216
+ setItem: (key, value) => values.set(key, value),
217
+ removeItem: (key) => {
218
+ if (key !== blockedKey) values.delete(key)
219
+ },
220
+ }
221
+ const rollbackDrafts = new ChatDrafts(null, storage)
222
+ const olderKey = rollbackDrafts.saveSubmissionBackup(
223
+ '101',
224
+ 'older failed submission',
225
+ { updatedAt: 1000 },
226
+ )
227
+ blockedKey = olderKey
228
+
229
+ const rolledBackKey = rollbackDrafts.saveSubmissionBackup(
230
+ '101',
231
+ 'newer failed submission',
232
+ { updatedAt: 2000 },
233
+ )
234
+
235
+ expect(rolledBackKey).toBeNull()
236
+ expect(rollbackDrafts.latestSubmissionBackup('101')).toMatchObject({
237
+ key: olderKey,
238
+ text: 'older failed submission',
239
+ })
240
+ expect([...values.keys()]).toEqual([olderKey])
241
+ })
242
+
243
+ test('tracks a candidate when rollback fails and clears only through that version', () => {
244
+ jest.spyOn(Date, 'now').mockReturnValue(3000)
245
+ const values = new Map()
246
+ let removalsBlocked = true
247
+ const storage = {
248
+ get length() { return values.size },
249
+ key: (index) => [...values.keys()][index] ?? null,
250
+ getItem: (key) => values.get(key) ?? null,
251
+ setItem: (key, value) => values.set(key, value),
252
+ removeItem: (key) => {
253
+ if (!removalsBlocked) values.delete(key)
254
+ },
255
+ }
256
+ const retryDrafts = new ChatDrafts(null, storage)
257
+ retryDrafts._writerId = 'a'
258
+ const olderKey = retryDrafts.saveSubmissionBackup(
259
+ '101',
260
+ 'older failed submission',
261
+ { updatedAt: 1000 },
262
+ )
263
+ const candidateKey = retryDrafts.saveSubmissionBackup(
264
+ '101',
265
+ 'candidate whose rollback is blocked',
266
+ { updatedAt: 2000 },
267
+ )
268
+ retryDrafts._writerId = 'z'
269
+ const newerKey = retryDrafts.saveSubmissionBackup(
270
+ '101',
271
+ 'newer concurrent failure',
272
+ { updatedAt: 3000 },
273
+ )
274
+
275
+ expect(candidateKey).not.toBeNull()
276
+ expect(newerKey).not.toBeNull()
277
+ removalsBlocked = false
278
+ expect(retryDrafts.clearSubmissionBackupsThrough(candidateKey)).toBe(true)
279
+
280
+ expect(values.has(olderKey)).toBe(false)
281
+ expect(values.has(candidateKey)).toBe(false)
282
+ expect(retryDrafts.latestSubmissionBackup('101')).toMatchObject({
283
+ key: newerKey,
284
+ text: 'newer concurrent failure',
285
+ })
286
+ expect(retryDrafts.clearSubmissionBackupsThrough('unrelated-key')).toBe(false)
287
+ expect(retryDrafts.clearSubmissionBackupsThrough(
288
+ 'collavre_chat_drafts_9_pending:101:missing',
289
+ )).toBe(false)
290
+ })
291
+
292
+ test('stores submission backups independently and removes exact backups', () => {
293
+ let now = 1000
294
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
295
+ expect(drafts.saveSubmissionBackup(null, 'draft')).toBeNull()
296
+ expect(drafts.saveSubmissionBackup('101', ' ')).toBeNull()
297
+ expect(drafts.latestSubmissionBackup(null)).toBeNull()
298
+
299
+ const firstKey = drafts.saveSubmissionBackup('101', 'first submission')
300
+ now += 1
301
+ const secondKey = drafts.saveSubmissionBackup('101', 'second submission')
302
+
303
+ expect(drafts.latestSubmissionBackup('101')).toEqual({
304
+ key: secondKey,
305
+ text: 'second submission',
306
+ updatedAt: 1001,
307
+ })
308
+ drafts.removeSubmissionBackup('unrelated-key')
309
+ drafts.removeSubmissionBackup(secondKey, 'collavre_chat_drafts_other')
310
+ expect(drafts.latestSubmissionBackup('101')?.key).toBe(secondKey)
311
+ drafts.removeSubmissionBackup(secondKey)
312
+ expect(window.sessionStorage.getItem(firstKey)).toBeNull()
313
+ expect(drafts.latestSubmissionBackup('101')).toBeNull()
314
+ drafts.clearSubmissionBackups('101')
315
+ drafts.clearSubmissionBackups(null)
316
+ expect(drafts.latestSubmissionBackup('101')).toBeNull()
317
+
318
+ now += 1
319
+ const tiedFirst = drafts.saveSubmissionBackup('101', 'tied first')
320
+ const tiedSecond = drafts.saveSubmissionBackup('101', 'tied second')
321
+ expect(window.sessionStorage.getItem(tiedFirst)).toBeNull()
322
+ expect(drafts.latestSubmissionBackup('101')?.key).toBe(tiedSecond)
323
+
324
+ const prefix = drafts._backupPrefix('tied')
325
+ window.sessionStorage.setItem(`${prefix}a`, JSON.stringify({ text: 'a', updatedAt: now }))
326
+ window.sessionStorage.setItem(`${prefix}b`, JSON.stringify({ text: 'b', updatedAt: now }))
327
+ expect(drafts.latestSubmissionBackup('tied')?.text).toBe('b')
328
+ })
329
+
330
+ test('clears submission backups once for each observed logout nonce', () => {
331
+ drafts.saveSubmissionBackup('101', 'failed before logout')
332
+
333
+ expect(drafts.clearSubmissionBackupsForClear(null, 'logout-1')).toBe(false)
334
+ expect(drafts.clearSubmissionBackupsForClear(
335
+ 'collavre_chat_drafts_9',
336
+ null,
337
+ )).toBe(false)
338
+ expect(drafts.clearSubmissionBackupsForClear(
339
+ 'collavre_chat_drafts_9',
340
+ 'logout-1',
341
+ )).toBe(true)
342
+ expect(drafts.latestSubmissionBackup('101')).toBeNull()
343
+
344
+ drafts.saveSubmissionBackup('101', 'failed after logout')
345
+ expect(drafts.clearSubmissionBackupsForClear(
346
+ 'collavre_chat_drafts_9',
347
+ 'logout-1',
348
+ )).toBe(true)
349
+ expect(drafts.latestSubmissionBackup('101')?.text).toBe('failed after logout')
350
+
351
+ expect(drafts.clearSubmissionBackupsForClear(
352
+ 'collavre_chat_drafts_9',
353
+ 'logout-2',
354
+ )).toBe(true)
355
+ expect(drafts.latestSubmissionBackup('101')).toBeNull()
356
+ })
357
+
358
+ test('retries logout backup cleanup when storage cannot complete it', () => {
359
+ const backupKey = 'collavre_chat_drafts_9_pending:101:backup'
360
+ const values = new Map([[
361
+ backupKey,
362
+ JSON.stringify({ text: 'failed submission', updatedAt: Date.now() }),
363
+ ]])
364
+ const blockedRemoval = {
365
+ get length() { return values.size },
366
+ key: (index) => [...values.keys()][index] ?? null,
367
+ getItem: (key) => values.get(key) ?? null,
368
+ setItem: (key, value) => values.set(key, value),
369
+ removeItem: () => {},
370
+ }
371
+ const blockedDrafts = new ChatDrafts(null, blockedRemoval)
372
+
373
+ expect(blockedDrafts.clearSubmissionBackupsForClear(
374
+ 'collavre_chat_drafts_9',
375
+ 'logout-1',
376
+ )).toBe(false)
377
+ expect(blockedDrafts.latestSubmissionBackup('101')?.text)
378
+ .toBe('failed submission')
379
+
380
+ const silentAcknowledgement = {
381
+ length: 0,
382
+ key: () => null,
383
+ getItem: () => null,
384
+ setItem: () => {},
385
+ removeItem: () => {},
386
+ }
387
+ expect(new ChatDrafts(null, silentAcknowledgement)
388
+ .clearSubmissionBackupsForClear(
389
+ 'collavre_chat_drafts_9',
390
+ 'logout-1',
391
+ )).toBe(false)
392
+ })
393
+
394
+ test('moves submission backups without changing their ordering metadata', () => {
395
+ expect(drafts.moveSubmissionBackups(null, 'effective')).toEqual(new Map())
396
+ expect(drafts.moveSubmissionBackups('raw', 'raw')).toEqual(new Map())
397
+ const sourceKey = drafts.saveSubmissionBackup('raw', 'linked submission')
398
+ const source = drafts.latestSubmissionBackup('raw')
399
+
400
+ const moved = drafts.moveSubmissionBackups('raw', 'effective')
401
+
402
+ expect(moved.get(sourceKey)).toBe(drafts.latestSubmissionBackup('effective').key)
403
+ expect(drafts.latestSubmissionBackup('effective')).toMatchObject({
404
+ text: 'linked submission',
405
+ updatedAt: source.updatedAt,
406
+ })
407
+ expect(drafts.latestSubmissionBackup('raw')).toBeNull()
408
+ })
409
+
410
+ test('keeps the newest submission backup when linked keys converge', () => {
411
+ let now = 1000
412
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
413
+ drafts.saveSubmissionBackup('older-raw', 'older raw')
414
+ now += 1
415
+ const newerTargetKey = drafts.saveSubmissionBackup('newer-target', 'newer target')
416
+
417
+ expect(drafts.moveSubmissionBackups('older-raw', 'newer-target')).toEqual(new Map())
418
+ expect(drafts.latestSubmissionBackup('newer-target')?.key).toBe(newerTargetKey)
419
+ expect(drafts.latestSubmissionBackup('older-raw')).toBeNull()
420
+
421
+ drafts.saveSubmissionBackup('older-target', 'older target')
422
+ now += 1
423
+ const newerSourceKey = drafts.saveSubmissionBackup('newer-raw', 'newer raw')
424
+ const moved = drafts.moveSubmissionBackups('newer-raw', 'older-target')
425
+ expect(moved.get(newerSourceKey)).toBe(drafts.latestSubmissionBackup('older-target').key)
426
+ expect(drafts.latestSubmissionBackup('older-target')?.text).toBe('newer raw')
427
+
428
+ const tiedSourceKey = drafts.saveSubmissionBackup('tied-raw', 'tied raw')
429
+ const tiedTargetKey = drafts.saveSubmissionBackup('tied-target', 'tied target')
430
+ expect(drafts.moveSubmissionBackups('tied-raw', 'tied-target')).toEqual(new Map())
431
+ expect(drafts.latestSubmissionBackup('tied-target')?.key).toBe(tiedTargetKey)
432
+ expect(window.sessionStorage.getItem(tiedSourceKey)).toBeNull()
433
+ })
434
+
435
+ test('keeps a submission backup when its move cannot be persisted', () => {
436
+ const storage = {
437
+ get length() { return window.localStorage.length },
438
+ key: (index) => window.localStorage.key(index),
439
+ getItem: (key) => window.localStorage.getItem(key),
440
+ setItem: (key, value) => {
441
+ if (key.startsWith('collavre_chat_drafts_9_pending:effective:')) {
442
+ throw new Error('quota')
443
+ }
444
+ window.localStorage.setItem(key, value)
445
+ },
446
+ removeItem: (key) => window.localStorage.removeItem(key),
447
+ }
448
+ const failingDrafts = new ChatDrafts(storage)
449
+ failingDrafts.saveSubmissionBackup('raw', 'keep at source')
450
+
451
+ expect(failingDrafts.moveSubmissionBackups('raw', 'effective')).toEqual(new Map())
452
+ expect(failingDrafts.latestSubmissionBackup('raw')?.text).toBe('keep at source')
453
+ expect(failingDrafts.latestSubmissionBackup('effective')).toBeNull()
454
+
455
+ window.localStorage.clear()
456
+ let rollbackNow = 1000
457
+ jest.spyOn(Date, 'now').mockImplementation(() => rollbackNow)
458
+ const rollbackStorage = {
459
+ get length() { return window.localStorage.length },
460
+ key: storage.key,
461
+ getItem: storage.getItem,
462
+ setItem: (key, value) => {
463
+ if (
464
+ key.startsWith('collavre_chat_drafts_9_pending:effective:') &&
465
+ value.includes('newer source')
466
+ ) throw new Error('quota')
467
+ window.localStorage.setItem(key, value)
468
+ },
469
+ removeItem: storage.removeItem,
470
+ }
471
+ const rollbackDrafts = new ChatDrafts(rollbackStorage)
472
+ rollbackDrafts.saveSubmissionBackup('effective', 'older target')
473
+ rollbackNow += 1
474
+ rollbackDrafts.saveSubmissionBackup('raw', 'newer source')
475
+
476
+ expect(rollbackDrafts.moveSubmissionBackups('raw', 'effective')).toEqual(new Map())
477
+ expect(rollbackDrafts.latestSubmissionBackup('effective')?.text).toBe('older target')
478
+ expect(rollbackDrafts.latestSubmissionBackup('raw')?.text).toBe('newer source')
479
+
480
+ window.localStorage.clear()
481
+ const droppedStorage = {
482
+ get length() { return window.localStorage.length },
483
+ key: storage.key,
484
+ getItem: storage.getItem,
485
+ setItem: (key, value) => {
486
+ if (
487
+ !key.startsWith('collavre_chat_drafts_9_pending:effective:') ||
488
+ !value.includes('verify destination')
489
+ ) {
490
+ window.localStorage.setItem(key, value)
491
+ }
492
+ },
493
+ removeItem: storage.removeItem,
494
+ }
495
+ const droppedDrafts = new ChatDrafts(droppedStorage)
496
+ droppedDrafts.saveSubmissionBackup('effective', 'target before dropped write')
497
+ rollbackNow += 1
498
+ droppedDrafts.saveSubmissionBackup('raw', 'verify destination')
499
+ expect(droppedDrafts.moveSubmissionBackups('raw', 'effective')).toEqual(new Map())
500
+ expect(droppedDrafts.latestSubmissionBackup('raw')?.text).toBe('verify destination')
501
+ expect(droppedDrafts.latestSubmissionBackup('effective')?.text)
502
+ .toBe('target before dropped write')
503
+
504
+ const values = new Map()
505
+ const stickySourceStorage = {
506
+ get length() { return values.size },
507
+ key: (index) => [...values.keys()][index] || null,
508
+ getItem: (key) => values.get(key) ?? null,
509
+ setItem: (key, value) => values.set(key, value),
510
+ removeItem: (key) => {
511
+ if (!key.startsWith('collavre_chat_drafts_9_pending:raw:')) values.delete(key)
512
+ },
513
+ }
514
+ const stickyDrafts = new ChatDrafts(stickySourceStorage)
515
+ stickyDrafts.saveSubmissionBackup('effective', 'target before sticky source')
516
+ rollbackNow += 1
517
+ stickyDrafts.saveSubmissionBackup('raw', 'source cannot be removed')
518
+ expect(stickyDrafts.moveSubmissionBackups('raw', 'effective')).toEqual(new Map())
519
+ expect(stickyDrafts.latestSubmissionBackup('raw')?.text).toBe('source cannot be removed')
520
+ expect(stickyDrafts.latestSubmissionBackup('effective')?.text)
521
+ .toBe('target before sticky source')
522
+
523
+ values.clear()
524
+ const stickyTargetStorage = {
525
+ get length() { return values.size },
526
+ key: stickySourceStorage.key,
527
+ getItem: stickySourceStorage.getItem,
528
+ setItem: stickySourceStorage.setItem,
529
+ removeItem: (key) => {
530
+ if (!key.startsWith('collavre_chat_drafts_9_pending:effective:')) values.delete(key)
531
+ },
532
+ }
533
+ const stickyTargetDrafts = new ChatDrafts(stickyTargetStorage)
534
+ let now = 1000
535
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
536
+ stickyTargetDrafts.saveSubmissionBackup('effective', 'target cannot be removed')
537
+ now += 1
538
+ stickyTargetDrafts.saveSubmissionBackup('raw', 'newer source')
539
+ expect(stickyTargetDrafts.moveSubmissionBackups('raw', 'effective')).toEqual(new Map())
540
+ expect(stickyTargetDrafts.latestSubmissionBackup('effective')?.text)
541
+ .toBe('target cannot be removed')
542
+ expect(stickyTargetDrafts.latestSubmissionBackup('raw')?.text).toBe('newer source')
543
+ })
544
+
545
+ test('prunes expired and malformed submission backups', () => {
546
+ const prefix = drafts._backupPrefix('101')
547
+ const expiredKey = `${prefix}expired`
548
+ const malformedKey = `${prefix}malformed`
549
+ const blankKey = `${prefix}blank`
550
+ window.sessionStorage.setItem(expiredKey, JSON.stringify({
551
+ text: 'expired submission',
552
+ updatedAt: Date.now() - 8 * 24 * 60 * 60 * 1000,
553
+ }))
554
+ window.sessionStorage.setItem(malformedKey, '{bad')
555
+ window.sessionStorage.setItem(blankKey, JSON.stringify({ text: '', updatedAt: Date.now() }))
556
+ expect(drafts.latestSubmissionBackup('101')).toBeNull()
557
+ const validKey = drafts.saveSubmissionBackup('101', 'valid submission')
558
+
559
+ expect(drafts.latestSubmissionBackup('101')?.key).toBe(validKey)
560
+ expect(window.sessionStorage.getItem(expiredKey)).toBeNull()
561
+ expect(window.sessionStorage.getItem(malformedKey)).toBeNull()
562
+ expect(window.sessionStorage.getItem(blankKey)).toBeNull()
563
+
564
+ drafts.clearAll({ broadcast: false })
565
+ expect(window.sessionStorage.getItem(validKey)).toBeNull()
566
+ })
567
+
568
+ test('bounds submission backups to the newest fifty entries', () => {
569
+ let now = 1000
570
+ jest.spyOn(Date, 'now').mockImplementation(() => (now += 1))
571
+ const keys = Array.from({ length: 51 }, (_, index) => (
572
+ drafts.saveSubmissionBackup(`chat-${index}`, `submission ${index}`)
573
+ ))
574
+
575
+ expect(window.sessionStorage.getItem(keys[0])).toBeNull()
576
+ expect(window.sessionStorage.getItem(keys[50])).not.toBeNull()
577
+ expect(drafts.latestSubmissionBackup('chat-50')?.text).toBe('submission 50')
578
+ })
579
+
580
+ test('submission backups tolerate unavailable storage', () => {
581
+ const broken = {
582
+ length: 1,
583
+ key: () => 'collavre_chat_drafts_9_pending:101:backup',
584
+ getItem: () => { throw new Error('denied') },
585
+ setItem: () => { throw new Error('quota') },
586
+ removeItem: () => { throw new Error('denied') },
587
+ }
588
+ const brokenDrafts = new ChatDrafts(broken)
589
+
590
+ expect(brokenDrafts.saveSubmissionBackup('101', 'draft')).toBeNull()
591
+ expect(brokenDrafts.latestSubmissionBackup('101')).toBeNull()
592
+ expect(() => brokenDrafts.removeSubmissionBackup(
593
+ 'collavre_chat_drafts_9_pending:101:backup',
594
+ )).not.toThrow()
595
+ expect(() => brokenDrafts.clearSubmissionBackups('101')).not.toThrow()
596
+ expect(brokenDrafts.clearSubmissionBackupsForClear(
597
+ 'collavre_chat_drafts_9',
598
+ 'logout-1',
599
+ )).toBe(false)
600
+
601
+ const writeOnlyFailure = {
602
+ length: 0,
603
+ key: () => null,
604
+ getItem: () => null,
605
+ setItem: () => { throw new Error('quota') },
606
+ removeItem: () => {},
607
+ }
608
+ expect(new ChatDrafts(null, writeOnlyFailure)
609
+ .saveSubmissionBackup('101', 'draft')).toBeNull()
610
+ })
611
+
612
+ test('a clear tombstone survives obsolete aggregate cleanup', () => {
613
+ let now = 1000
614
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
615
+ drafts.set('101', 'clear me')
616
+ now += 1
617
+ drafts.clear('101')
618
+
619
+ window.localStorage.setItem('collavre_chat_drafts_9', JSON.stringify({
620
+ 101: { text: 'old tab draft', updatedAt: 1000 },
621
+ }))
622
+
623
+ expect(drafts.get('101')).toBeNull()
624
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
625
+ })
626
+
627
+ test('a conditional tombstone does not hide a newer concurrent operation', () => {
628
+ let now = 1000
629
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
630
+ drafts.set('101', 'eviction candidate')
631
+ const candidate = drafts._entry('101')
632
+ now += 1
633
+ drafts.set('101', 'newer concurrent input')
634
+ now += 1
635
+ drafts._append('101', drafts._newEntry('', candidate, {
636
+ deleted: true,
637
+ deletesThrough: drafts._versionOf(candidate),
638
+ }))
639
+
640
+ expect(drafts.get('101')).toBe('newer concurrent input')
641
+ drafts._compact('101')
642
+ expect(drafts.get('101')).toBe('newer concurrent input')
643
+ })
644
+
645
+ test('clearing a draft does not hide text concurrently saved from the same revision', () => {
646
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
647
+ drafts.set('101', 'shared draft')
648
+
649
+ const clearingTab = new ChatDrafts()
650
+ const writingTab = new ChatDrafts()
651
+ clearingTab._writerId = 'z-clear'
652
+ writingTab._writerId = 'a-write'
653
+ const append = clearingTab._append.bind(clearingTab)
654
+ jest.spyOn(clearingTab, '_append').mockImplementation((id, entry) => {
655
+ if (entry.deleted) writingTab.set('101', 'concurrent new text')
656
+ return append(id, entry)
657
+ })
658
+
659
+ clearingTab.clear('101')
660
+
661
+ expect(drafts.get('101')).toBe('concurrent new text')
662
+ expect(drafts.revision('101')).toContain('a-write')
663
+ })
664
+
665
+ test('compaction keeps a tombstone that blocks a delayed stale operation', () => {
666
+ let now = 1000
667
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
668
+ drafts.set('101', 'draft to clear')
669
+ const stale = drafts._entry('101')
670
+ now += 1
671
+ drafts.clear('101')
672
+
673
+ expect(drafts.get('101')).toBeNull()
674
+ expect(drafts._append('101', stale)).toBe(true)
675
+ expect(drafts.get('101')).toBeNull()
676
+ })
677
+
678
+ test('bounds tombstones during repeated set and clear cycles', () => {
679
+ for (let index = 0; index < 1000; index += 1) {
680
+ drafts.set('101', `draft ${index}`)
681
+ drafts.clear('101')
682
+ }
683
+
684
+ expect(storedValues('collavre_chat_drafts_9:101:')).toHaveLength(1)
685
+ })
686
+
687
+ test('retains each chat deletion guard until TTL cleanup', () => {
688
+ drafts.set('victim', 'draft to clear')
689
+ const stale = drafts._entry('victim')
690
+ drafts.clear('victim')
691
+ for (let index = 0; index < 50; index += 1) {
692
+ drafts.set(`other-${index}`, `draft ${index}`)
693
+ drafts.clear(`other-${index}`)
694
+ }
695
+
696
+ drafts._append('victim', stale)
697
+
698
+ expect(drafts.get('victim')).toBeNull()
699
+ })
700
+
701
+ test('compaction retains only the strongest deletion guard', () => {
702
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
703
+ const ceiling = (updatedAt, version) => ({ updatedAt, version })
704
+ const tombstones = [
705
+ { text: '', updatedAt: 11, version: 't1', deleted: true, deletesThrough: ceiling(10, 'v1') },
706
+ { text: '', updatedAt: 31, version: 't2', deleted: true, deletesThrough: ceiling(30, 'v3') },
707
+ { text: '', updatedAt: 21, version: 't3', deleted: true, deletesThrough: ceiling(20, 'v2') },
708
+ { text: '', updatedAt: 32, version: 't4', deleted: true, deletesThrough: ceiling(30, 'v3') },
709
+ { text: '', updatedAt: 30, version: 't0', deleted: true, deletesThrough: ceiling(30, 'v3') },
710
+ ]
711
+ tombstones.forEach((entry) => drafts._append('101', entry))
712
+
713
+ drafts._compact('101')
714
+
715
+ expect(storedValues('collavre_chat_drafts_9:101:')).toEqual([
716
+ JSON.stringify(tombstones[3]),
717
+ ])
718
+ })
719
+
720
+ test('exposes a monotonic update timestamp per draft', () => {
721
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
722
+ expect(drafts.updatedAt(null)).toBeNull()
723
+ expect(drafts.updatedAt('missing')).toBeNull()
724
+ expect(drafts.revision(null)).toBeNull()
725
+ expect(drafts.revision('missing')).toBeNull()
726
+
727
+ drafts.set('101', 'same text')
728
+ const firstUpdatedAt = drafts.updatedAt('101')
729
+ drafts.set('101', 'same text')
730
+
731
+ expect(firstUpdatedAt).toBe(1000)
732
+ expect(drafts.updatedAt('101')).toBe(1001)
733
+ })
734
+
735
+ test('storage key is namespaced by current user id', () => {
736
+ drafts.set('101', 'hello')
737
+ expect(storedValues('collavre_chat_drafts_9:101:').join()).toContain('hello')
738
+ })
739
+
740
+ test('recomputes the storage namespace when the current user changes', () => {
741
+ drafts.set('101', 'user 9 draft')
742
+
743
+ document.body.dataset.currentUserId = '10'
744
+ expect(drafts.get('101')).toBeNull()
745
+ drafts.set('101', 'user 10 draft')
746
+
747
+ expect(storedValues('collavre_chat_drafts_9:101:').join()).toContain('user 9 draft')
748
+ expect(storedValues('collavre_chat_drafts_10:101:').join()).toContain('user 10 draft')
749
+ })
750
+
751
+ test('exposes the current namespace for asynchronous ownership checks', () => {
752
+ expect(drafts.namespace()).toBe('collavre_chat_drafts_9')
753
+ document.body.dataset.currentUserId = '10'
754
+ expect(drafts.namespace()).toBe('collavre_chat_drafts_10')
755
+ })
756
+
757
+ test('falls back to guest namespace without a current user id', () => {
758
+ delete document.body.dataset.currentUserId
759
+ const guestDrafts = new ChatDrafts()
760
+ guestDrafts.set('101', 'guest draft')
761
+ expect(storedValues('collavre_chat_drafts_guest:101:').join()).toContain('guest draft')
762
+ })
763
+
764
+ test('blank text hides the entry behind a deletion tombstone', () => {
765
+ drafts.set('101', 'hello')
766
+ drafts.set('101', ' ')
767
+ expect(drafts.get('101')).toBeNull()
768
+ expect(storedValues('collavre_chat_drafts_9:101:').some((value) => (
769
+ JSON.parse(value).deleted === true
770
+ ))).toBe(true)
771
+ })
772
+
773
+ test('set with blank text on a missing entry is a no-op (no write)', () => {
774
+ drafts.set('101', '')
775
+ expect(storedValues('collavre_chat_drafts_9:101:')).toEqual([])
776
+ })
777
+
778
+ test('clear removes a single draft', () => {
779
+ drafts.set('101', 'a')
780
+ drafts.set('102', 'b')
781
+ drafts.clear('101')
782
+ expect(drafts.get('101')).toBeNull()
783
+ expect(drafts.get('102')).toBe('b')
784
+ })
785
+
786
+ test('moves a draft to an empty destination', () => {
787
+ drafts.set('raw', 'loading-window draft')
788
+
789
+ drafts.move('raw', 'effective')
790
+
791
+ expect(drafts.get('raw')).toBeNull()
792
+ expect(drafts.get('effective')).toBe('loading-window draft')
793
+
794
+ drafts.move('raw', 'effective')
795
+ expect(drafts.get('effective')).toBe('loading-window draft')
796
+ })
797
+
798
+ test('retries a move when the source changes concurrently', () => {
799
+ drafts.set('raw', 'first source')
800
+ const sameEntry = drafts._sameEntry.bind(drafts)
801
+ let changed = false
802
+ jest.spyOn(drafts, '_sameEntry').mockImplementation((source, latest) => {
803
+ if (!changed) {
804
+ changed = true
805
+ drafts.set('raw', 'newer source')
806
+ return false
807
+ }
808
+ return sameEntry(source, latest)
809
+ })
810
+
811
+ drafts.move('raw', 'effective')
812
+
813
+ expect(drafts.get('raw')).toBeNull()
814
+ expect(drafts.get('effective')).toBe('newer source')
815
+ })
816
+
817
+ test('retries a move when a newer source survives the move marker', () => {
818
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
819
+ drafts.set('raw', 'first source')
820
+
821
+ const writingTab = new ChatDrafts()
822
+ const append = drafts._append.bind(drafts)
823
+ let sourceWritten = false
824
+ const writeNewerSource = () => {
825
+ sourceWritten = true
826
+ writingTab.set('raw', 'newer source')
827
+ }
828
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => {
829
+ if (id === 'raw' && entry.movedTo && !sourceWritten) writeNewerSource()
830
+ return append(id, entry)
831
+ })
832
+
833
+ drafts.move('raw', 'effective')
834
+
835
+ expect(drafts.get('raw')).toBeNull()
836
+ expect(drafts.get('effective')).toBe('newer source')
837
+ })
838
+
839
+ test('retries a move when a concurrent source clear survives the marker', () => {
840
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
841
+ drafts._writerId = 'z-marker'
842
+ drafts.set('raw', 'source to clear')
843
+
844
+ const clearingTab = new ChatDrafts()
845
+ clearingTab._writerId = 'a-clear'
846
+ const append = drafts._append.bind(drafts)
847
+ let sourceCleared = false
848
+ const clearSource = () => {
849
+ sourceCleared = true
850
+ clearingTab.clear('raw')
851
+ }
852
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => {
853
+ if (id === 'raw' && entry.movedTo && !sourceCleared) clearSource()
854
+ return append(id, entry)
855
+ })
856
+
857
+ drafts.move('raw', 'effective')
858
+
859
+ expect(drafts.get('raw')).toBeNull()
860
+ expect(drafts.get('effective')).toBeNull()
861
+ })
862
+
863
+ test('reports an incomplete move after concurrent source writes exhaust retries', () => {
864
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
865
+ drafts.set('raw', 'first source')
866
+
867
+ const writingTab = new ChatDrafts()
868
+ const append = drafts._append.bind(drafts)
869
+ let sourceWrites = 0
870
+ const writeNewerSource = () => {
871
+ sourceWrites += 1
872
+ writingTab.set('raw', `newer source ${sourceWrites}`)
873
+ }
874
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => {
875
+ if (id === 'raw' && entry.movedTo) writeNewerSource()
876
+ return append(id, entry)
877
+ })
878
+
879
+ expect(drafts.move('raw', 'effective')).toBe(false)
880
+ expect(drafts.get('raw')).toBe('newer source 3')
881
+ })
882
+
883
+ test('handles move marker write failure', () => {
884
+ drafts.set('raw', 'source')
885
+ const append = drafts._append.bind(drafts)
886
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => (
887
+ entry.movedTo ? false : append(id, entry)
888
+ ))
889
+ expect(() => drafts.move('raw', 'effective')).not.toThrow()
890
+ expect(drafts.get('raw')).toBe('source')
891
+ })
892
+
893
+ test('keeps the source when the move target cannot be stored', () => {
894
+ drafts.set('raw', 'source')
895
+ const append = drafts._append.bind(drafts)
896
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => (
897
+ id === 'effective' ? false : append(id, entry)
898
+ ))
899
+
900
+ expect(drafts.move('raw', 'effective')).toBe(false)
901
+
902
+ expect(drafts.get('raw')).toBe('source')
903
+ expect(drafts.get('effective')).toBeNull()
904
+ })
905
+
906
+ test('moves only the newer draft when the destination already exists', () => {
907
+ let now = 1000
908
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
909
+ drafts.set('effective', 'older canonical draft')
910
+ now += 1
911
+ drafts.set('raw', 'newer loading-window draft')
912
+
913
+ drafts.move('raw', 'effective')
914
+
915
+ expect(drafts.get('raw')).toBeNull()
916
+ expect(drafts.get('effective')).toBe('newer loading-window draft')
917
+ })
918
+
919
+ test('keeps a newer destination when removing a stale source draft', () => {
920
+ let now = 1000
921
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
922
+ drafts.set('raw', 'stale raw draft')
923
+ now += 1
924
+ drafts.set('effective', 'newer canonical draft')
925
+
926
+ drafts.move('raw', 'effective')
927
+
928
+ expect(drafts.get('raw')).toBeNull()
929
+ expect(drafts.get('effective')).toBe('newer canonical draft')
930
+ })
931
+
932
+ test('moves a newer blank tombstone by clearing the destination', () => {
933
+ let now = 1000
934
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
935
+ drafts.set('effective', 'canonical draft')
936
+ now += 1
937
+ drafts.set('raw', '', { preserveBlank: true })
938
+
939
+ expect(drafts.get('raw')).toBeNull()
940
+ expect(drafts.updatedAt('raw')).toBe(1001)
941
+ expect(drafts.revision('raw')).not.toBeNull()
942
+
943
+ drafts.move('raw', 'effective')
944
+
945
+ expect(drafts.get('raw')).toBeNull()
946
+ expect(drafts.updatedAt('raw')).toBeNull()
947
+ expect(drafts.revision('raw')).toBeNull()
948
+ expect(drafts.get('effective')).toBeNull()
949
+ })
950
+
951
+ test('a blank migration does not hide the first concurrent destination draft', () => {
952
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
953
+ drafts._writerId = 'z-clear'
954
+ drafts.set('raw', '', { preserveBlank: true })
955
+
956
+ const writingTab = new ChatDrafts()
957
+ writingTab._writerId = 'a-write'
958
+ const entry = drafts._entry.bind(drafts)
959
+ let destinationWritten = false
960
+ jest.spyOn(drafts, '_entry').mockImplementation((id) => {
961
+ const current = entry(id)
962
+ if (id === 'effective' && !current && !destinationWritten) {
963
+ destinationWritten = true
964
+ writingTab.set('effective', 'first canonical draft')
965
+ }
966
+ return current
967
+ })
968
+
969
+ drafts.move('raw', 'effective')
970
+
971
+ expect(drafts.get('raw')).toBeNull()
972
+ expect(drafts.get('effective')).toBe('first canonical draft')
973
+ expect(drafts.revision('effective')).toContain('a-write')
974
+ })
975
+
976
+ test('a migrated blank does not hide text concurrently saved from the destination revision', () => {
977
+ let now = 1000
978
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
979
+ drafts._writerId = 'z-clear'
980
+ drafts.set('effective', 'canonical draft')
981
+ now += 1
982
+ drafts.set('raw', '', { preserveBlank: true })
983
+
984
+ const writingTab = new ChatDrafts()
985
+ writingTab._writerId = 'a-write'
986
+ const append = drafts._append.bind(drafts)
987
+ jest.spyOn(drafts, '_append').mockImplementation((id, entry) => {
988
+ if (id === 'effective' && entry.deleted) {
989
+ writingTab.set('effective', 'concurrent canonical text')
990
+ }
991
+ return append(id, entry)
992
+ })
993
+
994
+ drafts.move('raw', 'effective')
995
+
996
+ expect(drafts.get('effective')).toBe('concurrent canonical text')
997
+ expect(drafts.revision('effective')).toContain('a-write')
998
+ })
999
+
1000
+ test('move ignores invalid, identical, and missing source ids', () => {
1001
+ drafts.set('101', 'existing draft')
1002
+ const stored = storedValues('collavre_chat_drafts_9:101:')
1003
+
1004
+ drafts.move(null, '102')
1005
+ drafts.move('101', null)
1006
+ drafts.move('101', '101')
1007
+ drafts.move('missing', '102')
1008
+
1009
+ expect(storedValues('collavre_chat_drafts_9:101:')).toEqual(stored)
1010
+ })
1011
+
1012
+ test('clearAll removes the storage key', () => {
1013
+ drafts.set('101', 'a')
1014
+ drafts.clearAll()
1015
+ expect(storedValues('collavre_chat_drafts_9:101:')).toEqual([])
1016
+ })
1017
+
1018
+ test('clearAll broadcasts its namespace for other tabs', () => {
1019
+ drafts.clearAll()
1020
+ const signalKey = drafts._clearEventKey()
1021
+ const signal = window.localStorage.getItem(signalKey)
1022
+ const legacySignal = window.localStorage.getItem('collavre_chat_drafts_clear')
1023
+
1024
+ expect(JSON.parse(signal).namespace).toBe('collavre_chat_drafts_9')
1025
+ expect(JSON.parse(signal)).toMatchObject(JSON.parse(legacySignal))
1026
+ expect(JSON.parse(signal).legacyNonce).toBe(JSON.parse(legacySignal).nonce)
1027
+ expect(drafts.clearNonce()).toBe(JSON.parse(signal).nonce)
1028
+ expect(drafts.clearNoncePending()).toBe(false)
1029
+ expect(drafts.clearNonce('collavre_chat_drafts_10')).toBeNull()
1030
+ const signalWithoutNonce = JSON.stringify({
1031
+ namespace: 'collavre_chat_drafts_9',
1032
+ })
1033
+ window.localStorage.setItem(signalKey, signalWithoutNonce)
1034
+ window.localStorage.setItem('collavre_chat_drafts_clear', signalWithoutNonce)
1035
+ expect(drafts.clearNonce()).toBeNull()
1036
+ window.localStorage.setItem(signalKey, signal)
1037
+ window.localStorage.setItem('collavre_chat_drafts_clear', signal)
1038
+ expect(drafts.wasCleared(new StorageEvent('storage', {
1039
+ key: signalKey,
1040
+ newValue: signal,
1041
+ }))).toBe(true)
1042
+ expect(drafts.wasCleared(new StorageEvent('storage', {
1043
+ key: drafts._clearEventKey('collavre_chat_drafts_10'),
1044
+ newValue: JSON.stringify({ namespace: 'collavre_chat_drafts_10' }),
1045
+ }))).toBe(false)
1046
+ expect(drafts.wasCleared(new StorageEvent('storage', {
1047
+ key: 'unrelated',
1048
+ newValue: signal,
1049
+ }))).toBe(false)
1050
+ expect(drafts.wasCleared(new StorageEvent('storage', {
1051
+ key: signalKey,
1052
+ newValue: '{invalid',
1053
+ }))).toBe(false)
1054
+ window.localStorage.setItem(signalKey, '{invalid')
1055
+ expect(drafts.clearNonce()).toBe(JSON.parse(signal).nonce)
1056
+ window.localStorage.setItem(signalKey, '{invalid')
1057
+ window.localStorage.setItem('collavre_chat_drafts_clear', '{invalid')
1058
+ expect(drafts.clearNonce()).toBeNull()
1059
+ })
1060
+
1061
+ test('reads and observes legacy clear signals', () => {
1062
+ const signal = JSON.stringify({
1063
+ namespace: 'collavre_chat_drafts_9',
1064
+ nonce: 'legacy-logout',
1065
+ })
1066
+ window.localStorage.setItem('collavre_chat_drafts_clear', signal)
1067
+
1068
+ expect(drafts.clearNonce()).toBe('legacy-logout')
1069
+ expect(drafts.wasCleared(new StorageEvent('storage', {
1070
+ key: 'collavre_chat_drafts_clear',
1071
+ newValue: signal,
1072
+ }))).toBe(true)
1073
+ })
1074
+
1075
+ test('promotes a newer legacy clear signal into its namespace marker', () => {
1076
+ const signalKey = drafts._clearEventKey()
1077
+ window.localStorage.setItem(signalKey, JSON.stringify({
1078
+ namespace: 'collavre_chat_drafts_9',
1079
+ nonce: '1000-new-client',
1080
+ updatedAt: 1000,
1081
+ }))
1082
+ window.localStorage.setItem('collavre_chat_drafts_clear', JSON.stringify({
1083
+ namespace: 'collavre_chat_drafts_9',
1084
+ nonce: '2000-old-client',
1085
+ }))
1086
+
1087
+ expect(drafts.clearNonce()).toBe('2000-old-client')
1088
+ expect(JSON.parse(window.localStorage.getItem(signalKey)).nonce)
1089
+ .toBe('2000-old-client')
1090
+ })
1091
+
1092
+ test('treats a changed synchronized legacy signal as newer across clock rollback', () => {
1093
+ const signalKey = drafts._clearEventKey()
1094
+ window.localStorage.setItem(signalKey, JSON.stringify({
1095
+ namespace: 'collavre_chat_drafts_9',
1096
+ nonce: '2000-z-new-client',
1097
+ updatedAt: 2000,
1098
+ legacyNamespace: 'collavre_chat_drafts_9',
1099
+ legacyNonce: '2000-z-new-client',
1100
+ }))
1101
+ window.localStorage.setItem('collavre_chat_drafts_clear', JSON.stringify({
1102
+ namespace: 'collavre_chat_drafts_9',
1103
+ nonce: '1000-a-old-client',
1104
+ }))
1105
+
1106
+ expect(drafts.clearNonce()).toBe('1000-a-old-client')
1107
+ expect(JSON.parse(window.localStorage.getItem(signalKey))).toMatchObject({
1108
+ nonce: '1000-a-old-client',
1109
+ legacyNonce: '1000-a-old-client',
1110
+ })
1111
+ })
1112
+
1113
+ test('prefers the current legacy signal when old markers lack ordering metadata', () => {
1114
+ const namespace = 'collavre_chat_drafts_9'
1115
+ window.localStorage.setItem(drafts._clearEventKey(namespace), JSON.stringify({
1116
+ namespace,
1117
+ nonce: 'new-client-marker',
1118
+ }))
1119
+ window.localStorage.setItem('collavre_chat_drafts_clear', JSON.stringify({
1120
+ namespace,
1121
+ nonce: 'old-client-logout',
1122
+ }))
1123
+
1124
+ expect(drafts.clearNonce()).toBe('old-client-logout')
1125
+ })
1126
+
1127
+ test('keeps a new namespaced marker when the legacy write remains stale', () => {
1128
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
1129
+ const namespace = 'collavre_chat_drafts_9'
1130
+ const namespacedKey = drafts._clearEventKey(namespace)
1131
+ const staleLegacy = JSON.stringify({
1132
+ namespace,
1133
+ nonce: '3000-stale-legacy',
1134
+ updatedAt: 3000,
1135
+ })
1136
+ const values = new Map([['collavre_chat_drafts_clear', staleLegacy]])
1137
+ const storage = {
1138
+ get length() { return values.size },
1139
+ key: (index) => [...values.keys()][index] ?? null,
1140
+ getItem: (key) => values.get(key) ?? null,
1141
+ setItem: (key, value) => {
1142
+ if (key !== 'collavre_chat_drafts_clear') values.set(key, value)
1143
+ },
1144
+ removeItem: (key) => values.delete(key),
1145
+ }
1146
+ const partialDrafts = new ChatDrafts(storage)
1147
+
1148
+ partialDrafts.clearAll()
1149
+
1150
+ const namespacedSignal = JSON.parse(values.get(namespacedKey))
1151
+ expect(namespacedSignal.legacyNonce).toBe('3000-stale-legacy')
1152
+ expect(partialDrafts.clearNonce()).toBe(namespacedSignal.nonce)
1153
+ })
1154
+
1155
+ test('does not observe a legacy promotion until its write is verified', () => {
1156
+ const namespace = 'collavre_chat_drafts_9'
1157
+ const namespacedKey = drafts._clearEventKey(namespace)
1158
+ const namespacedSignal = JSON.stringify({
1159
+ namespace,
1160
+ nonce: 'logout-1',
1161
+ legacyNamespace: namespace,
1162
+ legacyNonce: 'logout-1',
1163
+ })
1164
+ const legacySignal = JSON.stringify({ namespace, nonce: 'logout-2' })
1165
+ const values = new Map([
1166
+ [namespacedKey, namespacedSignal],
1167
+ ['collavre_chat_drafts_clear', legacySignal],
1168
+ ])
1169
+ let writesBlocked = true
1170
+ const storage = {
1171
+ get length() { return values.size },
1172
+ key: (index) => [...values.keys()][index] ?? null,
1173
+ getItem: (key) => values.get(key) ?? null,
1174
+ setItem: (key, value) => {
1175
+ if (!writesBlocked) values.set(key, value)
1176
+ },
1177
+ removeItem: (key) => values.delete(key),
1178
+ }
1179
+
1180
+ const silentDrafts = new ChatDrafts(storage)
1181
+ expect(silentDrafts.clearNonce(namespace)).toBe('logout-2')
1182
+ expect(silentDrafts.clearNoncePending(namespace)).toBe(true)
1183
+ expect(values.get(namespacedKey)).toBe(namespacedSignal)
1184
+
1185
+ const throwingStorage = {
1186
+ ...storage,
1187
+ setItem: () => { throw new Error('denied') },
1188
+ }
1189
+ const throwingDrafts = new ChatDrafts(throwingStorage)
1190
+ expect(throwingDrafts.clearNonce(namespace)).toBe('logout-2')
1191
+ expect(throwingDrafts.clearNoncePending(namespace)).toBe(true)
1192
+
1193
+ writesBlocked = false
1194
+ expect(new ChatDrafts(storage).clearNonce(namespace)).toBe('logout-2')
1195
+ expect(silentDrafts.clearNonce(namespace)).toBe('logout-2')
1196
+ expect(silentDrafts.clearNoncePending(namespace)).toBe(false)
1197
+ })
1198
+
1199
+ test('uses a valid clear marker when the other marker cannot be read', () => {
1200
+ const namespace = 'collavre_chat_drafts_9'
1201
+ const namespacedKey = drafts._clearEventKey(namespace)
1202
+ const signal = JSON.stringify({ namespace, nonce: 'logout-1' })
1203
+ const storage = {
1204
+ length: 0,
1205
+ key: () => null,
1206
+ getItem: (key) => {
1207
+ if (key === 'collavre_chat_drafts_clear') throw new Error('denied')
1208
+ return key === namespacedKey ? signal : null
1209
+ },
1210
+ setItem: () => {},
1211
+ removeItem: () => {},
1212
+ }
1213
+
1214
+ expect(new ChatDrafts(storage).clearNonce(namespace)).toBe('logout-1')
1215
+ })
1216
+
1217
+ test('retains clear nonces for each user namespace', () => {
1218
+ drafts.clearAll()
1219
+ const userNineNonce = drafts.clearNonce()
1220
+
1221
+ document.body.dataset.currentUserId = '10'
1222
+ drafts.clearAll()
1223
+
1224
+ expect(drafts.clearNonce()).not.toBe(userNineNonce)
1225
+ expect(drafts.clearNonce('collavre_chat_drafts_9')).toBe(userNineNonce)
1226
+ })
1227
+
1228
+ test('clearAll can skip the cross-tab broadcast', () => {
1229
+ drafts.set('101', 'a')
1230
+ drafts.clearAll({ broadcast: false })
1231
+
1232
+ expect(storedValues('collavre_chat_drafts_9:101:')).toEqual([])
1233
+ expect(window.localStorage.getItem(drafts._clearEventKey())).toBeNull()
1234
+ })
1235
+
1236
+ test('ignores falsy chat ids', () => {
1237
+ drafts.set(null, 'x')
1238
+ drafts.set('', 'x')
1239
+ expect(drafts.get(null)).toBeNull()
1240
+ expect(drafts.get('')).toBeNull()
1241
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
1242
+ })
1243
+
1244
+ test('evicts the oldest drafts beyond MAX_DRAFTS (50)', () => {
1245
+ let now = 1_000_000
1246
+ jest.spyOn(Date, 'now').mockImplementation(() => (now += 1000))
1247
+ for (let i = 1; i <= 51; i++) drafts.set(`chat-${i}`, `draft ${i}`)
1248
+ expect(drafts.get('chat-1')).toBeNull()
1249
+ expect(drafts.get('chat-2')).toBe('draft 2')
1250
+ expect(drafts.get('chat-51')).toBe('draft 51')
1251
+ })
1252
+
1253
+ test('discards the obsolete aggregate format on load', () => {
1254
+ window.localStorage.setItem('collavre_chat_drafts_9', JSON.stringify({
1255
+ old: { text: 'stale', updatedAt: Date.now() },
1256
+ fresh: { text: 'recent', updatedAt: Date.now() },
1257
+ }))
1258
+
1259
+ expect(drafts.get('fresh')).toBeNull()
1260
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
1261
+ })
1262
+
1263
+ test('prunes expired independent operations while loading another chat', () => {
1264
+ const oldKey = 'collavre_chat_drafts_9:old:expired'
1265
+ window.localStorage.setItem(oldKey, JSON.stringify({
1266
+ text: 'stale sensitive text',
1267
+ updatedAt: Date.now() - 8 * 24 * 60 * 60 * 1000,
1268
+ version: 'expired',
1269
+ }))
1270
+ drafts.set('fresh', 'recent')
1271
+
1272
+ expect(drafts.get('fresh')).toBe('recent')
1273
+ expect(window.localStorage.getItem(oldKey)).toBeNull()
1274
+ })
1275
+
1276
+ test('survives corrupted stored JSON', () => {
1277
+ window.localStorage.setItem('collavre_chat_drafts_9', '{not json')
1278
+ expect(drafts.get('101')).toBeNull()
1279
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
1280
+ drafts.set('101', 'recovered')
1281
+ expect(drafts.get('101')).toBe('recovered')
1282
+ })
1283
+
1284
+ test('survives non-object stored JSON and malformed entries', () => {
1285
+ window.localStorage.setItem('collavre_chat_drafts_9', JSON.stringify(['array']))
1286
+ expect(drafts.get('101')).toBeNull()
1287
+ window.localStorage.setItem(
1288
+ 'collavre_chat_drafts_9',
1289
+ JSON.stringify({ bad: { text: 42 }, worse: null }),
1290
+ )
1291
+ expect(drafts.get('bad')).toBeNull()
1292
+ expect(drafts.get('worse')).toBeNull()
1293
+ expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
1294
+ })
1295
+
1296
+ test('survives a throwing storage backend', () => {
1297
+ const broken = {
1298
+ getItem: () => { throw new Error('denied') },
1299
+ setItem: () => { throw new Error('quota') },
1300
+ removeItem: () => { throw new Error('denied') },
1301
+ }
1302
+ const brokenDrafts = new ChatDrafts(broken)
1303
+ expect(() => brokenDrafts.set('101', 'x')).not.toThrow()
1304
+ expect(brokenDrafts.get('101')).toBeNull()
1305
+ expect(brokenDrafts.clearNonce()).toBeUndefined()
1306
+ expect(() => brokenDrafts.clearAll()).not.toThrow()
1307
+ })
1308
+
1309
+ test('falls back when Web Storage property access is denied', () => {
1310
+ jest.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
1311
+ throw new DOMException('denied', 'SecurityError')
1312
+ })
1313
+ jest.spyOn(window, 'sessionStorage', 'get').mockImplementation(() => {
1314
+ throw new DOMException('denied', 'SecurityError')
1315
+ })
1316
+ const unavailableDrafts = new ChatDrafts()
1317
+
1318
+ expect(() => unavailableDrafts.snapshot('101')).not.toThrow()
1319
+ expect(unavailableDrafts.clearNonce()).toBeUndefined()
1320
+ expect(unavailableDrafts.clearSubmissionBackupsForClear(
1321
+ 'collavre_chat_drafts_9',
1322
+ 'logout-1',
1323
+ )).toBe(false)
1324
+ expect(unavailableDrafts._backend().key(0)).toBeNull()
1325
+ expect(unavailableDrafts._backend().getItem('missing')).toBeNull()
1326
+ unavailableDrafts.set('101', 'ephemeral draft')
1327
+ expect(unavailableDrafts.get('101')).toBe('ephemeral draft')
1328
+
1329
+ const backupKey = unavailableDrafts.saveSubmissionBackup('101', 'failed send')
1330
+ expect(unavailableDrafts.latestSubmissionBackup('101')).toMatchObject({
1331
+ key: backupKey,
1332
+ text: 'failed send',
1333
+ })
1334
+ expect(() => unavailableDrafts.clearAll()).not.toThrow()
1335
+ expect(unavailableDrafts.get('101')).toBeNull()
1336
+ expect(unavailableDrafts.latestSubmissionBackup('101')).toBeNull()
1337
+ })
1338
+
1339
+ test('handles malformed independent entries and key enumeration failures', () => {
1340
+ window.localStorage.setItem('collavre_chat_drafts_9:bad-json:op', '{bad')
1341
+ window.localStorage.setItem(
1342
+ 'collavre_chat_drafts_9:bad-entry:op',
1343
+ JSON.stringify({ text: 42, updatedAt: Date.now() }),
1344
+ )
1345
+ expect(drafts.get('bad-json')).toBeNull()
1346
+ expect(drafts._entries()).toEqual([])
1347
+
1348
+ const unlistable = new ChatDrafts({
1349
+ length: 1,
1350
+ key: () => { throw new Error('denied') },
1351
+ getItem: () => null,
1352
+ setItem: () => {},
1353
+ removeItem: () => {},
1354
+ })
1355
+ expect(unlistable._keys()).toEqual([])
1356
+ })
1357
+
1358
+ test('ignores an operation removed during key enumeration', () => {
1359
+ const disappearing = new ChatDrafts({
1360
+ length: 1,
1361
+ key: () => 'collavre_chat_drafts_9:101:gone',
1362
+ getItem: () => null,
1363
+ setItem: () => {},
1364
+ removeItem: () => {},
1365
+ })
1366
+
1367
+ expect(disappearing.get('101')).toBeNull()
1368
+ })
1369
+
1370
+ test('ignores an operation removed during entry enumeration', () => {
1371
+ let reads = 0
1372
+ const entry = JSON.stringify({ text: 'draft', updatedAt: Date.now(), version: 'v1' })
1373
+ const disappearing = new ChatDrafts({
1374
+ length: 1,
1375
+ key: () => 'collavre_chat_drafts_9:101:v1',
1376
+ getItem: () => ((reads += 1) === 1 ? entry : null),
1377
+ setItem: () => {},
1378
+ removeItem: () => {},
1379
+ })
1380
+
1381
+ expect(disappearing._entries()).toEqual([])
1382
+ })
1383
+
1384
+ test('removes operation keys with malformed encoded chat ids', () => {
1385
+ const key = 'collavre_chat_drafts_9:%E0%A4%A:v1'
1386
+ window.localStorage.setItem(key, JSON.stringify({
1387
+ text: 'draft',
1388
+ updatedAt: Date.now(),
1389
+ version: 'v1',
1390
+ }))
1391
+
1392
+ expect(drafts._entries()).toEqual([])
1393
+ expect(window.localStorage.getItem(key)).toBeNull()
1394
+
1395
+ const unremovable = new ChatDrafts({
1396
+ length: 1,
1397
+ key: () => key,
1398
+ getItem: () => JSON.stringify({
1399
+ text: 'draft',
1400
+ updatedAt: Date.now(),
1401
+ version: 'v1',
1402
+ }),
1403
+ setItem: () => {},
1404
+ removeItem: () => { throw new Error('denied') },
1405
+ })
1406
+ expect(unremovable._entries()).toEqual([])
1407
+ })
1408
+
1409
+ test('skips invalid operations during compaction and selects the newest operation', () => {
1410
+ jest.spyOn(Date, 'now').mockReturnValue(1000)
1411
+ const older = { text: 'older', updatedAt: 1000, version: 'v1' }
1412
+ const newer = { text: 'newer', updatedAt: 1001, version: 'v2' }
1413
+ const stale = { text: 'stale', updatedAt: 999, version: 'v0' }
1414
+ drafts._append('101', older)
1415
+ drafts._append('101', newer)
1416
+ drafts._append('101', stale)
1417
+ window.localStorage.setItem('collavre_chat_drafts_9:101:invalid', '{bad')
1418
+
1419
+ expect(drafts._entries()).toEqual([{ id: '101', entry: newer }])
1420
+ window.localStorage.setItem('collavre_chat_drafts_9:101:invalid-entry', '{bad')
1421
+ expect(drafts._entry('101')).toEqual(newer)
1422
+ window.localStorage.setItem('collavre_chat_drafts_9:101:invalid-again', '{bad')
1423
+ expect(() => drafts._compact('101')).not.toThrow()
1424
+ })
1425
+
1426
+ test('handles storage failures while pruning and clearing independent entries', () => {
1427
+ const invalid = JSON.stringify({ text: 42, updatedAt: Date.now() })
1428
+ const backend = {
1429
+ length: 1,
1430
+ key: () => 'collavre_chat_drafts_9:101',
1431
+ getItem: (key) => (key.endsWith(':101') ? invalid : null),
1432
+ setItem: () => { throw new Error('quota') },
1433
+ removeItem: () => { throw new Error('denied') },
1434
+ }
1435
+ const failingDrafts = new ChatDrafts(backend)
1436
+
1437
+ expect(failingDrafts.get('101')).toBeNull()
1438
+ expect(() => failingDrafts.clearAll()).not.toThrow()
1439
+ })
1440
+
1441
+ test('bounds immutable append verification retries', () => {
1442
+ const entry = { text: 'draft', updatedAt: Date.now(), version: 'v1' }
1443
+ const noWriteStorage = {
1444
+ getItem: () => null,
1445
+ setItem: () => {},
1446
+ removeItem: () => {},
1447
+ }
1448
+ expect(new ChatDrafts(noWriteStorage)._append('101', entry)).toBe(false)
1449
+ })
1450
+
1451
+ test('compares immutable operations and exact entry identity', () => {
1452
+ const entry = { text: 'draft', updatedAt: 1000, version: 'v1' }
1453
+ expect(drafts._compare(entry, null)).toBe(1)
1454
+ expect(drafts._compare(entry, { ...entry })).toBe(0)
1455
+ expect(drafts._sameEntry(entry, { ...entry })).toBe(true)
1456
+ })
1457
+
1458
+ })