experimental-a2 0.19.0 → 0.21.1

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 (320) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  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-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  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-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.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 +1 -1
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +36 -9
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  25. package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
  26. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
  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 +4 -4
  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 +84 -75
  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-9IeTsiAs.js} +5 -5
  40. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  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-Bc4PvfHk.js} +10 -3
  52. package/dist/internal-Bc4PvfHk.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-BWmht60z.js +12 -0
  65. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +7 -7
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  71. package/dist/scheduler-vercel.d.ts +2 -2
  72. package/dist/scheduler-vercel.d.ts.map +1 -1
  73. package/dist/scheduler-vercel.js +5 -4
  74. package/dist/scheduler-vercel.js.map +1 -1
  75. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  76. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  77. package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
  78. package/dist/server-DvIB8kj5.js.map +1 -0
  79. package/dist/server.browser.js +1 -1
  80. package/dist/server.browser.js.map +1 -1
  81. package/dist/server.d.ts +4 -4
  82. package/dist/server.js +2 -2
  83. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  84. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  85. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  86. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -1
  88. package/dist/store-Z2qjw50n.d.ts +523 -0
  89. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  90. package/dist/store-codec-DwrWlgQx.js +65 -0
  91. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  92. package/dist/store-memory.d.ts +1 -1
  93. package/dist/store-memory.d.ts.map +1 -1
  94. package/dist/store-memory.js +393 -210
  95. package/dist/store-memory.js.map +1 -1
  96. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  97. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  98. package/dist/store-postgres.d.ts +1 -1
  99. package/dist/store-postgres.d.ts.map +1 -1
  100. package/dist/store-postgres.js +1595 -673
  101. package/dist/store-postgres.js.map +1 -1
  102. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  103. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  104. package/dist/store-redis-http.d.ts +1 -1
  105. package/dist/store-redis-http.js +4 -4
  106. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  107. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  108. package/dist/store-redis.d.ts +1 -1
  109. package/dist/store-redis.js +2 -2
  110. package/dist/store-sqlite.d.ts +1 -1
  111. package/dist/store-sqlite.d.ts.map +1 -1
  112. package/dist/store-sqlite.js +517 -287
  113. package/dist/store-sqlite.js.map +1 -1
  114. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  115. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  116. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  117. package/dist/wire-CZGqNZRx.js.map +1 -0
  118. package/docs/01-quickstart.mdx +28 -19
  119. package/docs/02-examples.mdx +1 -1
  120. package/docs/actors/01-introduction.mdx +9 -1
  121. package/docs/actors/02-concurrency.mdx +1 -1
  122. package/docs/actors/meta.ts +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +13 -12
  125. package/docs/agents/03-conversations.mdx +31 -21
  126. package/docs/agents/04-harnesses.mdx +7 -3
  127. package/docs/agents/meta.ts +1 -1
  128. package/docs/client/03-presence.mdx +18 -14
  129. package/docs/client/meta.ts +1 -1
  130. package/docs/contract/01-events.mdx +2 -2
  131. package/docs/contract/02-state.mdx +18 -14
  132. package/docs/contract/03-sessions.mdx +32 -25
  133. package/docs/index.mdx +31 -26
  134. package/docs/reference/01-core.mdx +12 -12
  135. package/docs/reference/02-server.mdx +247 -192
  136. package/docs/reference/04-ai.mdx +29 -20
  137. package/docs/reference/05-telemetry.mdx +146 -0
  138. package/docs/reference/06-errors.mdx +24 -18
  139. package/docs/reference/meta.ts +1 -1
  140. package/docs/schedulers/01-overview.mdx +112 -0
  141. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  142. package/docs/schedulers/03-qstash.mdx +123 -0
  143. package/docs/schedulers/meta.ts +1 -0
  144. package/docs/server/01-consumers.mdx +415 -0
  145. package/docs/server/02-timers.mdx +33 -23
  146. package/docs/server/03-cancellation.mdx +52 -44
  147. package/docs/server/04-routes.mdx +4 -1
  148. package/docs/server/05-sql-projections.mdx +391 -0
  149. package/docs/server/06-guarantees.mdx +60 -50
  150. package/docs/stores/01-overview.mdx +37 -0
  151. package/docs/stores/02-redis.mdx +57 -0
  152. package/docs/stores/03-redis-http.mdx +56 -0
  153. package/docs/stores/04-postgres.mdx +172 -0
  154. package/docs/stores/05-sqlite.mdx +36 -0
  155. package/docs/stores/06-memory.mdx +22 -0
  156. package/docs/stores/meta.ts +1 -0
  157. package/docs/upgrades/01-overview.mdx +30 -0
  158. package/docs/upgrades/79-0-21.mdx +165 -0
  159. package/docs/upgrades/80-0-20.mdx +43 -0
  160. package/docs/upgrades/81-0-19.mdx +33 -0
  161. package/docs/upgrades/82-0-18.mdx +33 -0
  162. package/docs/upgrades/83-0-17.mdx +69 -0
  163. package/docs/upgrades/meta.ts +3 -0
  164. package/examples/playground/DEPLOY.md +48 -11
  165. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  166. package/examples/playground/app/a0/chat.test.tsx +421 -330
  167. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  168. package/examples/playground/app/a0/server.ts +4 -4
  169. package/examples/playground/app/a0/shell.tsx +1 -1
  170. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  171. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  172. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  173. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  174. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  175. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  176. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  177. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  178. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  179. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  180. package/examples/playground/app/a2slack/db/index.ts +73 -0
  181. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  182. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  183. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  184. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  186. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  187. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  188. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  189. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  190. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  191. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  192. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  193. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  194. package/examples/playground/app/a2slack/events/route.ts +36 -0
  195. package/examples/playground/app/a2slack/identity.ts +44 -0
  196. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  197. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  198. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  199. package/examples/playground/app/a2slack/lag.ts +73 -0
  200. package/examples/playground/app/a2slack/layout.tsx +43 -0
  201. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  202. package/examples/playground/app/a2slack/model.test.ts +208 -0
  203. package/examples/playground/app/a2slack/model.ts +369 -0
  204. package/examples/playground/app/a2slack/models.test.ts +72 -0
  205. package/examples/playground/app/a2slack/page.tsx +10 -0
  206. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  207. package/examples/playground/app/a2slack/search/route.ts +40 -0
  208. package/examples/playground/app/a2slack/search-index.ts +108 -0
  209. package/examples/playground/app/a2slack/server.test.ts +315 -0
  210. package/examples/playground/app/a2slack/server.ts +55 -0
  211. package/examples/playground/app/a2slack/sessions.ts +50 -0
  212. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  213. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  214. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  215. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  216. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  217. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  218. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  219. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  220. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  221. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  222. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  223. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  224. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  225. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  226. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  227. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  228. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  229. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  230. package/examples/playground/app/agent/ingress.test.ts +41 -17
  231. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  232. package/examples/playground/app/agent/server.ts +3 -3
  233. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  234. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  235. package/examples/playground/app/chat/server.test.ts +38 -65
  236. package/examples/playground/app/chat/server.ts +45 -35
  237. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  238. package/examples/playground/app/components/demo-icons.tsx +7 -0
  239. package/examples/playground/app/components/demos.tsx +7 -0
  240. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  241. package/examples/playground/app/initial-state.test.tsx +86 -45
  242. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  243. package/examples/playground/app/orders/page.tsx +1 -1
  244. package/examples/playground/app/orders/server.ts +49 -30
  245. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  246. package/examples/playground/app/recovery/page.tsx +4 -4
  247. package/examples/playground/app/recovery/server.ts +23 -32
  248. package/examples/playground/drizzle.config.ts +14 -0
  249. package/examples/playground/next.config.ts +6 -1
  250. package/examples/playground/package.json +13 -1
  251. package/examples/playground/scripts/migrate.mjs +31 -0
  252. package/examples/playground/test/agent.ts +35 -0
  253. package/examples/playground/test/model.ts +149 -0
  254. package/examples/playground/test/serve.ts +184 -0
  255. package/examples/playground/test/server-only.ts +1 -0
  256. package/examples/playground/vercel.json +9 -0
  257. package/examples/playground/vitest.config.ts +27 -0
  258. package/examples/playground/vitest.drills.config.ts +32 -0
  259. package/package.json +7 -2
  260. package/sql/postgres/drizzle-schema.ts +206 -0
  261. package/sql/postgres/notifications.sql +24 -0
  262. package/sql/postgres/schema.sql +276 -0
  263. package/src/actor.ts +101 -71
  264. package/src/ai-agent.ts +154 -115
  265. package/src/ai-control-server.ts +8 -8
  266. package/src/ai-fx.ts +41 -9
  267. package/src/ai-harness.ts +28 -2
  268. package/src/ai-pi.ts +2 -2
  269. package/src/ai-server.ts +2 -2
  270. package/src/ai.ts +7 -7
  271. package/src/contract.ts +1 -1
  272. package/src/deterministic-id.ts +26 -3
  273. package/src/errors.ts +9 -6
  274. package/src/idempotent-replay.ts +0 -1
  275. package/src/index.ts +1 -1
  276. package/src/internal.ts +17 -6
  277. package/src/otel.ts +1 -1
  278. package/src/postgres-notifications.ts +0 -22
  279. package/src/postgres-resources.ts +6 -9
  280. package/src/postgres-schema.ts +121 -0
  281. package/src/push-envelope.ts +20 -0
  282. package/src/runtime-consumer.ts +10 -0
  283. package/src/scheduler-qstash.ts +12 -5
  284. package/src/scheduler-task.ts +2 -2
  285. package/src/scheduler-vercel.ts +10 -4
  286. package/src/server.browser.ts +1 -1
  287. package/src/server.ts +839 -238
  288. package/src/session-socket.ts +1 -1
  289. package/src/store-codec.ts +66 -0
  290. package/src/store-memory.ts +581 -255
  291. package/src/store-postgres.ts +1984 -943
  292. package/src/store-redis-core.ts +1624 -1152
  293. package/src/store-redis-notify.ts +2 -0
  294. package/src/store-sqlite.ts +891 -486
  295. package/src/store.ts +227 -97
  296. package/src/telemetry.ts +2 -1
  297. package/src/wire.ts +1 -0
  298. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  299. package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
  300. package/dist/ai-mMiWul1T.js.map +0 -1
  301. package/dist/contract-BYKASlCV.js.map +0 -1
  302. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  303. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  304. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  305. package/dist/internal-CscSY7K_.js.map +0 -1
  306. package/dist/server-D-Y4pqwu.js.map +0 -1
  307. package/dist/server-DYAATFuT.d.ts.map +0 -1
  308. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  309. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  310. package/dist/store-CyLUy86v.d.ts +0 -404
  311. package/dist/store-CyLUy86v.d.ts.map +0 -1
  312. package/dist/store-codec-DTG0Ftek.js +0 -10
  313. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  314. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  315. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  316. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  317. package/dist/wire-BKxH1vbP.js.map +0 -1
  318. package/docs/reference/05-adapters.mdx +0 -351
  319. package/docs/server/01-handlers.mdx +0 -200
  320. package/docs/server/05-production.mdx +0 -323
