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.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/actor-shared-DFET5q16.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +86 -73
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +4 -5
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +209 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search, in the Upstash Redis the playground already has: one JSON
|
|
3
|
+
* document per message under PREFIX, which Upstash Redis Search indexes as
|
|
4
|
+
* it's written (consumers/search.ts writes them). Where Redis Search isn't
|
|
5
|
+
* configured, as in development, search reads the feed's table instead.
|
|
6
|
+
*
|
|
7
|
+
* The index's name versions it with its consumer's: to change the schema,
|
|
8
|
+
* rename both, and the new consumer fills the new index from the beginning.
|
|
9
|
+
*/
|
|
10
|
+
import 'server-only'
|
|
11
|
+
import { Redis, s } from '@upstash/redis'
|
|
12
|
+
import { searchFeed } from './db/queries'
|
|
13
|
+
import type { SearchResult } from './model'
|
|
14
|
+
|
|
15
|
+
const NAME = 'a2slack-messages-v1'
|
|
16
|
+
const PREFIX = `${NAME}:message:`
|
|
17
|
+
|
|
18
|
+
const schema = s.object({
|
|
19
|
+
channel: s.keyword(),
|
|
20
|
+
threadId: s.keyword(),
|
|
21
|
+
authorId: s.keyword(),
|
|
22
|
+
authorName: s.string(),
|
|
23
|
+
text: s.string(),
|
|
24
|
+
postedAt: s.number(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export type SearchDocument = {
|
|
28
|
+
channel: string
|
|
29
|
+
/** Empty for a top-level message. */
|
|
30
|
+
threadId: string
|
|
31
|
+
authorId: string
|
|
32
|
+
authorName: string
|
|
33
|
+
text: string
|
|
34
|
+
postedAt: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Upstash's REST credentials: Vercel's default names, or this project's prefix.
|
|
38
|
+
const url = process.env.KV_REST_API_URL || process.env.REDIS_KV_REST_API_URL
|
|
39
|
+
const token =
|
|
40
|
+
process.env.KV_REST_API_TOKEN || process.env.REDIS_KV_REST_API_TOKEN
|
|
41
|
+
|
|
42
|
+
const redis = url && token ? new Redis({ url, token }) : undefined
|
|
43
|
+
|
|
44
|
+
/** Whether this deployment searches with Upstash Redis Search. */
|
|
45
|
+
export const hasSearchIndex: boolean = redis !== undefined
|
|
46
|
+
|
|
47
|
+
let created: Promise<unknown> | undefined
|
|
48
|
+
|
|
49
|
+
/** The index, created once per process if it doesn't exist yet. */
|
|
50
|
+
async function index() {
|
|
51
|
+
if (!redis) throw new Error('Upstash Redis Search is not configured')
|
|
52
|
+
created ??= redis.search
|
|
53
|
+
.createIndex({
|
|
54
|
+
name: NAME,
|
|
55
|
+
prefix: PREFIX,
|
|
56
|
+
dataType: 'json',
|
|
57
|
+
schema,
|
|
58
|
+
existsOk: true,
|
|
59
|
+
})
|
|
60
|
+
.catch((error: unknown) => {
|
|
61
|
+
created = undefined
|
|
62
|
+
throw error
|
|
63
|
+
})
|
|
64
|
+
await created
|
|
65
|
+
return redis.search.index({ name: NAME, schema })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function putDocument(
|
|
69
|
+
id: string,
|
|
70
|
+
document: SearchDocument,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await index()
|
|
73
|
+
await redis!.json.set(`${PREFIX}${id}`, '$', document)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function setDocumentText(id: string, text: string): Promise<void> {
|
|
77
|
+
await index()
|
|
78
|
+
// A path below the root can't be set on a missing document, and RedisJSON
|
|
79
|
+
// refuses it: an edit of a message never indexed, or since deleted, is a no-op.
|
|
80
|
+
if (!(await redis!.exists(`${PREFIX}${id}`))) return
|
|
81
|
+
await redis!.json.set(`${PREFIX}${id}`, '$.text', JSON.stringify(text))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function deleteDocument(id: string): Promise<void> {
|
|
85
|
+
await redis!.del(`${PREFIX}${id}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Waits until Upstash has indexed every document written so far. */
|
|
89
|
+
export async function indexed(): Promise<void> {
|
|
90
|
+
if (redis) await (await index()).waitIndexing()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The messages that best match `q`, across every channel. */
|
|
94
|
+
export async function search(q: string): Promise<SearchResult[]> {
|
|
95
|
+
if (!redis) return searchFeed(q)
|
|
96
|
+
const hits = await (await index()).query({ filter: { text: q }, limit: 50 })
|
|
97
|
+
return hits.map(({ key, data }) => {
|
|
98
|
+
const result: SearchResult = {
|
|
99
|
+
channel: data.channel,
|
|
100
|
+
id: key.slice(PREFIX.length),
|
|
101
|
+
author: { id: data.authorId, name: data.authorName },
|
|
102
|
+
text: data.text,
|
|
103
|
+
at: new Date(data.postedAt).toISOString(),
|
|
104
|
+
}
|
|
105
|
+
if (data.threadId !== '') result.threadId = data.threadId
|
|
106
|
+
return result
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { asc, eq } from 'drizzle-orm'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { database } from './db'
|
|
4
|
+
import {
|
|
5
|
+
FEED,
|
|
6
|
+
inboxPage,
|
|
7
|
+
messagePage,
|
|
8
|
+
position,
|
|
9
|
+
searchFeed,
|
|
10
|
+
threadPage,
|
|
11
|
+
} from './db/queries'
|
|
12
|
+
import { notifications } from './db/schema'
|
|
13
|
+
import { feedEvent } from './consumers/feed'
|
|
14
|
+
import { mentionedNames } from './consumers/notify'
|
|
15
|
+
import { liveMessages, WORKSPACE, type Author } from './model'
|
|
16
|
+
import { POST } from './events/route'
|
|
17
|
+
import { a2slackServer } from './server'
|
|
18
|
+
|
|
19
|
+
const ada: Author = { id: 'user-ada', name: 'ada' }
|
|
20
|
+
const bob: Author = { id: 'user-bob', name: 'bob' }
|
|
21
|
+
const cat: Author = { id: 'user-cat', name: 'cat' }
|
|
22
|
+
|
|
23
|
+
const room = a2slackServer.session(WORKSPACE)
|
|
24
|
+
|
|
25
|
+
/** Waits until the feed has applied every event in the log so far. */
|
|
26
|
+
async function caughtUp(): Promise<number> {
|
|
27
|
+
const head = (await room.history()).at(-1)?.index ?? 0
|
|
28
|
+
const db = await database()
|
|
29
|
+
await vi.waitFor(async () => {
|
|
30
|
+
expect(await position(db, FEED)).toBeGreaterThanOrEqual(head)
|
|
31
|
+
})
|
|
32
|
+
return head
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('welcomes a new channel once', async () => {
|
|
36
|
+
const created = {
|
|
37
|
+
type: 'channel.created',
|
|
38
|
+
payload: { id: 'welcomed', by: ada },
|
|
39
|
+
} as const
|
|
40
|
+
await room.append(created)
|
|
41
|
+
await room.append(created)
|
|
42
|
+
|
|
43
|
+
await vi.waitFor(async () => {
|
|
44
|
+
const page = await messagePage('welcomed')
|
|
45
|
+
expect(page.messages.map((message) => message.text)).toEqual([
|
|
46
|
+
'#welcomed is open — ada started it.',
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reads pages and threads from the feed, as of its position', async () => {
|
|
52
|
+
const [root] = await room.append({
|
|
53
|
+
type: 'message.posted',
|
|
54
|
+
payload: { channel: 'pages', author: ada, text: 'the deploy is green' },
|
|
55
|
+
})
|
|
56
|
+
await room.append(
|
|
57
|
+
{
|
|
58
|
+
type: 'message.posted',
|
|
59
|
+
payload: {
|
|
60
|
+
channel: 'pages',
|
|
61
|
+
author: bob,
|
|
62
|
+
text: 'nice',
|
|
63
|
+
threadId: root!.id,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'reaction.added',
|
|
68
|
+
payload: {
|
|
69
|
+
channel: 'pages',
|
|
70
|
+
messageId: root!.id,
|
|
71
|
+
emoji: '🎉',
|
|
72
|
+
by: bob,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'message.edited',
|
|
77
|
+
payload: {
|
|
78
|
+
channel: 'pages',
|
|
79
|
+
messageId: root!.id,
|
|
80
|
+
text: 'the deploy is green again',
|
|
81
|
+
by: ada,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
const head = await caughtUp()
|
|
86
|
+
|
|
87
|
+
const page = await messagePage('pages')
|
|
88
|
+
expect(page.through).toBeGreaterThanOrEqual(head)
|
|
89
|
+
expect(page.messages).toMatchObject([
|
|
90
|
+
{
|
|
91
|
+
text: 'the deploy is green again',
|
|
92
|
+
replyCount: 1,
|
|
93
|
+
reactions: [{ emoji: '🎉', by: [bob] }],
|
|
94
|
+
},
|
|
95
|
+
])
|
|
96
|
+
expect((await threadPage(root!.id)).replies.map((m) => m.text)).toEqual([
|
|
97
|
+
'nice',
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
// A page read before the edit, kept live with the events after it,
|
|
101
|
+
// matches the page read now.
|
|
102
|
+
const events = await room.history()
|
|
103
|
+
const stale = {
|
|
104
|
+
...page,
|
|
105
|
+
messages: [{ ...page.messages[0]!, text: 'the deploy is green' }],
|
|
106
|
+
through: root!.index + 2,
|
|
107
|
+
}
|
|
108
|
+
expect(
|
|
109
|
+
liveMessages(stale, events, { channel: 'pages', append: true }),
|
|
110
|
+
).toEqual(page.messages)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('applies each event to the feed once', async () => {
|
|
114
|
+
const [event] = await room.append({
|
|
115
|
+
type: 'message.posted',
|
|
116
|
+
payload: { channel: 'once', author: ada, text: 'only once' },
|
|
117
|
+
})
|
|
118
|
+
await caughtUp()
|
|
119
|
+
await feedEvent(await database(), event as Parameters<typeof feedEvent>[1])
|
|
120
|
+
expect((await messagePage('once')).messages).toHaveLength(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('notifies whoever is mentioned and whoever is in the thread', async () => {
|
|
124
|
+
// bob and cat have posted, so `@bob` and `@cat` name them.
|
|
125
|
+
await room.append(
|
|
126
|
+
{
|
|
127
|
+
type: 'message.posted',
|
|
128
|
+
payload: { channel: 'notify', author: bob, text: 'hi' },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'message.posted',
|
|
132
|
+
payload: { channel: 'notify', author: cat, text: 'hey' },
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
const [root] = await room.append({
|
|
136
|
+
type: 'message.posted',
|
|
137
|
+
payload: { channel: 'notify', author: ada, text: '@bob can you look?' },
|
|
138
|
+
})
|
|
139
|
+
await room.append(
|
|
140
|
+
{
|
|
141
|
+
type: 'message.posted',
|
|
142
|
+
payload: {
|
|
143
|
+
channel: 'notify',
|
|
144
|
+
author: bob,
|
|
145
|
+
text: 'sure, @cat too',
|
|
146
|
+
threadId: root!.id,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'message.posted',
|
|
151
|
+
payload: {
|
|
152
|
+
channel: 'notify',
|
|
153
|
+
author: ada,
|
|
154
|
+
text: 'thanks',
|
|
155
|
+
threadId: root!.id,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const db = await database()
|
|
161
|
+
await vi.waitFor(async () => {
|
|
162
|
+
const sent = await db
|
|
163
|
+
.select({ to: notifications.userId, reason: notifications.reason })
|
|
164
|
+
.from(notifications)
|
|
165
|
+
.where(eq(notifications.threadId, root!.id))
|
|
166
|
+
.orderBy(asc(notifications.logIndex), asc(notifications.userId))
|
|
167
|
+
expect(sent).toEqual([
|
|
168
|
+
{ to: bob.id, reason: 'mention' },
|
|
169
|
+
{ to: ada.id, reason: 'reply' },
|
|
170
|
+
{ to: cat.id, reason: 'mention' },
|
|
171
|
+
{ to: bob.id, reason: 'reply' },
|
|
172
|
+
{ to: cat.id, reason: 'reply' },
|
|
173
|
+
])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const inbox = await inboxPage(bob.id)
|
|
177
|
+
expect(inbox.items).toMatchObject([
|
|
178
|
+
{ threadId: root!.id, text: 'thanks', unread: 2 },
|
|
179
|
+
])
|
|
180
|
+
await room.append({
|
|
181
|
+
type: 'inbox.read',
|
|
182
|
+
payload: { by: bob.id, threadId: root!.id, through: inbox.items[0]!.index },
|
|
183
|
+
})
|
|
184
|
+
await caughtUp()
|
|
185
|
+
expect((await inboxPage(bob.id)).items).toMatchObject([{ unread: 0 }])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('finds mentions only where they are names', () => {
|
|
189
|
+
expect(mentionedNames('@Ada and @bob-2, not me@x.com, @ada again')).toEqual([
|
|
190
|
+
'ada',
|
|
191
|
+
'bob-2',
|
|
192
|
+
])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('searches every channel in the feed where Redis Search is not configured', async () => {
|
|
196
|
+
await room.append(
|
|
197
|
+
{
|
|
198
|
+
type: 'message.posted',
|
|
199
|
+
payload: { channel: 'search-a', author: ada, text: '100% green' },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
type: 'message.posted',
|
|
203
|
+
payload: { channel: 'search-b', author: bob, text: 'green_ish tea' },
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
await caughtUp()
|
|
207
|
+
expect(
|
|
208
|
+
(await searchFeed('green')).map((r) => [r.channel, r.text]).toSorted(),
|
|
209
|
+
).toEqual(
|
|
210
|
+
[
|
|
211
|
+
['pages', 'the deploy is green again'],
|
|
212
|
+
['search-a', '100% green'],
|
|
213
|
+
['search-b', 'green_ish tea'],
|
|
214
|
+
].toSorted(),
|
|
215
|
+
)
|
|
216
|
+
// LIKE's wildcards match only themselves.
|
|
217
|
+
expect((await searchFeed('%')).map((r) => r.text)).toEqual(['100% green'])
|
|
218
|
+
expect((await searchFeed('n_i')).map((r) => r.text)).toEqual([
|
|
219
|
+
'green_ish tea',
|
|
220
|
+
])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/** Pushes one event to the workspace through the public route. */
|
|
224
|
+
const pushEvent = (event: Record<string, unknown>) =>
|
|
225
|
+
POST(
|
|
226
|
+
new Request('http://a2slack.test/a2slack/events', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'content-type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ sessionId: WORKSPACE, events: [event] }),
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
it('serves only the workspace session to requests', async () => {
|
|
234
|
+
const push = (sessionId: string) =>
|
|
235
|
+
POST(
|
|
236
|
+
new Request('http://a2slack.test/a2slack/events', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'content-type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
sessionId,
|
|
241
|
+
events: [
|
|
242
|
+
{ type: 'channel.created', payload: { id: 'elsewhere', by: ada } },
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
expect((await push('another')).status).toBe(403)
|
|
248
|
+
expect(await a2slackServer.session('another').history()).toEqual([])
|
|
249
|
+
expect((await push(WORKSPACE)).status).toBe(200)
|
|
250
|
+
|
|
251
|
+
// Nor an event the server appends, nor an id the client didn't make at random.
|
|
252
|
+
const label = {
|
|
253
|
+
type: 'message.labeled',
|
|
254
|
+
payload: { channel: 'general', messageId: 'event-1', label: 'idea' },
|
|
255
|
+
}
|
|
256
|
+
expect((await pushEvent(label)).status).toBe(403)
|
|
257
|
+
const named = {
|
|
258
|
+
type: 'channel.created',
|
|
259
|
+
payload: { id: 'named', by: ada },
|
|
260
|
+
id: 'my-own-id',
|
|
261
|
+
}
|
|
262
|
+
expect((await pushEvent(named)).status).toBe(403)
|
|
263
|
+
expect((await pushEvent({ ...named, id: crypto.randomUUID() })).status).toBe(
|
|
264
|
+
200,
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it("keeps the feed's position to the workspace's own log", async () => {
|
|
269
|
+
const head = await caughtUp()
|
|
270
|
+
const db = await database()
|
|
271
|
+
// Another session, further along than the workspace: its indexes would pass the feed's position.
|
|
272
|
+
const elsewhere = a2slackServer.session('elsewhere')
|
|
273
|
+
for (let n = 0; n < head + 20; n += 1) {
|
|
274
|
+
await elsewhere.append({
|
|
275
|
+
type: 'channel.created',
|
|
276
|
+
payload: { id: `far-${n}`, by: ada },
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
// Every consumer has run the other session's events.
|
|
280
|
+
await vi.waitFor(
|
|
281
|
+
async () => {
|
|
282
|
+
expect((await a2slackServer.drain('elsewhere')).settled).toBe(true)
|
|
283
|
+
},
|
|
284
|
+
{ timeout: 10_000 },
|
|
285
|
+
)
|
|
286
|
+
const ownHead = (await room.history()).at(-1)!.index
|
|
287
|
+
expect(await position(db, FEED)).toBeLessThanOrEqual(ownHead)
|
|
288
|
+
|
|
289
|
+
await room.append({
|
|
290
|
+
type: 'message.posted',
|
|
291
|
+
payload: { channel: 'general', author: ada, text: 'still here' },
|
|
292
|
+
})
|
|
293
|
+
await caughtUp()
|
|
294
|
+
const page = await messagePage('general')
|
|
295
|
+
expect(page.messages.map((message) => message.text)).toContain('still here')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('refuses NUL in text that the feed writes to Postgres', async () => {
|
|
299
|
+
await expect(
|
|
300
|
+
room.append({
|
|
301
|
+
type: 'message.posted',
|
|
302
|
+
payload: { channel: 'general', author: ada, text: 'a\u0000b' },
|
|
303
|
+
}),
|
|
304
|
+
).rejects.toThrow(/invalid payload/)
|
|
305
|
+
await expect(
|
|
306
|
+
room.append({
|
|
307
|
+
type: 'message.posted',
|
|
308
|
+
payload: {
|
|
309
|
+
channel: 'general',
|
|
310
|
+
author: { id: 'user-\u0000', name: 'nul' },
|
|
311
|
+
text: 'hi',
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
).rejects.toThrow(/invalid payload/)
|
|
315
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2slack's server: one contract, one session, and the consumers that read
|
|
3
|
+
* its log. Each keeps its own place in the log, retries on its own, and
|
|
4
|
+
* either writes somewhere else or returns events into the log:
|
|
5
|
+
*
|
|
6
|
+
* - `welcome` posts a new channel's opening message.
|
|
7
|
+
* - `a2slack-feed` keeps the tables every page reads (db/schema.ts).
|
|
8
|
+
* - `a2slack-search-v1` keeps the search index in Upstash Redis Search,
|
|
9
|
+
* where it's configured (search-index.ts).
|
|
10
|
+
* - `a2slack-notify` works out who should hear about each message, and
|
|
11
|
+
* returns a notification addressed to each of them.
|
|
12
|
+
* - `a2slack-agent` is the bot, woken by the notifications addressed to it.
|
|
13
|
+
* - `a2slack-triage` labels top-level messages. It's off for now: AI Gateway
|
|
14
|
+
* rejects its model, so every message retried until it dead-lettered.
|
|
15
|
+
*
|
|
16
|
+
* The bot and triage call a model, so they run only where AI Gateway is
|
|
17
|
+
* reachable.
|
|
18
|
+
*/
|
|
19
|
+
import 'server-only'
|
|
20
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
21
|
+
import { createServer } from 'experimental-a2/server'
|
|
22
|
+
import { store } from '@/lib/store'
|
|
23
|
+
import { agent } from './consumers/agent'
|
|
24
|
+
import { feed } from './consumers/feed'
|
|
25
|
+
import { notify } from './consumers/notify'
|
|
26
|
+
import { searchIndex } from './consumers/search'
|
|
27
|
+
// import { triage } from './consumers/triage'
|
|
28
|
+
import { welcome } from './consumers/welcome'
|
|
29
|
+
import { workspace } from './model'
|
|
30
|
+
import { hasSearchIndex } from './search-index'
|
|
31
|
+
|
|
32
|
+
export const a2slackScheduler =
|
|
33
|
+
process.env.VERCEL || process.env.A2_QUEUES
|
|
34
|
+
? vercelQueues({ topic: 'a2-a2slack' })
|
|
35
|
+
: undefined
|
|
36
|
+
|
|
37
|
+
/** AI Gateway authenticates with an API key, or with the deployment's OIDC token. */
|
|
38
|
+
const canReachGateway = Boolean(
|
|
39
|
+
process.env.AI_GATEWAY_API_KEY ||
|
|
40
|
+
process.env.VERCEL_OIDC_TOKEN ||
|
|
41
|
+
process.env.VERCEL,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const a2slackServer = createServer({
|
|
45
|
+
...(store ? { store } : {}),
|
|
46
|
+
...(a2slackScheduler ? { scheduler: a2slackScheduler } : {}),
|
|
47
|
+
contract: workspace,
|
|
48
|
+
consumers: [
|
|
49
|
+
welcome,
|
|
50
|
+
feed,
|
|
51
|
+
notify,
|
|
52
|
+
...(hasSearchIndex ? [searchIndex] : []),
|
|
53
|
+
...(canReachGateway ? [agent /* , triage */] : []),
|
|
54
|
+
],
|
|
55
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole client/server seam: one client for the one workspace session.
|
|
3
|
+
* Its state is just the channel list (and when each opened); pages read everything else from the
|
|
4
|
+
* server and stay live from `events` (model.ts's live* helpers). Nothing in
|
|
5
|
+
* this file touches `experimental-a2/server`.
|
|
6
|
+
*/
|
|
7
|
+
'use client'
|
|
8
|
+
import { createElement, type ReactNode } from 'react'
|
|
9
|
+
import type { StateSnapshot } from 'experimental-a2'
|
|
10
|
+
import { createClient } from 'experimental-a2/client'
|
|
11
|
+
import { createReact } from 'experimental-a2/react'
|
|
12
|
+
import { channelsReducer, workspace } from '@/app/a2slack/model'
|
|
13
|
+
import { useIdentity } from '@/app/a2slack/identity'
|
|
14
|
+
|
|
15
|
+
const workspaceClient = createClient({
|
|
16
|
+
contract: workspace,
|
|
17
|
+
api: '/a2slack/events',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const { SessionProvider, useSession } = createReact({
|
|
21
|
+
client: workspaceClient,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const useWorkspace = useSession
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The workspace session, with whoever is typing as its participant. The
|
|
28
|
+
* name is picked in the browser, so the provider takes it from there; the
|
|
29
|
+
* session handle adopts it once it's known.
|
|
30
|
+
*/
|
|
31
|
+
export function WorkspaceProvider({
|
|
32
|
+
sessionId,
|
|
33
|
+
initial,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
sessionId: string
|
|
37
|
+
initial: StateSnapshot<(typeof channelsReducer)['initialState']>
|
|
38
|
+
children: ReactNode
|
|
39
|
+
}): ReactNode {
|
|
40
|
+
const identity = useIdentity()
|
|
41
|
+
return createElement(
|
|
42
|
+
SessionProvider,
|
|
43
|
+
{
|
|
44
|
+
sessionId,
|
|
45
|
+
initial,
|
|
46
|
+
...(identity ? { participant: identity.id } : {}),
|
|
47
|
+
},
|
|
48
|
+
children,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { threadPage } from '../db/queries'
|
|
2
|
+
|
|
3
|
+
/** A thread's root and every reply. */
|
|
4
|
+
export async function GET(request: Request): Promise<Response> {
|
|
5
|
+
const root = new URL(request.url).searchParams.get('root')
|
|
6
|
+
if (!root) return Response.json({ error: 'no root' }, { status: 400 })
|
|
7
|
+
return Response.json(await threadPage(root))
|
|
8
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useMemo, type ReactNode } from 'react'
|
|
3
|
+
import { usePathname, useSearchParams } from 'next/navigation'
|
|
4
|
+
import { liveMessages, type MessagePage } from '@/app/a2slack/model'
|
|
5
|
+
import { useWorkspace } from '../sessions'
|
|
6
|
+
import { Composer } from './Composer'
|
|
7
|
+
import { Feed } from './Feed'
|
|
8
|
+
import { ThreadPanel } from './ThreadPanel'
|
|
9
|
+
|
|
10
|
+
function Status(): ReactNode {
|
|
11
|
+
const { connection } = useWorkspace()
|
|
12
|
+
if (connection.status === 'live') {
|
|
13
|
+
return (
|
|
14
|
+
<span className="a2slack-status" data-status="live">
|
|
15
|
+
live
|
|
16
|
+
</span>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
if (connection.status === 'connecting') {
|
|
20
|
+
return (
|
|
21
|
+
<span className="a2slack-status" data-status="connecting">
|
|
22
|
+
{connection.reconnects > 0 ? 'reconnecting…' : 'connecting…'}
|
|
23
|
+
</span>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return <span className="a2slack-status">offline</span>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One channel: its newest page from the server, kept live from the workspace's events. */
|
|
30
|
+
export function ChannelView({
|
|
31
|
+
channel,
|
|
32
|
+
page,
|
|
33
|
+
}: {
|
|
34
|
+
channel: string
|
|
35
|
+
page: MessagePage
|
|
36
|
+
}): ReactNode {
|
|
37
|
+
const { events } = useWorkspace()
|
|
38
|
+
const pathname = usePathname()
|
|
39
|
+
|
|
40
|
+
// The open thread lives in the URL (?thread=<root message id>): deep
|
|
41
|
+
// links work, back closes the panel, and switching channels drops it.
|
|
42
|
+
// Native pushState is Next's shallow routing: useSearchParams tracks
|
|
43
|
+
// it without re-running the server component.
|
|
44
|
+
const threadId = useSearchParams().get('thread')
|
|
45
|
+
const openThread = (id: string): void =>
|
|
46
|
+
window.history.pushState(
|
|
47
|
+
null,
|
|
48
|
+
'',
|
|
49
|
+
`${pathname}?thread=${encodeURIComponent(id)}`,
|
|
50
|
+
)
|
|
51
|
+
const closeThread = (): void => window.history.pushState(null, '', pathname)
|
|
52
|
+
|
|
53
|
+
const newest = useMemo(
|
|
54
|
+
() => liveMessages(page, events, { channel, append: true }),
|
|
55
|
+
[page, events, channel],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="a2slack-split">
|
|
60
|
+
<div className="a2slack-view">
|
|
61
|
+
<header className="a2slack-header">
|
|
62
|
+
<span className="a2slack-header-name">#{channel}</span>
|
|
63
|
+
<Status />
|
|
64
|
+
</header>
|
|
65
|
+
<Feed
|
|
66
|
+
channel={channel}
|
|
67
|
+
newest={newest}
|
|
68
|
+
more={page.more}
|
|
69
|
+
onOpenThread={openThread}
|
|
70
|
+
/>
|
|
71
|
+
<Composer
|
|
72
|
+
channel={channel}
|
|
73
|
+
placeholder={`message #${channel}… or ask @bot`}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
{threadId !== null && (
|
|
77
|
+
<ThreadPanel
|
|
78
|
+
key={threadId}
|
|
79
|
+
channel={channel}
|
|
80
|
+
rootId={threadId}
|
|
81
|
+
known={newest.find((m) => m.id === threadId) ?? null}
|
|
82
|
+
onClose={closeThread}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|