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.
- package/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
- package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
- 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-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
- 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-CvEnqVfo.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
- package/dist/ai-CzE672Q3.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 +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +36 -9
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
- package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +4 -4
- 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 +84 -75
- 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-9IeTsiAs.js} +5 -5
- package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
- 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-Bc4PvfHk.js} +10 -3
- package/dist/internal-Bc4PvfHk.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-BWmht60z.js +12 -0
- package/dist/runtime-consumer-BWmht60z.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +7 -7
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
- package/dist/server-BshFZ0eL.d.ts.map +1 -0
- package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
- package/dist/server-DvIB8kj5.js.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-Bk_29eEw.d.ts} +2 -2
- package/dist/session-clear-Bk_29eEw.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-N8PXxDAS.js.map +1 -1
- package/dist/store-Z2qjw50n.d.ts +523 -0
- package/dist/store-Z2qjw50n.d.ts.map +1 -0
- 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 +393 -210
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.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 +1595 -673
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
- package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
- package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +517 -287
- package/dist/store-sqlite.js.map +1 -1
- 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-CZGqNZRx.js} +5 -4
- package/dist/wire-CZGqNZRx.js.map +1 -0
- package/docs/01-quickstart.mdx +28 -19
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +9 -1
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +13 -12
- package/docs/agents/03-conversations.mdx +31 -21
- package/docs/agents/04-harnesses.mdx +7 -3
- package/docs/agents/meta.ts +1 -1
- package/docs/client/03-presence.mdx +18 -14
- package/docs/client/meta.ts +1 -1
- 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 +31 -26
- package/docs/reference/01-core.mdx +12 -12
- package/docs/reference/02-server.mdx +247 -192
- package/docs/reference/04-ai.mdx +29 -20
- package/docs/reference/05-telemetry.mdx +146 -0
- package/docs/reference/06-errors.mdx +24 -18
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-consumers.mdx +415 -0
- package/docs/server/02-timers.mdx +33 -23
- 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 +60 -50
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +57 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +172 -0
- package/docs/stores/05-sqlite.mdx +36 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/docs/upgrades/01-overview.mdx +30 -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 +48 -11
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- 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 +206 -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/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/_journal.json +34 -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/[agentId]/agent-queue.test.tsx +116 -90
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- 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/next.config.ts +6 -1
- package/examples/playground/package.json +13 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +7 -2
- package/sql/postgres/drizzle-schema.ts +206 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +276 -0
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +154 -115
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +41 -9
- package/src/ai-harness.ts +28 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +26 -3
- package/src/errors.ts +9 -6
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +17 -6
- package/src/otel.ts +1 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +121 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +10 -4
- package/src/server.browser.ts +1 -1
- package/src/server.ts +839 -238
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +581 -255
- package/src/store-postgres.ts +1984 -943
- package/src/store-redis-core.ts +1624 -1152
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +891 -486
- package/src/store.ts +227 -97
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
- package/dist/ai-mMiWul1T.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-D-Y4pqwu.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.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-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/01-handlers.mdx +0 -200
- 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 {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
16
|
+
let served: ServedAgent
|
|
17
|
+
let server: ServedAgent['server']
|
|
18
|
+
let probe: ServedAgent['probe']
|
|
19
|
+
let id: string
|
|
8
20
|
|
|
9
|
-
|
|
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
|
|
41
|
+
const page = async () =>
|
|
12
42
|
render(
|
|
13
|
-
<
|
|
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(
|
|
88
|
+
fireEvent.click(button('Edit queued message 1'))
|
|
36
89
|
}
|
|
37
90
|
const save = (text: string): void => {
|
|
38
|
-
fireEvent.change(
|
|
39
|
-
fireEvent.click(
|
|
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
|
-
|
|
44
|
-
const
|
|
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(
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
80
|
-
second.resolve()
|
|
81
|
-
await second.promise
|
|
82
|
-
})
|
|
113
|
+
expect(shownText()).toBe('Second')
|
|
83
114
|
})
|
|
84
115
|
|
|
85
|
-
it('restores the latest submitted draft
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await act(
|
|
115
|
-
|
|
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
|
)
|