@@ -0,0 +1,199 @@
1
+ 'use client'
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
3
+ import Link from 'next/link'
4
+ import { usePathname, useRouter } from 'next/navigation'
5
+ import { SiteNav } from '@/app/components/site-header'
6
+ import { channelIdSchema } from '@/app/a2slack/model'
7
+ import { useIdentity } from '../identity'
8
+ import { useWorkspace } from '../sessions'
9
+ import { useInboxPanel } from './InboxContext'
10
+ import { useSearch } from './SearchContext'
11
+
12
+ const MIN_WIDTH = 180
13
+ const MAX_WIDTH = 400
14
+ const WIDTH_KEY = 'a2slack-sidebar-width'
15
+
16
+ function slugify(raw: string): string {
17
+ return raw
18
+ .toLowerCase()
19
+ .trim()
20
+ .replace(/\s+/g, '-')
21
+ .replace(/[^a-z0-9-]/g, '')
22
+ .replace(/^-+/, '')
23
+ .slice(0, 32)
24
+ }
25
+
26
+ type SidebarSession = ReturnType<typeof useWorkspace> & {
27
+ state: NonNullable<ReturnType<typeof useWorkspace>['state']>
28
+ }
29
+
30
+ export function Sidebar(): ReactNode {
31
+ const session = useWorkspace()
32
+ if (session.state === undefined) {
33
+ const error =
34
+ 'error' in session.connection ? session.connection.error : null
35
+ return (
36
+ <p className="a2slack-loading" role="status">
37
+ {error?.message ?? 'Loading…'}
38
+ </p>
39
+ )
40
+ }
41
+ return <SidebarView session={{ ...session, state: session.state }} />
42
+ }
43
+
44
+ function SidebarView({
45
+ session,
46
+ }: Record<never, never> & { session: SidebarSession }): ReactNode {
47
+ const identity = useIdentity()
48
+ const search = useSearch()
49
+ const inbox = useInboxPanel()
50
+ const { state, push } = session
51
+ const pathname = usePathname()
52
+ const router = useRouter()
53
+ const [draft, setDraft] = useState('')
54
+ const [width, setWidth] = useState(224)
55
+ const [resizing, setResizing] = useState(false)
56
+ const startRef = useRef({ x: 0, width: 224 })
57
+
58
+ useEffect(() => {
59
+ const stored = Number(localStorage.getItem(WIDTH_KEY))
60
+ if (stored >= MIN_WIDTH && stored <= MAX_WIDTH) setWidth(stored)
61
+ }, [])
62
+
63
+ const create = (): void => {
64
+ const id = slugify(draft)
65
+ if (!identity || !channelIdSchema.safeParse(id).success) return
66
+ if (!state.channels.includes(id)) {
67
+ push({ type: 'channel.created', payload: { id, by: identity } }).catch(
68
+ () => {},
69
+ )
70
+ }
71
+ setDraft('')
72
+ search.clear()
73
+ inbox.setOpen(false)
74
+ router.push(`/a2slack/c/${id}`)
75
+ }
76
+
77
+ return (
78
+ <nav className="a2slack-sidebar" style={{ width }}>
79
+ <div className="site-header a2slack-head">
80
+ <SiteNav />
81
+ </div>
82
+ <form
83
+ className="a2slack-form a2slack-search"
84
+ role="search"
85
+ onSubmit={(e) => {
86
+ e.preventDefault()
87
+ search.submit()
88
+ }}
89
+ >
90
+ <input
91
+ ref={search.input}
92
+ className="a2slack-input"
93
+ aria-label="search messages"
94
+ enterKeyHint="search"
95
+ placeholder="search…"
96
+ value={search.query}
97
+ onChange={(e) => {
98
+ search.setQuery(e.target.value)
99
+ inbox.setOpen(false)
100
+ }}
101
+ onKeyDown={(e) => {
102
+ if (e.key !== 'Escape') return
103
+ e.preventDefault()
104
+ search.clear()
105
+ }}
106
+ />
107
+ </form>
108
+ <div className="a2slack-list">
109
+ <button
110
+ type="button"
111
+ className="a2slack-channel a2slack-inbox-row"
112
+ aria-current={inbox.open ? 'page' : undefined}
113
+ aria-label={`inbox, ${inbox.unread} unread`}
114
+ onClick={() => {
115
+ search.clear()
116
+ inbox.setOpen(!inbox.open)
117
+ }}
118
+ >
119
+ <span>inbox</span>
120
+ {inbox.unread > 0 && (
121
+ <span className="a2slack-unread">{inbox.unread}</span>
122
+ )}
123
+ </button>
124
+ <span className="a2slack-label a2slack-label-spaced">channels</span>
125
+ {state.channels.map((id) => (
126
+ <Link
127
+ key={id}
128
+ href={`/a2slack/c/${id}`}
129
+ className="a2slack-channel"
130
+ aria-current={
131
+ !inbox.open && pathname === `/a2slack/c/${id}`
132
+ ? 'page'
133
+ : undefined
134
+ }
135
+ onClick={() => {
136
+ search.clear()
137
+ inbox.setOpen(false)
138
+ }}
139
+ >
140
+ #{id}
141
+ </Link>
142
+ ))}
143
+ </div>
144
+ <form
145
+ className="a2slack-form"
146
+ onSubmit={(e) => {
147
+ e.preventDefault()
148
+ create()
149
+ }}
150
+ >
151
+ <input
152
+ className="a2slack-input"
153
+ placeholder="+ new channel"
154
+ value={draft}
155
+ onChange={(e) => setDraft(e.target.value)}
156
+ />
157
+ </form>
158
+ {identity && (
159
+ <div className="a2slack-you">
160
+ <span>you: {identity.name}</span>
161
+ <Link className="a2slack-lag-link" href="/a2slack/lag">
162
+ lag
163
+ </Link>
164
+ </div>
165
+ )}
166
+ <div
167
+ className="a2slack-handle"
168
+ onPointerDown={(e) => {
169
+ e.preventDefault()
170
+ startRef.current = { x: e.clientX, width }
171
+ setResizing(true)
172
+ e.currentTarget.setPointerCapture(e.pointerId)
173
+ }}
174
+ onPointerMove={(e) => {
175
+ if (!resizing) return
176
+ const next = Math.min(
177
+ MAX_WIDTH,
178
+ Math.max(
179
+ MIN_WIDTH,
180
+ startRef.current.width + e.clientX - startRef.current.x,
181
+ ),
182
+ )
183
+ setWidth(next)
184
+ }}
185
+ onPointerUp={(e) => {
186
+ if (!resizing) return
187
+ setResizing(false)
188
+ e.currentTarget.releasePointerCapture(e.pointerId)
189
+ localStorage.setItem(WIDTH_KEY, String(width))
190
+ }}
191
+ >
192
+ <span
193
+ className="a2slack-handle-line"
194
+ data-active={resizing || undefined}
195
+ />
196
+ </div>
197
+ </nav>
198
+ )
199
+ }
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
3
+ import { X } from 'lucide-react'
4
+ import {
5
+ liveThread,
6
+ type ChatMessage,
7
+ type ThreadPage,
8
+ } from '@/app/a2slack/model'
9
+ import { useWorkspace } from '../sessions'
10
+ import { Composer } from './Composer'
11
+ import { useInboxPanel } from './InboxContext'
12
+ import { MessageRow } from './MessageRow'
13
+
14
+ /**
15
+ * One thread, read from `/a2slack/thread` when it opens and kept live from
16
+ * the workspace's events. Until the read answers it shows the root as the
17
+ * feed has it, if the feed has it.
18
+ */
19
+ export function ThreadPanel({
20
+ channel,
21
+ rootId,
22
+ known,
23
+ onClose,
24
+ }: {
25
+ channel: string
26
+ rootId: string
27
+ /** The root, live, when it's on the channel's newest page. */
28
+ known: ChatMessage | null
29
+ onClose: () => void
30
+ }): ReactNode {
31
+ const { events } = useWorkspace()
32
+ const inbox = useInboxPanel()
33
+ const [page, setPage] = useState<ThreadPage | null>(null)
34
+ const [failed, setFailed] = useState(false)
35
+
36
+ useEffect(() => {
37
+ const controller = new AbortController()
38
+ const read = async (): Promise<void> => {
39
+ try {
40
+ const response = await fetch(
41
+ `/a2slack/thread?root=${encodeURIComponent(rootId)}`,
42
+ { signal: controller.signal },
43
+ )
44
+ if (!response.ok) throw new Error(`thread: ${response.status}`)
45
+ setPage((await response.json()) as ThreadPage)
46
+ } catch {
47
+ if (!controller.signal.aborted) setFailed(true)
48
+ }
49
+ }
50
+ void read()
51
+ return () => controller.abort()
52
+ }, [rootId])
53
+
54
+ const thread = useMemo(
55
+ () => (page ? liveThread(page, events, rootId) : null),
56
+ [page, events, rootId],
57
+ )
58
+ const root = thread?.root ?? known
59
+ const replies = thread?.replies ?? []
60
+
61
+ // Reading the thread reads its notifications, including any that arrive
62
+ // while it's open.
63
+ const unread =
64
+ inbox.items.find((item) => item.threadId === rootId)?.unread ?? 0
65
+ const { markRead } = inbox
66
+ useEffect(() => {
67
+ if (unread > 0) markRead(rootId)
68
+ }, [unread, rootId, markRead])
69
+
70
+ return (
71
+ <aside className="a2slack-thread">
72
+ <header className="a2slack-thread-header">
73
+ <span className="a2slack-thread-title">thread</span>
74
+ <button className="a2slack-close" onClick={onClose} title="close">
75
+ <X size={15} strokeWidth={1.75} />
76
+ </button>
77
+ </header>
78
+ <div className="a2slack-thread-scroll">
79
+ {root === null ? (
80
+ <div className="a2slack-divider">
81
+ <span>
82
+ {failed
83
+ ? "this thread didn't load"
84
+ : page
85
+ ? 'this message is gone'
86
+ : 'loading…'}
87
+ </span>
88
+ <span className="a2slack-divider-line" />
89
+ </div>
90
+ ) : (
91
+ <>
92
+ <MessageRow message={root} grouped={false} inThread />
93
+ <div className="a2slack-divider">
94
+ <span>
95
+ {root.replyCount === 0
96
+ ? 'no replies yet'
97
+ : `${root.replyCount} ${root.replyCount === 1 ? 'reply' : 'replies'}`}
98
+ {thread === null &&
99
+ root.replyCount > 0 &&
100
+ (failed ? " · these didn't load" : ' · loading…')}
101
+ </span>
102
+ <span className="a2slack-divider-line" />
103
+ </div>
104
+ {replies.map((reply, i) => {
105
+ const prev = replies[i - 1]
106
+ const grouped =
107
+ prev !== undefined &&
108
+ !prev.deleted &&
109
+ prev.author.id === reply.author.id &&
110
+ Date.parse(reply.at) - Date.parse(prev.at) < 5 * 60 * 1000
111
+ return (
112
+ <MessageRow
113
+ key={reply.id}
114
+ message={reply}
115
+ grouped={grouped}
116
+ inThread
117
+ />
118
+ )
119
+ })}
120
+ </>
121
+ )}
122
+ </div>
123
+ {root !== null && (
124
+ <Composer
125
+ channel={channel}
126
+ threadId={root.id}
127
+ placeholder="reply… or ask @bot"
128
+ autoFocus
129
+ compact
130
+ />
131
+ )}
132
+ </aside>
133
+ )
134
+ }
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { liveInbox, type InboxItem, type InboxPage } from '@/app/a2slack/model'
4
+ import { useWorkspace } from '../sessions'
5
+
6
+ export type Inbox = {
7
+ /** One per thread with news for `me`, newest first. */
8
+ items: InboxItem[]
9
+ /** Unread notifications across every thread. */
10
+ unread: number
11
+ /** The page hasn't answered yet. */
12
+ loading: boolean
13
+ error: string | null
14
+ }
15
+
16
+ /**
17
+ * `me`'s inbox: the page `/a2slack/inbox` read, kept live from the
18
+ * workspace's events after it. A new `me` reads a new page.
19
+ */
20
+ export function useInbox(me: string | null): Inbox {
21
+ const { events } = useWorkspace()
22
+ const [read, setRead] = useState<{
23
+ me: string
24
+ page: InboxPage | null
25
+ error: string | null
26
+ } | null>(null)
27
+
28
+ useEffect(() => {
29
+ if (me === null) return
30
+ const controller = new AbortController()
31
+ const load = async (): Promise<void> => {
32
+ try {
33
+ const response = await fetch(
34
+ `/a2slack/inbox?user=${encodeURIComponent(me)}`,
35
+ { signal: controller.signal },
36
+ )
37
+ const body = (await response.json().catch(() => ({}))) as
38
+ InboxPage | { error?: string }
39
+ if (response.ok && 'items' in body) {
40
+ setRead({ me, page: body, error: null })
41
+ return
42
+ }
43
+ const error = 'error' in body ? body.error : undefined
44
+ setRead({
45
+ me,
46
+ page: null,
47
+ error: error ?? `the inbox failed (${response.status})`,
48
+ })
49
+ } catch {
50
+ if (controller.signal.aborted) return
51
+ setRead({ me, page: null, error: "the inbox didn't answer" })
52
+ }
53
+ }
54
+ void load()
55
+ return () => controller.abort()
56
+ }, [me])
57
+
58
+ const current = me !== null && read?.me === me ? read : null
59
+ const items = useMemo(
60
+ () =>
61
+ current?.page && me !== null ? liveInbox(current.page, events, me) : [],
62
+ [current, events, me],
63
+ )
64
+
65
+ return {
66
+ items,
67
+ unread: items.reduce((sum, item) => sum + item.unread, 0),
68
+ loading: me !== null && current === null,
69
+ error: current?.error ?? null,
70
+ }
71
+ }
@@ -1,124 +1,150 @@
1
1
  // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, expect, it, vi } from 'vitest'
