From 794859e4a487b206c7fb85b74fbfd19327fe5676 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 7 Oct 2026 21:38:33 +0900 Subject: [PATCH] feat: publish shared task and reply activities through MCP --- README.md | 26 +- docs/activity-mcp-design.md | 239 +++++ docs/agent-publishing-design.md | 6 + docs/execution-support.md | 28 +- docs/http-mcp.md | 36 +- docs/storage-and-oauth.md | 7 + drizzle/0006_cheerful_siren.sql | 21 + drizzle/meta/0006_snapshot.json | 818 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- e2e/activity-fixtures.ts | 171 ++++ e2e/integrations/activities.test.ts | 142 +++ e2e/integrations/auth.test.ts | 8 +- e2e/integrations/home.test.ts | 127 ++- e2e/integrations/support.test.ts | 100 ++- e2e/integrations/url-state.test.ts | 32 +- e2e/integrations/vitals.test.ts | 21 +- e2e/models/ActivitiesPage.ts | 56 ++ e2e/models/AuthPage.ts | 5 +- e2e/models/JournalPage.ts | 3 +- e2e/models/OnboardingPage.ts | 9 +- e2e/models/ReaderPage.ts | 7 +- e2e/models/VitalsPage.ts | 14 +- e2e/models/ready.ts | 18 + src/features/activities/activity-card.tsx | 97 +++ .../activities/activity-controls.test.tsx | 109 +++ src/features/activities/activity-controls.tsx | 239 +++++ .../activities/activity-state.test.tsx | 93 ++ src/features/activities/activity-state.tsx | 163 ++++ src/features/activities/events.server.ts | 18 + .../activities/http-events.server.test.ts | 54 ++ src/features/activities/http-events.server.ts | 65 ++ src/features/activities/model.ts | 162 ++++ .../activities/reply-activity-detail.tsx | 190 ++++ src/features/activities/repository.server.ts | 304 +++++++ src/features/activities/repository.test.ts | 346 ++++++++ src/features/activities/server-functions.ts | 41 + src/features/auth/auth-page.test.tsx | 14 + src/features/auth/auth-page.tsx | 19 +- src/features/auth/onboarding-page.tsx | 25 +- src/features/home/daily-brief.tsx | 29 +- src/features/home/execution-workbench.tsx | 84 +- src/features/home/home-page.tsx | 65 +- src/features/mcp/http.server.test.ts | 2 + src/features/mcp/workspace-tools.server.ts | 34 +- src/features/mcp/workspace-tools.test.ts | 72 ++ src/features/navigation/use-search-input.ts | 2 +- src/features/research/repository.test.ts | 35 +- src/features/storage/migrations.generated.ts | 10 + src/features/storage/schema.ts | 30 + src/features/support/support-page.tsx | 239 +++-- src/features/workspace/workspace-state.tsx | 43 +- src/routeTree.gen.ts | 21 + src/routes/api/activities/events.ts | 21 + 53 files changed, 4076 insertions(+), 453 deletions(-) create mode 100644 docs/activity-mcp-design.md create mode 100644 drizzle/0006_cheerful_siren.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 e2e/activity-fixtures.ts create mode 100644 e2e/integrations/activities.test.ts create mode 100644 e2e/models/ActivitiesPage.ts create mode 100644 e2e/models/ready.ts create mode 100644 src/features/activities/activity-card.tsx create mode 100644 src/features/activities/activity-controls.test.tsx create mode 100644 src/features/activities/activity-controls.tsx create mode 100644 src/features/activities/activity-state.test.tsx create mode 100644 src/features/activities/activity-state.tsx create mode 100644 src/features/activities/events.server.ts create mode 100644 src/features/activities/http-events.server.test.ts create mode 100644 src/features/activities/http-events.server.ts create mode 100644 src/features/activities/model.ts create mode 100644 src/features/activities/reply-activity-detail.tsx create mode 100644 src/features/activities/repository.server.ts create mode 100644 src/features/activities/repository.test.ts create mode 100644 src/features/activities/server-functions.ts create mode 100644 src/features/auth/auth-page.test.tsx create mode 100644 src/routes/api/activities/events.ts diff --git a/README.md b/README.md index bc144c9..a35c541 100644 --- a/README.md +++ b/README.md @@ -11,27 +11,27 @@ original language. Home uses a Tiimo-inspired day flow with expandable activities and direct controls for routine steps, tasks, replies and weight entries. Messages uses a Beeper-inspired conversation layout with visible source messages, editable -drafts and a contextual side panel. These execution surfaces remain mocks; -their records share browser-session state and external sending is simulated. -Reply review can call Jev through Vercel AI Gateway when the server has -`AI_GATEWAY_API_KEY`. It checks the draft against the sample conversation; -Beeper synchronization and sending remain unconnected. +drafts and a contextual side panel. Tasks and reply activities share persistent +SQLite records across Home, Messages, and MCP. User edits and decisions survive +agent updates and reloads. Routines and Journal remain session-only prototypes; +Beeper history synchronization and actual message sending remain unconnected. ## Workspaces and current status | Workspace | Route | Current implementation | | --------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | -| Home | `/` | State-derived daily brief, flexible tasks and routines, and natural-language action proposals. Local prototype. | -| Messages | `/support` | Sample conversations, editable replies, scheduling previews, and deferral. Sending is simulated. | +| Home | `/` | Persistent mixed task/reply inbox and action proposals; routine and measurement controls remain local prototypes. | +| Messages | `/support` | Published reply activities, source excerpts, persistent draft edits and deferral. No external sending. | | Journal | `/journal` | Notes, resume points, medication and weight entries, append-only corrections, and summary previews. Local prototype. | | Reader | `/inbox` | Searchable sample articles and a responsive article reader. Recommendation from activity history is not connected yet. | | Vitals | `/vitals` | Body and Environment dashboards with sample readings, charts, and simulated controls. No health or device connection. | | Research | `/deck` | Twitter and Mastodon columns, connected accounts, persistent decks, and optional Codex research chat. | -Home, Messages, and Journal share in-memory state across navigation; reloading -resets it. The home input uses a deterministic English/Japanese interpreter to -preview an action before confirmation, rather than an LLM. Research decks, -connections, and chat history use server-side persistence. +Home and Messages share activity IDs, user decisions, and drafts through a common +server service. Journal and routine prototype state resets on reload. The home +input uses a deterministic English/Japanese interpreter to preview an action +before confirmation, rather than an LLM. Research decks, connections, and chat +history also use server-side persistence. See [execution support](docs/execution-support.md) and the [vitals preview](docs/vitals-preview.md) for prototype scope. @@ -324,6 +324,10 @@ The app serves Streamable HTTP MCP at `/mcp` on its existing port. External clients can list connections and SNS lists, read saved decks, create/replace/delete saved decks, and fetch posts from a source or saved column. These operations share the web UI's SQLite and SNS services; no resident Codex run is required. +`get_activity_context` reads the shared task/reply inbox and user decisions; +`publish_activities` creates or updates proposals with stable IDs, retry receipts, +and revision checks. Publication preserves user edits and never sends messages. +See the [Activity MCP contract](docs/activity-mcp-design.md) for inputs and examples. An OpenAI Secure MCP Tunnel client can forward to this private endpoint. MCP authentication is currently deferred: `/mcp` bypasses browser login and diff --git a/docs/activity-mcp-design.md b/docs/activity-mcp-design.md new file mode 100644 index 0000000..ef35191 --- /dev/null +++ b/docs/activity-mcp-design.md @@ -0,0 +1,239 @@ +# 統合インボックスの Activity MCP + +実装契約、2026-10-07。共通の行動ストア、以下の2ツール、Home / Messages の接続を実装。 +実際の会話履歴の同期・送信、計測・ルーティンの永続化は後続の範囲。 + +[既存の公開設計](agent-publishing-design.md)を、返信と雑用の登録に絞って具体化する。 +既存の `/mcp` に追加し、同じポートと dotnix 管理の Secure MCP Tunnel を使う。 +認証の追加は今回の対象外。既存の Research 用ツールはそのまま使う。 + +## 目的と責任 + +外部 Codex が依頼や生活上の用事を具体的な行動にし、MCP 経由で共通の +インボックスへ登録する。Home と Messages は同じ行動 ID と実行状態を参照する。 +例として「資料を確認して、青木さんに打ち合わせ候補日時を返信する」と +「洗剤を買う」を同じ一覧に置く。前者からは根拠となる会話と返信案を開ける。 + +外部 Codex が文脈の解釈・行動の具体化・life の読み書きを担当し、アプリは +保存・表示・本人の操作を担当する。MCP の呼び出しでアプリ内の推論を起動しない。 +life は人物・関係の正本、元メッセージは会話サービス、実行状態はこのアプリが持つ。 + +```mermaid +flowchart LR + S[Beeper / life / 本人の依頼] --> C[外部 Codex] + C -->|publish_activities| A[共通 Activity サービス] + A --> D[(SQLite)] + H[Home] <--> A + M[Messages] <--> A + A -->|get_activity_context| C +``` + +受信メッセージそのものを全件タスク化しない。依頼の検出と本人が引き受けた約束は +区別する。登録は行動候補の提示であり、今日の義務の確定を意味しない。 + +## 最初のツールは2つ + +| ツール | 用途 | 入力 | +| ---------------------- | -------------------------------------------------- | --------------------------------------------- | +| `get_activity_context` | 登録済みの行動、本人の判断・編集、出典を読む | 任意の `activityIds`、`cursor`、`limit` | +| `publish_activities` | 行動を新規登録、または既存行動の提案内容を更新する | `requestId`、`expectedRevision`、`activities` | + +単件のタスク作成も `activities` が1件の公開として扱う。最初から単件 CRUD と +一括公開の両方を用意せず、返信・雑用とも同じ検証と保存処理を通す。 +ここでの公開はワークスペースへの保存であり、外部へのメッセージ送信ではない。 + +### `get_activity_context` + +入力は省略可能。`activityIds` は最大50件。指定時は対象を絞り、存在しない ID は +`missingActivityIds` に返す。`limit` は既定50・最大100。結果には完了・延期・ +対応不要も含め、公開側がそれらを再提案しないための情報を取得できるようにする。 + +返却形は `{ok: true, revision, activities, missingActivityIds, nextCursor}`。 +各行動は `proposal`、`userState`、`userOverrides`、それらを合成した `effective` +を持つ。`userOverrides` は本人が編集したフィールドだけを保持する。 +`effective` は Home / Messages が実際に表示する内容。 +出典はその行動に含め、関連しない人物情報や会話履歴を丸ごと返さない。 + +`revision` は行動領域全体の非負整数。行動の提案・本人の編集・状態・送信結果が +変わると増加する。Research のデッキ更新では増加しない。 +ページは ID 順とし、カーソルに revision・絞り込み条件・位置を結び付ける。 +各ページの revision と内容は同じ読み取りトランザクションで取得する。 +途中で revision が変わった場合は `stale-cursor` として最初から再取得する。 +`nextCursor: null` で読み取り完了。ID で絞っても、書き込みには領域全体の revision を使う。 + +### `publish_activities` + +`activities` は1〜50件。各要素は提案内容の完全な値を送る。既存 ID は提案部分を +置き換え、新しい ID は作成する。バッチから省略した行動は変更・削除しない。 +必須フィールドの省略や未知のフィールドはエラー。任意フィールドの省略は、 +その行動の提案からの削除を意味するが、本人の編集値には影響しない。 + +最小の雑用作成例: + +```json +{ + "requestId": "publish-buy-detergent-1", + "expectedRevision": 12, + "activities": [ + { + "id": "buy-detergent-2026-10-07", + "kind": "task", + "title": "洗剤を買う", + "nextAction": "使っている洗剤の詰め替えを1袋買う", + "sources": [] + } + ] +} +``` + +成功時は `{ok: true, requestId, revision, activityIds, receivedAt}` を返す。 +書き込んだのは提案であり、表示内容は本人の編集を優先する。必要なら同じ ID で +`get_activity_context` を呼び、実効値を確認する。 + +## 行動の契約 + +| フィールド | 内容 | +| ---------------- | --------------------------------------------------------------------------------------- | +| `id` | 必須。公開側が決める安定した ID、1〜128文字 | +| `kind` | 必須。初期は `task` / `reply`。既存 ID の種類変更は禁止 | +| `title` | 必須。具体的な行動名、1〜300文字 | +| `nextAction` | 必須。着手時に何をするか、1〜2000文字 | +| `detail` | 任意。前提作業・判断材料・完了条件、最大8000文字 | +| `recommendation` | 任意。`{section: "focus" \| "optional", reason?: string}`。本人の優先順位を上書きしない | +| `sources` | 必須。最大20件の出典。直接依頼された雑用は空配列でもよい | +| `reply` | `reply` のときだけ必須。以下の返信固有情報 | + +`task` は `reply` フィールドを受け付けない。ルーティンは今後の行動種類であり、 +今回の `recommendation.section` に混ぜない。日時指定や繰り返し規則も別途設計する。 +制限は文字列の長さに加え、公開1回の UTF-8 JSON 合計256 KiBとする。 +表示区分は本人の指定、提案の `recommendation.section`、既定の `optional` の順に決める。 + +返信の例(架空データ): + +```json +{ + "id": "aoki-design-review-propose-times", + "kind": "reply", + "title": "資料を確認して青木さんに候補日時を返信する", + "nextAction": "レビュー資料を開き、30分の打ち合わせ候補を2つ選ぶ", + "detail": "資料に不明点があれば、日時と一緒に確認する。", + "sources": [ + { + "id": "aoki-request", + "kind": "message", + "provider": "beeper", + "targetId": "personal-server", + "accountId": "account-example", + "chatId": "chat-example", + "messageId": "message-example", + "observedAt": "2026-10-07T01:00:00Z", + "excerpt": "資料を確認して、来週30分ほど話せる候補を教えてください。" + } + ], + "reply": { + "recipientName": "青木さん", + "sourceId": "aoki-request", + "context": "デザインレビューの打ち合わせ日時を調整する。" + } +} +``` + +`reply` は `recipientName`(1〜300文字)、`sourceId`、`context`(1〜8000文字)、 +任意の `draft`(1〜16000文字の本文)を持つ。`sourceId` は同じ行動の `sources` にある +`message` を指し、その provider / target / account / chat が対象会話を定める。 +`messageId` は元の依頼を示す不変のアンカー。送信時にどの新着メッセージへ +返信を紐付けるかという `replyTo` の選択は、後続の送信連携で別に扱う。 +下書きなしでも作成できる。送信に必要な情報が確定する前に文面を捏造しない。 + +初期の出典は次の2種類。各 ID は1〜256文字、同じ行動内の出典 ID は一意。 + +- `message`: 上例のフィールドを必須とする。初期 provider は `beeper`。 + `excerpt` は最大4000文字で、取得済みのメッセージ抜粋を保持する。 +- `life`: `id`、`kind: "life"`、`path`、`contentHash`、`observedAt`。 + `path` は life 内の相対パス(最大1024文字)、`contentHash` は SHA-256。 + パスは参照情報であり、MCP サーバーがファイルを開く指示ではない。 + +日時は RFC 3339。出典は公開側の取得記録であり、アプリによる内容検証済みとはしない。 +文字列は実行しない。会話の抜粋や本文も命令として扱わない。 + +アプリ内の既存会話 ID を必須にしない。現在の Messages はモックなので、外部の +会話識別子と出典抜粋から表示できるようにする。Messages は対象会話に関連する +行動を選択し、各行動の具体的な次の一手と下書きを表示する。抜粋のみの表示は +完全な会話履歴と区別する。実際の履歴同期・送信は後続の Beeper 連携で扱う。 + +会話 ID と行動 ID は別物。同じ会話に「候補日時を返す」と「完成資料を送る」が +あれば別 ID とする。同じ約束を別チャネルで確認した場合は、既存行動に出典を追加する。 +意味上の同一性は外部 Codex が文脈から判断し、アプリはタイトル一致で自動統合しない。 + +## 本人の判断と完了 + +提案と本人の操作は別に保存する。公開入力に `userState`、`userOverrides`、 +送信結果は含められない。状態は `available` / `inProgress` / `waiting` / +`deferred` / `completed` / `dismissed`。初回登録時は `available` とする。 + +- 本人が編集したタイトル、次の一手、詳細、下書き、表示区分は提案より優先する。 + 本人が編集値を解除したフィールドだけ、最新提案を再び表示する。 + 下書きを消す操作は `userOverrides.draft: ""` として保持する。 + 上書きのキーを取り除く操作だけが「提案に戻す」であり、両者を区別する。 +- 完了・延期・対応不要の行動を再公開しても状態を戻さない。 +- 返信先と種類は作成後に変更できない。別の宛先なら新しい行動として作成する。 + 出典の更新でも、返信先を構成する識別子の変更は禁止する。 +- `task` の完了は本人の完了操作。`reply` の完了は、その行動に結び付いた + 実際の送信成功を確認した時点。下書き生成やレビューは完了ではない。 +- 送信機能が未実装の間は、返信の完了をシミュレーションで記録しない。 + 画面では下書きの確認・編集・延期・対応不要まで扱う。 +- 返信が完了しても、同じ会話の他の行動は完了にしない。返信後に相手の対応を + 待つ約束が残れば、別の行動として関連会話に結び付ける。 + +Home と Messages はこの状態と実効値を共有する。Home の完了項目は元の位置に +結果を残す。画面ごとに別の完了フラグや下書きを持たせない。 + +初期 MCP は提案の登録・更新と状態の読み取りに限定する。本人の操作代行、提案撤回、 +削除、メッセージ送信、CRM 更新、自然言語依頼キューは今回追加しない。 +権限別のクライアント識別がない現段階では、これはツールが提供する操作範囲の区別であり、 +呼び出し元を認証したという意味ではない。 + +## 再送・競合・保存 + +`requestId` は1回の公開操作の識別子(1〜128文字)、行動 ID は同じ行動の識別子。 +同じ行動の内容を更新するときは、同じ行動 ID と新しい `requestId` を使う。 + +1. アプリ内で `requestId` と正規化入力のハッシュ、成功時の返却値を保存する。 + 当面はワークスペース単位の名前空間とし、公開側は十分に一意な ID を生成する。 +2. 同一 ID・同一入力の再送は、revision 検査より先に元の成功結果を返す。 + 同一 ID・異なる入力は `idempotency-conflict` として拒否する。 +3. `expectedRevision` が最新と違えば `revision-conflict`。読み直し、本人の判断と + 調整した入力を新しい `requestId` で送る。強制上書きは用意しない。 +4. 全入力の検証・revision 確認・全行動の保存・receipt 保存・revision の増加を + 1つの SQLite トランザクションで行う。失敗時は全件変更なし。 +5. コミット後に画面へ更新通知する。接続し直した画面は保存済みの状態を取得する。 + +同じバッチ内の行動 ID 重複は拒否。receipt は初期版では削除しない。 +本人の画面操作も同じサービスを使い、読み込み時の revision を確認する。 +通信結果が不明なら入力と `requestId` を変えずに再送する。 + +失敗は既存 MCP に合わせ `isError: true` と +`{ok: false, error: {code, message}}` を返す。 +code は `invalid-input`、`payload-too-large`、`revision-conflict`、 +`idempotency-conflict`、`stale-cursor`、`immutable-field`、`internal-error`。 +入力全体やメッセージ本文をエラー・ログへ転載しない。 + +## 実装順と受け入れ条件 + +1. `task` / `reply` のモデル、提案と本人の操作を保存する共通サービスを作る。 + ブラウザ専用 Task 配列の永続化だけで済ませない。 +2. そのサービスに2つの MCP ツールを接続する。 +3. Home と Messages を同じ行動 ID に接続する。架空データを実際の行動として + 保存しない。既存の計測・ルーティンのモックは今回の永続化対象に混ぜない。 +4. 次を結合テストで確認する。 + +- 雑用と返信を1回で登録し、Home の同じ一覧に出る。再起動後も残る。 +- 返信を Home から開くと、その行動の出典・次の一手・下書きが Messages に出る。 +- Messages の下書き編集・延期が Home と MCP の読み取りにも反映される。 +- 同じ会話に2つの行動を作れ、一方の操作が他方を完了させない。 +- 公開の再送で重複しない。古い revision やバッチ内の不正な1件で部分保存しない。 +- 再公開で本人の編集・完了・延期・対応不要を消さない。 +- MCP 公開から外部送信は発生せず、返信が完了扱いにもならない。 + +HTTP/CLI の別の公開 API、定期ジョブ、全体 brief、人物プロジェクション、 +計測・ルーティンの永続化は、今回の2ツールに必要になった時点で拡張する。 diff --git a/docs/agent-publishing-design.md b/docs/agent-publishing-design.md index 3dc4e09..982dda6 100644 --- a/docs/agent-publishing-design.md +++ b/docs/agent-publishing-design.md @@ -4,6 +4,12 @@ Proposal, 2026-09-28. No endpoints, timers, or inference jobs in this document have been implemented. This supersedes the earlier proposal for app-managed inference and an application-owned life filesystem adapter. +The [Activity MCP design](activity-mcp-design.md) (2026-10-07) narrows the first +implementation to shared task/reply publication over the existing HTTP MCP. +Its tool contract and initial scope take precedence over the proposed HTTP/CLI +entrypoints and broader first slice below. The responsibility and user-state +boundaries remain applicable. + ## Responsibility External Codex reads Beeper and the life filesystem, reasons about them, and diff --git a/docs/execution-support.md b/docs/execution-support.md index e5fd136..85dd5b9 100644 --- a/docs/execution-support.md +++ b/docs/execution-support.md @@ -1,4 +1,30 @@ -# Execution support prototype +# Execution support + +## Persistent task and reply inbox + +Home and Messages now share SQLite-backed activities. External Codex publishes +concrete tasks and replies with `publish_activities` and reads proposals, user +decisions, and effective content through `get_activity_context`. The +[Activity MCP contract](activity-mcp-design.md) defines their data and retry rules. +Home task creation uses the same service and preserves the owner's entered fields. + +Replies include the next action, a source message excerpt, context, and an optional +draft. Home links to `/support?activity=&view=all`; Messages groups activities +by source conversation while keeping each commitment distinct. Draft edits, +deferral, dismissal, and ordinary-task completion persist across reloads and +refresh other open views after a committed write. Republishing cannot replace +user overrides or reopen completed/deferred/dismissed activities. + +These reply surfaces use published excerpts, not fictional contacts or a full +Beeper history sync. Real sending is not connected, and they do not simulate +successful sending or allow a reply to be marked complete without delivery. + +## Earlier prototype and remaining local features + +The sections below describe the original prototype. Its sample conversation, +scheduling, and simulated-send flows have been superseded on Home and Messages +by the persistent inbox above. Routine steps and Journal remain local prototypes; +their records are not copied into the persistent activity store. The shared sidebar opens `/support` (Messages) and `/journal` (Journal), alongside Reader and Research. These are UI-only prototypes with fictional contacts diff --git a/docs/http-mcp.md b/docs/http-mcp.md index 02c805c..c44933f 100644 --- a/docs/http-mcp.md +++ b/docs/http-mcp.md @@ -7,17 +7,23 @@ listener, browser requirement, or nested Codex execution for external calls. ## Tools -| Tool | Input and effect | -| -------------------- | --------------------------------------------------------------------------------- | -| `list_connections` | `{}`; discover account IDs and status, without credentials | -| `list_lists` | `connectionId`; discover Twitter or Mastodon lists | -| `list_decks` | `{}`; saved deck IDs, titles, revisions and column counts | -| `get_deck` | `deckId`; saved definition including column IDs and revision | -| `create_deck` | `title`, `columns`, optional new `deckId`; immediately persist | -| `replace_deck` | `deckId`, `expectedRevision`, `title`, complete `columns`; reject stale revisions | -| `delete_deck` | `deckId`, `expectedRevision`; permanently delete | -| `fetch_posts` | `connectionId`, `source`, optional `cursor`; read without saving a deck | -| `fetch_column_posts` | `deckId`, `columnId`, optional `cursor`; read a saved column | +For the shared inbox tools, see +[Activity MCP design](activity-mcp-design.md). Replies and chores share one +activity model across Home, Messages, and MCP. + +| Tool | Input and effect | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `get_activity_context` | Optional `activityIds`, `cursor`, `limit`; proposals, effective content, user decisions and revision | +| `publish_activities` | `requestId`, `expectedRevision`, `activities`; atomically persist task/reply proposals, preserving user decisions | +| `list_connections` | `{}`; discover account IDs and status, without credentials | +| `list_lists` | `connectionId`; discover Twitter or Mastodon lists | +| `list_decks` | `{}`; saved deck IDs, titles, revisions and column counts | +| `get_deck` | `deckId`; saved definition including column IDs and revision | +| `create_deck` | `title`, `columns`, optional new `deckId`; immediately persist | +| `replace_deck` | `deckId`, `expectedRevision`, `title`, complete `columns`; reject stale revisions | +| `delete_deck` | `deckId`, `expectedRevision`; permanently delete | +| `fetch_posts` | `connectionId`, `source`, optional `cursor`; read without saving a deck | +| `fetch_column_posts` | `deckId`, `columnId`, optional `cursor`; read a saved column | Columns follow the [deck source contracts](webmcp-prototype.md). A deck has at most six columns. Preserve column IDs on edits. Stable deck and column IDs allow @@ -31,6 +37,13 @@ External tools are stateless and do not share the internal research turn's 12-request budget. No tool writes to an SNS account. Tool failures return `isError: true` and a structured error in text content. +Activity publication uses the same service as Home and Messages. Exact retries +return the original receipt before revision checks; conflicting publications +make no changes. Browser clients refresh on committed changes through the +session-protected `/api/activities/events` stream. Reconnection also refreshes +the saved snapshot. Source excerpts are supplied evidence, not a full synced +conversation. Publishing a reply neither sends it nor marks it complete. + ## Internal research The resident research chat connects Codex to `/mcp?research=`. @@ -90,6 +103,7 @@ dotnix's flake input and `homes/nixos/UM790-Pro/twitter-lite.nix`. nix develop -c pnpm test nix develop -c pnpm typecheck nix develop -c pnpm test:e2e e2e/integrations/mcp-http.test.ts --project=desktop +nix develop -c pnpm test:e2e e2e/integrations/activities.test.ts ``` The HTTP E2E uses the actual app middleware, an isolated SQLite database, and diff --git a/docs/storage-and-oauth.md b/docs/storage-and-oauth.md index 3798337..aa73a50 100644 --- a/docs/storage-and-oauth.md +++ b/docs/storage-and-oauth.md @@ -50,6 +50,13 @@ PKCE verifiers are authenticated encrypted envelopes in separate fields. Their associated data binds each secret to its record and purpose. Public connection responses never select those fields. +Shared task/reply activities also live in SQLite: `activities` stores proposals, +user overrides and execution state; `activity_revision` serializes changes; +`activity_receipts` records exact mutation retries. These tables are included in +the same database backup. Activity publication preserves user-owned edits and +decisions, and sends browser invalidation only after the transaction commits. +See the [Activity MCP contract](activity-mcp-design.md). + ## Backups and restore Use the live SQLite backup API instead of copying only the main file while WAL diff --git a/drizzle/0006_cheerful_siren.sql b/drizzle/0006_cheerful_siren.sql new file mode 100644 index 0000000..9073a3b --- /dev/null +++ b/drizzle/0006_cheerful_siren.sql @@ -0,0 +1,21 @@ +CREATE TABLE `activities` ( + `id` text PRIMARY KEY NOT NULL, + `proposal` text NOT NULL, + `user_state` text NOT NULL, + `user_overrides` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `activity_receipts` ( + `request_id` text PRIMARY KEY NOT NULL, + `input_hash` text NOT NULL, + `result` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `activity_revision` ( + `id` integer PRIMARY KEY NOT NULL, + `revision` integer NOT NULL, + CONSTRAINT "activity_revision_singleton" CHECK("activity_revision"."id" = 1), + CONSTRAINT "activity_revision_nonnegative" CHECK("activity_revision"."revision" >= 0) +); diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..381ec1c --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,818 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7d253ed3-d2b7-42df-ae40-e21e55f55803", + "prevId": "7a44ef34-89ad-4ab4-829d-f6669bd72471", + "tables": { + "activities": { + "name": "activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "proposal": { + "name": "proposal", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_state": { + "name": "user_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_overrides": { + "name": "user_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "activity_receipts": { + "name": "activity_receipts", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "activity_revision": { + "name": "activity_revision", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "activity_revision_singleton": { + "name": "activity_revision_singleton", + "value": "\"activity_revision\".\"id\" = 1" + }, + "activity_revision_nonnegative": { + "name": "activity_revision_nonnegative", + "value": "\"activity_revision\".\"revision\" >= 0" + } + } + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_owner_id_workspace_owner_id_fk": { + "name": "auth_sessions_owner_id_workspace_owner_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "workspace_owner", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_throttle": { + "name": "auth_throttle", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset_at": { + "name": "reset_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "auth_throttle_singleton": { + "name": "auth_throttle_singleton", + "value": "\"auth_throttle\".\"id\" = 1" + } + } + }, + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": [ + "platform", + "origin", + "account_id" + ], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": [ + "origin", + "relay_profile" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": [ + "deck_id", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": [ + "deck_id", + "id" + ], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "legacy_imports": { + "name": "legacy_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_ids": { + "name": "deck_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": [ + "origin", + "redirect_uri", + "scopes" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_sessions": { + "name": "research_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_owner": { + "name": "workspace_owner", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_owner_singleton": { + "name": "workspace_owner_singleton", + "value": "\"workspace_owner\".\"id\" = 1" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 329b7ef..dc8d823 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1790591720807, "tag": "0005_salty_madripoor", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1791374963891, + "tag": "0006_cheerful_siren", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/e2e/activity-fixtures.ts b/e2e/activity-fixtures.ts new file mode 100644 index 0000000..2e2302f --- /dev/null +++ b/e2e/activity-fixtures.ts @@ -0,0 +1,171 @@ +import { randomUUID } from "node:crypto"; +import { basename, dirname, isAbsolute } from "node:path"; +import type { APIRequestContext } from "@playwright/test"; +import type { + ActivityContext, + ActivityProposal, + ActivityReceipt, +} from "../src/features/activities/model"; +import { openDatabase } from "../src/features/storage/database.server"; +import { expect, test as base } from "./fixtures"; +import { ActivitiesPage } from "./models/ActivitiesPage"; +import { HomePage } from "./models/HomePage"; +import { MessagesPage } from "./models/MessagesPage"; + +class ActivityMessagesPage extends MessagesPage { + override async goTo(search = "") { + await super.goTo(search); + await expect(this.rows.first()).toHaveCount(1); + } +} + +class ActivityHomePage extends HomePage { + override async goTo(search = "") { + await super.goTo(search); + // The brief appears after the persisted activity snapshot has loaded and hydrated. + await expect(this.dailyBrief).toBeVisible(); + } +} + +export const activityProposals: ActivityProposal[] = [ + { + id: "buy-detergent", + kind: "task", + title: "Buy detergent", + nextAction: "Buy one refill of the usual detergent", + recommendation: { section: "focus" }, + sources: [], + }, + ...["Propose review times", "Send the completed documents"].map( + (title, index): ActivityProposal => ({ + id: index === 0 ? "review-times" : "send-documents", + kind: "reply", + title, + nextAction: + index === 0 + ? "Read the review materials and choose two times" + : "Check the final PDF before sharing it", + recommendation: { section: "focus" }, + sources: [ + { + id: `request-${index}`, + kind: "message", + provider: "beeper", + targetId: "e2e-target", + accountId: "e2e-account", + chatId: "same-chat", + messageId: `message-${index}`, + observedAt: "2026-10-07T01:00:00Z", + excerpt: + index === 0 + ? "Please review the materials and suggest two times." + : "Please share the completed documents when they are ready.", + }, + ], + reply: { + recipientName: "Haruka Aoki", + sourceId: `request-${index}`, + context: "Arrange the design review", + draft: + index === 0 + ? "Could we meet on Tuesday or Thursday?" + : "The completed review documents are ready.", + }, + }), + ), +]; +export class ActivityMcp { + constructor(readonly client: APIRequestContext) {} + async tool>(name: string, args: unknown = {}): Promise { + const response = await this.client.post("/mcp", { + headers: { Accept: "application/json, text/event-stream" }, + data: { + jsonrpc: "2.0", + id: randomUUID(), + method: "tools/call", + params: { name, arguments: args }, + }, + }); + expect(response.status()).toBe(200); + const body = await response.text(); + const data = body.startsWith("event:") + ? body + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + : body; + const rpc = JSON.parse(data ?? "null"); + expect(rpc.error).toBeUndefined(); + return JSON.parse(rpc.result.content[0].text) as T; + } + async context() { + const result = await this.tool("get_activity_context"); + expect(result.ok).toBe(true); + return result; + } + async publish(activities = activityProposals) { + const { revision } = await this.context(); + const result = await this.tool("publish_activities", { + requestId: randomUUID(), + expectedRevision: revision, + activities, + }); + expect(result.ok).toBe(true); + return result; + } +} +function clearActivities() { + const path = process.env.TWITTER_LITE_E2E_DB_PATH; + if ( + !path || + !isAbsolute(path) || + !basename(dirname(path)).startsWith("twitter-lite-e2e-") || + basename(path) !== "workspace.sqlite" + ) + throw new Error("Isolated E2E database is required."); + const database = openDatabase(path); + try { + database.$client.exec( + "DELETE FROM activities; DELETE FROM activity_receipts; DELETE FROM activity_revision;", + ); + } finally { + database.$client.close(); + } +} +export const test = base.extend<{ + activitiesPage: ActivitiesPage; + activityMcp: ActivityMcp; + resetActivities: undefined; +}>({ + homePage: async ({ page }, use) => { + await use(new ActivityHomePage(page)); + }, + messagesPage: async ({ page }, use) => { + await use(new ActivityMessagesPage(page)); + }, + resetActivities: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments. + async ({}, use) => { + clearActivities(); + await use(undefined); + clearActivities(); + }, + { auto: true }, + ], + activitiesPage: async ({ page }, use) => { + await use(new ActivitiesPage(page)); + }, + activityMcp: async ({ playwright, baseURL }, use) => { + const client = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: {}, + storageState: { cookies: [], origins: [] }, + }); + try { + await use(new ActivityMcp(client)); + } finally { + await client.dispose(); + } + }, +}); +export { expect }; diff --git a/e2e/integrations/activities.test.ts b/e2e/integrations/activities.test.ts new file mode 100644 index 0000000..fc05ab1 --- /dev/null +++ b/e2e/integrations/activities.test.ts @@ -0,0 +1,142 @@ +import { activityProposals, expect, test } from "../activity-fixtures"; +import { ActivitiesPage } from "../models/ActivitiesPage"; + +test("shares published chores and reply activities with Home, Messages and MCP without losing user changes", async ({ + page, + context, + homePage, + workspacePage, + messagesPage, + activitiesPage, + activityMcp, +}) => { + await homePage.goTo(); + await expect(activitiesPage.card("buy-detergent")).toHaveCount(0); + expect((await activityMcp.context()).activities).toEqual([]); + await activityMcp.publish(); + await expect(activitiesPage.card("buy-detergent")).toBeVisible(); + await expect(activitiesPage.card("review-times")).toBeVisible(); + await expect(activitiesPage.card("send-documents")).toBeVisible(); + await activitiesPage.complete("Buy detergent").click(); + await expect(activitiesPage.card("buy-detergent")).toHaveAttribute("data-status", "done"); + await activitiesPage.openReply("review-times"); + await expect(page).toHaveURL(/\/support\?.*activity=review-times/); + await expect(activitiesPage.details).toContainText( + "Read the review materials and choose two times", + ); + await expect(activitiesPage.sources).toContainText( + "Please review the materials and suggest two times.", + ); + await expect(activitiesPage.sources).toContainText("not the full conversation history"); + await expect(activitiesPage.action("Send the completed documents")).toBeVisible(); + const edited = "I checked the materials. Thursday at 14:00 works for me."; + await activitiesPage.saveReply(edited); + await expect + .poll( + async () => + (await activityMcp.context()).activities.find((item) => item.id === "review-times") + ?.userOverrides.draft, + ) + .toBe(edited); + await activitiesPage.later.click(); + await expect + .poll( + async () => + (await activityMcp.context()).activities.find((item) => item.id === "review-times") + ?.userState, + ) + .toBe("deferred"); + const before = await activityMcp.context(); + expect(before.activities.find((item) => item.id === "send-documents")?.userState).toBe( + "available", + ); + const refreshed = activityProposals.map((proposal) => + proposal.kind === "reply" + ? { ...proposal, reply: { ...proposal.reply, draft: "A newer agent suggestion" } } + : proposal, + ); + await activityMcp.publish(refreshed); + const after = await activityMcp.context(); + expect(after.activities).toHaveLength(3); + expect(after.activities.find((item) => item.id === "review-times")).toMatchObject({ + userState: "deferred", + userOverrides: { draft: edited }, + effective: { reply: { draft: edited } }, + proposal: { reply: { draft: "A newer agent suggestion" } }, + }); + expect(after.activities.find((item) => item.id === "buy-detergent")?.userState).toBe("completed"); + await page.reload(); + await expect(activitiesPage.draft).toHaveValue(edited); + await expect( + activitiesPage.details.getByRole("button", { + name: /^(Send|Mark complete|Try sending)$/, + exact: true, + }), + ).toHaveCount(0); + await activitiesPage.action("Send the completed documents").click(); + await expect(activitiesPage.draft).toHaveValue("A newer agent suggestion"); + await messagesPage.showConversationList(); + await workspacePage.navigateTo("Home"); + await expect(activitiesPage.card("review-times")).toContainText("Set aside"); + const device = await context.newPage(); + try { + const other = new ActivitiesPage(device); + await device.goto("/"); + await expect(other.card("buy-detergent")).toHaveAttribute("data-status", "done"); + await activityMcp.publish([ + { + id: "later-chore", + kind: "task", + title: "Take the recycling out", + nextAction: "Bring the paper bag downstairs", + sources: [], + }, + ]); + await expect(activitiesPage.card("later-chore")).toBeVisible(); + await expect(other.card("later-chore")).toBeVisible(); + await device.reload(); + await expect(other.card("later-chore")).toBeVisible(); + } finally { + await device.close(); + } +}); + +test("retries publication without duplicates and rejects invalid batches without partial writes", async ({ + activityMcp, +}) => { + const input = { + requestId: "stable-publication", + expectedRevision: 0, + activities: activityProposals, + }; + const receipt = await activityMcp.tool("publish_activities", input); + expect(receipt.ok).toBe(true); + expect(await activityMcp.tool("publish_activities", input)).toEqual(receipt); + expect( + await activityMcp.tool("publish_activities", { ...input, requestId: "stale" }), + ).toMatchObject({ ok: false, error: { code: "revision-conflict" } }); + const snapshot = await activityMcp.context(); + expect( + await activityMcp.tool("publish_activities", { + requestId: "invalid", + expectedRevision: snapshot.revision, + activities: [ + { + id: "valid-sibling", + kind: "task", + title: "Valid sibling", + nextAction: "Do the chore", + sources: [], + }, + { + id: "invalid-sibling", + kind: "reply", + title: "Missing reply target", + nextAction: "Check", + sources: [], + }, + ], + }), + ).toMatchObject({ ok: false, error: { code: "invalid-input" } }); + expect(await activityMcp.context()).toEqual(snapshot); +}); diff --git a/e2e/integrations/auth.test.ts b/e2e/integrations/auth.test.ts index 3f1429e..37c2d62 100644 --- a/e2e/integrations/auth.test.ts +++ b/e2e/integrations/auth.test.ts @@ -14,7 +14,7 @@ test("creates the owner once and enters onboarding with a real session", async ( db.$client.close(); await context.clearCookies(); await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await expect(authPage.createAccountButton).toBeVisible(); await expect(page).toHaveURL(/\/setup$/); expect((await a11y().analyze()).violations).toEqual([]); await authPage.createAccount({ @@ -24,7 +24,7 @@ test("creates the owner once and enters onboarding with a real session", async ( setupCode: "isolated-e2e-setup-token-not-for-production", }); await expect(page).toHaveURL(/\/onboarding$/); - await page.waitForLoadState("networkidle"); + await expect(onboardingPage.continueButton).toBeEnabled(); await onboardingPage.complete(); await expect(homePage.greeting("Yuta")).toBeVisible(); }); @@ -70,7 +70,7 @@ test("signs in and revokes the session on sign out", async ({ await authPage.password.fill("E2E-only-passphrase-2026"); await authPage.signInButton.click(); await expect(page).toHaveURL("/"); - await page.waitForLoadState("networkidle"); + await expect(workspacePage.manageAccounts).toBeVisible(); const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session"); expect(token?.httpOnly).toBe(true); expect(token?.sameSite).toBe("Lax"); @@ -99,7 +99,7 @@ test("requires onboarding and remembers its completion and name", async ({ try { expect((await request.get("/api/research/events")).status()).toBe(403); await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await expect(onboardingPage.name).toBeVisible(); await expect(page).toHaveURL(/\/onboarding$/); expect((await a11y().analyze()).violations).toEqual([]); await onboardingPage.name.fill("Yuta Test"); diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index c7f8456..68ac535 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -1,4 +1,8 @@ -import { expect, test } from "../fixtures"; +import { expect, test } from "../activity-fixtures"; + +test.beforeEach(async ({ activityMcp }) => { + await activityMcp.publish(); +}); test("executes routine steps and records a measurement in the shared journal", async ({ page, @@ -8,7 +12,7 @@ test("executes routine steps and records a measurement in the shared journal", a workspacePage, }, testInfo) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await homePage.morningRoutine.click(); await expect(page).toHaveURL(/activity=task/); await homePage.routineStep(/Drink water/).check(); @@ -30,15 +34,16 @@ test("shows the day flow and conversation context at desktop width", async ({ page, homePage, messagesPage, + activitiesPage, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1080 }); await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await expect(homePage.todayPlan).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("home-desktop.png") }); - await messagesPage.goTo("?conversation=design-review"); - await page.waitForLoadState("networkidle"); - await expect(messagesPage.reply).toBeVisible(); + await messagesPage.goTo("?activity=review-times&view=all"); + await page.waitForLoadState("domcontentloaded"); + await expect(activitiesPage.draft).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") }); }); @@ -49,7 +54,7 @@ test("previews a natural-language memo and shares the executed record with journ workspacePage, }) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); const memo = "明日のレビューは導線から確認する"; await homePage.previewCommand(`Note: ${memo}`); const execute = homePage.applyAction; @@ -63,7 +68,7 @@ test("previews a natural-language memo and shares the executed record with journ test("keeps the home usable on narrow screens", async ({ page, a11y, homePage }, testInfo) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); await expect(homePage.input).toBeVisible(); @@ -73,80 +78,77 @@ test("keeps the home usable on narrow screens", async ({ page, a11y, homePage }, } }); -test("adds tasks only after confirmation and can defer and restore them", async ({ +test("adds tasks only after confirmation and persists their deferred and completed state", async ({ page, homePage, + activitiesPage, + activityMcp, }) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); - const complete = homePage.completeTask("grocery shopping"); + const complete = activitiesPage.complete("grocery shopping"); await homePage.previewCommand("Add grocery shopping"); await expect(complete).toHaveCount(0); await homePage.applyAction.click(); await expect(complete).toBeVisible(); await homePage.previewCommand("Defer grocery shopping"); - await expect(complete).toBeVisible(); await homePage.applyAction.click(); - await expect(complete).toHaveCount(0); - await homePage.restoreTask("grocery shopping").click(); + await expect + .poll( + async () => + (await activityMcp.context()).activities.find( + (item) => item.effective.title === "grocery shopping", + )?.userState, + ) + .toBe("deferred"); + await page.reload(); await complete.click(); - await expect(complete).toHaveCount(0); - await expect(homePage.restoreTask("grocery shopping")).toBeVisible(); + await expect(activitiesPage.restore("grocery shopping")).toBeVisible(); }); -test("opens a conversation page from home and retains outcomes on return", async ({ +test("opens a reply activity from home and retains its state through history and reload", async ({ page, a11y, homePage, messagesPage, workspacePage, + activitiesPage, }) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); - const conversationLink = homePage.conversation("Haruka Aoki"); - await expect(conversationLink).toHaveAttribute("href", /conversation=design-review/); - await conversationLink.click(); - await expect(page).toHaveURL(/\/support\?.*conversation=design-review/); - await expect(messagesPage.dialogs).toHaveCount(0); - await expect(messagesPage.reply).toBeVisible(); + await activitiesPage.openReply("review-times"); + await expect(page).toHaveURL(/activity=review-times/); + await expect(activitiesPage.draft).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); await page.goBack(); - await expect(conversationLink).toBeVisible(); + await expect(activitiesPage.card("review-times")).toBeVisible(); await page.goForward(); - await expect(messagesPage.reply).toBeVisible(); + await expect(activitiesPage.draft).toBeVisible(); await page.reload(); - await page.waitForLoadState("networkidle"); - await expect(messagesPage.reply).toBeVisible(); - await messagesPage.leaveForLater.click(); - await workspacePage.navigateTo("Home"); - await expect(conversationLink).toContainText("Set aside"); - await conversationLink.click(); - await expect(page).toHaveURL(/conversation=design-review/); - await expect(messagesPage.moveToInbox).toBeVisible(); - await messagesPage.moveToInbox.click(); + await expect(activitiesPage.draft).toBeVisible(); + await activitiesPage.later.click(); await messagesPage.showConversationList(); await workspacePage.navigateTo("Home"); - await expect(conversationLink).toBeVisible(); + await expect(activitiesPage.card("review-times")).toContainText("Set aside"); + await activitiesPage.openReply("review-times"); + await activitiesPage.bringBack.click(); + await expect(activitiesPage.later).toBeVisible(); }); -test("updates the prose brief as priorities change and opens the referenced conversation", async ({ - page, +test("updates the prose brief as activities change and opens the referenced reply", async ({ homePage, messagesPage, workspacePage, + activitiesPage, }) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); const brief = homePage.dailyBrief; - await expect(brief).toContainText("Review the documents"); - await expect(brief).toContainText("3 conversations"); - await homePage.completeTask("Review the documents").click(); - await expect(brief).not.toContainText("Review the documents"); - await homePage.openBriefConversation("Haruka Aoki"); - await messagesPage.leaveForLater.click(); + await expect(brief).toContainText("Buy detergent"); + await activitiesPage.complete("Buy detergent").click(); + await expect(brief).not.toContainText("Buy detergent"); + await brief.getByRole("button", { name: "Propose review times", exact: true }).click(); + await activitiesPage.later.click(); + await messagesPage.showConversationList(); await workspacePage.navigateTo("Home"); - await expect(brief).toContainText("2 conversations"); - await expect(brief).not.toContainText("Haruka Aoki"); + await expect(brief).not.toContainText("Propose review times"); await homePage.previewCommand("再開地点:資料の2ページ目から"); await homePage.applyAction.click(); await expect(brief).toContainText("資料の2ページ目から"); @@ -156,32 +158,19 @@ test("keeps completed actions in their original positions with visible outcomes" page, a11y, homePage, - messagesPage, - workspacePage, + activitiesPage, }, testInfo) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); - const titles = homePage.flowTitles; - const before = await titles.allTextContents(); - await homePage.completeTask("Review the documents").click(); - await expect(homePage.restoreTask("Review the documents")).toHaveAttribute( - "aria-pressed", - "true", - ); - await expect(homePage.completedTask("Review the documents")).toBeVisible(); - await homePage.conversation("Haruka Aoki").click(); - await messagesPage.reviewReply.click(); - await messagesPage.trySending.click(); - await workspacePage.navigateTo("Home"); - await expect(homePage.conversation("Haruka Aoki")).toContainText("Reply recorded"); + await expect(activitiesPage.card("send-documents")).toBeVisible(); + const before = await homePage.flowTitles.allTextContents(); + await activitiesPage.complete("Buy detergent").click(); + await expect(activitiesPage.restore("Buy detergent")).toHaveAttribute("aria-pressed", "true"); + await expect(activitiesPage.card("buy-detergent")).toContainText("Completed"); await homePage.recordMeasurement("65.2"); await expect(homePage.recordedWeight("65.2")).toBeVisible(); - expect(await titles.allTextContents()).toEqual(before); + expect(await homePage.flowTitles.allTextContents()).toEqual(before); expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") }); - await homePage.restoreTask("Review the documents").click(); - await expect(homePage.completeTask("Review the documents")).toHaveAttribute( - "aria-pressed", - "false", - ); + await activitiesPage.restore("Buy detergent").click(); + await expect(activitiesPage.complete("Buy detergent")).toHaveAttribute("aria-pressed", "false"); }); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index 054b2c5..bd81fef 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -1,71 +1,68 @@ -import { expect, test } from "../fixtures"; +import { expect, test } from "../activity-fixtures"; -const reviewSubject = "Schedule next week’s design review"; -const coffeeSubject = "Catch up over coffee"; +test.beforeEach(async ({ activityMcp }) => { + await activityMcp.publish(); +}); -test("reviews an edited reply and keeps the simulated result in history", async ({ +test("saves an edited reply without recording a simulated send", async ({ page, a11y, messagesPage, + activitiesPage, + activityMcp, }) => { - await messagesPage.goTo(); - await page.waitForLoadState("networkidle"); - const rows = messagesPage.rows; - await expect(rows).toHaveCount(3); - await messagesPage.conversation(reviewSubject).click(); - await messagesPage.chooseReplySuggestion(/^Polite/); - const reply = messagesPage.reply; - await expect(reply).toHaveValue(/Thank you for sharing/); + await messagesPage.goTo("?activity=review-times&view=all"); const edited = "I reviewed the materials. Let’s discuss the first-time experience."; - await reply.fill(edited); - const slot = "Mon, Oct 5, 14:00–14:30"; - await messagesPage.addSuggestedTime(slot); - await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`); - await messagesPage.reviewReply.click(); - const dialog = messagesPage.reviewDialog; - await expect(dialog).toContainText(edited); - await expect(dialog).toContainText(slot); - await expect(dialog).toContainText("No message will be sent"); + await activitiesPage.saveReply(edited); + await expect(page.getByRole("status").filter({ hasText: "Draft saved" })).toBeVisible(); + await expect(activitiesPage.details).toContainText("Sending messages is not connected yet."); + await expect( + activitiesPage.details.getByRole("button", { name: "Mark complete", exact: true }), + ).toHaveCount(0); + await page.reload(); + await expect(activitiesPage.draft).toHaveValue(edited); + const activity = (await activityMcp.context()).activities.find( + (item) => item.id === "review-times", + ); + expect(activity).toMatchObject({ userState: "available", userOverrides: { draft: edited } }); expect((await a11y().analyze()).violations).toEqual([]); - await messagesPage.editReply.click(); - await expect(reply).toHaveValue(new RegExp(edited)); - await messagesPage.reviewReply.click(); - await messagesPage.trySending.click(); - await expect(dialog).toHaveCount(0); - await expect(messagesPage.conversation(reviewSubject)).toHaveCount(0); - await messagesPage.selectView("Archive"); - await messagesPage.conversation(reviewSubject).click(); - await expect(messagesPage.outcome).toContainText(edited); - await expect(messagesPage.outcome).toContainText(slot); }); -test("defers a conversation and restores it to the current queue", async ({ - page, +test("defers one reply while keeping another action in the same conversation available", async ({ messagesPage, + activitiesPage, + activityMcp, }) => { - await messagesPage.goTo(); - await page.waitForLoadState("networkidle"); - await messagesPage.conversation(coffeeSubject).click(); - await expect(messagesPage.conversation(coffeeSubject)).toHaveAttribute("aria-current", "true"); - await messagesPage.reply.fill("土曜の予定を確認してから返事するね。"); - await messagesPage.leaveForLater.click(); - await expect(messagesPage.conversation(coffeeSubject)).toHaveCount(0); - await messagesPage.selectView("Set aside"); - await messagesPage.conversation(coffeeSubject).click(); - await expect(messagesPage.reply).toHaveValue("土曜の予定を確認してから返事するね。"); - await messagesPage.moveToInbox.click(); - await messagesPage.showConversationList(); - await expect(messagesPage.conversation(coffeeSubject)).toBeVisible(); - await expect(messagesPage.inbox).toHaveAttribute("aria-pressed", "true"); + await messagesPage.goTo("?activity=review-times&view=all"); + await activitiesPage.saveReply("土曜の予定を確認してから返事するね。"); + await expect(activitiesPage.useProposedDraft).toBeVisible(); + await activitiesPage.later.click(); + await expect(activitiesPage.bringBack).toBeVisible(); + await activitiesPage.action("Send the completed documents").click(); + await expect(activitiesPage.details).toContainText("Ready when you are"); + const snapshot = await activityMcp.context(); + expect(snapshot.activities.find((item) => item.id === "review-times")?.userState).toBe( + "deferred", + ); + expect(snapshot.activities.find((item) => item.id === "send-documents")?.userState).toBe( + "available", + ); + await activitiesPage.action("Propose review times").click(); + await expect(activitiesPage.draft).toHaveValue("土曜の予定を確認してから返事するね。"); + await activitiesPage.bringBack.click(); + await expect(activitiesPage.later).toBeVisible(); }); test("keeps corrections as new records and summarizes only selected current facts", async ({ page, a11y, journalPage, + homePage, + workspacePage, }) => { - await journalPage.goTo(); - await page.waitForLoadState("networkidle"); + await homePage.goTo(); + await workspacePage.navigateTo("Journal"); + await page.waitForLoadState("domcontentloaded"); await expect(journalPage.saveEntry).toBeDisabled(); await journalPage.entryText.fill("次はレビュー資料の導線を確認する"); await journalPage.saveEntry.click(); @@ -97,16 +94,17 @@ test("fits communication and journal interactions in narrow screens", async ({ page, a11y, messagesPage, + activitiesPage, journalPage, workspacePage, }, testInfo) => { for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); await messagesPage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await messagesPage.rows.first().click(); - await expect(messagesPage.reply).toBeVisible(); + await expect(activitiesPage.draft).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath(`messages-${width}.png`) }); diff --git a/e2e/integrations/url-state.test.ts b/e2e/integrations/url-state.test.ts index 897bdb3..673b018 100644 --- a/e2e/integrations/url-state.test.ts +++ b/e2e/integrations/url-state.test.ts @@ -1,4 +1,14 @@ -import { expect, test } from "../fixtures"; +import type { Page } from "@playwright/test"; +import { expect, test } from "../activity-fixtures"; +import { waitForWorkspaceHydration } from "../models/ready"; + +async function reloadHydrated(page: Page) { + await Promise.all([waitForWorkspaceHydration(page), page.reload()]); +} + +test.beforeEach(async ({ activityMcp }) => { + await activityMcp.publish(); +}); test("restores article selection with browser history and reload", async ({ page, readerPage }) => { await readerPage.goTo(); @@ -8,7 +18,7 @@ test("restores article selection with browser history and reload", async ({ page await expect(readerPage.articleTitle).toHaveText(heading); const selectedUrl = page.url(); expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy(); - await page.reload({ waitUntil: "networkidle" }); + await reloadHydrated(page); await expect(readerPage.articleTitle).toHaveText(heading ?? ""); await page.goBack(); await expect(readerPage.workspace).toHaveAttribute("data-reading", "false"); @@ -24,14 +34,14 @@ test("restores search and replaces typing history", async ({ workspacePage, }) => { await homePage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await workspacePage.navigateTo("Reader"); const search = readerPage.searchInput; await search.pressSequentially("browser"); await expect(search).toHaveValue("browser"); await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser"); const count = await readerPage.rows.count(); - await page.reload({ waitUntil: "networkidle" }); + await reloadHydrated(page); await expect(search).toHaveValue("browser"); await expect(readerPage.rows).toHaveCount(count); await page.goBack(); @@ -46,7 +56,7 @@ test("restores account dialog without losing underlying article", async ({ await readerPage.goTo(); await connectedAccounts.open(); await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true"); - await page.reload({ waitUntil: "networkidle" }); + await reloadHydrated(page); await expect(connectedAccounts.dialog).toBeVisible(); await page.goBack(); await expect(connectedAccounts.dialog).toHaveCount(0); @@ -56,16 +66,16 @@ test("restores account dialog without losing underlying article", async ({ test("restores a message view and conversation from its URL", async ({ page, messagesPage }) => { await messagesPage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await messagesPage.selectView("All"); - const row = messagesPage.rows.nth(1); - const heading = await messagesPage.rowName(1).innerText(); + const row = messagesPage.rows.first(); + const heading = await messagesPage.rowName(0).innerText(); await row.click(); await expect(messagesPage.toolbarName).toHaveText(heading); const url = page.url(); expect(new URL(url).searchParams.get("view")).toBe("all"); - expect(new URL(url).searchParams.get("conversation")).toBeTruthy(); - await page.reload({ waitUntil: "networkidle" }); + expect(new URL(url).searchParams.get("activity")).toBeTruthy(); + await reloadHydrated(page); await expect(messagesPage.toolbarName).toHaveText(heading ?? ""); await expect(messagesPage.workspace).toHaveAttribute("data-reading", "true"); }); @@ -84,7 +94,7 @@ test("debounces search and cancels pending input on history navigation", async ( messagesPage, }) => { await messagesPage.goTo(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await messagesPage.selectView("All"); await expect(page).toHaveURL(/view=all/); await page.clock.install(); diff --git a/e2e/integrations/vitals.test.ts b/e2e/integrations/vitals.test.ts index fea7257..ec33fcf 100644 --- a/e2e/integrations/vitals.test.ts +++ b/e2e/integrations/vitals.test.ts @@ -7,8 +7,7 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1 await vitalsPage.selectSystem("Cardiovascular"); await expect(vitalsPage.systems).toHaveCount(1); await expect(page).toHaveURL(/vitalTab=Cardiovascular/); - await page.reload(); - await page.waitForLoadState("networkidle"); + await vitalsPage.reload(); await expect(vitalsPage.systems).toHaveCount(1); await vitalsPage.openFirstSystem(); const dialog = vitalsPage.systemDialog("Cardiovascular"); @@ -21,8 +20,7 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1 await vitalsPage.showLabHistory(); await expect(vitalsPage.history).toContainText("Jul 28"); await expect(vitalsPage.history).toContainText("93"); - await page.reload(); - await page.waitForLoadState("networkidle"); + await vitalsPage.reload(); await expect(vitalsPage.history).toContainText("Jul 28"); await vitalsPage.showSystems(); await vitalsPage.selectSystem("Overview"); @@ -30,25 +28,19 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1 expect((await a11y().analyze()).violations).toEqual([]); }); -test("explores environment charts and simulated climate controls", async ({ - page, - vitalsPage, - a11y, -}) => { +test("explores environment charts and simulated climate controls", async ({ vitalsPage, a11y }) => { await vitalsPage.goTo(); await vitalsPage.navigateTo("Environment"); await expect(vitalsPage.environmentHeading).toBeVisible(); await vitalsPage.selectTrendPeriod("7D"); await vitalsPage.selectTrendMetric("CO₂"); await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible(); - await page.reload(); - await page.waitForLoadState("networkidle"); + await vitalsPage.reload(); await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible(); await vitalsPage.openMetric("Room temperature"); const dialog = vitalsPage.metricDialog("Room temperature"); await expect(dialog).toContainText("64.0"); - await page.reload(); - await page.waitForLoadState("networkidle"); + await vitalsPage.reload(); await expect(dialog).toContainText("64.0"); await vitalsPage.closeMetric("Room temperature"); await vitalsPage.syncReadings(); @@ -63,8 +55,7 @@ test("explores environment charts and simulated climate controls", async ({ await expect(temperature).toHaveValue("69"); await vitalsPage.turnOffPurifier(); await expect(vitalsPage.purifierOffButton).toHaveAttribute("aria-pressed", "false"); - await page.reload(); - await page.waitForLoadState("networkidle"); + await vitalsPage.reload(); await expect(temperature).toHaveValue("68"); await expect(vitalsPage.autoButton).toHaveAttribute("aria-pressed", "true"); await expect(vitalsPage.purifierOnButton).toHaveAttribute("aria-pressed", "true"); diff --git a/e2e/models/ActivitiesPage.ts b/e2e/models/ActivitiesPage.ts new file mode 100644 index 0000000..7283bad --- /dev/null +++ b/e2e/models/ActivitiesPage.ts @@ -0,0 +1,56 @@ +import type { Page } from "@playwright/test"; + +export class ActivitiesPage { + constructor(readonly page: Page) {} + card(id: string) { + return this.page.locator(`[data-activity-id="${id}"]`); + } + async expand(id: string) { + await this.card(id).getByRole("button", { expanded: false }).click(); + } + async openReply(id: string) { + await this.expand(id); + await this.card(id) + .getByRole("link", { name: /^Review reply to / }) + .click(); + } + complete(title: string) { + return this.page.getByRole("button", { name: `Complete ${title}`, exact: true }); + } + restore(title: string) { + return this.page.getByRole("button", { name: `Restore ${title}`, exact: true }); + } + get draft() { + return this.page.getByRole("textbox", { name: "Reply draft", exact: true }); + } + get saveDraft() { + return this.page.getByRole("button", { name: "Save draft", exact: true }); + } + get useProposedDraft() { + return this.page.getByRole("button", { name: "Use proposed draft", exact: true }); + } + get later() { + return this.page.getByRole("button", { name: "Later", exact: true }); + } + get bringBack() { + return this.page.getByRole("button", { name: "Bring back", exact: true }); + } + get notNeeded() { + return this.page.getByRole("button", { name: "Not needed", exact: true }); + } + get sources() { + return this.page.getByRole("region", { name: "Source excerpts" }); + } + get details() { + return this.page.getByRole("region", { name: "Message details" }); + } + action(title: string) { + return this.page + .getByRole("navigation", { name: "Conversation actions" }) + .getByRole("button", { name: title, exact: true }); + } + async saveReply(text: string) { + await this.draft.fill(text); + await this.saveDraft.click(); + } +} diff --git a/e2e/models/AuthPage.ts b/e2e/models/AuthPage.ts index 0fed464..ad82752 100644 --- a/e2e/models/AuthPage.ts +++ b/e2e/models/AuthPage.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; export class AuthPage { readonly name: Locator; @@ -24,7 +24,8 @@ export class AuthPage { } async goTo(mode: "login" | "setup" = "login") { - await this.page.goto(`/${mode}`, { waitUntil: "networkidle" }); + await this.page.goto(`/${mode}`); + await expect(this.email).toBeVisible(); } async signIn(email: string, password: string) { diff --git a/e2e/models/JournalPage.ts b/e2e/models/JournalPage.ts index 43cfd24..8426ec2 100644 --- a/e2e/models/JournalPage.ts +++ b/e2e/models/JournalPage.ts @@ -1,4 +1,5 @@ import type { Locator, Page } from "@playwright/test"; +import { navigateHydrated } from "./ready"; export class JournalPage { readonly page: Page; @@ -29,7 +30,7 @@ export class JournalPage { this.summary = this.summaryDialog.getByRole("textbox", { name: "Entry summary" }); } async goTo(search = "") { - await this.page.goto(`/journal${search}`); + await navigateHydrated(this.page, `/journal${search}`); } async correctWeight(weight: string) { await this.weightEntries.getByRole("button", { name: "Correct" }).click(); diff --git a/e2e/models/OnboardingPage.ts b/e2e/models/OnboardingPage.ts index fb22efa..9f98911 100644 --- a/e2e/models/OnboardingPage.ts +++ b/e2e/models/OnboardingPage.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; export class OnboardingPage { readonly name: Locator; @@ -12,11 +12,14 @@ export class OnboardingPage { } async goTo() { - await this.page.goto("/onboarding", { waitUntil: "networkidle" }); + await this.page.goto("/onboarding"); + await expect( + this.name.or(this.page.getByRole("textbox", { name: "Home input" })), + ).toBeVisible(); } async complete() { - await this.page.waitForLoadState("networkidle"); + await expect(this.continueButton).toBeEnabled(); await this.continueButton.click(); await this.openWorkspace.click(); } diff --git a/e2e/models/ReaderPage.ts b/e2e/models/ReaderPage.ts index 49334ae..cf97056 100644 --- a/e2e/models/ReaderPage.ts +++ b/e2e/models/ReaderPage.ts @@ -1,4 +1,5 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { navigateHydrated } from "./ready"; export class ReaderPage { readonly rows: Locator; @@ -30,7 +31,9 @@ export class ReaderPage { } async goTo(search = "") { - await this.page.goto(`/inbox${search}`, { waitUntil: "networkidle" }); + await navigateHydrated(this.page, `/inbox${search}`); + if (new URL(this.page.url()).pathname === "/inbox") + await expect(this.searchInput).toBeVisible(); } article(title: string) { diff --git a/e2e/models/VitalsPage.ts b/e2e/models/VitalsPage.ts index 42d65ed..e366ffd 100644 --- a/e2e/models/VitalsPage.ts +++ b/e2e/models/VitalsPage.ts @@ -1,4 +1,5 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { navigateHydrated, waitForWorkspaceHydration } from "./ready"; export class VitalsPage { readonly bodyHeading: Locator; @@ -36,7 +37,16 @@ export class VitalsPage { } async goTo(search = "") { - await this.page.goto(`/vitals${search}`, { waitUntil: "networkidle" }); + await navigateHydrated(this.page, `/vitals${search}`); + if (new URL(this.page.url()).pathname === "/vitals") await this.waitUntilReady(); + } + + async reload() { + await Promise.all([waitForWorkspaceHydration(this.page), this.page.reload()]); + } + + async waitUntilReady() { + await expect(this.bodyHeading.or(this.environmentHeading)).toBeVisible(); } async navigateTo(section: "Body" | "Environment") { diff --git a/e2e/models/ready.ts b/e2e/models/ready.ts new file mode 100644 index 0000000..cb86171 --- /dev/null +++ b/e2e/models/ready.ts @@ -0,0 +1,18 @@ +import type { Page } from "@playwright/test"; + +/** Register before navigation: this stream starts in a client effect after hydration. */ +export function waitForWorkspaceHydration(page: Page) { + return page.waitForResponse((response) => { + if (response.status() !== 200) return false; + const path = new URL(response.url()).pathname; + return ( + path === "/api/activities/events" || + (response.request().isNavigationRequest() && + ["/login", "/setup", "/onboarding"].includes(path)) + ); + }); +} + +export async function navigateHydrated(page: Page, url: string) { + await Promise.all([waitForWorkspaceHydration(page), page.goto(url)]); +} diff --git a/src/features/activities/activity-card.tsx b/src/features/activities/activity-card.tsx new file mode 100644 index 0000000..4faf45a --- /dev/null +++ b/src/features/activities/activity-card.tsx @@ -0,0 +1,97 @@ +import { Link } from "@tanstack/react-router"; +import { Check, ChevronDown, Circle, MessageCircle } from "lucide-react"; +import { + ActivityControls, + ActivityEditor, + ActivitySources, + stateLabels, +} from "./activity-controls"; +import { useActivities } from "./activity-state"; +import type { Activity } from "./model"; + +export function ActivityCard({ + activity, + expanded, + onToggle, +}: { + activity: Activity; + expanded: boolean; + onToggle: () => void; +}) { + const { busy, change } = useActivities(); + const item = activity.effective; + return ( +
+
+ {item.kind === "task" ? ( + + ) : ( + + )} + + +
+ {expanded && ( +
+

{stateLabels[activity.userState]}

+

{item.nextAction}

+ {item.detail && ( +

{item.detail}

+ )} + {item.kind === "reply" && ( + + Review reply to {item.reply.recipientName} + + )} + + + +
+ )} +
+ ); +} diff --git a/src/features/activities/activity-controls.test.tsx b/src/features/activities/activity-controls.test.tsx new file mode 100644 index 0000000..053f052 --- /dev/null +++ b/src/features/activities/activity-controls.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ActivityEditor } from "./activity-controls"; +import type { Activity } from "./model"; + +const store = vi.hoisted(() => ({ + change: vi.fn["change"]>(), + busy: false, + revision: 7, + currentRevision: () => store.revision, +})); +vi.mock("./activity-state", () => ({ useActivities: () => store })); +const activity: Activity = { + id: "review-materials", + proposal: { + id: "review-materials", + kind: "task", + title: "Review materials", + nextAction: "Open the document", + detail: "Check the scope", + sources: [], + }, + userState: "available", + userOverrides: {}, + effective: { + id: "review-materials", + kind: "task", + title: "Review materials", + nextAction: "Open the document", + detail: "Check the scope", + sources: [], + section: "optional", + }, +}; +beforeEach(() => { + vi.clearAllMocks(); + store.change.mockResolvedValue(true); + store.busy = false; + store.revision = 7; +}); + +it("saves edited next action and an explicitly cleared detail without overriding untouched fields", async () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Edit activity" })); + fireEvent.change(screen.getByRole("textbox", { name: "Next action" }), { + target: { value: "Compare the three proposed screens" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Details" }), { target: { value: "" } }); + store.revision = 8; + view.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Save activity" })); + await waitFor(() => + expect(store.change).toHaveBeenCalledWith( + activity.id, + { overrides: { nextAction: "Compare the three proposed screens", detail: "" } }, + 7, + ), + ); + expect(await screen.findByRole("button", { name: "Edit activity" })).toBeVisible(); +}); + +it("keeps the edited text after a conflict and disables all inputs during a save", async () => { + store.change.mockResolvedValue(false); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Edit activity" })); + fireEvent.change(screen.getByRole("textbox", { name: "Next action" }), { + target: { value: "My next step" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save activity" })); + await waitFor(() => expect(store.change).toHaveBeenCalledTimes(1)); + expect(screen.getByRole("textbox", { name: "Next action" })).toHaveValue("My next step"); + expect(screen.getByRole("textbox", { name: "Next action" })).toBeRequired(); + store.busy = true; + view.rerender(); + for (const input of screen.getAllByRole("textbox")) expect(input).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Placement" })).toBeDisabled(); +}); + +it("pins the refreshed revision after a conflict even if another update arrives before retry", async () => { + store.change.mockImplementation(async () => { + store.revision = 8; + return false; + }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Edit activity" })); + fireEvent.change(screen.getByRole("textbox", { name: "Next action" }), { + target: { value: "My next step" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save activity" })); + await waitFor(() => expect(store.change).toHaveBeenCalledTimes(1)); + store.revision = 9; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Save activity" })); + await waitFor(() => + expect(store.change).toHaveBeenNthCalledWith( + 2, + activity.id, + { overrides: { nextAction: "My next step" } }, + 8, + ), + ); +}); diff --git a/src/features/activities/activity-controls.tsx b/src/features/activities/activity-controls.tsx new file mode 100644 index 0000000..682a9b0 --- /dev/null +++ b/src/features/activities/activity-controls.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { Button } from "#/components/ui/button"; +import { Input } from "#/components/ui/input"; +import { Textarea } from "#/components/ui/textarea"; +import { useActivities } from "./activity-state"; +import type { Activity, ActivityOverrides, ActivityState } from "./model"; + +export const stateLabels: Record = { + available: "Ready when you are", + inProgress: "In progress", + waiting: "Waiting", + deferred: "Set aside", + completed: "Completed", + dismissed: "Not needed", +}; + +export function ActivityFeedback() { + const { loading, error, refresh, retryPending } = useActivities(); + if (loading) + return ( +
+ ); + if (!error) return null; + return ( +
+

{error}

+ +
+ ); +} + +export function ActivityControls({ activity }: { activity: Activity }) { + const { change, busy } = useActivities(); + const setState = (state: ActivityState) => void change(activity.id, { state }); + return ( +
+ {activity.effective.kind === "task" && activity.userState !== "completed" && ( + + )} + {activity.userState === "available" && ( + + )} + {(activity.userState === "available" || activity.userState === "inProgress") && ( + + )} + {activity.userState !== "deferred" && activity.userState !== "completed" && ( + + )} + {activity.userState !== "dismissed" && activity.userState !== "completed" && ( + + )} + {activity.userState !== "available" && activity.userState !== "completed" && ( + + )} + {activity.effective.kind === "task" && activity.userState === "completed" && ( + + )} +
+ ); +} + +/** One editor for the shared action, regardless of which page opened it. */ +export function ActivityEditor({ activity }: { activity: Activity }) { + const { change, busy, revision, currentRevision } = useActivities(); + const [editRevision, setEditRevision] = useState(); + const [editing, setEditing] = useState(false); + const [changedFields, setChangedFields] = useState>(new Set()); + const [values, setValues] = useState({ + title: activity.effective.title, + nextAction: activity.effective.nextAction, + detail: activity.effective.detail ?? "", + section: activity.effective.section, + }); + const editable = ["title", "nextAction", "detail", "section"] as const; + if (!editing) + return ( + + ); + return ( +
{ + event.preventDefault(); + const overrides: ActivityOverrides = {}; + for (const key of editable) { + if (changedFields.has(key) && values[key] !== (activity.effective[key] ?? "")) + Object.assign(overrides, { [key]: values[key] }); + } + if ( + !Object.keys(overrides).length || + (await change(activity.id, { overrides }, editRevision)) + ) + setEditing(false); + else setEditRevision(currentRevision()); + }} + > + {( + [ + ["title", "Title", 300], + ["nextAction", "Next action", 2000], + ["detail", "Details", 8000], + ] as const + ).map(([key, label, max]) => ( +