experimental-a2 0.20.0 → 0.22.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 (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,314 @@
1
+ 'use client'
2
+ import { useRef, useState, type ReactNode } from 'react'
3
+ import { Ellipsis, MessageSquare, SmilePlus } from 'lucide-react'
4
+ import { BOT, type ChatMessage } from '@/app/a2slack/model'
5
+ import { useIdentity } from '../identity'
6
+ import { useWorkspace } from '../sessions'
7
+ import {
8
+ ContextMenuArea,
9
+ Menu,
10
+ MenuEmojiRow,
11
+ MenuItem,
12
+ MenuSeparator,
13
+ } from './Menu'
14
+
15
+ export const EMOJIS = [
16
+ '👍',
17
+ '❤️',
18
+ '😂',
19
+ '🎉',
20
+ '😮',
21
+ '😢',
22
+ '👀',
23
+ '✅',
24
+ '🔥',
25
+ '💯',
26
+ '🙏',
27
+ '👎',
28
+ ] as const
29
+
30
+ const timeOf = (at: string): string =>
31
+ new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
32
+
33
+ function Reactions({ message }: { message: ChatMessage }): ReactNode {
34
+ const identity = useIdentity()
35
+ const { push } = useWorkspace()
36
+ if (message.reactions.length === 0) return null
37
+
38
+ const toggle = (emoji: string): void => {
39
+ if (!identity) return
40
+ const mine = message.reactions
41
+ .find((r) => r.emoji === emoji)
42
+ ?.by.some((a) => a.id === identity.id)
43
+ push({
44
+ type: mine ? 'reaction.removed' : 'reaction.added',
45
+ payload: {
46
+ channel: message.channel,
47
+ messageId: message.id,
48
+ emoji,
49
+ by: identity,
50
+ },
51
+ }).catch(() => {})
52
+ }
53
+
54
+ return (
55
+ <div className="a2slack-reactions">
56
+ {message.reactions.map((reaction) => {
57
+ const mine = reaction.by.some((a) => a.id === identity?.id)
58
+ return (
59
+ <button
60
+ key={reaction.emoji}
61
+ className="a2slack-chip"
62
+ data-mine={mine || undefined}
63
+ title={reaction.by.map((a) => a.name).join(', ')}
64
+ onClick={() => toggle(reaction.emoji)}
65
+ >
66
+ <span>{reaction.emoji}</span>
67
+ <span>{reaction.by.length}</span>
68
+ </button>
69
+ )
70
+ })}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ function Editor({
76
+ message,
77
+ onDone,
78
+ }: {
79
+ message: ChatMessage
80
+ onDone: () => void
81
+ }): ReactNode {
82
+ const identity = useIdentity()
83
+ const { push } = useWorkspace()
84
+ const [text, setText] = useState(message.text)
85
+
86
+ const save = (): void => {
87
+ const trimmed = text.trim()
88
+ if (identity && trimmed && trimmed !== message.text) {
89
+ push({
90
+ type: 'message.edited',
91
+ payload: {
92
+ channel: message.channel,
93
+ messageId: message.id,
94
+ text: trimmed,
95
+ by: identity,
96
+ },
97
+ }).catch(() => {})
98
+ }
99
+ onDone()
100
+ }
101
+
102
+ return (
103
+ <div className="a2slack-edit-box">
104
+ <textarea
105
+ className="a2slack-edit-area"
106
+ autoFocus
107
+ rows={Math.min(6, text.split('\n').length)}
108
+ value={text}
109
+ onChange={(e) => setText(e.target.value)}
110
+ onKeyDown={(e) => {
111
+ if (e.key === 'Enter' && !e.shiftKey) {
112
+ e.preventDefault()
113
+ save()
114
+ }
115
+ if (e.key === 'Escape') onDone()
116
+ }}
117
+ />
118
+ <span className="a2slack-edit-hint">enter to save · esc to cancel</span>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ export function MessageRow({
124
+ message,
125
+ grouped,
126
+ inThread = false,
127
+ onOpenThread,
128
+ }: {
129
+ message: ChatMessage
130
+ grouped: boolean
131
+ inThread?: boolean
132
+ onOpenThread?: (rootId: string) => void
133
+ }): ReactNode {
134
+ const identity = useIdentity()
135
+ const { push } = useWorkspace()
136
+ const row = useRef<HTMLDivElement>(null)
137
+ const [hovered, setHovered] = useState(false)
138
+ const [menuOpen, setMenuOpen] = useState(false)
139
+ const [editing, setEditing] = useState(false)
140
+
141
+ // A menu's popup sits outside the row, so the pointer can leave the row
142
+ // over it without a mouseleave. When a menu closes, ask the row itself.
143
+ const onMenuOpenChange = (open: boolean): void => {
144
+ setMenuOpen(open)
145
+ if (!open) setHovered(row.current?.matches(':hover') ?? false)
146
+ }
147
+
148
+ const mine = identity !== null && identity.id === message.author.id
149
+ const showActions = !editing && !message.deleted && (hovered || menuOpen)
150
+
151
+ const react = (emoji: string): void => {
152
+ if (!identity) return
153
+ const already = message.reactions
154
+ .find((r) => r.emoji === emoji)
155
+ ?.by.some((a) => a.id === identity.id)
156
+ push({
157
+ type: already ? 'reaction.removed' : 'reaction.added',
158
+ payload: {
159
+ channel: message.channel,
160
+ messageId: message.id,
161
+ emoji,
162
+ by: identity,
163
+ },
164
+ }).catch(() => {})
165
+ }
166
+
167
+ const remove = (): void => {
168
+ if (!identity) return
169
+ push({
170
+ type: 'message.deleted',
171
+ payload: {
172
+ channel: message.channel,
173
+ messageId: message.id,
174
+ by: identity,
175
+ },
176
+ }).catch(() => {})
177
+ }
178
+
179
+ const menuItems = (
180
+ <>
181
+ <MenuEmojiRow emojis={EMOJIS.slice(0, 6)} onPick={react} />
182
+ <MenuSeparator />
183
+ {!inThread && onOpenThread && (
184
+ <MenuItem onClick={() => onOpenThread(message.id)}>
185
+ reply in thread
186
+ </MenuItem>
187
+ )}
188
+ <MenuItem
189
+ onClick={() => {
190
+ navigator.clipboard.writeText(message.text).catch(() => {})
191
+ }}
192
+ >
193
+ copy text
194
+ </MenuItem>
195
+ {mine && (
196
+ <>
197
+ <MenuSeparator />
198
+ <MenuItem onClick={() => setEditing(true)}>edit message</MenuItem>
199
+ <MenuItem danger onClick={remove}>
200
+ delete message
201
+ </MenuItem>
202
+ </>
203
+ )}
204
+ </>
205
+ )
206
+
207
+ // What triage called a top-level message: beside the author and time,
208
+ // or after the text when the row is grouped under the one before it.
209
+ const badge = message.threadId === undefined &&
210
+ message.label !== undefined &&
211
+ !message.deleted && <span className="a2slack-badge">{message.label}</span>
212
+
213
+ const body = message.deleted ? (
214
+ <p className="a2slack-tombstone">message removed</p>
215
+ ) : editing ? (
216
+ <Editor message={message} onDone={() => setEditing(false)} />
217
+ ) : (
218
+ <p className="a2slack-text">
219
+ {message.text}
220
+ {message.editedAt !== undefined && (
221
+ <span className="a2slack-edited">(edited)</span>
222
+ )}
223
+ {grouped && badge}
224
+ </p>
225
+ )
226
+
227
+ return (
228
+ <ContextMenuArea menu={menuItems} onOpenChange={onMenuOpenChange}>
229
+ <div
230
+ ref={row}
231
+ className="a2slack-row"
232
+ data-thread={inThread || undefined}
233
+ data-spaced={!grouped || undefined}
234
+ data-hover={((hovered || menuOpen) && !editing) || undefined}
235
+ onMouseEnter={() => setHovered(true)}
236
+ onMouseLeave={() => setHovered(false)}
237
+ >
238
+ {!grouped && (
239
+ <div className="a2slack-meta">
240
+ <span className="a2slack-author">{message.author.name}</span>
241
+ {message.author.id === BOT.id && (
242
+ <span className="a2slack-badge">bot</span>
243
+ )}
244
+ <span className="a2slack-time">{timeOf(message.at)}</span>
245
+ {badge}
246
+ </div>
247
+ )}
248
+ {grouped && hovered && !inThread && (
249
+ <span className="a2slack-gutter-time">{timeOf(message.at)}</span>
250
+ )}
251
+ {body}
252
+ <Reactions message={message} />
253
+ {!inThread && message.replyCount > 0 && onOpenThread && (
254
+ <button
255
+ className="a2slack-thread-pill"
256
+ onClick={() => onOpenThread(message.id)}
257
+ >
258
+ <span>
259
+ {message.replyCount}{' '}
260
+ {message.replyCount === 1 ? 'reply' : 'replies'} →
261
+ </span>
262
+ {message.lastReplyAt !== undefined && (
263
+ <span className="a2slack-thread-pill-time">
264
+ last {timeOf(message.lastReplyAt)}
265
+ </span>
266
+ )}
267
+ </button>
268
+ )}
269
+ {showActions && (
270
+ <div className="a2slack-actions" data-thread={inThread || undefined}>
271
+ <button
272
+ className="a2slack-action-button a2slack-quick-emoji"
273
+ title="react 👍"
274
+ onClick={() => react('👍')}
275
+ >
276
+ 👍
277
+ </button>
278
+ <Menu
279
+ trigger={
280
+ <SmilePlus
281
+ size={15}
282
+ strokeWidth={1.75}
283
+ aria-label="add reaction"
284
+ />
285
+ }
286
+ triggerClassName="a2slack-action-button"
287
+ onOpenChange={onMenuOpenChange}
288
+ >
289
+ <MenuEmojiRow emojis={EMOJIS} onPick={react} />
290
+ </Menu>
291
+ {!inThread && onOpenThread && (
292
+ <button
293
+ className="a2slack-action-button"
294
+ title="reply in thread"
295
+ onClick={() => onOpenThread(message.id)}
296
+ >
297
+ <MessageSquare size={15} strokeWidth={1.75} />
298
+ </button>
299
+ )}
300
+ <Menu
301
+ trigger={
302
+ <Ellipsis size={15} strokeWidth={1.75} aria-label="more" />
303
+ }
304
+ triggerClassName="a2slack-action-button"
305
+ onOpenChange={onMenuOpenChange}
306
+ >
307
+ {menuItems}
308
+ </Menu>
309
+ </div>
310
+ )}
311
+ </div>
312
+ </ContextMenuArea>
313
+ )
314
+ }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+ import { useState, type ReactNode } from 'react'
3
+ import { saveIdentity } from '../identity'
4
+ import { DotsShadow } from './DotsShadow'
5
+
6
+ export function NameGate(): ReactNode {
7
+ const [name, setName] = useState('')
8
+
9
+ return (
10
+ <div className="a2slack-gate">
11
+ <div className="a2slack-gate-wrapper">
12
+ <form
13
+ className="a2slack-gate-box"
14
+ onSubmit={(e) => {
15
+ e.preventDefault()
16
+ if (name.trim()) saveIdentity(name)
17
+ }}
18
+ >
19
+ <p className="a2slack-gate-title">a2slack</p>
20
+ <p className="a2slack-gate-label">what should we call you?</p>
21
+ <input
22
+ className="a2slack-gate-input"
23
+ autoFocus
24
+ maxLength={32}
25
+ placeholder="your name"
26
+ value={name}
27
+ onChange={(e) => setName(e.target.value)}
28
+ />
29
+ <p className="a2slack-gate-hint">⏎ to join</p>
30
+ </form>
31
+ <DotsShadow />
32
+ </div>
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The search box's state, shared by the sidebar (where you type) and the
3
+ * main column (where the results show). Typing searches once it settles;
4
+ * Enter searches now. Results answer `GET /a2slack/search?q=`, which reads
5
+ * the messages table.
6
+ */
7
+ 'use client'
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ useEffect,
12
+ useRef,
13
+ useState,
14
+ type ReactNode,
15
+ type RefObject,
16
+ } from 'react'
17
+ import type { SearchResult } from '@/app/a2slack/model'
18
+
19
+ const SETTLE_MS = 250
20
+
21
+ type Request = { q: string; n: number }
22
+
23
+ type Outcome = Request & { results: SearchResult[]; error: string | null }
24
+
25
+ export type Search = {
26
+ /** What's in the box. Results show while it has text. */
27
+ query: string
28
+ setQuery: (text: string) => void
29
+ /** Searches for the box's text now, without waiting for it to settle. */
30
+ submit: () => void
31
+ /** Empties the box, which closes the results. */
32
+ clear: () => void
33
+ /** The latest answer, and the query it answers. */
34
+ outcome: Outcome | null
35
+ /** A search for the box's text hasn't answered yet. */
36
+ loading: boolean
37
+ input: RefObject<HTMLInputElement | null>
38
+ }
39
+
40
+ const SearchContext = createContext<Search | null>(null)
41
+
42
+ export function useSearch(): Search {
43
+ const search = useContext(SearchContext)
44
+ if (search === null) throw new Error('useSearch needs a SearchProvider')
45
+ return search
46
+ }
47
+
48
+ async function fetchResults(
49
+ q: string,
50
+ signal: AbortSignal,
51
+ ): Promise<{ results: SearchResult[]; error: string | null }> {
52
+ const response = await fetch(`/a2slack/search?q=${encodeURIComponent(q)}`, {
53
+ signal,
54
+ })
55
+ const body = (await response.json().catch(() => ({}))) as {
56
+ results?: SearchResult[]
57
+ error?: string
58
+ }
59
+ if (response.ok && body.results !== undefined) {
60
+ return { results: body.results, error: null }
61
+ }
62
+ return {
63
+ results: [],
64
+ error: body.error ?? `search failed (${response.status})`,
65
+ }
66
+ }
67
+
68
+ export function SearchProvider({
69
+ children,
70
+ }: {
71
+ children: ReactNode
72
+ }): ReactNode {
73
+ const [query, setQueryText] = useState('')
74
+ const [request, setRequest] = useState<Request | null>(null)
75
+ const [outcome, setOutcome] = useState<Outcome | null>(null)
76
+ const input = useRef<HTMLInputElement>(null)
77
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
78
+ const sent = useRef(0)
79
+
80
+ useEffect(() => () => clearTimeout(timer.current), [])
81
+
82
+ useEffect(() => {
83
+ if (request === null) return
84
+ const controller = new AbortController()
85
+ fetchResults(request.q, controller.signal)
86
+ .then((answer) => setOutcome({ ...request, ...answer }))
87
+ .catch(() => {
88
+ if (controller.signal.aborted) return
89
+ setOutcome({ ...request, results: [], error: "search didn't answer" })
90
+ })
91
+ return () => controller.abort()
92
+ }, [request])
93
+
94
+ const send = (q: string): void => {
95
+ clearTimeout(timer.current)
96
+ sent.current += 1
97
+ setRequest({ q, n: sent.current })
98
+ }
99
+
100
+ const setQuery = (text: string): void => {
101
+ setQueryText(text)
102
+ clearTimeout(timer.current)
103
+ const q = text.trim()
104
+ if (q === '') {
105
+ setRequest(null)
106
+ setOutcome(null)
107
+ return
108
+ }
109
+ if (q === request?.q) return
110
+ timer.current = setTimeout(() => send(q), SETTLE_MS)
111
+ }
112
+
113
+ const submit = (): void => {
114
+ const q = query.trim()
115
+ if (q !== '') send(q)
116
+ }
117
+
118
+ const clear = (): void => {
119
+ clearTimeout(timer.current)
120
+ setQueryText('')
121
+ setRequest(null)
122
+ setOutcome(null)
123
+ }
124
+
125
+ const answered =
126
+ outcome !== null &&
127
+ request !== null &&
128
+ outcome.n === request.n &&
129
+ outcome.q === query.trim()
130
+
131
+ return (
132
+ <SearchContext
133
+ value={{
134
+ query,
135
+ setQuery,
136
+ submit,
137
+ clear,
138
+ outcome,
139
+ loading: query.trim() !== '' && !answered,
140
+ input,
141
+ }}
142
+ >
143
+ {children}
144
+ </SearchContext>
145
+ )
146
+ }
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import Link from 'next/link'
4
+ import type { SearchResult } from '@/app/a2slack/model'
5
+ import { useSearch } from './SearchContext'
6
+
7
+ const whenOf = (at: string): string =>
8
+ new Date(at).toLocaleString([], {
9
+ month: 'short',
10
+ day: 'numeric',
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ })
14
+
15
+ /** A reply opens its thread; a top-level message opens its channel. */
16
+ const hrefOf = (result: SearchResult): string =>
17
+ result.threadId === undefined
18
+ ? `/a2slack/c/${result.channel}`
19
+ : `/a2slack/c/${result.channel}?thread=${encodeURIComponent(result.threadId)}`
20
+
21
+ /** The text, with each case-insensitive match of `q` marked, as the search's ILIKE matches it. */
22
+ function Highlighted({ text, q }: { text: string; q: string }): ReactNode {
23
+ if (q === '') return text
24
+ const pattern = new RegExp(
25
+ `(${q.replaceAll(/[.*+?^${}()|[\]\\/]/g, '\\$&')})`,
26
+ 'iu',
27
+ )
28
+ return text
29
+ .split(pattern)
30
+ .map((part, i) => (i % 2 === 1 ? <mark key={i}>{part}</mark> : part))
31
+ }
32
+
33
+ /** The main column while the search box has text: every match, newest first. */
34
+ export function SearchResults(): ReactNode {
35
+ const search = useSearch()
36
+ const query = search.query.trim()
37
+ if (query === '') return null
38
+
39
+ const { outcome, loading } = search
40
+ const results = outcome?.results ?? []
41
+ const error = outcome?.error ?? null
42
+ const status =
43
+ error !== null && !loading
44
+ ? 'unavailable'
45
+ : loading
46
+ ? 'searching…'
47
+ : `${results.length} ${results.length === 1 ? 'result' : 'results'}`
48
+
49
+ return (
50
+ <section
51
+ className="a2slack-results"
52
+ aria-label="search results"
53
+ onKeyDown={(e) => {
54
+ if (e.key !== 'Escape') return
55
+ search.clear()
56
+ search.input.current?.focus()
57
+ }}
58
+ >
59
+ <header className="a2slack-header">
60
+ <span className="a2slack-header-name">search · {query}</span>
61
+ <span className="a2slack-status" role="status">
62
+ {status}
63
+ </span>
64
+ </header>
65
+ <div className="a2slack-results-list">
66
+ {error !== null ? (
67
+ <p className="a2slack-results-hint">{error}</p>
68
+ ) : (
69
+ !loading &&
70
+ results.length === 0 && (
71
+ <p className="a2slack-results-hint">no messages match</p>
72
+ )
73
+ )}
74
+ {results.map((result) => (
75
+ <Link
76
+ key={result.id}
77
+ href={hrefOf(result)}
78
+ className="a2slack-result"
79
+ onClick={() => search.clear()}
80
+ >
81
+ <div className="a2slack-meta">
82
+ <span className="a2slack-result-channel">#{result.channel}</span>
83
+ <span className="a2slack-author">{result.author.name}</span>
84
+ <span className="a2slack-time">{whenOf(result.at)}</span>
85
+ {result.threadId !== undefined && (
86
+ <span className="a2slack-time">in a thread</span>
87
+ )}
88
+ </div>
89
+ <p className="a2slack-result-text">
90
+ <Highlighted text={result.text} q={outcome?.q ?? query} />
91
+ </p>
92
+ </Link>
93
+ ))}
94
+ </div>
95
+ </section>
96
+ )
97
+ }
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+ import { useEffect, useState, type ReactNode } from 'react'
3
+ import { useIdentity } from '@/app/a2slack/identity'
4
+ import { InboxProvider } from '@/app/a2slack/ui/InboxContext'
5
+ import { InboxView } from '@/app/a2slack/ui/InboxView'
6
+ import { NameGate } from '@/app/a2slack/ui/NameGate'
7
+ import { SearchProvider } from '@/app/a2slack/ui/SearchContext'
8
+ import { SearchResults } from '@/app/a2slack/ui/SearchResults'
9
+ import { Sidebar } from '@/app/a2slack/ui/Sidebar'
10
+
11
+ export function Shell({ children }: { children: ReactNode }): ReactNode {
12
+ const identity = useIdentity()
13
+ const [mounted, setMounted] = useState(false)
14
+ useEffect(() => setMounted(true), [])
15
+
16
+ // The root layout already renders the page's <main>, so this column is
17
+ // a div. Search results and the inbox cover it; the channel underneath
18
+ // stays mounted.
19
+ return (
20
+ <SearchProvider>
21
+ <InboxProvider>
22
+ <div className="a2slack-shell">
23
+ <Sidebar />
24
+ <div className="a2slack-main">
25
+ {children}
26
+ <InboxView />
27
+ <SearchResults />
28
+ </div>
29
+ {mounted && !identity && <NameGate />}
30
+ </div>
31
+ </InboxProvider>
32
+ </SearchProvider>
33
+ )
34
+ }