4
- import type { UIMessage } from 'ai'
5
- import { AgentQueue } from './agent-queue'
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react'
10
+ import { inputs } from 'experimental-a2/ai'
11
+ import type { A2Operation } from 'experimental-a2/server'
12
+ import { afterEach, beforeEach, expect, it } from 'vitest'
13
+ import { serveAgent, type ServedAgent } from '@/test/agent'
14
+ import { AgentClient } from './agent-client'
6
15
 
7
- afterEach(cleanup)
16
+ let served: ServedAgent
17
+ let server: ServedAgent['server']
18
+ let probe: ServedAgent['probe']
19
+ let id: string
8
20
 
9
- type Edit = (message: UIMessage) => Promise<void>
21
+ beforeEach(async () => {
22
+ served = await serveAgent()
23
+ ;({ server, probe } = served)
24
+ id = crypto.randomUUID()
25
+ // Paused, so the message waits in the queue rather than starting a turn.
26
+ await server.session(id).append(
27
+ ...inputs.pause({ when: 'now' }),
28
+ ...inputs.message({
29
+ id: 'queued',
30
+ role: 'user',
31
+ parts: [{ type: 'text', text: 'Original' }],
32
+ }),
33
+ )
34
+ })
35
+
36
+ afterEach(async () => {
37
+ cleanup()
38
+ await served.close()
39
+ })
10
40
 
