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,117 @@
1
+ 'use client'
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
3
+ import { useIdentity } from '../identity'
4
+ import { useWorkspace } from '../sessions'
5
+
6
+ const TYPING_IDLE_MS = 3000
7
+
8
+ export function Composer({
9
+ channel,
10
+ threadId,
11
+ placeholder,
12
+ autoFocus = false,
13
+ compact = false,
14
+ }: {
15
+ channel: string
16
+ threadId?: string
17
+ placeholder: string
18
+ autoFocus?: boolean
19
+ compact?: boolean
20
+ }): ReactNode {
21
+ const identity = useIdentity()
22
+ const { push, presence, setPresence } = useWorkspace()
23
+ const [text, setText] = useState('')
24
+ const areaRef = useRef<HTMLTextAreaElement>(null)
25
+ const idleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
26
+ // The workspace session outlives this channel, so leaving it while typing
27
+ // has to say so: nothing else would clear the field.
28
+ const stopRef = useRef<(() => void) | null>(null)
29
+
30
+ const stopTyping = (): void => {
31
+ clearTimeout(idleTimer.current)
32
+ stopRef.current = null
33
+ setPresence({ typing: null })
34
+ }
35
+
36
+ useEffect(
37
+ () => () => {
38
+ clearTimeout(idleTimer.current)
39
+ stopRef.current?.()
40
+ },
41
+ [],
42
+ )
43
+
44
+ useEffect(() => {
45
+ const el = areaRef.current
46
+ if (!el) return
47
+ el.style.height = 'auto'
48
+ el.style.height = `${Math.min(el.scrollHeight, 160)}px`
49
+ }, [text])
50
+
51
+ // Everyone typing in this channel but you.
52
+ const typing = Object.entries(presence)
53
+ .filter(([participant]) => participant !== identity?.id)
54
+ .flatMap(([, fields]) => {
55
+ const value = fields.typing?.value
56
+ return value?.channel === channel ? [value.name] : []
57
+ })
58
+
59
+ const canSend = identity !== null && text.trim().length > 0
60
+
61
+ const send = (): void => {
62
+ const trimmed = text.trim()
63
+ if (!identity || !trimmed) return
64
+ push({
65
+ type: 'message.posted',
66
+ payload: {
67
+ channel,
68
+ author: identity,
69
+ text: trimmed,
70
+ ...(threadId !== undefined ? { threadId } : {}),
71
+ },
72
+ }).catch(() => {})
73
+ setText('')
74
+ stopTyping()
75
+ }
76
+
77
+ return (
78
+ <div className="a2slack-composer" data-compact={compact || undefined}>
79
+ <div className="a2slack-composer-box">
80
+ <textarea
81
+ className="a2slack-composer-textarea"
82
+ ref={areaRef}
83
+ autoFocus={autoFocus}
84
+ rows={1}
85
+ maxLength={2000}
86
+ placeholder={identity ? placeholder : 'pick a name first'}
87
+ disabled={!identity}
88
+ value={text}
89
+ onChange={(e) => {
90
+ setText(e.target.value)
91
+ if (!identity) return
92
+ setPresence({ typing: { name: identity.name, channel } })
93
+ stopRef.current = () => setPresence({ typing: null })
94
+ clearTimeout(idleTimer.current)
95
+ idleTimer.current = setTimeout(stopTyping, TYPING_IDLE_MS)
96
+ }}
97
+ onKeyDown={(e) => {
98
+ if (e.key === 'Enter' && !e.shiftKey) {
99
+ e.preventDefault()
100
+ send()
101
+ }
102
+ }}
103
+ />
104
+ <div className="a2slack-composer-footer">
105
+ <span className="a2slack-typing">
106
+ {!compact &&
107
+ typing.length > 0 &&
108
+ `${typing.join(', ')} ${typing.length === 1 ? 'is' : 'are'} typing…`}
109
+ </span>
110
+ <button className="a2slack-send" disabled={!canSend} onClick={send}>
111
+ send ⏎
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+ import { DotGrid } from '@paper-design/shaders-react'
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+
5
+ function useShaderColors(): { background: string; foreground: string } {
6
+ const [dark, setDark] = useState(
7
+ () => window.matchMedia('(prefers-color-scheme: dark)').matches,
8
+ )
9
+
10
+ useEffect(() => {
11
+ const query = window.matchMedia('(prefers-color-scheme: dark)')
12
+ const onChange = (): void => setDark(query.matches)
13
+ query.addEventListener('change', onChange)
14
+ return () => query.removeEventListener('change', onChange)
15
+ }, [])
16
+
17
+ return {
18
+ background: dark ? '#04040495' : '#fafafa95',
19
+ foreground: dark ? '#404040' : '#d9d9d9',
20
+ }
21
+ }
22
+
23
+ export function DotsShadow(): ReactNode {
24
+ const colors = useShaderColors()
25
+
26
+ return (
27
+ <div className="a2slack-dots" aria-hidden>
28
+ <DotGrid
29
+ colorBack={colors.background}
30
+ colorFill={colors.foreground}
31
+ gapX={16}
32
+ gapY={16}
33
+ opacityRange={0}
34
+ scale={0.2}
35
+ shape="circle"
36
+ size={5}
37
+ sizeRange={0.6}
38
+ style={{ width: '100%', height: '100%' }}
39
+ />
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,157 @@
1
+ 'use client'
2
+ import {
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react'
9
+ import {
10
+ liveMessages,
11
+ type ChatMessage,
12
+ type MessagePage,
13
+ } from '@/app/a2slack/model'
14
+ import { useWorkspace } from '../sessions'
15
+ import { MessageRow } from './MessageRow'
16
+
17
+ const dayOf = (at: string): string =>
18
+ new Date(at).toLocaleDateString([], {
19
+ month: 'short',
20
+ day: 'numeric',
21
+ year: 'numeric',
22
+ })
23
+
24
+ /**
25
+ * The channel's messages: the pages read from `/a2slack/messages` while
26
+ * scrolling back, then the newest page, each kept live from the workspace's
27
+ * events. Only the newest page gains new messages.
28
+ */
29
+ export function Feed({
30
+ channel,
31
+ newest,
32
+ more,
33
+ onOpenThread,
34
+ }: {
35
+ channel: string
36
+ /** The newest page, already live. */
37
+ newest: ChatMessage[]
38
+ /** Older messages exist before the newest page. */
39
+ more: boolean
40
+ onOpenThread: (rootId: string) => void
41
+ }): ReactNode {
42
+ const { events } = useWorkspace()
43
+ // Pages loaded by scrolling back, oldest first.
44
+ const [older, setOlder] = useState<MessagePage[]>([])
45
+ const [loading, setLoading] = useState(false)
46
+ const [failed, setFailed] = useState(false)
47
+
48
+ const feed = useMemo(
49
+ () => [
50
+ ...older.flatMap((page) =>
51
+ liveMessages(page, events, { channel, append: false }),
52
+ ),
53
+ ...newest,
54
+ ],
55
+ [older, events, channel, newest],
56
+ )
57
+
58
+ const scrollRef = useRef<HTMLDivElement>(null)
59
+ const pinnedRef = useRef(true)
60
+ // Where the viewport stood when an older page arrived, to keep it there.
61
+ const anchorRef = useRef<{ height: number; top: number } | null>(null)
62
+ const lastId = feed.at(-1)?.id
63
+
64
+ // Follow new messages while pinned to the bottom.
65
+ useLayoutEffect(() => {
66
+ const el = scrollRef.current
67
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight
68
+ }, [lastId])
69
+
70
+ // Keep the viewport anchored when an older page prepends above it.
71
+ useLayoutEffect(() => {
72
+ const el = scrollRef.current
73
+ const anchor = anchorRef.current
74
+ if (!el || !anchor) return
75
+ anchorRef.current = null
76
+ el.scrollTop = anchor.top + el.scrollHeight - anchor.height
77
+ }, [older])
78
+
79
+ const oldest = older[0]?.messages[0] ?? newest[0]
80
+ const hasMore = older[0]?.more ?? more
81
+
82
+ const loadEarlier = async (): Promise<void> => {
83
+ if (oldest === undefined || loading) return
84
+ setLoading(true)
85
+ setFailed(false)
86
+ pinnedRef.current = false
87
+ try {
88
+ const response = await fetch(
89
+ `/a2slack/messages?channel=${encodeURIComponent(channel)}&before=${oldest.index}`,
90
+ )
91
+ if (!response.ok) throw new Error(`messages: ${response.status}`)
92
+ const page = (await response.json()) as MessagePage
93
+ const el = scrollRef.current
94
+ if (el) anchorRef.current = { height: el.scrollHeight, top: el.scrollTop }
95
+ setOlder((pages) => [page, ...pages])
96
+ } catch {
97
+ setFailed(true)
98
+ } finally {
99
+ setLoading(false)
100
+ }
101
+ }
102
+
103
+ return (
104
+ <div
105
+ ref={scrollRef}
106
+ className="a2slack-feed"
107
+ onScroll={(e) => {
108
+ const el = e.currentTarget
109
+ pinnedRef.current =
110
+ el.scrollHeight - el.scrollTop - el.clientHeight < 40
111
+ }}
112
+ >
113
+ {hasMore && (
114
+ <button
115
+ className="a2slack-older"
116
+ disabled={loading}
117
+ onClick={() => void loadEarlier()}
118
+ >
119
+ {loading
120
+ ? 'loading…'
121
+ : failed
122
+ ? "↑ earlier messages (didn't load, try again)"
123
+ : '↑ earlier messages'}
124
+ </button>
125
+ )}
126
+ {feed.length === 0 && (
127
+ <p className="a2slack-feed-empty">no messages yet — say something</p>
128
+ )}
129
+ {feed.map((message, i) => {
130
+ const prev = feed[i - 1]
131
+ const newDay =
132
+ prev === undefined || dayOf(prev.at) !== dayOf(message.at)
133
+ const grouped =
134
+ !newDay &&
135
+ prev !== undefined &&
136
+ !prev.deleted &&
137
+ prev.author.id === message.author.id &&
138
+ Date.parse(message.at) - Date.parse(prev.at) < 5 * 60 * 1000
139
+ return (
140
+ <div key={message.id}>
141
+ {newDay && (
142
+ <div className="a2slack-day">
143
+ <span>{dayOf(message.at)}</span>
144
+ <span className="a2slack-day-line" />
145
+ </div>
146
+ )}
147
+ <MessageRow
148
+ message={message}
149
+ grouped={grouped}
150
+ onOpenThread={onOpenThread}
151
+ />
152
+ </div>
153
+ )
154
+ })}
155
+ </div>
156
+ )
157
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The inbox, shared by the sidebar (its row and unread badge), the main
3
+ * column (the list), and the thread panel (which marks what it shows read).
4
+ * One page read serves all three.
5
+ */
6
+ 'use client'
7
+ import {
8
+ createContext,
9
+ useContext,
10
+ useRef,
11
+ useState,
12
+ type ReactNode,
13
+ } from 'react'
14
+ import { useIdentity } from '../identity'
15
+ import { useWorkspace } from '../sessions'
16
+ import { useInbox, type Inbox } from './useInbox'
17
+
18
+ export type InboxPanel = Inbox & {
19
+ /** The list shows in the main column. */
20
+ open: boolean
21
+ setOpen: (open: boolean) => void
22
+ /** Marks a thread's notifications read, through its latest one. */
23
+ markRead: (threadId: string) => void
24
+ }
25
+
26
+ const InboxContext = createContext<InboxPanel | null>(null)
27
+
28
+ export function useInboxPanel(): InboxPanel {
29
+ const inbox = useContext(InboxContext)
30
+ if (inbox === null) throw new Error('useInboxPanel needs an InboxProvider')
31
+ return inbox
32
+ }
33
+
34
+ export function InboxProvider({
35
+ children,
36
+ }: {
37
+ children: ReactNode
38
+ }): ReactNode {
39
+ const identity = useIdentity()
40
+ const { push } = useWorkspace()
41
+ const inbox = useInbox(identity?.id ?? null)
42
+ const [open, setOpen] = useState(false)
43
+ // What this tab has already marked, so a re-render before the read lands
44
+ // doesn't send it again.
45
+ const marked = useRef(new Map<string, number>())
46
+
47
+ const markRead = (threadId: string): void => {
48
+ const item = inbox.items.find((other) => other.threadId === threadId)
49
+ if (!identity || !item || item.unread === 0) return
50
+ if ((marked.current.get(threadId) ?? -1) >= item.index) return
51
+ marked.current.set(threadId, item.index)
52
+ push({
53
+ type: 'inbox.read',
54
+ payload: { by: identity.id, threadId, through: item.index },
55
+ }).catch(() => {})
56
+ }
57
+
58
+ return (
59
+ <InboxContext value={{ ...inbox, open, setOpen, markRead }}>
60
+ {children}
61
+ </InboxContext>
62
+ )
63
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import Link from 'next/link'
4
+ import type { InboxItem } from '@/app/a2slack/model'
5
+ import { useInboxPanel } from './InboxContext'
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
+ const REASON: Record<InboxItem['reason'], string> = {
16
+ mention: 'mentioned you',
17
+ reply: "replied in a thread you're in",
18
+ }
19
+
20
+ /** The main column while the inbox is open: one row per thread with news. */
21
+ export function InboxView(): ReactNode {
22
+ const inbox = useInboxPanel()
23
+ if (!inbox.open) return null
24
+
25
+ const status = inbox.loading
26
+ ? 'loading…'
27
+ : inbox.error !== null
28
+ ? 'unavailable'
29
+ : `${inbox.unread} unread`
30
+
31
+ return (
32
+ <section
33
+ className="a2slack-results"
34
+ aria-label="inbox"
35
+ onKeyDown={(e) => {
36
+ if (e.key === 'Escape') inbox.setOpen(false)
37
+ }}
38
+ >
39
+ <header className="a2slack-header">
40
+ <span className="a2slack-header-name">inbox</span>
41
+ <span className="a2slack-status" role="status">
42
+ {status}
43
+ </span>
44
+ </header>
45
+ <div className="a2slack-results-list">
46
+ {inbox.error !== null ? (
47
+ <p className="a2slack-results-hint">{inbox.error}</p>
48
+ ) : (
49
+ !inbox.loading &&
50
+ inbox.items.length === 0 && (
51
+ <p className="a2slack-results-hint">
52
+ nothing yet — mentions and replies in your threads land here
53
+ </p>
54
+ )
55
+ )}
56
+ {inbox.items.map((item) => (
57
+ <Link
58
+ key={item.threadId}
59
+ href={`/a2slack/c/${item.channel}?thread=${encodeURIComponent(item.threadId)}`}
60
+ className="a2slack-result"
61
+ data-unread={item.unread > 0 || undefined}
62
+ onClick={() => {
63
+ inbox.markRead(item.threadId)
64
+ inbox.setOpen(false)
65
+ }}
66
+ >
67
+ <div className="a2slack-meta">
68
+ <span className="a2slack-result-channel">#{item.channel}</span>
69
+ <span className="a2slack-author">{item.from.name}</span>
70
+ <span className="a2slack-time">{REASON[item.reason]}</span>
71
+ <span className="a2slack-time">{whenOf(item.at)}</span>
72
+ {item.unread > 0 && (
73
+ <span className="a2slack-unread">{item.unread}</span>
74
+ )}
75
+ </div>
76
+ <p className="a2slack-result-text">{item.text}</p>
77
+ </Link>
78
+ ))}
79
+ </div>
80
+ </section>
81
+ )
82
+ }
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+ import { Menu as BaseMenu } from '@base-ui/react/menu'
3
+ import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
4
+ import { useCallback, useState, type ReactNode } from 'react'
5
+ import { DotsShadow } from './DotsShadow'
6
+
7
+ /**
8
+ * Menus portal into the page root, not the body, so they keep a2slack's
9
+ * tokens. Returns a ref for the trigger and the root it found.
10
+ */
11
+ function usePageRoot(): [
12
+ (element: Element | null) => void,
13
+ HTMLElement | null,
14
+ ] {
15
+ const [root, setRoot] = useState<HTMLElement | null>(null)
16
+ const ref = useCallback((element: Element | null) => {
17
+ if (element) setRoot(element.closest<HTMLElement>('.a2slack-page'))
18
+ }, [])
19
+ return [ref, root]
20
+ }
21
+
22
+ function Popup({ children }: { children: ReactNode }): ReactNode {
23
+ return (
24
+ <div className="a2slack-menu-wrapper">
25
+ <BaseMenu.Popup className="a2slack-menu-popup">{children}</BaseMenu.Popup>
26
+ <DotsShadow />
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export function Menu({
32
+ trigger,
33
+ children,
34
+ onOpenChange,
35
+ triggerClassName = 'a2slack-menu-trigger',
36
+ }: {
37
+ trigger: ReactNode
38
+ children: ReactNode
39
+ onOpenChange?: (open: boolean) => void
40
+ /** Replaces the trigger's own look. */
41
+ triggerClassName?: string
42
+ }): ReactNode {
43
+ const [ref, root] = usePageRoot()
44
+ return (
45
+ <BaseMenu.Root {...(onOpenChange ? { onOpenChange } : {})}>
46
+ <BaseMenu.Trigger ref={ref} className={triggerClassName}>
47
+ {trigger}
48
+ </BaseMenu.Trigger>
49
+ <BaseMenu.Portal container={root}>
50
+ <BaseMenu.Positioner
51
+ className="a2slack-menu-positioner"
52
+ align="end"
53
+ sideOffset={4}
54
+ >
55
+ <Popup>{children}</Popup>
56
+ </BaseMenu.Positioner>
57
+ </BaseMenu.Portal>
58
+ </BaseMenu.Root>
59
+ )
60
+ }
61
+
62
+ export function ContextMenuArea({
63
+ menu,
64
+ children,
65
+ onOpenChange,
66
+ }: {
67
+ menu: ReactNode
68
+ children: ReactNode
69
+ onOpenChange?: (open: boolean) => void
70
+ }): ReactNode {
71
+ const [ref, root] = usePageRoot()
72
+ return (
73
+ <BaseContextMenu.Root {...(onOpenChange ? { onOpenChange } : {})}>
74
+ <BaseContextMenu.Trigger ref={ref}>{children}</BaseContextMenu.Trigger>
75
+ <BaseContextMenu.Portal container={root}>
76
+ <BaseContextMenu.Positioner className="a2slack-menu-positioner">
77
+ <Popup>{menu}</Popup>
78
+ </BaseContextMenu.Positioner>
79
+ </BaseContextMenu.Portal>
80
+ </BaseContextMenu.Root>
81
+ )
82
+ }
83
+
84
+ export function MenuItem({
85
+ onClick,
86
+ danger,
87
+ children,
88
+ }: {
89
+ onClick: () => void
90
+ danger?: boolean
91
+ children: ReactNode
92
+ }): ReactNode {
93
+ return (
94
+ <BaseMenu.Item
95
+ className="a2slack-menu-item"
96
+ {...(danger === true ? { 'data-danger': '' } : {})}
97
+ onClick={onClick}
98
+ >
99
+ {children}
100
+ </BaseMenu.Item>
101
+ )
102
+ }
103
+
104
+ export function MenuSeparator(): ReactNode {
105
+ return <BaseMenu.Separator className="a2slack-menu-separator" />
106
+ }
107
+
108
+ export function MenuEmojiRow({
109
+ emojis,
110
+ onPick,
111
+ }: {
112
+ emojis: readonly string[]
113
+ onPick: (emoji: string) => void
114
+ }): ReactNode {
115
+ return (
116
+ <div className="a2slack-emoji-row">
117
+ {emojis.map((emoji) => (
118
+ <BaseMenu.Item
119
+ key={emoji}
120
+ className="a2slack-menu-item a2slack-emoji-item"
121
+ onClick={() => onPick(emoji)}
122
+ >
123
+ {emoji}
124
+ </BaseMenu.Item>
125
+ ))}
126
+ </div>
127
+ )
128
+ }