11
- const queue = (edit: Edit) =>
41
+ const page = async () =>
12
42
  render(
13
- <AgentQueue
14
- items={[
15
- {
16
- id: 'queued',
17
- role: 'user',
18
- message: {
19
- id: 'queued',
20
- role: 'user',
21
- parts: [{ type: 'text', text: 'Original' }],
22
- },
23
- generate: true,
24
- },
25
- ]}
26
- paused
27
- edit={edit}
28
- move={vi.fn<() => void>()}
29
- remove={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
30
- sendNow={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
31
- />,
43
+ <AgentClient agentId={id} initial={await server.session(id).state()} />,
32
44
  )
33
45
 
46
+ const isEdit = (operation: A2Operation): boolean =>
47
+ operation.type === 'push' &&
48
+ operation.sessionId === id &&
49
+ operation.events.some(
50
+ (event) =>
51
+ event.type === 'ai.control.requested' &&
52
+ (event.payload as { action?: unknown }).action === 'edit',
53
+ )
54
+
55
+ /** The texts each edit pushed to the server carried. */
56
+ const editsReceived = (): string[] =>
57
+ probe.operations.filter(isEdit).flatMap((operation) =>
58
+ operation.type === 'push'
59
+ ? operation.events.map(
60
+ (event) =>
61
+ (
62
+ event.payload as {
63
+ message: { parts: { text: string }[] }
64
+ }
65
+ ).message.parts[0]!.text,
66
+ )
67
+ : [],
68
+ )
69
+
70
+ const queuedText = async (): Promise<string | undefined> => {
71
+ const { state } = await server.session(id).state()
72
+ const part = state.inbox.items[0]?.message.parts[0]
73
+ return part?.type === 'text' ? part.text : undefined
74
+ }
75
+
76
+ /** The queued message's text, as the queue shows it. */
77
+ const shownText = (): string | null | undefined =>
78
+ document.querySelector('.agent-queue-text')?.textContent
79
+
80
+ const editor = () =>
81
+ screen.queryByLabelText(
82
+ 'Edit text for queued message 1',
83
+ ) as HTMLTextAreaElement | null
84
+ const button = (name: string) =>
85
+ screen.getByRole('button', { name }) as HTMLButtonElement
86
+
34
87
  const open = (): void => {
35
- fireEvent.click(screen.getByRole('button', { name: 'Edit queued message 1' }))
88
+ fireEvent.click(button('Edit queued message 1'))
36
89
  }
37
90
  const save = (text: string): void => {
38
- fireEvent.change(screen.getByRole('textbox'), { target: { value: text } })
39
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
91
+ fireEvent.change(editor()!, { target: { value: text } })
92
+ fireEvent.click(button('Save'))
40
93
  }
41
94
 
42
95
  it('closes immediately and permits another edit while saves remain in flight', async () => {
43
- const first = Promise.withResolvers<void>()
44
- const second = Promise.withResolvers<void>()
45
- const edit = vi
46
- .fn<Edit>()
47
- .mockImplementationOnce(() => first.promise)
48
- .mockImplementationOnce(() => second.promise)
49
- queue(edit)
96
+ await page()
97
+ const first = probe.hold(isEdit)
50
98
  open()
51
99
  save('First')
52
- expect(screen.queryByRole('textbox')).toBeNull()
53
- expect(
54
- (
55
- screen.getByRole('button', {
56
- name: 'Edit queued message 1',
57
- }) as HTMLButtonElement
58
- ).disabled,
59
- ).toBe(false)
60
- expect(
61
- (
62
- screen.getByRole('button', {
63
- name: 'Send queued message 1 after current step',
64
- }) as HTMLButtonElement
65
- ).disabled,
66
- ).toBe(false)
100
+ expect(editor()).toBeNull()
101
+ expect(button('Edit queued message 1').disabled).toBe(false)
102
+ expect(button('Send queued message 1 after current step').disabled).toBe(
103
+ false,
104
+ )
105
+ await first.arrived
67
106
  open()
68
107
  save('Second')
69
- expect(edit.mock.calls.map(([message]) => message.parts)).toEqual([
70
- [{ type: 'text', text: 'First' }],
71
- [{ type: 'text', text: 'Second' }],
72
- ])
73
- await act(async () => {
74
- first.reject(new Error('Earlier save failed'))
75
- await first.promise.catch(() => {})
76
- })
77
- expect(screen.queryByRole('textbox')).toBeNull()
108
+ first.deny()
109
+ await waitFor(async () => expect(await queuedText()).toBe('Second'))
110
+ expect(editsReceived()).toEqual(['First', 'Second'])
111
+ expect(editor()).toBeNull()
78
112
  expect(screen.queryByRole('alert')).toBeNull()
79
- await act(async () => {
80
- second.resolve()
81
- await second.promise
82
- })
113
+ expect(shownText()).toBe('Second')
83
114
  })
84
115
 
85
- it('restores the latest submitted draft on transport failure', async () => {
86
- const pending = Promise.withResolvers<void>()
87
- queue(() => pending.promise)
116
+ it('restores the latest submitted draft when its save fails', async () => {
117
+ await page()
118
+ const pending = probe.hold(isEdit)
88
119
  open()
89
120
  save('Keep this draft')
90
- expect(screen.queryByRole('textbox')).toBeNull()
91
- await act(async () => {
92
- pending.reject(new Error('Save failed'))
93
- await pending.promise.catch(() => {})
94
- })
95
- expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe(
96
- 'Keep this draft',
121
+ expect(editor()).toBeNull()
122
+ await pending.arrived
123
+ pending.deny()
124
+ await waitFor(() => expect(editor()?.value).toBe('Keep this draft'))
125
+ expect(screen.getByRole('alert').textContent).toBe(
126
+ 'the operation is not authorized',
97
127
  )
98
- expect(screen.getByRole('alert').textContent).toBe('Save failed')
128
+ expect(await queuedText()).toBe('Original')
99
129
  })
100
130
 
101
131
  it.each(['open', 'cancel'] as const)(
102
132
  'does not restore an old failed save after a newer draft is %s',
103
133
  async (action) => {
104
- const pending = Promise.withResolvers<void>()
105
- queue(() => pending.promise)
134
+ await page()
135
+ const pending = probe.hold(isEdit)
106
136
  open()
107
137
  save('Old draft')
138
+ await pending.arrived
108
139
  open()
109
- fireEvent.change(screen.getByRole('textbox'), {
110
- target: { value: 'New draft' },
111
- })
112
- if (action === 'cancel')
113
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
114
- await act(async () => {
115
- pending.reject(new Error('Old save failed'))
116
- await pending.promise.catch(() => {})
117
- })
118
- expect(
119
- (screen.queryByRole('textbox') as HTMLTextAreaElement | null)?.value ??
120
- null,
121
- ).toBe(action === 'open' ? 'New draft' : null)
140
+ fireEvent.change(editor()!, { target: { value: 'New draft' } })
141
+ if (action === 'cancel') fireEvent.click(button('Cancel'))
142
+ pending.deny()
143
+ // The failed save leaves the queue; then its handler has had its turn.
144
+ await waitFor(() => expect(shownText()).toBe('Original'))
145
+ await act(() => new Promise((resolve) => setTimeout(resolve, 10)))
146
+ expect(editor()?.value ?? null).toBe(action === 'open' ? 'New draft' : null)
122
147
  expect(screen.queryByRole('alert')).toBeNull()
148
+ expect(await queuedText()).toBe('Original')
123
149
  },
124
150
  )