56 Commits
Author SHA1 Message Date
yuta 48ef396ec1 refactor: organize E2E tests with page objects 2026-10-07 21:13:40 +09:00
yuta 1e03e8f4ff refactor: migrate UI to Tailwind and unify chat with shadcn 2026-10-07 20:51:05 +09:00
yuta f41d0c1964 feat: serve workspace MCP over the web app HTTP endpoint 2026-10-07 20:46:08 +09:00
yuta e917cafd88 fix(nix): align dependency hash with pinned pnpm 2026-10-06 13:44:15 +09:00
yuta e51cafa570 feat: streamline conversation workflow and add Jev reply reviews 2026-09-28 23:45:07 +09:00
yuta 54d7a542f7 feat: redesign daily execution and messaging interfaces 2026-09-28 22:38:58 +09:00
yuta 970553bd8f docs: research open loops and personal execution boundaries 2026-09-28 22:37:51 +09:00
yuta b0458c4b91 docs: design Codex publishing and routine execution workflows 2026-09-28 21:43:13 +09:00
yuta 8985ef9fff feat: add public health endpoint 2026-09-28 21:03:46 +09:00
yuta 423d9edbfa fix: restore authenticated Codex research tool execution 2026-09-28 20:59:59 +09:00
yuta f1914a5d0a fix: show deck skeletons while loading connections 2026-09-28 20:59:53 +09:00
yuta 7a7bc983e6 feat: add skeleton placeholders for loading posts 2026-09-28 20:38:53 +09:00
yuta 0bab009984 chore: migrate linting and formatting to Oxc 2026-09-28 20:08:38 +09:00
yuta 8085ad2f90 feat: add URL-driven workspace state and streamed Codex research 2026-09-28 20:08:28 +09:00
yuta 85d23a328b feat: require owner login and add onboarding 2026-09-28 19:14:14 +09:00
yuta 57882cf5f9 refactor: rename workspace branding and update project overview 2026-09-28 18:54:10 +09:00
yuta 1cf32705a7 feat: translate system interface to English 2026-09-28 18:42:16 +09:00
yuta e870bdfcbb feat: add daily home and vitals dashboard prototypes 2026-09-28 18:30:59 +09:00
yuta 0ee5d181a5 feat: add communication support and journal prototypes 2026-09-28 18:03:06 +09:00
yuta 4e8957c94a feat: add minimal reader and shared account navigation 2026-09-28 17:53:38 +09:00
yuta 96e545401e feat: migrate workspace to shadcn sidebar and chat components 2026-09-28 17:29:08 +09:00
yuta 2346848839 feat: support private network access without identity headers 2026-09-24 20:25:41 +09:00
yuta 80cadf7242 feat: persist research conversations and refine deck headers 2026-09-24 18:50:44 +09:00
yuta ceab2e2384 feat: refine deck layout and prefer account2 for Twitter research 2026-09-24 18:23:46 +09:00
yuta e82a1187af fix: explain AI and manual deck creation in empty state 2026-09-24 18:19:13 +09:00
yuta bd9c1b77b7 feat: render research Markdown and navigate citations to deck posts 2026-09-24 18:18:15 +09:00
yuta 9c3917541b feat: start fresh research chats while preserving deck context 2026-09-24 18:03:29 +09:00
yuta b2db67ea1c feat: add live Codex research chat and reusable deck sources 2026-09-24 17:55:51 +09:00
yuta c47f58f065 feat: add shared decks and multi-account Mastodon OAuth 2026-09-24 16:52:55 +09:00
yuta d2cbf4dbd3 feat: unify research in profile-bound decks with TweetDeck-style UI 2026-09-24 15:59:54 +09:00
yuta da8f52e605 feat: add research decks with WebMCP controls 2026-09-24 15:38:06 +09:00
yuta 2bc052c354 feat: prototype WebMCP reader tools 2026-09-24 14:48:56 +09:00
yuta 9daf6e71dc fix(nix): refresh pnpm dependency hash 2026-09-23 21:46:32 +09:00
yuta 5406855e1c test(e2e): generate relay fixture types from OpenAPI 2026-08-12 00:24:55 +09:00
yuta 8eb299f46d Add button-driven search filters 2026-08-10 04:17:54 +09:00
yuta 079aaa1664 Add relay profile switching 2026-07-31 21:53:43 +09:00
yuta 67ee53c305 Put search first in navigation 2026-07-26 16:05:19 +09:00
yuta 70cacc2c2d Fix reader media and thread behavior (#4)
Reviewed-on: #4
2026-07-26 15:32:28 +09:00
yuta 1794255ebb Improve reader design (#3)
Reviewed-on: #3
2026-07-26 14:52:08 +09:00
yuta 24939e6f00 Add Hallmark agent skill 2026-07-26 14:08:51 +09:00
yuta f5274195b9 Add Axe accessibility E2E tests (#2)
Reviewed-on: #2
2026-07-26 13:55:56 +09:00
yuta 8679a850e5 Refactor E2E tests with page objects (#1)
Reviewed-on: #1
2026-07-26 13:43:31 +09:00
yuta 766d13dfcc refactor: use X-style route paths 2026-07-26 13:29:38 +09:00
yuta 604c7f16e2 feat: add authenticated list reading 2026-07-26 12:58:35 +09:00
yuta 83992bd962 Add Knip unused-code checks 2026-07-23 16:53:28 +09:00
yuta 234f4354f9 feat: add NixOS service module 2026-07-23 15:08:03 +09:00
yuta 6a4f7ca068 chore: consume Bird from Gitea Packages 2026-07-14 03:00:09 +09:00
yuta d8827b6513 fix: classify malformed thread metadata 2026-07-14 02:04:11 +09:00
yuta f82f6641b2 docs: reconcile intentional entry points 2026-07-14 01:43:08 +09:00
yuta acc4fe841b docs: document tweet conversations 2026-07-14 01:28:23 +09:00
yuta fa26ce18d7 fix: exclude browser test artifacts from lint 2026-07-14 01:21:13 +09:00
yuta 4efd709234 test: verify tweet conversation flows 2026-07-14 01:09:14 +09:00
yuta da72094e07 feat: add tweet detail page 2026-07-14 00:50:08 +09:00
yuta 8e6ff70d81 feat: paginate tweet conversations 2026-07-14 00:29:03 +09:00
yuta 1eb2733b38 feat: load tweet conversations 2026-07-14 00:14:54 +09:00
yuta 45cee54cff docs: refine conversation test plan 2026-07-14 00:05:39 +09:00
303 changed files with 43880 additions and 4151 deletions
+21 -1
View File
@@ -1,2 +1,22 @@
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
BIRD_PROFILE_NAME= TWITTER_LITE_ORIGIN=https://your-host.your-tailnet.ts.net
TWITTER_LITE_ALLOWED_LOGIN=your-tailscale-login
TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
# Required when connecting Mastodon; keep this runtime file outside Git.
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
# Optional Codex research. The AI SDK provider manages its local app-server.
# If your signed-in CLI uses a custom home, use the same path on the server.
# CODEX_HOME=/absolute/path/to/codex-home
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
# Internal Codex connects to the app's own HTTP MCP endpoint.
# Defaults to http://127.0.0.1:${PORT:-3000}/mcp; set for a different bind address/port.
# TWITTER_LITE_MCP_URL=http://127.0.0.1:3000/mcp
# Optional initial owner setup code (32+ characters). Otherwise generated beside DB.
# WORKSPACE_SETUP_TOKEN=
# Optional real Jev reply evaluation through Vercel AI Gateway.
# AI_GATEWAY_API_KEY=
+4
View File
@@ -1,9 +1,13 @@
.superpowers/ .superpowers/
.worktrees/ .worktrees/
/.*/skills/
.env* .env*
!.env.example !.env.example
.output/ .output/
.tanstack/
dist/ dist/
node_modules/ node_modules/
playwright-report/ playwright-report/
test-results/ test-results/
.data/
*.setup-token
+49
View File
@@ -0,0 +1,49 @@
[
{
"date": "2026-09-28",
"macrostructure": "daily home with natural-language composer and flexible plan",
"theme": "shadcn base-nova neutral",
"scope": "home",
"enrichment": "none",
"brief": "Greeting and large composer above time-free routines, tasks, contact actions and context; shared session state"
},
{
"date": "2026-09-28",
"macrostructure": "communication workbench and append-only journal",
"theme": "shadcn base-nova neutral",
"scope": "support",
"enrichment": "none",
"brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections"
},
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Inbox",
"theme": "shadcn base-nova neutral",
"scope": "inbox",
"enrichment": "none",
"brief": "Shared navigation with Twitter Lite, inferred-interest article list and reading pane; no manual interest settings"
},
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Workbench",
"theme": "shadcn base-nova neutral",
"scope": "app",
"enrichment": "none",
"brief": "Base UI icon rail and contextual sidebar with shadcn chat primitives and independent deck columns"
},
{
"date": "2026-09-24",
"macrostructure": "Workbench",
"theme": "custom",
"theme_axes": "dark navy / system sans / blue",
"enrichment": "none",
"brief": "Full-height TweetDeck-style research columns with compact sidebar and modal editors"
},
{
"date": "2026-07-26",
"macrostructure": "Workbench",
"theme": "Garden",
"enrichment": "none",
"brief": "Personal read-only research client with a soft, deliberate workspace"
}
]
+10
View File
@@ -0,0 +1,10 @@
{
"date": "2026-09-24",
"font_stack": "System sans; tokens.css",
"palette": "Dark navy and blue OKLCH tokens",
"motion": "motion-cut",
"spacing": "named token scale",
"framework": "React 19 + TanStack Start + Vite 8",
"preserve": ["deck data model", "profile binding", "WebMCP contracts"],
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
}
+1
View File
@@ -0,0 +1 @@
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [".output/**", ".tanstack/**", "src/routeTree.gen.ts"]
}
+240
View File
@@ -0,0 +1,240 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error"
},
"options": {
"typeAware": true,
"typeCheck": true
},
"plugins": [
"eslint",
"typescript",
"unicorn",
"oxc",
"import",
"node",
"promise",
"react",
"react-perf",
"jsx-a11y"
],
"jsPlugins": [
"@shadcn/lint"
],
"rules": {
"no-console": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"typescript/consistent-type-exports": "error",
"typescript/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"typescript/no-non-null-assertion": "error",
"typescript/no-explicit-any": "error",
"unicorn/no-useless-spread": "error",
"jsx-a11y/no-autofocus": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/prefer-tag-over-role": "off",
"react/exhaustive-deps": "error",
"react/purity": "error",
"react/rules-of-hooks": "error",
"react/set-state-in-effect": "error",
"shadcn/no-arbitrary-values": [
"error",
{
"allow": [
"layout"
]
}
],
"shadcn/no-inline-styles": "error",
"shadcn/no-raw-colors": "error",
"shadcn/no-restyle": [
"error",
{
"allow": [
"layout"
],
"contracts": [
{
"pattern": "^Sidebar$",
"allow": [
"layout",
"border-r",
"border-r-0",
"border-border",
"transition-none"
]
},
{
"pattern": "^SidebarHeader$",
"allow": [
"layout",
"py-4",
"gap-4",
"border-b",
"p-5"
]
},
{
"pattern": "^SidebarGroup$",
"allow": [
"layout",
"px-3"
]
},
{
"pattern": "^SidebarFooter$",
"allow": [
"layout",
"px-3",
"pb-4"
]
},
{
"pattern": "^SidebarMenuButton$",
"allow": [
"layout",
"p-0"
]
},
{
"pattern": "^Label$",
"allow": [
"layout",
"gap-2",
"text-sm",
"text-xs",
"text-muted-foreground"
]
},
{
"pattern": "^MessageScrollerContent$",
"allow": [
"layout",
"gap-5",
"p-4"
]
},
{
"pattern": "^(Marker|Collapsible)$",
"allow": [
"layout",
"text-research-label",
"text-muted-foreground"
]
},
{
"pattern": "^SidebarProvider$",
"allow": [
"layout",
"bg-vital-bg",
"text-vital-text",
"font-arial"
]
},
{
"pattern": "^Button$",
"allow": [
"layout",
"px-2"
]
},
{
"pattern": "^Input$",
"allow": [
"layout",
"pl-9"
]
},
{
"pattern": "^SidebarContent$",
"allow": [
"layout",
"gap-0"
]
},
{
"pattern": "^Badge$",
"allow": [
"layout",
"gap-1"
]
}
]
}
],
"shadcn/no-unknown-classes": "error",
"shadcn/require-static-classes": "error"
},
"overrides": [
{
"files": [
"scripts/**"
],
"rules": {
"no-console": "off"
}
},
{
"files": [
"src/components/ui/**"
],
"rules": {
"shadcn/no-arbitrary-values": "off",
"shadcn/no-restyle": "off",
"shadcn/require-static-classes": "off"
}
},
{
"files": [
"src/**/*.{test,spec}.{ts,tsx}",
"tests/**/*.{ts,tsx}"
],
"plugins": [
"vitest"
],
"rules": {
"typescript/unbound-method": "error",
"vitest/expect-expect": "error",
"vitest/no-conditional-expect": "error",
"vitest/no-standalone-expect": "error",
"vitest/require-mock-type-parameters": "error"
}
},
{
"files": [
"e2e/**/*.{ts,tsx}"
],
"rules": {
"react/rules-of-hooks": "off"
}
}
],
"ignorePatterns": [
"**/.pnpm-store/**",
"**/.direnv/**",
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/playwright-report/**",
"**/test-results/**",
"**/.vercel/**",
"**/drizzle/**",
"**/*.tsbuildinfo",
".output/**",
".tanstack/**",
"src/routeTree.gen.ts",
"e2e/generated/**",
"src/features/storage/migrations.generated.ts"
]
}
+376 -37
View File
@@ -1,72 +1,411 @@
# Twitter Lite # Personal Workspace
Twitter Lite is an intentional, read-only X reader. It stays empty until you A personal workspace for understanding the day, choosing the next action,
enter a user handle, profile URL, or search query. keeping context, and exploring information. Home brings together a short brief,
flexible tasks and routines, messages, notes, and reading. A shared sidebar opens
each dedicated workspace; its bottom avatar manages connected accounts.
## Scope The interface is in English. User-entered text and external posts retain their
original language.
- User timelines and raw X search syntax Home uses a Tiimo-inspired day flow with expandable activities and direct
- Popular (`Top`) and chronological (`Latest`) search controls for routine steps, tasks, replies and weight entries. Messages uses
- Optional `filter:follows` search a Beeper-inspired conversation layout with visible source messages, editable
- Infinite cursor pagination with explicit retry drafts and a contextual side panel. These execution surfaces remain mocks;
- Read-only cards for text, media, quotes, articles, and quiet engagement counts 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.
It intentionally has no home feed, recommendations, trends, notifications, ## Workspaces and current status
history, account directory, or write actions.
| 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. |
| 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.
See [execution support](docs/execution-support.md) and the
[vitals preview](docs/vitals-preview.md) for prototype scope.
## URL state
Reader search and article selection, Messages views and conversations, Home
previews, account management, Journal review/correction views, and Vitals
navigation are represented in search parameters. Research uses deck, run, and
citation IDs. Reloading or browser Back/Forward restores the view while its
underlying data remains available. Login preserves the requested workspace URL.
Explicit selections add browser history; search input replaces it after a
300 ms debounce using TanStack Pacer. Navigation cancels pending search updates,
and IME composition waits until confirmed. URL navigation never submits prompts,
sends messages, or repeats device actions. Drafts, secrets, results, and task
contents remain outside the URL. Unsaved mock records and manual temporary
decks still disappear on reload; a URL does not persist their contents.
## Direction: personal context and execution
The intended connection with the separate private `life` / Rensheng repository
is a loop: read relevant personal context, suggest useful next actions, record
what happened, and compile meaningful outcomes back into durable context.
This integration is planned; the app does not currently read or update life.
Rensheng would retain personal context, preferences, and the background of
ongoing matters. This workspace would own its execution state and interaction
history, with original conversations and appointments remaining in their source
systems. Context should carry source references and verification dates.
Interests are queries over activity history, not settings the user must maintain.
Routines provide optional guidance rather than a daily completion quota. Home
should help the user choose a manageable next step, with room to defer, dismiss,
or change direction.
## Research capabilities
- Multiple named decks with up to six columns each
- Twitter search, user timelines, and lists with independent relay accounts
- Mastodon OAuth, multiple accounts, search, user, list, and hashtag columns
- Native X search syntax, Top/Latest ranking, and optional `filter:follows`
- List discovery for the account selected in the column editor
- Column editing, ordering, deletion/undo, refresh, and cursor pagination
- SQLite-backed shared decks, revision conflicts, and URL-based selection
- Temporary views for exploration, with explicit saving
- Server-only encrypted Mastodon credentials and configurable Tailscale owner access
- Read-only post cards with source links, text, media, and quotes
- Experimental WebMCP tools to manage decks and read their columns
- Same-port HTTP MCP for SNS retrieval and saved-deck operations through a private tunnel
- Optional resident Codex research chat, cited host-side Markdown reports,
and saved conversations with deck, account, and citation restoration
Original-post links open their source site; SNS reply threads are not rendered
inside the app. Bluesky, Threads, and Nostr connectors are not implemented.
## Login and onboarding
The workspace requires a single owner account with email and password. First
use opens account setup, followed by a short onboarding. Run
`nix develop -c pnpm account:setup` on the server to obtain the private setup
code. See [owner login](docs/login.md) for configuration, sessions, and limits.
`GET /health` returns `{"status":"ok"}` without authentication or database access.
## Requirements and setup ## Requirements and setup
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The package link also Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
requires a local Bird checkout at `../bird`, exactly at unpublished commit routes the `@yuta` scope to the public Gitea Packages registry.
`81921226f099e7f7a2107be15fc9783218a6f400`.
A fresh clone of the remote Bird repository alone does **not** contain that
local Top/Latest commit yet. Obtain the supplied checkout; do not assume the
commit or a compatible package has been published.
```bash ```bash
cd ../bird
git rev-parse HEAD
nix develop -c pnpm install --frozen-lockfile
nix develop -c pnpm run build:dist
cd ../twitter-lite
nix develop -c pnpm install --frozen-lockfile nix develop -c pnpm install --frozen-lockfile
cp .env.example .env.local cp .env.example .env.local
``` ```
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set The Nix development shell installs Hallmark's agent skill for the supported
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same local agent targets. Set the runtime configuration in `.env.local` for Vite
values in the process environment before `start` or `test:live`: development, or export it before running the production server. In addition to
the relay URL, configure the Serve origin, owner login, and an absolute SQLite
path from `.env.example`. Mastodon needs an allowed instance and a runtime
encryption key file; see [storage, OAuth and restore](docs/storage-and-oauth.md).
```bash ```bash
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
export BIRD_PROFILE_NAME=
``` ```
Both values are read by server-only code at runtime; neither value nor Bird is The app discovers available account profiles through the relay's `/profiles`
sent to the browser. endpoint. Select a profile for each column. There is no global account selector,
profile cookie, or application-level `BIRD_PROFILE_NAME` default. Requests
validate that the bound profile still exists before fetching posts or lists.
Bird and relay credentials stay on the server; saved deck definitions include
stable connection IDs. The connection metadata resolves Twitter IDs to relay
profiles; Mastodon credentials never go to the browser.
## Commands ## Commands
```bash ```bash
nix develop -c pnpm dev nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale nix develop -c pnpm dev:tailscale
nix develop -c pnpm generate:e2e-openapi
nix develop -c pnpm lint
nix develop -c pnpm format
nix develop -c pnpm format:check
nix develop -c pnpm typecheck
nix develop -c pnpm knip
nix develop -c pnpm test nix develop -c pnpm test
nix develop -c pnpm test:e2e nix develop -c pnpm test:e2e
nix develop -c pnpm test:live nix develop -c pnpm test:live
nix develop -c pnpm build nix develop -c pnpm build
nix develop -c pnpm start nix develop -c pnpm start
nix develop -c pnpm account:setup
nix develop -c pnpm db:generate
``` ```
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by ## Home Codex research prototype
the dev shell. `test:live` is an explicit, opt-in smoke test that performs
exactly one read-only Top search using the configured relay.
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to Run `codex login` as the host user. The backend uses Vercel AI SDK 7 and
`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it `ai-sdk-provider-codex-cli` 2.3 to manage a local Codex app-server over stdio.
only on a trusted network and obtain the Tailscale address with A separate WebSocket service is no longer required. Use Codex CLI 0.156.0 or
`tailscale ip -4`. newer; set an explicit executable path if it is not on PATH.
The child receives an environment allowlist; inherited MCP servers, hooks,
web search and multi-agent execution are disabled for these research turns.
The user's Codex settings are not changed.
Configure the web app in `.env.local`:
```dotenv
TWITTER_LITE_CODEX_MODEL=gpt-6-astra
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
# Optional:
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# CODEX_HOME=/absolute/path/to/codex-home
```
If your CLI sets `CODEX_HOME`, set the same path in the server environment
(`.env.local` for development). A systemd service does not inherit your shell's
environment. Without it, research uses `~/.codex`, which may have different or
expired credentials. Authentication failures appear separately in chat; the
underlying error and conversation ID are logged on the server.
This provider does not register AI SDK `tools` as Codex dynamic tools.
The validated, account-scoped research functions are exposed through the app's
own HTTP `/mcp` endpoint. Each turn has a separate registration that is removed
when it finishes or is cancelled. Set `TWITTER_LITE_MCP_URL` when the app is not
reachable at `http://127.0.0.1:${PORT:-3000}/mcp`. The app-server control connection
still uses stdio; its MCP calls use HTTP. Streaming uses `streamText`
and `smoothStream` with 15 ms pacing and Japanese-aware chunking:
`/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/`.
Streamdown renders the Markdown and animates new text only while the final
assistant message is live; completed history is immediate and reduced-motion
preferences are respected. `@shadcn/helpers/ai-sdk` supplies deterministic
streaming fixtures for renderer tests.
Use the chat on the left while browsing horizontally scrollable deck columns on
the right. Messages continue the same Codex thread and include the currently
open deck as context. The account selector controls which connections Codex can
use. It can inspect saved deck definitions and reuse them as temporary views,
as well as create new views. `list_lists(connectionId)` discovers existing
Twitter and Mastodon lists for a selected connected account; returned list IDs
can be reused in list columns. It uses the same catalog as the manual editor
(Twitter requests up to 100 lists; list discovery is not paginated).
The default prompt asks Codex to inspect existing decks and the selected
accounts' lists before planning new searches, then read relevant list columns
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
Manual deck creation and column editing remain
available. The backend handles tools even when the browser is closed.
Use **New chat** to leave the current conversation and start a fresh Codex
thread with your next message. The open deck and host reports remain available.
A running turn continues in the background when you open another chat.
Selection belongs to the current URL; another browser does not change it. **Chat history** reopens saved conversations with their account
selection, deck and citations; the next message resumes the same Codex thread.
SQLite stores conversation snapshots. Active selection is not stored globally.
Each turn is limited to 6 columns, 12 upstream requests shared between list
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
The prototype accepts up to 100 messages per backend process.
SSE pushes messages, tool activity and deck updates for the requested conversation
ID, without polling. Separate conversations have independent executions.
Codex replies render as Markdown, including tables, lists and code. Clicking a
cited post link scrolls to its card and briefly highlights it. Loaded cards take
priority; otherwise the app shows the post captured during research in a column
with matching source and account settings, creating a temporary citation deck
when needed. These captured posts are labeled and do not change feed pagination
or reveal sensitive content automatically. Other links still open externally;
Ctrl/Cmd-click on a citation also opens the original site.
Citation metadata stays with the saved conversation across reconnects,
follow-up messages and server restarts. Markdown HTML is not executed and
Markdown images are rendered as links.
Reopening the page receives the latest state and restores the temporary deck.
Later updates modify the same temporary view; they do not overwrite saved decks
or switch away from another deck you are reading. Use the existing save button
to persist a deck. The agent and visible columns currently fetch posts separately.
Codex writes `<report root>/<run ID>/report.md` using its workspace file tools.
The UI displays its host path after validating the file. A conversation or deck
edit can finish without producing a report. Citation quality is model output,
not independently verified. Report contents remain on the host filesystem;
the app DB stores the conversation, thread ID, deck context and citation metadata.
Browser disconnection does not cancel research; reconnecting restores the latest
state through `/api/research/events?run=<id>`, under the same owner access checks as the
rest of the app. After a backend restart, unfinished turns appear as interrupted;
sending another message explicitly resumes the same thread through the provider.
It does not automatically rerun interrupted requests. Files already written remain.
Cancellation is explicit. The backend and report directory must
run under a user able to write those files. NixOS DynamicUser service integration
and dedicated service credentials are outside this prototype.
`test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright
integration and Axe accessibility tests live under `e2e/`. A standalone mock
HTTP relay exercises the production server-function boundary without contacting
X or a personal relay. Orval generates API fixture types from a pinned
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba).
Generated files are committed; run `generate:e2e-openapi` only when changing
the pin or selected operations. Runtime requests use Bird.
Browser scenarios use the Page Object Model, following the
[web-app-template E2E structure](https://github.com/hiroppy/web-app-template/tree/main/e2e).
`e2e/models/` owns page-specific locators and user actions; `WorkspacePage`
owns shared navigation and sidebar behavior. `e2e/fixtures.ts` supplies these
objects per test, alongside the isolated database, session, and accessibility
fixtures. Specs in `e2e/integrations/` retain scenario assertions, network mocks,
API checks, and browser history/viewport controls. For a new screen, add its
model and fixture instead of duplicating selectors across specs. A model can
also be constructed with another browser page to test multiple sessions.
`test:live` is an explicit, opt-in smoke test that performs one read-only Top
search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account
and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
## Deck workspace
The interface uses shadcn/ui's Base UI components and Tailwind CSS v4, with a
collapsible sidebar based on
[sidebar-09](https://github.com/shadcn-ui/ui/tree/98a1fe67b439324ddc857f47fbdce056600a4329/apps/v4/registry/bases/base/blocks/sidebar-09).
The workspace retains compact column headers and independently scrolling post
columns. On narrow screens, the sidebar opens as a sheet. Add/edit forms use
shadcn dialogs with Escape handling and focus restoration. Column menus contain
editing, ordering, and deletion; refresh stays available directly in each header.
Shared UI components live in `src/components/ui`; `components.json` configures
the registry and theme entrypoint. Research and Messages follow the
[shadcn chatbot template](https://github.com/shadcn-ui/chatbot-template), composing
MessageScroller, Message, Bubble and InputGroup without separate chat primitives.
Research uses Marker and Streamdown for tool progress and streamed Markdown,
with reconnectable snapshot SSE and saved conversations. Enter sends a research
message; Shift+Enter inserts a line break and IME composition does not submit. The icon rail switches between chat and deck management;
closing the mobile sheet or crossing the desktop breakpoint preserves chat
drafts, selected accounts and live deck updates. All application styling uses Tailwind utilities in the components.
`src/styles.css` contains the Tailwind imports, shared theme tokens, and base
utilities; there are no feature stylesheets. Named pixel spacing tokens preserve
the compact layouts alongside the existing rem scale. Tests use accessible
roles and explicit data attributes rather than styling classes.
`pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through
`oxlint-tsgolint`, including the
[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
class helpers, including shared UI components. Layout values remain available
for the sidebar and deck. Component contracts allow only the required shared-component treatments; Next.js-specific rules are omitted because this app uses TanStack Start.
`pnpm format` formats files with Oxfmt defaults, and
`pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports
of registry components for composition. Additional design rules can be configured
in `.oxlintrc.json`;
see the [available rules](https://github.com/shadcn-ui/lint#rules).
The database and OpenAPI generators format their output with Oxfmt. TanStack
Router owns the formatting of `src/routeTree.gen.ts`, which is excluded from
formatting; `pnpm check:routes` verifies that regeneration leaves it unchanged.
Create a deck for an investigation, then add columns for each perspective.
Choose a connected account and one of its supported sources. Account
changes affect only the edited column. Matching source conditions and accounts
share the query cache; different profiles never share posts or cursors.
Saved decks live on the server and refresh across devices on focus and while
visible. Conflicting revisions are rejected. Only the active deck is rendered
and fetched; posts and cursors are not archived. The active selection stays
local to the device.
AI-created decks open as temporary views reconstructed from their saved chat
on another device or after reload. Save a view to add it to the independent deck
library, or make a temporary copy of a saved deck to experiment.
Other unsaved temporary edits disappear on reload or navigation, including OAuth redirects.
Existing version-2 browser decks have an explicit import action; invalid older
data is left untouched. See [the deck model and persistence contract](docs/research-decks.md).
## HTTP MCP
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.
An OpenAI Secure MCP Tunnel client can forward to this private endpoint.
MCP authentication is currently deferred: `/mcp` bypasses browser login and
Tailscale identity checks. Keep the app on the private network. Browser routes
retain their existing access checks. See [HTTP MCP and tunnel operation](docs/http-mcp.md).
## WebMCP
A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`,
`set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and
`load_more_column` on `/deck`. Discover connection IDs first.
`set_deck` creates a temporary view when `deckId` is omitted. `save_deck`
persists it. Replacing or deleting a saved deck requires its `expectedRevision`.
Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the
local app. Use the
[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension)
to invoke tools. Registration uses native `document.modelContext` through
`usewebmcp`; there is no polyfill or external MCP transport. Unsupported
browsers retain the manual deck UI. See [tool contracts and verification](docs/webmcp-prototype.md).
Development and `dev:tailscale` both bind to `127.0.0.1`. Native WebMCP needs a secure context;
use a Tailscale Serve HTTPS origin for remote access, and allow its exact
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process
environment. HTTP and HTTPS origins have separate localStorage.
Set `TWITTER_LITE_ORIGIN` to the exact public HTTPS origin (no trailing slash).
The default `TWITTER_LITE_AUTH_MODE=tailscale` requires
`TWITTER_LITE_ALLOWED_LOGIN` to be your Tailscale login. The app requires
Serve's `Tailscale-User-Login` header and rejects other users. Keep the backend
on localhost: the trusted Serve proxy supplies identity. Tagged clients do not
provide user identity. Missing configuration fails closed; direct browser access
to localhost does not supply the required identity. Playwright supplies an
explicit fixture identity to its isolated test server.
For a private, tailnet-only reverse proxy such as Traefik, explicitly set
`TWITTER_LITE_AUTH_MODE=none` to disable the Tailscale identity check. This mode
does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers.
App login remains mandatory, including access to connected accounts and Codex.
Bind the backend to loopback or its Tailscale address and restrict proxy
access to the tailnet.
Both modes require the exact configured `Origin` for state-changing requests,
including chat and deck mutations. Missing origin or an unknown auth mode
fails closed. Mastodon OAuth uses the configured public origin for its callback.
## NixOS service
The flake provides a production package and a NixOS module:
```nix
{
inputs.twitter-lite.url =
"git+https://git.yutakobayashi.com/yuta/twitter-lite";
outputs = { nixpkgs, twitter-lite, ... }: {
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
twitter-lite.nixosModules.default
{
services.twitter-lite = {
enable = true;
relayBaseUrl = "http://127.0.0.1:6900";
publicOrigin = "https://home.example-tailnet.ts.net";
allowedLogin = "your-tailscale-login";
mastodonOrigins = [ "https://fedi.yutakobayashi.com" ];
credentialKeyFile = "/var/lib/secrets/twitter-lite-key";
};
}
];
};
};
}
```
The service listens on `127.0.0.1:3000`. Set
`services.twitter-lite.port` to change the port; the host remains loopback.
The module reserves `/var/lib/twitter-lite` with mode 0700 for persistent state.
`credentialKeyFile` can reference a runtime secret file for SNS credentials;
systemd loads it as a credential, separate from the database and Nix store.
Profiles are selected in column definitions, not service options.
The package can also be built directly with `nix build`.
## Reliability ## Reliability
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"files": {
"includes": ["**", "!src/routeTree.gen.ts", "!!**/.output"]
},
"formatter": { "enabled": true, "indentStyle": "space" },
"linter": { "enabled": true, "rules": { "recommended": true } },
"javascript": {
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded" }
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "#/components",
"utils": "#/lib/utils",
"ui": "#/components/ui",
"lib": "#/lib",
"hooks": "#/hooks"
}
}
+386
View File
@@ -0,0 +1,386 @@
# External Codex publishing into the workspace
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.
## Responsibility
External Codex reads Beeper and the life filesystem, reasons about them, and
maintains life under its existing Rensheng conventions. life remains the CRM
source of truth. The workspace accepts structured results, persists and displays
them, and records user decisions and requests. It does not need a new LLM loop,
CRM compiler, or Markdown editing engine.
```mermaid
flowchart LR
T[Host timer or manual invocation] --> C[External Codex job]
B[Beeper API / CLI] --> C
C <-->|Read and maintain CRM| L[Rensheng / life files]
C -->|Publish structured results| P[Application write API]
P --> D[SQLite projections and user decisions]
D --> U[Home / Messages / CRM]
U -->|Requests and corrections| Q[Durable request inbox]
Q --> C
```
The existing Research chat can remain as-is. This design does not require
removing that feature or putting its runtime in charge of CRM automation.
Deterministic Beeper reads, live UI updates, and confirmed sends are ordinary
application integration code; they do not imply app-owned inference.
## What gbrain actually does
Inspected revision: `e78f1c38b947b053f3a46881340f74f316be855a`.
- Its [cron convention](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/skills/conventions/cron-via-minions.md)
assigns scheduling to the host. It explicitly describes a native cron
scheduler inside `jobs work` as not yet shipped.
- [Minions workers](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/minions/worker.ts)
handle durable execution, leases and retries. Job idempotency and prevention
of overlapping runs are separate concerns.
- [Autopilot](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/commands/autopilot.ts)
is a maintenance daemon, not a ready-made external Codex scheduler. The
built-in [subagent handler](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/minions/handlers/subagent.ts)
uses gbrain's own model/tool execution.
- Its [page write operation](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/ops/pages.ts)
uses request receipts, expected revisions and transactional writes. These are
useful patterns for publishing into this app without importing its entire stack.
## Scheduling proposal
Use one external systemd timer and oneshot service, or the existing host scheduler
if one already owns this workflow. Do not create schedules during research.
Start with a morning brief and periodic daytime refresh; exact cadence and job
timeout are deployment choices, not established product requirements.
The runner sets an explicit working directory and `CODEX_HOME`, prevents
concurrent runs, reads the app context revision, then invokes Codex to inspect
sources and prepare a result. It publishes through the application API and keeps
a private result file until acknowledgment so a network retry need not repeat
inference. Do not put credentials in prompts or command arguments.
Use separate identifiers for the scheduled occurrence and publish attempt. A
retry of an unchanged result keeps its `runId`; regeneration after a conflict
uses a new `runId`. Host logs expose started, finished and failed runs without
dumping messages. The UI can show the last successful publication and source
freshness; it should retain the previous good result when a new run fails.
Beeper events can later mark work dirty and coalesce a wake-up. Do not start one
Codex process per message. Natural-language requests can use the same external
worker path; a future wake-up signal should only notify that worker, not execute
browser-supplied shell text.
## Write interface
Start with HTTP and a small CLI wrapper that accepts JSON from a file/stdin.
Add an MCP wrapper only if useful for tool discovery; it must call the same
validated service. HTTP, CLI and MCP are access methods, not three state stores.
Avoid direct SQLite writes and an arbitrary SQL, shell or filesystem tool.
Proposed first contract, keeping the repository's camelCase JSON convention:
| Method and path | Caller | Purpose | Success |
| -------------------------- | --------------------- | ----------------------------------------------------------------------- | ------------------------- |
| `GET /api/agent-context` | Agent credential | Read revision, current projections, user decisions and pending requests | `200` |
| `POST /api/agent-results` | Agent credential | Atomically publish a validated result and complete referenced requests | `201`; exact replay `200` |
| `POST /api/agent-requests` | Owner browser session | Persist a natural-language request or a requested life correction | `201`; exact replay `200` |
Browser snapshot reads use the same application service under owner session
authentication. An optional `GET /api/agent-results/{runId}` receipt endpoint can
be added if exact POST replay is insufficient. No public API version prefix is
needed for this first coordinated client/server contract.
The agent credential is limited to context reads and result publication. It must
not approve a send, change login settings, or mark user decisions on the user's
behalf. The existing session, Tailscale and Origin middleware does not accept
this authentication flow today. Implement a narrowly scoped agent route branch
that verifies its own credential; do not add these paths as unauthenticated
exceptions or spoof a Tailscale identity. Use loopback locally, TLS remotely.
### Result shape
Illustrative payload, with fictional data:
```json
{
"runId": "morning-2026-09-29-attempt-1",
"jobKey": "daily-brief",
"expectedRevision": 42,
"generatedAt": "2026-09-28T23:00:00Z",
"sourceRefs": [
{
"id": "life-person-example",
"kind": "life",
"path": "people/example.md",
"contentHash": "sha256:example",
"observedAt": "2026-09-28T22:59:00Z"
}
],
"brief": {
"date": "2026-09-29",
"timeZone": "Asia/Tokyo",
"text": "Start with the meeting reply.",
"evidenceIds": ["life-person-example"]
},
"people": [
{
"id": "person-example",
"name": "Example Person",
"context": "Discussing the next meeting.",
"evidenceIds": ["life-person-example"]
}
],
"activities": [],
"replyDrafts": [],
"retractions": [],
"completedRequests": []
}
```
`runId` and `jobKey` are required bounded strings; `expectedRevision` is a
nonnegative integer from the last context read. All timestamps are RFC 3339.
The server stamps `receivedAt` and authenticated publisher identity itself.
Dates used for the home brief include an explicit timezone.
People/activities/drafts are upserts with stable IDs, not position-based IDs or
display names. People are projections of life, not independently maintained CRM
facts. Activities use the typed contract below, with a title, references and
evidence IDs. Drafts include canonical target/account/chat IDs, reply target,
source message revision and candidate bodies/styles. Beeper evidence includes
target/account/chat/message IDs and observation time; life evidence includes a
relative path and content hash. A Git revision alone does not describe uncommitted
life edits. Source references are data, never filesystem access instructions.
The app validates structure, references, bounds and permissions; it does not
infer whether a CRM fact is true. Publisher-supplied evidence is provenance, not
proof that the app independently verified the original file or conversation.
Omitted `brief` leaves the brief unchanged; empty arrays perform no upserts.
Omission never deletes an existing item. Explicit typed retractions can withdraw
agent suggestions but cannot delete user-owned decisions. Reject unsupported
fields and nulls in this coordinated first version. Agree payload size, per-array
limits and bounded context pagination when implementing; do not accept unbounded
history or attachments through this endpoint.
### Atomic publication and conflicts
1. Scope receipts to authenticated publisher plus `runId`. Store a canonical
payload hash. A replay with the same payload returns the original receipt;
the same key with different content returns `409 idempotency_conflict`.
2. Check replay before checking the expected revision. A successful retry must
still work after unrelated later UI changes.
3. Validate the full result, check `expectedRevision`, write projections and the
receipt, and increment the context revision in one SQLite transaction.
A concurrent publish or user edit yields `409 revision_conflict` and no writes.
4. User edits, dismissals, completions and selections live separately from agent
suggestions. A new proposal cannot revive a dismissed action or replace a
user-edited draft. Context reads expose those decisions to Codex.
5. Emit a browser update only after commit. On reconnect, fetch a snapshot;
notifications are not the source of truth. A failed publication leaves the
previous brief intact.
Return `{runId, revision, receivedAt}` as the receipt. Keep deduplication receipts
for the MVP's lifetime; any future pruning must define a retry horizon first.
Retry transient transport failures with the exact payload and key. Conflicts
require a fresh context read and reconciliation, not blind retry.
Use a small stable error envelope such as
`{code, message, details, requestId}`: `400` malformed JSON, `401` invalid agent
credential, `403` forbidden operation, `409` conflicts, `413` oversized payload,
`422` invalid result structure, `429` throttling, and `5xx` transient server faults.
No partial successes. This is a new machine API; it need not refactor unrelated
existing UI error contracts.
## Returning user intent to Codex
The app must also expose what the user did. Otherwise the next scheduled run
cannot distinguish a stale suggestion from a deliberate dismissal or correction.
`POST /api/agent-requests` accepts a stable client `requestId`, a kind such as
`refresh`, `reply-draft`, or `crm-edit`, an optional entity ID/source revision,
and the user's text or requested edit. Persist before acknowledging. Repeated
IDs with identical bodies return the same request; mismatches return `409`.
A result links each completed request using
`completedRequests: [{requestId, sourceRefIds}]`. All source references must exist
in the publication; a `crm-edit` completion requires a life reference with the
resulting content hash. Reject completion of unknown or already cancelled
requests. This validates the claimed provenance link, not the file contents
independently.
A person-note edit appears as pending while external Codex applies it to life.
It becomes confirmed only after publication references that request and supplies
the resulting source revision. Failures keep the previous confirmed projection
and the pending request visible. Do not label an app-only edit as already saved
to life. Single-worker execution avoids requiring a job lease API for this MVP.
life changes and app publication are not one transaction. If the process stops
between them, the next run re-reads life and checks the request's outcome before
applying it again. Prefer desired-state edits over blind append operations and
preserve request provenance where the life workflow permits it.
Actual message sending remains a separate, revision-bound user command. Publishing
a draft or marking an agent request complete cannot send a message. Send results
can be exposed in the next agent context for Codex to update life.
## First useful slice
Implement context read and result publish, persist them, and render a published
brief plus a mixed activity list: weight entry, a simple optional task, routine
steps and a reply linked to published life context. Prove duplicate replay,
conflict handling, reload persistence and preservation
of user decisions with fixture data. Then run one external Codex job manually
before installing a timer. Add the request inbox when introducing on-demand
generation or CRM editing. This order gives the agent somewhere to write before
building automation around it.
The [Beeper Copilot captures](references/beeper-copilot/README.md) provide the UI
reference for those published results: conversation list, conversation body,
person context, evidence chips, reply candidates and explicit review.
## One activity list, different execution surfaces
Home should unify what the user needs to do, while preserving the right controls
for each activity. A shared task abstraction does not imply a universal checkbox
or a universal chat card. Activity ordering, deferral and visibility are shared;
execution and completion are domain-specific.
| Activity kind | Inline or expanded Home UI | Completion evidence |
| ------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- |
| `task` | Concise title, optional detail, completion control | User marks it complete |
| `measurement` | Weight number field, unit, last recorded value/time, Save | A measurement record is saved |
| `reply` | Person, latest relevant message, reason to reply, draft editor/review | Send is confirmed; a larger exchange may remain waiting |
| `routine` | Ordered child activities and progress | Required children complete, or an explicit skip is recorded |
Use common fields such as `id`, `title`, `kind`, `evidenceIds`, `personId`,
`parentId`, `occurrenceKey`, and optional recommendation/order metadata. User
state (available, in progress, waiting, deferred, completed or skipped) is stored
separately from Codex's recommendation. Each kind has a validated payload and a
fixed application renderer; do not accept agent-generated executable UI or HTML.
`routine` and `optional` in the current Home sections are grouping/priority
choices, not exclusive UI types. A weight activity can be a morning routine step;
a reply can be optional today. A routine groups child activities rather than
duplicating their records. Home and Messages refer to the same reply activity ID,
so editing or finishing it in either surface is immediately consistent.
For example, an illustrative measurement activity published by Codex:
```json
{
"id": "weight-2026-09-29",
"kind": "measurement",
"title": "Log weight",
"parentId": "morning-2026-09-29",
"occurrenceKey": "2026-09-29@Asia/Tokyo",
"payload": { "metric": "weight", "unit": "kg" },
"evidenceIds": []
}
```
Codex chooses or recommends the activity; the user supplies the measured number.
The app saves the observation and its occurrence time without invoking inference.
Corrections preserve the observation history. Completion must reflect that save,
not merely a checked box. The agent can read this execution evidence on its next
run and maintain life accordingly; the app does not need to infer health advice.
Recurring activities need stable per-occurrence IDs: publishing tomorrow's
routine must not reopen today's completed one. Missing time slots are valid;
the user explicitly wants flexible daily ordering rather than a strict calendar.
A real appointment can still retain its explicit date/time.
Reply completion follows the confirmed send state, not draft generation, review
or merely pressing Send. A conversation can contain multiple distinct commitments;
Codex determines those relationships from sources. The application stores explicit
links and user choices without deciding that one reply resolves every commitment.
The resulting Home can place a weight input, a routine's next step, a reply
preview and a simple task together. Use a compact shared outer row and expand
the relevant controls in place; detailed conversation/CRM views remain available.
Natural-language entry creates an external-agent request, while direct numeric
entry, checkboxes and draft edits remain immediate deterministic UI operations.
## Routine execution and workstyle learning
The user wants to import ideal routines from life, track their execution in this
app, and use external Codex analysis to develop a workable personal workstyle.
This adds explicit execution analytics to the earlier reference-only routine
concept; it does not imply that all ideals are already practiced or due daily.
Keep three levels distinct:
1. **Routine definition:** life source, stable ID, definition revision, purpose,
trigger, steps, and completion rule. Codex publishes a structured projection.
2. **Trial/activation:** which version is currently being tried, applicable days
or situations, and the chosen scope. Importing an ideal into the catalog does
not silently activate every step as a daily obligation.
3. **Occurrence and observations:** the actual applicable occasion, its frozen
definition revision, user actions, recorded values, skips, and corrections.
The application is the source for execution history; life links to it rather
than maintaining a second completion ledger.
An approved routine definition can be instantiated deterministically by the app
on its recurrence or a user action such as starting/ending work. Codex need not
run every time a routine appears. Situational occurrences should come from an
explicit trigger or confirmed source, not an assumed event inferred by the UI.
Keep occurrence identity stable across delayed cron runs and retries. Do not
turn unfinished instances into an ever-growing daily backlog automatically.
Proposed operating loop:
- **Import:** Codex reads life and publishes a routine catalog with sources and
revisions. Show what is already active and what remains an ideal/candidate.
- **Try:** choose a small set and a review window, for example one or two weeks.
These durations and counts are starting suggestions, not fixed app rules.
- **Execute:** Home presents due/applicable occurrences with kind-specific UI.
Save completion evidence as part of the natural action: entering a value,
sending a reply, writing a resumption note, or completing a task. Optional
friction feedback can be one tap; reporting a reason is not required.
- **Review:** the application calculates counts from recorded events. External
Codex reads the counts and underlying evidence, relates them to life context,
and publishes a short review with observations, uncertainties and proposals.
- **Adjust:** the user adopts, modifies or rejects a proposal. Codex updates the
ideal/working procedure in life and publishes a new definition revision.
Historical occurrences retain their previous revision and completion rule.
A scheduled daily job can refresh the overview, while a weekly job reviews the
experiment. Neither creates additional inference inside the app. The publishing
contract will need versioned routine definitions and review artifacts alongside
activities; context reads must expose execution observations and aggregate counts.
Those are deterministic data contracts, not a new agent runtime.
### Metrics that preserve meaning
Show counts and denominators next to percentages. For each routine revision and
review window, report completed, partially completed, explicitly skipped,
deferred, not applicable, and unrecorded occurrences separately. A past due item
without an observation remains unrecorded, not verified non-execution.
A useful default is **recorded attainment = completed / applicable planned
occurrences**, accompanied by observation coverage. For example, 5 completed,
1 explicitly skipped and 1 unrecorded out of 7 applicable occasions yields 5/7
recorded attainment and 6/7 observed outcomes. It does not prove that the person
failed to act on the unrecorded occasion. If a resolved-outcome rate is also
shown, label its different denominator explicitly. Keep skipped occasions in
the planned denominator; exclude not-applicable ones with a recorded reason and
retain plan revisions so exclusions cannot silently rewrite prior performance.
Do not count a routine parent and every child as independent equivalent successes
in a global percentage. Analyze routines and steps at separate levels. Optional
ideas that were never scheduled/adopted do not belong in the planned denominator.
Explicitly record reduced/partial completion without silently calling it full
completion. Measurement adherence is about saving a measurement, not moving the
number in a preferred direction; reply counts do not measure relationship quality.
Attainment alone cannot establish usefulness. Add lightweight, optional feedback
on effort and whether a routine helped, plus domain-specific evidence such as a
resumption note being available next time. Compare versions over named periods
and show sample sizes. Codex can suggest one change to try next, but sparse
observations or coincident changes do not establish a causal improvement.
The review should answer: what was practical, where execution stopped, what was
useful, and what small change is worth trying next. Good outcomes can include
shortening, reducing frequency, changing the trigger, or retiring a routine.
Do not optimize a single global completion score at the expense of the purpose
of the work or the user's freedom to change the plan.
+143
View File
@@ -0,0 +1,143 @@
# Beeper integration research
Researched 2026-09-28. Proposal only; no integration was installed during research.
## Current direction
External Codex performs inference, reads Beeper, and reads/writes life under
Rensheng conventions. life is the CRM source of truth, serving the role Scrapbox
plays in Beeper Copilot. This application accepts structured publications and
provides the UI, persistent execution state, and user feedback. It does not need
a new CRM inference runtime or life filesystem editing engine.
Home combines routines, optional tasks, measurements and communication as
activities, with different controls for each kind. A weight entry, reply editor,
routine checklist and simple completion control belong in one daily overview.
See [external agent publishing](agent-publishing-design.md) for the concrete
contract and gbrain cron findings, and the
[Beeper Copilot screenshot gallery](references/beeper-copilot/README.md) for UI
references. This supersedes earlier proposals for app-managed inference and
direct app-to-life editing.
## Verified locally
- Beeper CLI 0.6.2 and Server run on UM790-Pro.
- Public `GET http://127.0.0.1:23373/v1/info` returned Beeper 4.3.149 on Linux,
running, remote access disabled and MCP enabled.
- Discovery exposes `/v0/mcp`, `/v1/ws`, and `/v1/spec`. The live OpenAPI
reports API version 5.0.0, distinct from the application/SDK version domains.
- The existing CLI target `um790` successfully retrieved server info using
`--read-only`. This is discovery verification, not proof of complete account
synchronization or successful message delivery.
- No private conversation bodies were fetched, and no Beeper configuration or
messages were changed. The screenshots contain public LP demonstrations.
The [official CLI](https://github.com/beeper/cli) supports headless Server as
well as Desktop. No additional GUI machine or remote tunnel is needed here.
## HTTP, WebSocket, MCP and CLI
| Interface | Role |
| -------------------------------------------- | -------------------------------------------------------------- |
| Beeper HTTP / CLI / SDK | Read messages, reconcile changes, perform approved sends |
| Beeper WebSocket | Live UI changes or a coalesced signal for the external worker |
| App write API, optionally wrapped as CLI/MCP | Receive Codex results without inference |
| App context/request API | Expose user decisions, pending requests and revisions |
| Browser HTTP + SSE | Persisted views, direct UI operations and update notifications |
Existing CLI authentication can be reused with bounded, fixed-argument
`api get` calls and `watch --target um790 --read-only --json`. Do not expose
arbitrary CLI/RPC command strings as agent tools. The
[official TypeScript SDK](https://github.com/beeper/desktop-api-js) is an alternative
after explicitly provisioning an application credential. Choose one transport
inside the adapter; automatic CLI/SDK fallback is unnecessary.
The [MCP guide](https://developers.beeper.com/desktop-api/mcp/) uses Streamable
HTTP. The [changelog](https://developers.beeper.com/desktop-api/changelog/) removes
the old Beeper MCP SSE transport; our own browser SSE can remain.
## Synchronization findings
The running server's `/v1/spec` explicitly states that WebSocket delivery is
at-most-once, has no replay after reconnect, and uses per-connection sequence
numbers. The [WebSocket guide](https://developers.beeper.com/desktop-api/websocket-experimental/)
also describes experimental status and best-effort payloads. HTTP reconciliation
is required after disconnects and periodically. Do not start Codex per message.
Subscribe before initial reconciliation, track changed chats during it, then
refetch those chats. Commit progress only after persistence. Use canonical
target/account/chat/message IDs and revisions or content hashes. Edited/deleted
evidence invalidates affected drafts. One missing list result does not prove
deletion. CLI watch webhooks are best-effort and can drop on queue overflow;
a same-machine worker avoids needing a webhook receiver, not the need to reconcile.
The [message API](https://developers.beeper.com/desktop-api-reference/resources/messages/methods/list/)
returns opaque cursors. life's backfill guide records missed/duplicated pages
when CLI message IDs were used as cursors, plus API order differing from timestamp
order. Do not stop pagination merely because a date boundary was crossed.
## Reference findings
### life
`life/briefs/2026-09-25-executive-support-design.md` describes source-backed reply
assistance, explicit review and durable personal context. Existing
`life/docs/beeper-backfill.md` and `life/scripts/beeper_backfill.py` implement
reviewed, resumable CLI backfill on this machine.
That helper is not a continuous inbox worker: discovery covers 100 recent chats,
pages require review/acknowledgment, and some quotes, forwarded messages and
attachments are deliberately excluded. Its output is not a complete conversation
view. Reuse its identity/provenance rules. Its guide warns that CLI 0.6.2 raw
`status --json` can expose credentials.
Codex should maintain existing life domain pages. The app displays published
CRM projections; user corrections return as durable requests to Codex. It must
distinguish pending corrections from confirmed life changes.
### gbrain
Inspected revision `e78f1c38b947b053f3a46881340f74f316be855a`. No Beeper-specific
connector was found. Its [open-loop design](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/docs/guides/open-loops.md)
informs requests, commitments, waiting and source freshness. Its
[sync implementation](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/connectors/sync.ts)
advances progress after successful ingestion. Apply those principles in the
external Codex workflow. The [publishing proposal](agent-publishing-design.md)
covers its host scheduler, job execution and transactional write patterns.
### Rensheng / OpenBrief
Inspected local revision `c06c4e3f290800752a0c252c84fbca0eed61e45d`.
Rensheng's [setup](https://github.com/yutakobayashidev/rensheng/blob/c06c4e3f290800752a0c252c84fbca0eed61e45d/docs/setup.md)
reuses existing source tools. OpenBrief's
[proposal-only MCP](https://github.com/yutakobayashidev/rensheng/blob/c06c4e3f290800752a0c252c84fbca0eed61e45d/crates/openbrief-app/src/mcp.rs)
and [snapshot transport design](https://github.com/yutakobayashidev/rensheng/blob/c06c4e3f290800752a0c252c84fbca0eed61e45d/docs/adr/0004-separate-acp-local-and-remote-transports.md)
are useful boundaries. Neither requires adopting its Rust application or
duplicating its filesystem workflow in this app.
### Beeper Copilot
The [LP](https://beeper-copilot-lp.vercel.app/) was inspected in Chromium at
desktop/mobile sizes. The three-pane view, person context, evidence chips and
reply review are concrete UI references, documented in the
[capture gallery](references/beeper-copilot/README.md). Its linked source repository
returned 404 to unauthenticated access; screenshots verify the LP presentation,
not backend behavior. Use life in the Scrapbox role. EventKit, macOS deployment
and the demo's relationship scoring are not requirements here.
## Sending
The [send API](https://developers.beeper.com/desktop-api-reference/resources/messages/methods/send/)
returns a pending ID, not delivery confirmation. Bind approval to exact draft
revision, recipient and reply target, then resolve pending IDs before marking
sent. The inspected schema does not document a client idempotency key, so local
deduplication cannot guarantee exactly-once delivery after an ambiguous failure.
Disable automatic send retries and reconcile uncertain results. The
[SDK defaults](https://github.com/beeper/desktop-api-js#retries) include retries on
transport errors and timeouts. Publishing a draft/activity must not send a message.
The current [execution support](execution-support.md) is still fictional and
in-memory. Establish the external agent's write interface and persistent typed
activities before installing scheduled automation.
+100
View File
@@ -0,0 +1,100 @@
# Execution support prototype
The shared sidebar opens `/support` (Messages) and `/journal` (Journal), alongside
Reader and Research. These are UI-only prototypes with fictional contacts
and in-memory state. Reloading resets the prototype state. Tasks, communication status, outcomes, and
journal entries are shared between routes for the current browser session.
No messages, calendar events, medical records, or Rensheng pages are written.
## Reference and scope
The functional reference is the user's life brief
`briefs/2026-09-25-executive-support-design.md`, together with the public
[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/).
The prototype adopts the execution flows, not private facts from the life repo
or the reference site's deployment and privacy claims.
- Communication: unified conversations, a maximum of three initial actions,
deferral, waiting, dismissal, and a full list; source messages, summaries,
editable reply styles, explicit review, and simulated sending.
- Scheduling: choosing a candidate and reviewing a simulated calendar entry.
- Context: person notes with links back to the sample conversation and a local
record of the simulated outcome. No permanent trait or goal is inferred.
- Journal: free notes, resumption points, user-entered medication and weight
records; occurrence and recording times, append-only corrections, and a
selectable summary preview for a consultation.
The intended memory boundary remains Rensheng: execution events can eventually
be compiled into durable context after source comparison and user correction.
This prototype neither implements that compiler nor creates a second personal
context database. Missing records remain unknown; there are no medication,
dose, weight-target, or treatment recommendations.
The UI uses existing shadcn Base UI components and neutral theme tokens.
Shared account management continues to use the existing connection system;
the new communication and journal prototypes do not consume those accounts.
## Daily home
`/` presents a greeting, a large input, and a Tiimo-inspired flexible day flow grouped as
Start here, Routines, and If there’s room. There are no fixed time slots.
Tasks can be reordered, completed, deferred, restored, or followed as routine
steps. Communications open their Messages page at
`/support?view=all&conversation=<id>`; browser Back returns to the home with
shared outcomes intact. Journal entries and corrections appear on the home, and
reader samples open for reading. Research remains a separate work surface.
Activity blocks expand in place into controls suited to their content: routine
steps, a task's next action and resumption note, or a weight input that records
an entry in Journal. Completed and deferred activities stay in their original positions with visible
status and undo controls. Measurements retain the recorded value in place, and
reply previews retain their outcome, so the user need not remember what was done. Expanded activity selection
uses the `activity` URL parameter. This remains a session-only mock; a reload
restores navigation, not recorded activity data.
Messages uses a Beeper-inspired person list and visible original conversation,
with reply drafting and review next to source-backed context. On narrow screens
the list and conversation become separate views. Inbox, set-aside, waiting and
archive filters describe communication state; a simulated reply does not prove
that a separate promise was fulfilled. References include the saved
[Beeper Copilot captures](references/beeper-copilot/README.md),
[Beeper Desktop](https://blog.beeper.com/2025/02/24/try-out-the-new-beeper-desktop-and-ios-beta/),
and [open-loop research](gbrain-open-loops-research.md).
The input currently uses a deterministic English/Japanese mock interpreter, not an LLM.
It proposes typed actions for task creation/status/reordering, routine steps,
contact reply/review or deferral, notes/weight/medication records, and navigation.
Confirming a proposal dispatches the corresponding operation. Unknown or
ambiguous input does not change state. Message sending still goes through the
existing simulated review UI; this prototype performs no external send.
Examples: `Add grocery shopping`, `Defer Morning routine`, `Reply to Aoki`,
`Note: Resume from page 2`, `Weight: 65.2 kg`, and `Open Reader`.
The interaction reference is [Tiimo visual planning](https://www.tiimoapp.com/product/visual-planning),
reviewed on 2026-09-28: combined routines and tasks, flexible reordering, small
steps, and natural-language planning. Unlike its timed scheduling options, this
prototype deliberately keeps the day free of precise times as requested.
The home greeting includes a short prose brief derived from current task and
communication state. Completed or deferred items leave the brief; the next
contact opens directly from the text. A current resumption note takes precedence
over the optional-task suggestion. This is local templated copy, not an LLM report.
## Jev reply review
The message thread keeps the reply composer at the bottom on wide screens;
summary and source context live in the side panel. Expand Reply suggestions to
compare candidate wording before editing.
Opening Review reply calls an authenticated server function using AI SDK
`experimental_evaluate` with `typesafe-ai/jev`. Set `AI_GATEWAY_API_KEY` on
the server. The draft, recipient role, and original sample messages are sent
through Vercel AI Gateway to TypeSafe AI. The model evaluates whether the reply
answers the request, stays grounded, and uses an appropriate tone. The UI shows
boolean probabilities, not a calibrated pass/fail verdict or generated advice.
Editing the draft invalidates its evaluation. Missing configuration and provider
failures show an unavailable state; no mock approval is substituted. Review
results do not trigger sending, which remains a simulation.
See [Vercel’s Jev integration](https://vercel.com/changelog/typesafe-ai-jev-now-available-on-ai-gateway).
+202
View File
@@ -0,0 +1,202 @@
# gbrain open loops and personal execution support
Research, 2026-09-28. Source inspection at gbrain revision
`e78f1c38b947b053f3a46881340f74f316be855a`; upstream tests were read, not executed.
Application behavior below is proposed, not implemented. This supplements
[external Codex publishing](agent-publishing-design.md).
## What gbrain implements
### Scope: ingestion is broader than the open-loop engine
gbrain as a whole is not Gmail-only. Its implemented ingestion paths include:
- Google: Gmail, Calendar and Contacts.
- GitHub: issues, pull requests, comments, reviews and associated metadata.
- ChatGPT and Claude account history through live session-cookie connectors.
- Imported agent transcripts, including Codex, Claude Code, OpenClaw and Hermes,
plus consumer conversation exports.
- Markdown repositories and files, inbox-folder capture, CLI/MCP page writes,
and the HTTP ingest endpoint.
See [data ingestion](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/docs/guides/data-ingestion.md),
[GitHub source](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/docs/guides/github-source.md),
and the [live connector registry](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/connectors/registry.ts).
Custom ingestion extensions and agent-side service access are separate from
native synchronization. Documentation mentions Slack, iMessage and other
messaging services through external agent gateways; this does not establish
built-in history sync or open-loop detection for each service. No native Beeper
source was identified. The automatic open-loop detection path inspected below
is specifically wired to Google email threads, not every ingested page.
### Detection
Collection itself is implemented code, not an LLM asked to copy every item.
`gbrain sync --source <id>` dispatches to native Google/GitHub synchronization;
`gbrain connectors sync chatgpt` fetches conversation history and passes it to
the transcript importer. Agents can invoke these commands, but a scheduler can
invoke them directly without an interactive agent. Semantic extraction and
optional embeddings are separate model-using stages.
The [email recipe](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/recipes/email-to-brain.md)
explicitly says that earlier versions asked agents to build a collector script,
while current versions ship that logic in the native connector. The illustrative
`scripts/email-collector/email-collector.mjs` path in the deterministic-collectors
guide is not a shipped script at this revision. Agent-authored memory and custom
external integrations remain distinct ingestion paths; not every integration
mentioned in a recipe is a built-in connector.
The engine operates on Google/Gmail threads. It is not a ready-made Beeper
integration. It distinguishes five kinds of unfinished interaction:
| Kind | Detection |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Unanswered inbound | Last substantive message is from someone else, owner is in To, at least 24 hours old |
| Unanswered outbound | Last substantive message is from owner, external To recipient exists, body contains ASCII `?`, at least 72 hours old |
| Commitment owed by me | LLM extracts an unresolved promise by the owner |
| Commitment owed to me | LLM extracts an unresolved promise by someone else |
| Decision pending | LLM extracts an explicit unresolved choice or question |
The [detector](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/google/loop-detect.ts)
filters machine senders, calendar system messages, self-only threads, muted
senders/threads, and inbound list mail or CC-only delivery. Outbound attribution
uses the first external To recipient. Evidence includes the last message ID and
up to 200 characters of its body.
These rules are useful candidate detection, not proof that a response is owed.
Inbound messages need not contain a request. Outbound requests without `?`,
including full-width Japanese `?`, can be missed. In group chat, a third party's
message is not evidence that the intended recipient answered.
The [LLM extractor](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/google/loops-extract.ts)
processes eligible threads whose newest message is within 30 days. It sends the
newest 12,000 characters, requesting structured direction, counterparty, promise,
due date and quotation. Spam/trash and machine-only conversations are excluded;
owner participation can override bulk-mail exclusions. Updates remain eligible
because they may contain invoices or contracts.
Important implementation limits:
- A malformed item rejects the whole parsed response before writes. This does
not make the subsequent facts, loops and graph writes one atomic transaction.
- A quotation is checked against the supplied text after whitespace
normalization. A mismatch removes the quotation **but retains the loop**.
- Commitment confidence is a fixed `0.85`; decision confidence is `0.8`.
Neither is a calibrated probability or a model-generated confidence estimate.
- LLM evidence references a page; it does not require a specific message ID.
- Date-only deadlines are stored at `23:59:59Z`, not the user's local end of day.
- Deduplication hashes thread, direction and generated text. A paraphrase can
produce a second loop for the same real-world promise.
## Resolution and persistence
The [store](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/loops/loops-store.ts)
keeps `open`, `done`, `dropped` and `stale`, with closure history.
- A change in whose turn it is automatically closes the appropriate
deterministic unanswered loop. Muting or a same-side reminder does not.
- Reply-driven closure applies only to `deterministic_thread` loops.
- LLM extraction upserts candidates. It does **not** reconcile previously
stored promises that disappear from a later extraction. Asking the model to
omit fulfilled promises is insufficient to close those records.
- Users can mark a loop done or dropped. Reprocessing the same observed activity
does not resurrect it; genuinely newer activity can reopen it.
- LLM loops become stale after a deadline is over 14 days past **and** activity
is over 14 days old, or after 90 days without activity. Stale is not fulfilled.
- Dedicated snooze/defer operations are absent. Mutes prevent future detection;
they do not close existing loops.
For example, “I will send the document Friday” and “Thanks!” can settle the
reply exchange while leaving the document promise open. Completion needs its
own evidence or the user's decision.
## Scheduling, freshness and ranking
The detector runs when
[Google source processing](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/google/google-source.ts)
loads a thread. Delta sync processes threads changed in Gmail history. No
dedicated elapsed-time reevaluation was found in that path: a thread skipped
inside the grace period may not be reconsidered merely because 24/72 hours pass,
until a full sync or another touch. This is a code-based inference, not a
reproduced runtime failure.
Extraction jobs use source, page slug and newest-message timestamp as their key.
Pending work has a 500-job ceiling per source. Overflow is logged, but a durable
backlog of excluded candidates is not maintained; another thread change may be
needed to enqueue them. Successful source sync therefore does not imply complete
or current inference results.
The [waiting operations](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/core/ops/loops.ts)
group loops by person, defaulting to three groups. Ranking combines loop count,
deadline proximity, age and backlinks to the person's page. It is not a
personal-capacity or action-readiness model. Retrieval considers up to 500
recent loops before ranking and reports truncation.
The [CLI](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/src/commands/loops.ts)
normally refuses a waiting report if all Google sources are over 24 hours stale.
One fresh source permits display, so per-source freshness still matters. Remote
operations omit quotations, deep links and entity context that trusted-local
calls may expose.
## Proposed application flow
Keep life as CRM truth and external Codex as the reasoning and filesystem writer.
The app accepts structured proposals and records user decisions and execution.
1. Beeper supplies conversation events. External scheduled Codex runs also
reconsider items whose review time has arrived, even without a new message.
2. Codex distinguishes a request, an accepted promise, an unanswered exchange
and a decision. A request must not automatically become an accepted obligation.
3. Publish candidates with stable identities, source message references,
observations, direction, counterparty and any real deadline. Avoid using
generated prose as the only identity.
4. Home selects a small set of actionable activities alongside routines and
measurements. Open loops are candidate context, not automatically today's list.
5. Replies open conversation and draft review; document promises open the actual
next action. Weight entries and routines retain their own execution controls.
6. Keep waiting items available outside the active list. A review date can bring
a follow-up back without pretending the other person's work is the user's task.
7. Record done, not needed, defer and corrections separately. Codex consumes
these decisions and maintains life. New messages alone should not override
an explicit rejection of the same obligation.
Track source observation and inference completion separately. An old successful
report can remain visible with its timestamp; incomplete coverage must not be
presented as certainty that nothing remains. Old promises should enter review,
not silently count as resolved.
## ADHD-oriented design hypothesis
The intended benefit is to externalize remembering, choosing, starting and
resuming work. These are product hypotheses for personal use, not evidence that
this application treats ADHD or that all people with ADHD need the same UI.
[NICE guidance](https://www.nice.org.uk/guidance/ng87/chapter/recommendations)
describes individually chosen environmental modifications, reducing distraction,
shorter periods of focus with breaks, and written support for verbal requests.
[NIMH guidance](https://www.nimh.nih.gov/health/publications/attention-deficit-hyperactivity-disorder-what-you-need-to-know)
includes routines, written reminders and breaking large tasks into manageable
steps. These support the general direction, not a specific dashboard layout.
Design hypotheses to evaluate:
- Show a few concrete next actions with the material needed to start. Three is
a possible initial display limit, not a clinically established number.
- Store the broader context out of the user's working memory while keeping it
retrievable. Do not turn every detected message into another decision to triage.
- Support restarting after an interruption: what was last done, what remains,
and a small next step. A missed day must not require clearing a wall of debt.
- Keep flexible routines while preserving genuine external deadlines. Hiding
real deadlines is different from avoiding a rigid minute-by-minute schedule.
- Let ordinary actions use direct controls; natural language is an additional
route, not a requirement to formulate a prompt before every action.
- Use restrained reminders and easy correction. False positives and repeated
resurfacing are additional work, even when recall improves.
- Review routine fit and effort as well as completion. Useful questions include
whether important promises were missed, whether suggestions were wrong, and
whether the app made starting or resuming easier. Avoid optimizing a completion
percentage at the cost of meaningful work or rest.
An initial trial can combine one routine, one genuine promise and one waiting
item, then review the actual friction before expanding automatic capture.
+100
View File
@@ -0,0 +1,100 @@
# HTTP MCP and Secure MCP Tunnel
The web app handles Streamable HTTP at `/mcp` on its existing listening port.
It uses the MCP server SDK with a fresh server per HTTP request. Tools use the
same SQLite database and SNS services as Research. There is no extra MCP
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 |
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
identical creation retries without duplicates. Updates replace the entire
definition. Creation and deletion do not change another browser's selection;
saved definitions refresh through the existing UI mechanism.
Post retrieval returns up to 20 text records, source URLs, author information,
and a continuation cursor. Reuse a cursor only with its original source/account.
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.
## Internal research
The resident research chat connects Codex to `/mcp?research=<registration-id>`.
Each turn registers its existing selected-account tools, temporary view,
pagination budget, citation capture, and SSE updates. The registration expires
on completion, cancellation, or startup failure; expired IDs return 404.
External calls to `/mcp` access the persistent workspace tool catalog instead.
Set `TWITTER_LITE_MCP_URL` to a local URL that reaches this same web process.
The default is `http://127.0.0.1:${PORT:-3000}/mcp`. For example, when Vite uses
port 3002, set `TWITTER_LITE_MCP_URL=http://127.0.0.1:3002/mcp`.
The Codex app-server control connection remains stdio; MCP is HTTP.
## Access boundary
MCP authentication is intentionally deferred. The exact `/mcp` endpoint bypasses
browser owner/session checks; keep its listening address and reverse proxy
private. Do not expose this unauthenticated endpoint on a public ingress.
Browser pages and server functions retain their existing access checks.
The two protected-resource discovery paths return 404 because OAuth is not
configured. Unknown research IDs never fall through to workspace tools.
## Secure MCP Tunnel
Create a dedicated tunnel in [Platform tunnel settings](https://platform.openai.com/settings/organization/tunnels).
Use an installed `tunnel-client` or the [latest official release](https://github.com/openai/tunnel-client/releases/latest).
Configure an HTTP target, then supervise the client with systemd. The
[OpenAI guide](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels)
describes runtime key permissions and ChatGPT workspace associations.
```sh
tunnel-client init --sample sample_mcp_remote_no_auth \
--profile personal-workspace --tunnel-id tunnel_YOUR_ID \
--mcp-server-url http://127.0.0.1:3000/mcp
tunnel-client doctor --profile personal-workspace --explain
tunnel-client run --profile personal-workspace
```
Keep the runtime key in a private runtime file or environment, outside Git and
the Nix store. Point the tunnel at the actual app bind address and port. The
tunnel client has separate local health/readiness endpoints. Check readiness
after restarting the app, then select the dedicated tunnel when adding a custom
MCP server in ChatGPT. Authentication is None for this prototype.
On UM790-Pro, dotnix declares the system service
`tunnel-client-personal-workspace.service` in
`systems/nixos/UM790-Pro/personal-workspace-tunnel.nix`. It forwards to the
existing app at `http://100.91.91.87:3006/mcp`, reads its runtime API key through
systemd `LoadCredential` from the existing sops secret, and serves local health
checks on port 18791. The separate `tunnel-client-local-mcp.service` remains
unchanged. The app's package revision and internal MCP URL are managed by
dotnix's flake input and `homes/nixos/UM790-Pro/twitter-lite.nix`.
## Verification
```sh
nix develop -c pnpm test
nix develop -c pnpm typecheck
nix develop -c pnpm test:e2e e2e/integrations/mcp-http.test.ts --project=desktop
```
The HTTP E2E uses the actual app middleware, an isolated SQLite database, and
mock SNS relay. It checks anonymous MCP initialization, real saved-deck changes
visible in Research, pagination entry points, stale-revision rejection, and
continued protection of browser routes. Provider integration tests exercise
HTTP calls, conversation resume and interruption through the real SDK provider
with a fixture app-server.
+65
View File
@@ -0,0 +1,65 @@
# Owner login and onboarding
This is a single-owner workspace. App login is mandatory for all workspace
pages, server functions, and research streams. The existing Tailscale identity
check is an optional outer boundary; `TWITTER_LITE_AUTH_MODE=none` disables only
that outer check, not app login. Origin checks still protect mutations.
## First use
Start the server with its usual database and public-origin configuration, then
run this on the server using the same database:
```bash
nix develop -c pnpm account:setup
```
The Nix package also provides `twitter-lite-setup`; run it with the service
database path and filesystem permissions.
The command loads `.env.local` when present. An exported `TWITTER_LITE_DB_PATH`
takes precedence. It prints a setup code for `/setup`; keep that code private.
The code is created in `<database path>.setup-token` with mode 0600, or supplied
through `WORKSPACE_SETUP_TOKEN` (at least 32 characters). It is never returned
by the app's public API. The owner account can be created only once, including
when two setup requests arrive together. The code cannot register another
account or reset an existing password after setup.
Visit the app, enter the setup code, your name, email, and a password of 15–128
characters. The email is a login identifier; this prototype does not verify it
or send email. Choose your display name and finish the short onboarding to
open Home. Onboarding completion and the name persist in SQLite. Home greets
you by that name.
## Sessions
Passwords use salted scrypt hashes (N=2^17, r=8, p=1). Session cookies are
HttpOnly, SameSite=Lax, host-only, and Secure for an HTTPS public origin.
Only token hashes are stored in SQLite; sessions expire after 30 days.
Setup and login share a persistent limit of 10 attempts per 15 minutes;
successful authentication clears that limit.
Open the avatar menu and choose **Sign out** to revoke the current session.
Open research streams stop sending updates when the session is revoked or
expires, with an idle check every 15 seconds. Other logged-in devices retain
their own sessions. Authenticated responses are not cached.
Account registration is closed once the owner exists. Multiple users,
email-based password recovery, and account management are not implemented.
Back up the SQLite database as described in [storage](storage-and-oauth.md).
Home tasks, messages, and journal entries remain session-local prototypes;
onboarding does not make those records persistent.
## Implementation
- `src/features/auth/auth.server.ts`: owner, password hashing, throttling, sessions.
- `src/features/auth/gate.server.ts` and `src/start.ts`: request-level protection.
- `/api/auth`: session state, setup, sign-in, onboarding, and sign-out.
- Root route guard: client navigation and onboarding redirects.
- `/setup`, `/login`, `/onboarding`: English forms using the existing UI system.
Password and session handling follow the relevant
[OWASP password storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
and [session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
guidance. Tests cover invalid credentials, bootstrap ownership, expiry,
revocation, request protection, and onboarding persistence.
@@ -0,0 +1,291 @@
# Home-agent research
Historical design: the WebSocket transport and global active selection described
here were replaced by the [AI SDK research runtime](../research-runtime.md).
## Product boundary
The user starts a research task from the deck workspace. A home agent chooses
sources, opens a temporary deck, reads posts and writes a cited Markdown report
on the host. Twitter Lite provides the connected-account and deck tools; the
agent owns research and report writing.
SQLite stores conversation snapshots and the selected conversation alongside
saved deck definitions and account credentials. Reports remain host Markdown;
their contents are not copied into the DB. Adding a temporary deck to the saved
deck library remains an explicit user action.
## Proposed first implementation
1. Add a topic and connected-account selection form to the deck workspace.
2. Start one home Codex task with a dedicated output directory.
3. Supply tools for connection discovery, temporary deck creation and bounded
post retrieval using the existing validators and platform services.
4. Save conversation state and the latest generated deck in SQLite. SSE
sends the current snapshot on connection and pushes subsequent changes.
Reopened pages restore the temporary view; later definitions update the same
view. Closing that view does not delete the report or stop the agent.
5. Have the agent write `report.md` in its per-task directory with original post
URLs, collection limits and failed sources. Show progress and the output
location in the app. No report-history UI or scheduled execution yet.
Following the user's WebSocket question, the proposed transport is a persistent
`codex app-server --listen ws://127.0.0.1:4500` connected to the Node backend,
with dynamic tools handled by that backend. WebSocket transport is currently
experimental in the official documentation. Native browser WebMCP is not a remote
MCP endpoint: reuse application operations rather than assuming that host Codex
can directly invoke a user's browser tools. No externally exposed Codex socket
is necessary.
## Decisions
- Confirmed: use home Codex, rather than a separate OpenAI API integration.
- Confirmed: report artifacts are host Markdown files written by the agent.
- Prototyping: resident app-server over loopback WebSocket, using the existing
Codex login and model `gpt-6-astra`. No NixOS service migration in this step.
- Configure a dedicated report root and Codex authentication store. Preserve
refreshed authentication for the service; do not put credentials in prompts
or copy application secrets into the child environment.
- Keep manual execution bounded and cancellable. After restart, unfinished
conversations restore as interrupted. The next user message resumes the same
Codex thread after stopping any surviving orphan turn. No automatic rerun or
durable job queue is added.
## Conversation workspace
The next iteration places a persistent chat beside the horizontally scrollable
deck. The browser displays user messages, public Codex agent messages and tool
activity from app-server events. Each follow-up continues the existing thread
and supplies the deck currently open in that browser. Internal reasoning is not
part of this transcript.
The agent can list and inspect saved decks within the selected account scope,
then reuse their columns in a temporary view. Saved definitions remain unchanged
until the user saves a view. Existing manual creation, selection, column editing
and saving stay available alongside the chat.
`list_lists(connectionId)` also exposes the manual editor's Twitter and Mastodon
list catalogs. Discovery is read-only and checks the selected connected account
before contacting either provider. Returned list IDs can be used directly in
list-column sources. Discovery and post retrieval share the per-turn budget of
12 upstream calls, including failures. Twitter requests up to 100 entries through
the existing catalog service; discovery has no pagination yet.
For Twitter, the agent prefers the selected connected account named `account2`
for new columns and retrieval, resolving its ID through `list_connections`.
It uses another selected account when `account2` is unavailable, lacks access
(for example to a private list), or the user explicitly requests it. Existing
columns retain their account bindings.
The default agent instructions require this discovery at the start of research,
before planning new searches. Relevant existing lists become candidate sources;
their posts are retrieved through the regular column tool. Catalogs already read
in the conversation can be reused, and explicit user instructions take priority.
A conversational turn need not produce a research report: discussing a query or
editing a deck is a valid completed turn. Reports, when requested, remain host
Markdown files. SSE snapshots include the conversation and latest generated
deck so reopening a page restores both without restarting work or polling.
The chat header can reset an idle conversation. The next message starts a new
Codex thread while retaining the browser's current deck as context. Reset pushes
an empty conversation to every subscriber, preserves host reports and does not
delete deck definitions. Active turns must be stopped first; a stale reset must
not clear a newer conversation started on another device. The history selector
can reopen previous conversations while idle, restoring the generated deck (or
last supplied deck context), selected accounts and citations. Selection is shared
through SSE. Stale selections are rejected just like stale resets.
`research_sessions` stores the full public conversation snapshot and summary
metadata; `research_state` stores the selected conversation, including an explicit
blank selection for new chat. State is written before starting Codex and before
publishing updates. DB write failures stop research and surface a save error.
On restore, formerly active sessions become interrupted. `thread/resume` restores
Codex's persisted dynamic tools. Recovery suppresses old turn notifications and
tool calls, interrupts a still-running old turn, then starts the new message.
## Markdown and source navigation
Assistant messages use react-markdown with remark-gfm. Public tool messages and
user input remain plain text. Markdown links retain their original URLs; a
regular click on a known post navigates within the deck, while modified clicks
and unrelated URLs retain external navigation.
Successful agent post fetches capture the exact column definition and a public
post projection in the saved conversation. The projection excludes raw
provider responses, HTML, media and account credentials. References are retained
across turns, updated per source/account/post identity and retained in chat history.
The agent is instructed to cite exact returned post URLs in Markdown.
Navigation prefers a loaded card, then a workspace column with the same source
and connection. Missing sources open a one-column temporary deck. An unloaded
reference is displayed as a labeled research-time post without altering the
query cache or pagination. Cards receive focus, scroll into view and briefly
highlight; reduced-motion settings are respected and sensitive content stays
collapsed.
## Verification
- Protocol tests for dynamic-tool requests, failures, cancellation and process
exit, with no real model usage in ordinary tests.
- Tool tests for selected connection scope, source validation, pagination limits
and temporary-only deck creation.
- Browser test: start task, show generated temporary deck, retain manual save,
display progress and completion or failure.
- Opt-in real home Codex run: retrieve real posts and verify a readable report
exists at the reported host location with original-source links.
## Deployment boundary
The currently used UM790-Pro runs the development server. The existing dotnix
Twitter Lite service is configured on B450M-Pro4 with obsolete options and a
different public route. Do not apply that configuration to this task implicitly.
Service migration and backup scheduling need a concrete host configuration;
they are separate from validating this first agent workflow.
## References
- [Codex App Server](https://learn.chatgpt.com/docs/app-server)
- [Codex authentication](https://learn.chatgpt.com/docs/auth)
- [Existing WebMCP contract](../webmcp-prototype.md)
## Web-to-resident-Codex examples inspected
Research on 2026-09-24; README and implementation inspection only, not runtime
validation or adoption of these projects.
- [Redex](https://github.com/ladnir/redex/tree/a8032c49d1c9cc79db9679ad06b94a72defe3ec7):
its Python HTTP/SSE bridge connects to a standalone Codex app-server through
WebSocket. `src/redex/app_server.py` initializes the connection and sends
`thread/resume` then `turn/start`; `src/redex/bridge.py` has a long-lived
`LiveEventHub` separate from browser SSE connections. Desktop shared-runtime
integration in this project's README requires its Codex fork; standalone
WebSocket usage does not. The bridge suppresses the Origin header on its
server-side connection because Codex rejects browser-style Origin headers.
- [Pedregoneric/codex-webui](https://github.com/Pedregoneric/codex-webui/blob/5cbc718004c77657812f7151ae708e68cc6b398c/server.js):
a small personal/Tailscale-oriented example. One `CodexBridge` owns a long-lived
stdio child. Browser SSE close removes that subscriber, without killing Codex;
gateway shutdown does kill the child. This demonstrates that browser lifetime
independence is not itself a WebSocket feature. Its authentication is separate
from the existing Tailscale identity boundary used by Twitter Lite.
- [OpenAI's Codex Web architecture](https://openai.com/index/unlocking-the-codex-harness/):
the February 2026 engineering article describes HTTP/SSE from browser to
backend, and a worker maintaining a long-lived app-server connection. The
backend owns task state so browser disconnection does not stop work. This is an
architectural reference, not evidence of today's exact hosted implementation.
- [nathan-chappell/codex_web_ui](https://github.com/nathan-chappell/codex_web_ui/tree/79be149c427040d7fbb94246d841f705ab53ce9e):
the launcher starts a detached app-server on a private Unix socket. Its
TypeScript bridge performs a WebSocket upgrade over that socket. Browser
commands use HTTP RPC and events use SSE. Disconnecting an SSE subscriber does
not stop the bridge or Codex. Relevant files: `bin/codex-web-ui.js`,
`server/codexBridge.ts`, `server/appApi.ts`, `server/eventHub.ts`.
- [seo-rii/codex-webui](https://github.com/seo-rii/codex-webui/tree/2619d78c55c6095aa8b9a7a1412fd0c7abf5e564):
the Rust gateway connects directly over Unix-socket WebSocket in current code,
despite older docs mentioning a proxy. It separates browser disconnect from
gateway-restart handoff and normal shutdown. Request-ID deduplication avoids
executing replayed mutations twice. Its dynamic-tool response handling is a
useful reference, but we did not find custom SNS-style tool registration to
reuse. Relevant files: `backend/src/codex_app_server.rs`,
`backend/src/ws_transport_support.rs`, `backend/src/runtime_request_support.rs`.
The implementation should distinguish three events: browser disconnection,
Twitter Lite backend restart, and Codex process restart. A WebSocket connection
alone does not guarantee recovery across any of them. Agent tool requests should
be handled by the backend so research can continue without the initiating tab.
## Prototype verification
- Implemented a loopback WebSocket client, background run controller, bounded
account/deck/post tools, and a sidebar research dialog. No schema migrations.
- 261 unit/component tests passed (one existing opt-in live test skipped).
- Relevant Playwright coverage: 17 desktop cases, 6 mobile WebMCP cases, and
11 mobile deck/research cases passed. The new sidebar action initially
overlapped another mobile control; it now uses the existing compact pattern.
- Typecheck, lint, knip, and production build passed.
- A real Codex 0.156.1 / gpt-6-astra run started from the browser. The browser
was closed, reopened, and observed the task continuing to completion.
Local verification supplied the trusted Serve identity and origin headers;
the tagged host cannot authenticate as the owner through Serve itself.
- Codex generated two temporary columns, retrieved 20 Twitter posts and zero
Mastodon results, and wrote a Japanese report with original post links and
explicit limits on interpreting the empty Mastodon search. The report is
under the ignored `.data/research/<run ID>/report.md` directory.
- Reopening the run's generated deck produced two temporary columns. Saved
user decks were not replaced, and the report was not inserted into SQLite.
- After replacing polling with SSE, six desktop/mobile E2E cases passed for
initial snapshots, reconnect snapshots, owner checks and the unconfigured UI.
- A second real run pushed a one-column deck to the page. After closing and
reopening the browser, the latest deck restored automatically and grew to two
columns through SSE. Codex then completed `report.md`. Same-view updates,
preserving saved decks, ordered asynchronous updates and late start responses
are also covered by component/controller tests.
- The actual Codex launcher was restarted successfully with configured MCP
servers disabled; WebSocket `config/read` confirmed all seven disabled. Nix
wrapper config overrides must precede the `app-server` subcommand.
## Conversation workspace verification
- 270 unit/component tests and 40 desktop/mobile Playwright cases passed.
Typecheck, lint, knip and production build passed.
- Browser coverage includes conversation/deck restoration from SSE snapshots,
manual deck editing and saving, narrow viewports and existing WebMCP tools.
Readiness checks wait for usable controls rather than network idleness because
the event stream intentionally stays connected.
- A real three-message Codex conversation called `list_decks`, reused a manually
created column from the current-deck context, and edited that column after the
browser was closed and reopened. The app-server thread ID and generated deck
ID stayed unchanged; the deck advanced from version 1 to 2.
- The live database had no saved decks, which Codex reported correctly. Saved
deck discovery/reuse with populated records is covered by isolated tool tests.
The live check did not add or change saved deck records or create a report.
## List discovery verification
- 279 unit/component tests passed (one opt-in live test skipped), with typecheck,
lint and production build passing. Tool tests cover both platforms, selected
account scope, empty lists, upstream failures, shared request limits and using
discovered IDs to create and fetch list columns.
- A real browser-initiated Codex run discovered 54 lists for Twitter account1
and zero for Mastodon, then opened a temporary list column. Reopening the page
restored that deck and the conversation. Twitter account2 initially returned
an upstream `Dependency: Unspecified` error; a direct retry through the same
Bird catalog API returned three lists. A follow-up message in the same Codex
conversation then retried `list_lists` successfully and also reported three.
## New-chat verification
- 284 unit/component tests and eight desktop/mobile research E2E cases passed,
along with typecheck, lint and production build.
- A real Codex check on a separate development server reset a completed chat
across two browser pages, cleared the draft and retained the current deck.
The next message used a different thread ID, contained only the new user
message and received the current deck context. The separate server avoided
interrupting research already running in the public workspace.
## Markdown citation verification
- The full suite passed 306 unit/component tests, with one opt-in test skipped.
Typecheck, lint, knip and production build passed. Follow-up card tests verify
re-scrolling when a captured post is replaced by its loaded feed result.
- 42 desktop/mobile E2E cases passed, including Markdown tables and emphasis,
navigating to an offscreen column, captured-post display, focus/highlight,
accessibility and existing manual deck/WebMCP operations.
- A real Codex run on a separate development server fetched 20 Twitter posts,
cited two in a Markdown response, and navigated from a citation to its card.
Closing and reopening the page preserved working citation navigation.
- react-markdown 10.1.0 and remark-gfm 4.0.1 were added with the user's approval.
The pnpm dependency hash was updated and the x86_64-linux Nix package built.
## Conversation persistence verification
- SQLite repository tests reopen file-backed databases and verify conversation,
deck, account, citation and active-selection restoration, plus transaction rollback.
- Runner tests recreate the service against the same database, resume its thread,
reject stale history switches, interrupt orphan turns and reject late orphan
events. Failed writes prevent new execution or stop further tool calls.
- 52 desktop/mobile E2E cases passed. The final focused research suite passed
120 tests; typecheck, lint, knip and Nix packaging passed.
- An actual Codex run captured 20 Twitter posts. A fresh Node process restored
that conversation and used the same Codex thread and dynamic tools to add a
second column, retaining all citations. Separate desktop/mobile browser
contexts restored the saved chat, navigated citations, shared history selection
through SSE and retained blank-new-chat selection across reload.
@@ -0,0 +1,141 @@
# Mastodon複数アカウント・デッキ端末間共有の実装計画
状態: 機能実装・検証済み。2026-09-24に現行コード・公式仕様・Elkを調査。Drizzle ORM 0.45.3+Drizzle Kit 0.31.11+better-sqlite3 13.0.3と、Tailscale Serveの本人情報による利用制限を採用。実機確認の範囲と運用上の引き継ぎは以下に記録する。
## 実装・検証記録(2026-09-24)
- T0〜T6の機能を実装。ユーザーのブラウザでTailscale経由のMastodon認可・callback復帰を確認。
- サーバーはタグ付き端末なので、本人loginはSelf.UserIDから推測せず実際のユーザー情報で設定。タグ付き端末からの本人情報なしアクセスは引き続き拒否。
- 単体・統合202件、Playwrightの共有デッキ/WebMCP/アクセス制限38件と接続管理/callback4件を検証。
- 別ブラウザから保存済み認可を再利用し、実Mastodonを20件→40件へページ送り。同じ一時ビューでTwitter20件を取得。ブラウザエラーなし。確認用ビューは永続保存していない。
- Nixパッケージ単体で起動・3マイグレーション・DB書込・バックアップを確認。依存hash更新済み。
- 実アカウントでの接続はMastodon1件。同一インスタンスの複数アカウント・別インスタンス・再接続・解除・401競合・429はテストで検証し、実アカウントを増減させる破壊的試験は行っていない。
- NixOSサービスの実機デプロイは行わず、モジュール・永続ディレクトリ・credential設定と運用手順を用意。試用用devサーバーを稼働。
## 前提と到達点
- 自分専用。自宅の1台でアプリを稼働し、複数の自分の端末からTailscale ServeのHTTPSで利用する。
- 同じデッキにTwitterとMastodonを並べ、各カラムに接続アカウントを固定できる。
- 端末AでOAuth接続とデッキ作成を済ませれば、端末Bでも同じ接続・デッキを使える。
- デッキ定義・カラム順序・接続一覧を共有する。開いているデッキ、スクロール位置、編集中のフォームは端末ローカルとする案。
- AIが調査ごとに作るデッキは一時ビューとして開き、必要なものだけ保存済みデッキにする。一時ビューは端末間共有の対象外。
- 投稿の収集アーカイブ、AIサマリー、オフライン編集、他人との共同編集はこの増分に含めない。
## 推奨構成
```mermaid
flowchart LR
A[PC / スマホ] --> B[Tailscale Serve HTTPS]
B --> C[Twitter Lite / localhost]
C --> D[(SQLite / 自宅のローカルディスク)]
C --> E[Twitter Safe Relay]
C --> F[Mastodon各インスタンス]
```
SQLiteを採用する案。端末が増えても、各端末がSQLを実行するのではなく、同じアプリサーバーへアクセスする。デッキ設定とOAuth情報の小さな更新には単一サーバーのSQLiteで始められると判断する。DBファイルを端末間コピーしたり、NAS上のファイルを複数サーバーから直接開いたりしない。
| 候補 | 今回の評価 |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| SQLite | 推奨。別DBサービス不要。ローカルディスク、短いトランザクション、マイグレーション、復元試験を用意する |
| PostgreSQL | 複数アプリサーバーや大量の並列収集へ進む時に再評価。現時点では運用対象が増える |
| ブラウザDB+同期基盤 | 採用しない。今回必要なのはオンラインで同じサーバー状態を読むこと。オフライン同期エンジンを導入する必要はない |
SQLiteは同時書き込みが1つという制約がある。複数サーバー・高い書き込み並列度ではclient/server DBを検討する。[SQLiteの用途](https://www.sqlite.org/whentouse.html)
## 認証と保存の境界
アプリの利用許可とSNSアカウントのOAuthを分ける。最初は単一の私用ワークスペースとし、アプリ内のユーザー登録・パスワード管理は作らない。
Tailscale Serveの本人情報を利用する場合は、自分のloginを許可し、バックエンドをlocalhostに束縛する。現在の試用用 `0.0.0.0` 起動を、そのまま本人情報ヘッダーを信頼する本番構成に持ち込まない。タグ付き端末のアクセスには通常のユーザー情報ヘッダーが付かないため、最初の実機確認に含める。Serveからの経路、利用許可、変更リクエストのOrigin検証を共通化する。[Tailscale Serve identity headers](https://tailscale.com/docs/features/tailscale-serve#identity-headers)
OAuth callbackは固定のHTTPS URLにする。認可後に戻る主体はブラウザなので、その端末がtailnetへ到達できる構成を実機検証する。callbackのためにFunnelでアプリ全体を公開する方針は取らない。
## 保存モデル案
| 保存対象 | 主な内容 |
| ------------------------ | ---------------------------------------------------------------------------------------- |
| `decks` | ID、名前、revision、更新日時 |
| `deck_columns` | ID、deck ID、並び順、connection ID、名前、platform固有のsource JSON |
| `connections` | ID、platform、接続先origin、外部アカウントIDまたはrelay profile参照、表示名、接続状態 |
| `connection_credentials` | connection ID、暗号化したアクセストークン、鍵の識別子。通常の接続一覧とは分離 |
| `oauth_apps` | インスタンスorigin、callback・scope構成、client ID、暗号化したclient secret |
| `oauth_attempts` | 短時間有効なstate、開始ブラウザとの束縛、接続先、PKCE verifier、期限、一度限りの消費状態 |
単一利用者なので、この段階ではusers/organizations/roles等のテーブルを作らない。
カラムの現行 `profileName` を `connectionId` へ変更する。Twitterはconnectionにrelay profile名を保持し、トークンは引き続きrelay側で管理する。Mastodonは接続インスタンスoriginと `verify_credentials` のaccount IDで重複を判定する。表示handleを主キーにしない。同じインスタンスに別アカウントを追加でき、再接続時には既存のconnection IDを維持する。
DBには秘密を暗号化して保存し、暗号鍵はDB外の実行時credentialファイルから読む。鍵やtokenをNix store・Git・ブラウザ・SSR payload・WebMCPに含めない。DBと鍵の両方が揃って復元できる手順を用意する。
## PR単位のタスク
| ID | タスク | 依存 | 完了条件 |
| --- | -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T0 | 実行環境とアクセス経路を固定 | なし | 本番HTTPS origin/callback候補を決め、PC・スマホから同じ利用者として接続。Serve以外からのヘッダー偽装を許さない構成を確認。対象Mastodonのバージョン・OAuthメタデータも調べる |
| T1 | SQLiteと永続ディレクトリを導入 | T0 | 現行Node/Nixで動くdriver・migration方式を実証。`StateDirectory`等でDBを永続化。再起動・アプリ更新後も残り、バックアップから復元できる。依存追加時はflakeのpnpm hash更新まで行う |
| T2 | connectionモデルへTwitterを移行 | T1 | relay profile一覧から接続を作り、カラム・取得・キャッシュ・ページ送りがconnection IDを使う。2つのTwitter接続を混ぜずに並列表示。不明・削除済みの接続はエラーとして残す |
| T3 | デッキをサーバー保存し端末間共有 | T1,T2 | PCで作ったデッキが別ブラウザコンテキストに表示。revision競合で上書きを拒否。WebMCPも同じ保存処理を使う。既存localStorageからの明示インポートを提供し、重複取り込みと既存DBの破壊を防ぐ |
| T4 | Mastodon OAuthとトークン保管 | T0,T1,T2 | 接続先登録→認可→callback→本人確認→暗号化保存。同一インスタンス2アカウント・別インスタンス・再接続・解除が動く。拒否/state不一致・期限切れ・再利用を検証 |
| T5 | Mastodon取得と投稿正規化 | T4 | ユーザー投稿・リスト・ハッシュタグのカラムを実装し、全文検索を別の能力として扱う。CW・sensitiveメディア・boost・HTML本文を安全に表示。元投稿URLと取得インスタンスのローカルIDを区別 |
| T6 | 複数接続UIとWebMCPを統合 | T3,T5 | 接続管理から追加・再接続・解除。カラムは接続に応じたsourceを選べる。Twitter/Mastodonを同じデッキに並べ、AIも接続一覧を発見して作成・取得できる |
| T7 | 2端末・障害・運用の通し検証 | T3,T4,T6 | 端末AのOAuth接続を端末Bで再認可せず利用。編集競合・接続失効・429・再起動・DB復元の検証。UI/API/WebMCP/ログにトークンが出ないことを確認 |
各PRに必要な単体・統合テストを含める。T7までテストを先送りしない。
推奨順序: `T0 → T1 → T2 → T3` で既存Twitterの端末間共有を先に完成。T3とT4は保存モデル確定後に並列作業可能。その後 `T4 → T5 → T6 → T7`。
### T3: 同期の具体的な範囲
- 保存済みデッキはDBを正本にし、クライアントキャッシュへ読み込む。保存中・保存失敗を区別し、サーバーが受理していない編集を「保存済み」と表示しない。
- デッキ単位のrevisionを比較して、名前・カラム・順序を1トランザクションで更新。同じrevisionへの2回目の更新は競合として返す。
- 初期案はフォーカス復帰時と表示中の軽い定期再取得。SSE/WebSocket/CRDTは導入しない。編集中の内容は自動更新で消さず、競合時に再読み込みを案内する。
- 選択中デッキIDは端末ローカル。PCで別デッキを開いたためにスマホの画面まで勝手に切り替わる挙動を避ける。別端末から削除されたデッキを表示中なら、残りのデッキへ移動し通知する。
- `get_deck`等はrevisionを返し、既存デッキを置換する `set_deck` は期待revisionを指定する。投稿読取ツールにはDB内の秘密を含めない。
- localStorageインポートは一度限りの移行機能。DB完成後にlocalStorageへ書き戻す二重運用はしない。
### T3/T6: AIが作る一時ビューと保存
- 一時ビューと保存済みデッキは同じカラム定義・描画・接続参照を使う。別のプラットフォーム抽象や投稿取得経路は作らない。
- AIによる新規作成は既定で一時ビュー。タブ内のメモリで保持し、DBへは書かない。複数の一時ビューを切り替えられ、UIに「一時」と「デッキとして保存」を表示する。再読み込みやタブを閉じると失われることを画面で明示する。
- 保存操作は新規デッキをサーバーに作成する。成功後にその一時ビューを保存済みデッキへ置き換え、他の端末にも見えるようにする。失敗時は一時ビューを残し、再試行できる。二重クリック・同じ保存要求の再送で重複デッキを作らない。
- 保存済みデッキから一時コピーを作って、AIが検索語・カラムを組み替えて試せる。元デッキは変更しない。初期実装では保存先は新規デッキとし、元デッキへのマージ機能は作らない。
- WebMCPは一時ビューの作成・読取・編集・破棄と、明示的な保存を扱う。既存の保存済みデッキ更新には引き続き期待revisionを要求する。ツールの結果は一時か保存済みかを明示し、保存先を推測させない。
- 一時ビューにも通常のカラム数制限と接続の検証を適用する。投稿内容や認証情報の保存は行わず、保存するのは名前・カラム・検索条件等の定義のみ。
- 検証: AIの一時作成でDB件数が増えない、ページ送りできる、複数ビューを切り替えられる、保存後は別ブラウザから取得できる、保存失敗で内容を失わない、保存再送が重複しない、一時コピーの編集で元デッキを変更しない。
### T4: OAuthの具体的な範囲
- 初期対象はMastodon 4.3以降を提案。Authorization Code+PKCE S256とstateを使い、旧版互換の分岐は作らない。Mastodonはconfidential clientを前提にしているためclient secretも必要。[OAuth仕様](https://docs.joinmastodon.org/spec/oauth/)
- `POST /api/v1/apps`でインスタンスごとに登録。callback・scope構成と合わせて再利用する。[アプリ登録API](https://docs.joinmastodon.org/methods/apps/)
- 閲覧用scopeに絞る。候補は `read:accounts read:statuses read:lists read:search`。実装したカラムのAPI要件と照合する。同一インスタンスへの別アカウント追加は `force_login=true` を使う。[OAuth API](https://docs.joinmastodon.org/methods/oauth/)
- state/verifierは開始ブラウザに束縛し、期限付き・一度限りで検証。token交換と `verify_credentials` はサーバーで実施し、戻り先URLにtokenを含めない。[本人確認API](https://docs.joinmastodon.org/methods/accounts/#verify-account-credentials)
- 接続解除・401からの再接続を実装。通常のMastodon tokenは自動失効しないため、汎用refresh token基盤を先に作らない。[OAuth tokens](https://docs.joinmastodon.org/api/oauth-tokens/)
- 接続先入力をサーバーがfetchするため、origin・DNS解決先・redirectを検証する。初期は設定したインスタンスの許可リストに限定する案。OAuthやページ送りを家のLANへの汎用HTTP転送にしない。
### T5/T6: プラットフォーム抽象化
- 共通化するのはconnection参照、取得結果、エラー、継続ページの境界。全SNSへTwitterのTop/Latestや検索構文を要求しない。
- source schemaはplatformとkindで分岐。MastodonのリストID・アカウントIDは接続インスタンスの文脈を持つ。接続変更時はリストを選び直す。
- 投稿の同一性にはcanonical URIを使い、取得先のローカルstatus IDはAPI操作用に保持する。boostのwrapperと元投稿を区別する。
- HTML本文は許可する要素・URLを制限して処理。CW・sensitiveを初期表示で尊重し、全文を単に既存のtextへ詰めない。
- Mastodonの全文検索はインスタンスの検索環境次第。明示的な検索エラーと正常な空レスポンスを区別する。ただし4.5.3では検索バックエンド無効時も空配列を返すため、0件だけで全文検索への対応可否は判定できない。画面にも検索範囲が接続先の設定に依存することを表示する。[検索API](https://docs.joinmastodon.org/methods/search/)、[4.5.3の検索処理](https://github.com/mastodon/mastodon/blob/v4.5.3/app/services/search_service.rb)
- ホームタイムライン、通知、Streamingは最初のカラムが動いてから別タスク。既存の調査カラムと手動ページ送りを先に完成する。
## Elkから参考にする範囲
調査対象commit: `8a90074fca9f316a0c71f7249b1a31f21829a987`。
- インスタンス別のOAuth app登録・再利用: [server/utils/shared.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/utils/shared.ts)
- 認可URLと複数アカウント追加: [server/api/[server]/login.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/login.ts)
- アカウント本人確認・インスタンスと表示ドメインの区別: [app/composables/users.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/users.ts)
ElkはOAuth app情報をサーバーに持つが、ユーザーtokenはcallback URLを経由してブラウザへ渡し、IndexedDBの `elk-users` に保存する。今回のサーバーtoken保管・端末間同期は別実装にする。[callback](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/oauth/%5Borigin%5D.ts)、[ユーザー保存](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/plugins/0.setup-users.ts)
Elkのグローバルなcurrent accountの切り替えも、そのままカラムごとの並列取得には使わない。各カラムはconnection IDから独立したリクエスト文脈を得る。[Mastodonクライアント](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/masto/masto.ts)
## 後続タスク
- Bluesky: 接続・認可方式を別途調査し、MastodonのOAuth仕様を流用しない。
- Threads: アプリ登録・利用可能な読み取り権限・検索範囲を実機確認してからスコープを決める。
- Nostr: relay集合・署名/鍵の扱いを別に設計し、OAuthに無理に合わせない。
- AIサマリー/並列収集: 投稿snapshotと取得日時・クエリ・接続文脈を保存する別のデータモデルを追加する。今回は設定同期のDBに投稿アーカイブを混ぜない。
+58
View File
@@ -0,0 +1,58 @@
# shadcn workspace migration
> Tooling update: Biome has since been replaced by Oxlint for linting (including
> the shadcn rules) and Oxfmt for formatting. The checks recorded below describe
> the tooling used at the time of this migration; see README for current commands.
## Target
Replace the custom workspace UI with the Base UI version of shadcn sidebar-09
at commit 98a1fe67b439324ddc857f47fbdce056600a4329. Preserve its icon rail,
contextual second sidebar and SidebarInset. Adapt the rail to research chat and
deck management, keeping the research panel mounted so SSE state remains live.
On small screens, use the sidebar Sheet and keep all actions reachable.
Use official MessageScroller, Message, Bubble and Marker for the existing Codex
conversation. Keep transport, history, retry, stop, account selection, Markdown
and citation navigation. Use shadcn primitives for forms, dialogs, menus and
post metadata throughout the app. Use Tailwind v4 and shared shadcn semantic
tokens; remove legacy control styles that override primitives.
Install @shadcn/lint with an executable design-token policy alongside Biome.
Update README and Nix dependency hash as needed.
## Verification
- Typecheck, Biome, design lint, unit suite, production build, unused-code check.
- Browser integration suite: decks, source editing, connections, conversation,
citation navigation, menus and keyboard focus.
- Render desktop plus 320/375/414/768 px: rail collapse, mobile Sheet, independent
column scrolling, chat composer visibility, no document horizontal overflow.
- Inspect screenshots and run Axe on relevant states.
## Status
Completed on 2026-09-28.
- SidebarProvider, nested icon/context Sidebars and SidebarInset now implement
the referenced sidebar-09 structure; the mobile Sheet keeps its content mounted.
- Research state and deck-dialog drafts live above the responsive shell. Closing
or resizing the sidebar preserves drafts, selected accounts and retry identity.
- MessageScroller, Message, Bubble/BubbleContent, Marker/MarkerContent, InputGroup
and Collapsible replace the chat UI. Markdown list markers and headings remain
visible after Tailwind's reset; citation highlighting uses theme tokens.
- Forms, dialogs, dropdown menus, avatars, badges and empty states use Base UI
shadcn components. No backend transport, storage or access contracts changed.
- @shadcn/lint runs in pnpm lint and rejects raw palette colors. Nix dependency
hash and README/deck docs are updated.
Verification: 336 unit tests passed (one explicit live-relay test skipped), all
56 desktop/mobile E2E tests passed. After the final isolated chat styling fixes,
all 10 research E2E tests and 22 related unit tests passed again. Typecheck,
Biome/design lint, Knip, production build and the Nix package build passed.
Browser checks covered 320/375/414/768px, rendered chat and post screenshots,
Axe, dialog focus restoration, breakpoint draft preservation and closed-sidebar
SSE deck updates. Automated browser tests use mock relay/research data.
A subsequent dev-server smoke check through the Tailscale HTTPS URL confirmed
the real relay displayed 14 posts across six columns without UI alerts.
No production deployment was performed.
+116
View File
@@ -0,0 +1,116 @@
# Rensheng, gbrain and the personal workspace
Research and recommendation, 2026-09-28. No integration was installed or tested.
gbrain inspection: `e78f1c38b947b053f3a46881340f74f316be855a`.
Rensheng inspection: `c06c4e3f290800752a0c252c84fbca0eed61e45d`.
## Finding
There is substantial overlap. Memory, CRM, daily briefs, natural-language task
management and agent tools are not unique to this workspace. Its remaining
product hypothesis is a low-friction daily execution interface: turn personal
context into a few actionable choices, execute through appropriate controls,
retain actual outcomes, and improve routines from experience.
This hypothesis has not been demonstrated by the current mock. Home, Messages
and Journal still use in-memory prototype state; Beeper sending and life
integration are not connected. Research has real persisted decks and Codex chat.
See [current status](../README.md) and [execution prototype](execution-support.md).
## Honest comparison
| Concern | gbrain | Rensheng / life | This workspace |
| ------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Collection | Native Google/GitHub sync, chat-history connectors, imports and extension points | External agents and existing source tools; shared raw store remains planned | Real research connectors; communication integration proposed |
| Durable personal context | Pages, sourced facts, corrections, withdrawal, retrieval and graph | Small source-linked Markdown views, explicit user decisions and domain conventions | Should consume context, not own another CRM |
| Daily brief | Agent briefing skills | Briefs generated by external agents | State-derived prose prototype; externally published brief proposed |
| Tasks | Agent skill for add, complete, defer and remove in a structured task page | Procedures and context; execution tools retain occurrences | Direct routine/task controls, currently prototype |
| Execution by content type | No equivalent unified routine/measurement/reply screen identified in inspected UI | Separate OpenBrief implements attention handoff and return anchors | Content-specific execution controls are the intended focus |
| Routine learning | No equivalent occurrence-based routine review identified in inspected implementation | Stores chosen procedures and meaningful review conclusions | Durable events and statistics proposed, not implemented |
gbrain's [daily-task-manager skill](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/skills/daily-task-manager/SKILL.md)
defines stable task IDs, priority, completion archives and deferral. Its
[briefing skill](https://github.com/garrytan/gbrain/blob/e78f1c38b947b053f3a46881340f74f316be855a/skills/briefing/SKILL.md)
already addresses daily assistance. These are agent workflows, not evidence of
a unified execution app, but they are genuine alternatives for a user satisfied
with chat and linked source applications.
Rensheng is not uniquely local or agent-independent: gbrain also emphasizes
portable, sourced memory across agents. Rensheng's useful distinction here is
the deliberately small, directly readable context model and its operational
conventions. It does not ship a replacement for gbrain's complete ingestion and
retrieval infrastructure. See [Rensheng's comparison](https://github.com/yutakobayashidev/rensheng/blob/c06c4e3f290800752a0c252c84fbca0eed61e45d/COMPARISON.md).
## The nearer overlap: OpenBrief
The Rensheng repository also contains an implemented
[OpenBrief](https://github.com/yutakobayashidev/rensheng/blob/c06c4e3f290800752a0c252c84fbca0eed61e45d/docs/openbrief.md)
daemon, desktop app and mobile companion. It handles observations, bounded
briefs, agent proposals, confirmed decisions and return anchors. Thus resumption
support should not be described as absent from the existing Rensheng repository.
Before integrating that feature, choose one owner for its decisions and return
anchors. If OpenBrief remains active, this workspace should reference its
records rather than independently maintain conflicting copies. Reusing its
runtime is not automatically required: its ACP ownership and desktop collection
solve a narrower problem than this app's external-Codex publishing contract.
Actual compatibility needs a separate implementation check.
## Recommended division
- **Original systems:** messages, calendar events and source evidence.
- **life:** relationship context, explicit preferences, accepted goals and routine
definitions. External Codex maintains these files. Compiled claims remain
traceable to original evidence and user corrections.
- **External Codex:** interpretation, candidate selection, reply drafts and
periodic review. No new app-owned reasoning engine is required.
- **Workspace:** published suggestions, user choices, activity occurrences,
measurements and execution events, plus the controls to act on them.
- **gbrain, optional:** collection or retrieval infrastructure if a measured
source-coverage or search problem justifies operating it. Do not introduce a
second authoritative profile, task list and routine definition by default.
Durable context and execution events have different owners. Copy meaningful
outcomes into life through Codex; do not continuously reconcile two independently
editable task databases. If gbrain indexes life later, treat the indexed copy as
a projection and first verify its source-write behavior. This is an integration
proposal, not a tested read-only mode or a promise that whole-brain deployment
can be reduced to a dependency-free collector.
## Options and decision criteria
1. **Rensheng + Codex + existing apps:** lowest custom application upkeep. Prefer
this if a daily brief and source links already lead to action reliably.
2. **Rensheng + this workspace + Codex:** recommended experiment for the stated
need for routines, measurements and communication in one daily view. The
execution interface and recorded outcomes must earn their maintenance cost.
3. **Add gbrain underneath:** consider when collection, retrieval or cross-agent
memory becomes an observed bottleneck. Its connectors do not supply a native
Beeper integration or automatically replace external-Codex reasoning.
4. **gbrain + existing chat/source apps:** reasonable if generic memory, Gmail
waiting reports and conversational task management satisfy the actual need.
In that case, much of this workspace need not be built.
## Smallest useful experiment
Run a suggested one-to-two-week trial with one real life routine, one measurement
and a small set of real Beeper follow-ups. This duration is a practical starting
point, not a clinical protocol. Persist records before adding more mock screens.
Publish a short brief and actionable items through the proposed
[Codex interface](agent-publishing-design.md). Support direct execution, deferral
and correction. Keep reply completion distinct from fulfillment of a promise.
Retain events across reloads. Use observed results in one Codex-led review and
save only adopted routine changes to life.
Compare with the simpler baseline: Codex writes a daily brief to life and the
user acts through existing apps. Assess actual usefulness: easier starts and
returns, important commitments missed, false positives, correction effort,
and whether the workspace is opened without prompting. Completion rate can
support review but must preserve planned denominators, missing coverage and
routine-version changes. No specific UI is claimed to treat ADHD.
If the interface does not reduce friction, simplify to the baseline rather than
defend the project through feature count. Defer generic vector search, knowledge
graphs, a new agent framework, broad connector coverage and additional dashboard
expansion until this execution loop proves useful.
+50
View File
@@ -0,0 +1,50 @@
# Beeper Copilot UI reference captures
Source: <https://beeper-copilot-lp.vercel.app/>
Captured on 2026-09-28 with Chromium through agent-browser. These are screenshots
of the public landing page and its animated UI demonstrations, not screenshots
of a logged-in production application. Displayed people and conversations come
from that page, not this user's Beeper account. Animations can show different
stages between captures. No page contents were modified to create the images.
| Capture | Contents |
| ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| [Desktop three-pane](desktop-three-pane.png) | Conversation list, conversation body/composer, person context; 1440 × 720 viewport |
| [Reply review](desktop-reply-review.png) | Three reply styles, selected candidate, approval button; 1440 × 1080 |
| [CRM and evidence](desktop-crm.png) | Person panel, evidence-backed fields, people board; 1440 × 1080 |
| [Features](desktop-features.png) | Unified inbox, ghost completion, review and reply suggestions; 1440 × 1080 |
| [Feedback](desktop-feedback.png) | Candidate comparison and writing-rule feedback; 1440 × 1080 |
| [Desktop hero](desktop-hero.png) | Landing-page introduction and part of the demo; 1440 × 1080 |
| [Desktop full page](desktop-full.png) | Entire landing page at 1440px wide |
| [Mobile hero](mobile-hero.png) | Landing-page layout at 390 × 844 |
| [Mobile conversation](mobile-conversation.png) | The demo's compressed conversation layout at 390 × 844 |
## Patterns to reuse
- Three coordinated panes: compact list, a dominant conversation, and persistent
person context. In this app the right pane should show Codex-published life
context, source references and pending user corrections.
- Compact network badges on identities and messages. Keep original conversation
identity available even when presenting a person across networks.
- A reply review sits near the conversation and composer. Candidate selection,
editing and confirmation remain visible steps; approval is distinct from
generating or inserting a suggestion.
- Evidence chips next to contextual statements provide a path back to messages
or the published life source reference.
- Suggested actions include their reason and can populate the appropriate
editor. The app can render externally produced suggestions without inference.
The LP's dense right panel and desktop three-pane arrangement are useful
references. Its mobile demo squeezes the columns and is not a good mobile
navigation model for this app: use list → conversation with an accessible context
panel instead. The LP's relationship-phase labels, coverage scores and confidence
percentages are demo product choices, not requirements for this personal CRM.
For Home, borrow the contextual presentation inside a reply activity rather than
embedding the full inbox. Weight logging needs a numeric input; a routine needs
steps; an optional task needs a simple completion control. These can coexist in
one activity list without forcing every activity into a chat layout.
See [external agent publishing](../../agent-publishing-design.md) for the proposed
producer/consumer boundary and typed activity contract.
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+148
View File
@@ -0,0 +1,148 @@
# Research decks
## Workspace
`/deck` renders the research workspace; `/` is the integrated daily home. A collapsible shadcn/ui
sidebar based on `sidebar-09` shares its navigation rail with the article reader
at `/inbox`. Within Research, it switches between research chat and deck management.
Deck management switches decks and adds columns. The avatar at the bottom of the
shared sidebar opens connected-account management in a dialog from either workspace.
Up to six ordered columns scroll independently; only the active deck
mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
shadcn Base UI dialogs handle creation and editing, with Escape and focus
restoration. Column menus offer editing, ordering, and deletion; refresh and
pagination are manual. The adjacent chat uses MessageScroller, Message, Bubble,
Marker and InputGroup with the existing research backend.
Twitter and Mastodon can appear together, with different accounts in each
column. Original-post links open their source site. Separate reader, search,
list, user-profile, and conversation pages are not provided.
## Definitions and connections
`src/features/decks/model.ts` validates deck IDs, titles, ordered columns, and
unique column IDs within each deck. Each column contains `id`, `title`,
`connectionId`, and a platform-specific `source`.
| Platform | Source kind | Conditions |
| -------- | ----------- | ----------------------------------------------------------------------- |
| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` |
| Twitter | `user` | `target`: handle or X/Twitter profile URL |
| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL |
| Mastodon | `search` | `query`; results depend on the instance's search configuration |
| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID |
| Mastodon | `list` | `target`: numeric list ID for the connected account |
| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores |
`connectionId` is the account binding, distinct from a deck's name. Twitter
connections resolve to profiles discovered from the configured relay.
Mastodon connections identify accounts by instance origin and remote account
ID; OAuth tokens stay encrypted on the server. The connection manager supports
adding, reconnecting, and disconnecting Mastodon accounts. See
[storage and OAuth](storage-and-oauth.md) for configuration and recovery.
The editor discovers lists for the selected account. Changing accounts clears
target-based conditions so an instance-local ID is not reused accidentally.
Searches can retain conditions between accounts on the same platform. There is
no global account selector or fallback to another account. Missing or
disconnected bindings produce errors.
The complete source and connection ID form query-cache identity. Equal
conditions on the same connection share pages; different connections retain
separate results and cursors. Deck synchronization does not poll post feeds.
## Implementation boundaries
- `column-editor.tsx` owns the title, connection binding, and final submission.
- `column-source-editor.tsx` dispatches by platform and defines rebinding rules.
- `twitter-source-editor.tsx` and `mastodon-source-editor.tsx` own each
platform's source selection, fields, and account-specific list discovery.
- `use-research-feed.ts` dispatches requests and keys the cache by connection
and source. Server functions resolve credentials and fetch upstream pages.
- `platforms/types.ts` defines normalized `ResearchPost` and `ResearchPage`
records consumed by cards and column tools, without raw provider responses
or credentials.
Twitter posts use `twitter:<id>` keys. Mastodon posts use canonical status URIs
for keys and retain the fetched instance's native status ID separately. Boosts
keep the wrapper identity and identify the boosting account. Mastodon HTML is
sanitized on the server; cards honor content warnings and sensitive media.
## Saved decks and temporary views
SQLite is authoritative for saved definitions. Creating a named deck through
the UI saves it; subsequent saved-deck edits and deletions use a revision check
in a transaction. A stale revision is rejected. Save failures remain visible
and do not report unsaved edits as persisted.
Saved decks refresh on focus and every five seconds while visible. Refresh is
held while an editor is open. The `deck` URL parameter selects the active view;
selecting a deck does not switch another device's view. Browser history restores
selection, and saved decks can be opened from a copied URL. Deleting the active deck selects a remaining one. When none remain, the
app opens an empty temporary view.
WebMCP-created views are temporary by default. Temporary copies of saved decks
also remain in this tab's memory. Editing them does not write to SQLite. The
explicit save action replaces a temporary view with a shared saved deck,
preserving its ID for idempotent retries. Failed saves retain temporary
content. Temporary views disappear on reload or navigation, including OAuth
redirects. Only definitions are saved: posts, cursors, and scroll positions
are not archived.
Research chats separately retain the agent-generated deck and last supplied
deck context. Reopening a saved chat reconstructs a temporary view, including
on another device or after a server restart. Chat citations retain a limited
post projection for source navigation; this does not persist the deck's feed
cache, pagination cursors, scroll positions or unsent manual edits.
The UI offers an explicit, one-time import of valid version-2 data from
`twitter-lite-research-deck` in localStorage. It resolves old relay profile
names to connection IDs and creates new saved deck IDs transactionally. A
server marker prevents repeated imports; different content after the first
import is rejected. The browser copy is removed only after success. Invalid
or unsupported data is left untouched. HTTP and HTTPS have separate browser
storage, but authorized devices read the same server-backed decks.
## WebMCP
Use `list_connections` to discover bindings, then `set_deck` to create a
temporary view. This example uses IDs returned by discovery:
```json
{
"title": "WebMCPの反応",
"columns": [
{
"title": "Twitter",
"connectionId": "twitter-connection-id",
"source": { "platform": "twitter", "kind": "search", "query": "WebMCP lang:ja" }
},
{
"title": "Mastodon",
"connectionId": "mastodon-connection-id",
"source": { "platform": "mastodon", "kind": "hashtag", "target": "WebMCP" }
}
]
}
```
Read the returned deck ID and call `save_deck` only when the view should be
shared. Replacing a saved deck requires its `deckId` and `expectedRevision`;
include every column to retain. Post loading is asynchronous and can fail
independently of saving. See [all tool contracts](webmcp-prototype.md).
## Verification
Unit tests exercise source normalization, account-specific caches, OAuth,
credential storage, revisions, import, and temporary-view behavior. Playwright
uses an isolated SQLite database and mock relay through real server functions
to exercise shared decks across browser contexts, editing, persistence,
pagination, and native WebMCP. Deterministic automated tests do not require
live SNS credentials.
## Conversation execution
The [research runtime](research-runtime.md) uses AI SDK and the Codex app-server
provider. Conversations are selected by URL, with per-conversation background
execution and reconnectable SSE. Opening New chat or another saved conversation
does not stop an existing turn or change another browser's selection.
+58
View File
@@ -0,0 +1,58 @@
# Research runtime
Research uses Vercel AI SDK 7 `streamText` with
`ai-sdk-provider-codex-cli` 2.3's app-server provider. The provider owns its local
stdio child, thread resumption, protocol decoding, and abort handling. The app
owns accepted requests, account-scoped tools, snapshots, and report validation.
The separate WebSocket launcher is removed.
## Tools
The provider does not register AI SDK `tools` as Codex dynamic tools. Existing
research definitions and executors are exposed with `createSdkMcpServer` instead.
Validation, connected-account scope, request budgets, evidence tracking, and
read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is
one agent turn; there is no second AI SDK tool loop replaying those calls.
Only the registered research tools receive per-tool MCP approval overrides for
non-interactive execution. Shell approvals and external MCP servers remain
disabled. Failed or truncated Codex turns are treated as failures even when the
provider emits a finish event without an error event.
Resumed threads that already registered dynamic tools route their calls through
`onDynamicToolCall` to the same validated executor.
## Lifecycle and selection
The background runner is keyed by conversation ID. Acceptance saves the user
message before starting the model. A request ID identifies a new conversation
and deduplicates retries within the process. Overlapping turns of the same
conversation are rejected, while different conversations can run independently.
The URL selects a conversation. GET status and SSE subscribe to that ID;
selecting history never starts or cancels a turn. Reconnection sends the latest
complete snapshot. Closing a browser or changing its URL only detaches a viewer.
Only Stop, the execution timeout, or a server-side failure aborts generation.
Authentication expiry or logout closes the viewer's stream.
SQLite retains messages, thread IDs, generated decks, and citations. It no
longer stores a global active conversation pointer. Browser views cannot change
another browser's selection. After a backend restart, unfinished conversations
are marked interrupted and require an explicit follow-up. No turn is replayed
automatically. Reports remain files under the configured report root.
## Streaming presentation
The model stream uses `smoothStream` with 15 ms pacing and the supplied chunking
pattern `/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/`. The trailing buffer flushes at the
text-end event. The existing snapshot SSE remains the UI's reconnectable data
channel; an HTTP request's lifetime never owns the model execution.
Streamdown displays partial Markdown and animates only the latest live answer.
Completed messages render immediately. Reduced-motion preferences disable the
animation. HTML is skipped, unsafe link protocols are rejected, images remain
links, and retrieved post citations keep their deck navigation behavior.
`@shadcn/helpers/ai-sdk` provides deterministic streaming fixtures for UI tests.
References: [Codex provider](https://github.com/ben-vargas/ai-sdk-provider-codex-cli),
[AI SDK smoothing](https://ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream),
[Streamdown animation](https://streamdown.ai/docs/animation),
[shadcn AI SDK helpers](https://ui.shadcn.com/docs/helpers/ai-sdk).
+103
View File
@@ -0,0 +1,103 @@
# Shared storage and Mastodon OAuth
Personal Workspace runs as a single personal server behind Tailscale Serve. The
backend binds to loopback. The configured Tailscale identity is an outer access
check; a separate [owner login](login.md) is required in every deployment mode.
Browser requests that change state must have the configured Origin. OAuth
callbacks also pass the owner check; the browser must be able to reach the
tailnet HTTPS address after Mastodon authorization.
## Runtime configuration
| Variable | Value |
| ---------------------------------- | --------------------------------------------------------- |
| `TWITTER_LITE_ORIGIN` | Exact Serve HTTPS origin, without a trailing slash |
| `TWITTER_LITE_ALLOWED_LOGIN` | Owner's Tailscale login |
| `TWITTER_LITE_DB_PATH` | Absolute path to the SQLite database on local disk |
| `TWITTER_LITE_MASTODON_ORIGINS` | Comma-separated approved HTTPS instance origins |
| `TWITTER_LITE_CREDENTIAL_KEY_FILE` | Runtime file containing 32 random bytes encoded as base64 |
The credential key is required for Mastodon, but not for Twitter-only use.
Generate it once, keep it outside Git and the Nix store, and retain it when
updating the application. For example, with an existing private directory:
```sh
umask 077
nix develop -c node --input-type=module -e 'import {randomBytes} from "node:crypto"; import {writeFileSync} from "node:fs"; writeFileSync("/absolute/private/credential-key", randomBytes(32).toString("base64") + "\n", {flag: "wx", mode: 0o600})'
```
The command refuses to replace an existing file. Losing the key makes saved
SNS credentials unreadable. A separate protected backup of the key is needed
alongside database backups.
## Database and migrations
Drizzle ORM 0.45.3, Drizzle Kit 0.31.11 and better-sqlite3 13.0.3 are pinned.
The driver ships native prebuilds; dependency install scripts remain disabled.
Both the actual Nix Node runtime and the built Nix package have been exercised
with SQLite operations and the packaged backup command.
`drizzle/` contains generated SQL and metadata. `pnpm db:generate` generates
SQL and bundles it into TypeScript for the server. Commit both artifacts with
schema changes. The server applies pending migrations under an immediate
transaction before serving an authorized application request. It enables WAL,
foreign keys and a five-second busy timeout. Deployment does not depend on a
particular working directory or a separate migration command.
Deck definitions, ordered columns, connection metadata, OAuth applications
and short-lived OAuth attempts live in SQLite. Tokens, client secrets and
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.
## Backups and restore
Use the live SQLite backup API instead of copying only the main file while WAL
is active. The destination must be an absolute path that does not already
exist; backups are mode 0600 and pass a SQLite integrity check.
```sh
TWITTER_LITE_DB_PATH=/absolute/workspace.sqlite \
nix develop -c pnpm db:backup /absolute/backups/workspace-2026-09-24.sqlite
```
The Nix package exposes the same operation as `twitter-lite-backup`:
```sh
TWITTER_LITE_DB_PATH=/var/lib/twitter-lite/workspace.sqlite \
twitter-lite-backup /absolute/backups/workspace-2026-09-24.sqlite
```
Run it as an identity that can read the database and write the backup directory.
On NixOS the application uses `DynamicUser`, `StateDirectory=twitter-lite` and
mode 0700. The runtime key is passed with systemd `LoadCredential`.
For restore, stop the service first. Preserve the current state directory as
a separate recovery copy, then restore the verified backup as
`workspace.sqlite` in a clean state directory with the service's ownership and
permissions. Do not leave old `-wal` or `-shm` sidecars beside a restored main
database. Restore the matching credential key separately, then start the
service and verify decks and account access. Do not attempt to restore a newer
schema into an older application version.
## OAuth and instance support
Initial support targets Mastodon 4.3+ with PKCE S256. The first configured
instance, `https://fedi.yutakobayashi.com`, reported 4.5.3 and S256 on 2026-09-24.
Only configured HTTPS origins with public DNS addresses are accepted; server
requests pin the resolved address and refuse redirects.
Each instance/callback/scope combination has an OAuth application. Authorization
uses `read:accounts read:statuses read:lists read:search`, PKCE, a ten-minute
one-use state and an HttpOnly browser-binding cookie. Tokens are exchanged and
account identity is verified on the server. The callback URL never contains an
access token. Same-instance accounts remain separate; reconnecting preserves
the connection ID only for the same account.
An expired token marks its connection unavailable until reconnected. Disconnect
first revokes the token using the original OAuth application, then removes the
local credential while retaining the connection reference used by saved decks.
Search results depend on the instance's backend and indexing. A successful
empty response does not prove full-text search is enabled: Mastodon 4.5.3 also
returns empty status results when its search backend is disabled.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -12,10 +12,10 @@ visible conversation around it. Additional conversation pages load through the
same intentional infinite-scroll behavior as the existing user and search same intentional infinite-scroll behavior as the existing user and search
feeds. feeds.
This is an additive extension to This document originally extended `2026-07-13-twitter-lite-design.md` with the
`2026-07-13-twitter-lite-design.md`. Where that document lists only two routes post-detail route, now `/:handle/status/:tweetId`, and the narrowly scoped
and two server functions, this document adds one status route and one narrowly read-only server function, `loadThreadPosts`. The base design now incorporates
scoped read-only server function. both additions.
## Decisions ## Decisions
@@ -39,11 +39,13 @@ scoped read-only server function.
Add the typed route: Add the typed route:
```text ```text
/status/:tweetId /:handle/status/:tweetId
``` ```
`tweetId` is a non-empty decimal X post ID. Invalid IDs render a non-retryable Generated links use the focal post author's normalized `handle`, and `tweetId`
input error and make no relay request. is a non-empty decimal X post ID. Both path parameters are validated, while the
tweet lookup remains ID-based. Invalid parameters render a non-retryable input
error and make no relay request.
Every non-quoted post card gains an internal `詳細・スレッド` link in its Every non-quoted post card gains an internal `詳細・スレッド` link in its
footer. The existing external `元の投稿を開く` link remains available. The footer. The existing external `元の投稿を開く` link remains available. The
@@ -51,9 +53,9 @@ whole card is not clickable because cards can contain text links and media
controls. Quoted cards retain their current compact presentation. controls. Quoted cards retain their current compact presentation.
The status route uses the existing application shell. Neither `ユーザー` nor The status route uses the existing application shell. Neither `ユーザー` nor
`検索` receives `aria-current` on this third route. The status page adds no `検索` receives `aria-current` on the post-detail route. The status page adds
third global navigation tab; it is reachable only from a deliberate post link no global navigation tab; it is reachable only from a deliberate post link or
or a manually entered URL. a manually entered URL.
## Bird and Relay Boundary ## Bird and Relay Boundary
@@ -87,14 +89,14 @@ The server input has two valid states:
```ts ```ts
type InitialThreadInput = { type InitialThreadInput = {
tweetId: string tweetId: string;
} };
type ContinuedThreadInput = { type ContinuedThreadInput = {
tweetId: string tweetId: string;
conversationId: string conversationId: string;
cursor: string cursor: string;
} };
``` ```
All IDs must be decimal strings. Cursor values are opaque non-empty strings. All IDs must be decimal strings. Cursor values are opaque non-empty strings.
@@ -104,11 +106,11 @@ The successful page shape is:
```ts ```ts
type ThreadPage = { type ThreadPage = {
tweets: Post[] tweets: Post[];
focalPost?: Post focalPost?: Post;
conversationId: string conversationId: string;
nextCursor?: string nextCursor?: string;
} };
``` ```
`focalPost` is present only on the initial page. All returned posts pass through `focalPost` is present only on the initial page. All returned posts pass through
@@ -135,7 +137,7 @@ cannot rediscover the conversation root if the cursor page omits that reply.
1. Validate the supplied `tweetId`, `conversationId`, and cursor. 1. Validate the supplied `tweetId`, `conversationId`, and cursor.
2. Call `getThreadPaged(conversationId, { 2. Call `getThreadPaged(conversationId, {
cursor, maxPages: 1, pageDelayMs: 0 })` once. cursor, maxPages: 1, pageDelayMs: 0 })` once.
3. Return the sanitized tweets with the same conversation ID and new cursor. 3. Return the sanitized tweets with the same conversation ID and new cursor.
The original `tweetId` remains part of the query identity and validation The original `tweetId` remains part of the query identity and validation
@@ -147,7 +149,10 @@ continuation requests.
Extend the existing feed request union with: Extend the existing feed request union with:
```ts ```ts
{ kind: 'thread'; tweetId: string } {
kind: "thread";
tweetId: string;
}
``` ```
The thread query key includes the focal tweet ID. Its initial page parameter is The thread query key includes the focal tweet ID. Its initial page parameter is
@@ -10,14 +10,16 @@ Twitter Lite is a localhost-only reader for intentional access to X posts. It le
- enter a handle or profile URL and read that user's posts; - enter a handle or profile URL and read that user's posts;
- search posts in popular or chronological order; - search posts in popular or chronological order;
- optionally restrict search to accounts followed by the authenticated relay profile; and - optionally restrict search to accounts followed by the authenticated relay profile;
- follow a deliberate post link or manually enter a valid `/:handle/status/:tweetId` URL
to read its visible conversation; and
- continue through results with infinite scrolling. - continue through results with infinite scrolling.
The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It does not show content until the user enters a target or query. The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It shows content only after a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
## Product Principles ## Product Principles
1. **Intent before content.** The initial screen is empty. Every result begins with a typed handle, URL, or query. 1. **Intent before content.** The initial screen is empty. Every result begins with a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list. 2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list.
3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation. 3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation.
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist. 4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
@@ -33,6 +35,8 @@ The product reduces ambient discovery rather than limiting how far a deliberate
- raw X search syntax; - raw X search syntax;
- `Top` and `Latest` search products; - `Top` and `Latest` search products;
- `filter:follows` search filtering; - `filter:follows` search filtering;
- authenticated account list selection and list timelines;
- deliberate post detail pages with cursor-paginated visible conversations;
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird; - photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
- infinite cursor pagination; - infinite cursor pagination;
- explicit retry, empty, error, loading, and end states; - explicit retry, empty, error, loading, and end states;
@@ -42,39 +46,38 @@ The product reduces ambient discovery rather than limiting how far a deliberate
- authentication and multi-user accounts; - authentication and multi-user accounts;
- databases, cookies, saved preferences, viewing history, and analytics; - databases, cookies, saved preferences, viewing history, and analytics;
- home, notifications, trends, lists, bookmarks, and likes; - home, notifications, trends, bookmarks, and likes;
- all X mutation operations; - all X mutation operations;
- user autocomplete and typeahead; - user autocomplete and typeahead;
- People and Media search products; - People and Media search products;
- related-post feeds and automatic post discovery;
- a generic relay proxy; - a generic relay proxy;
- deployment beyond a personal localhost process. - deployment beyond a personal localhost process.
## Repositories and Delivery Sequence ## Repositories and Delivery Sequence
Work occurs in two repositories: Work spans two repositories and the Gitea package registry:
1. Clone Bird from `https://git.yutakobayashi.com/yuta/bird` into the sibling path `../bird`. 1. Extend Bird's public search interface in `https://git.yutakobayashi.com/yuta/bird`.
2. Extend Bird's public search interface and commit the change locally. 2. Release the compatible interface as `@yuta/[email protected]` in Gitea Packages.
3. Consume the local Bird checkout from Twitter Lite with the pnpm dependency `link:../bird`. 3. Route the `@yuta` scope through the committed `.npmrc` and pin Twitter Lite to `@yuta/[email protected]`.
4. Build and verify Twitter Lite against the linked Bird package. 4. Build and verify Twitter Lite against the published package without a sibling Bird checkout.
The Bird commit is not pushed and no package is published without separate user authorization. Once a compatible Bird version is published, replacing the local link with that version is a separate maintenance task.
## Bird Library Change ## Bird Library Change
Bird currently hard-codes `product: 'Latest'` in SearchTimeline requests. Add the following public contract: Bird currently hard-codes `product: 'Latest'` in SearchTimeline requests. Add the following public contract:
```ts ```ts
export type SearchProduct = 'Top' | 'Latest' export type SearchProduct = "Top" | "Latest";
export interface SearchFetchOptions { export interface SearchFetchOptions {
includeRaw?: boolean includeRaw?: boolean;
product?: SearchProduct product?: SearchProduct;
} }
export interface SearchPaginationOptions extends SearchFetchOptions { export interface SearchPaginationOptions extends SearchFetchOptions {
maxPages?: number maxPages?: number;
cursor?: string cursor?: string;
} }
``` ```
@@ -103,13 +106,16 @@ TanStack Start supplies the browser/server boundary. Bird is imported only by se
### Routes and URL State ### Routes and URL State
- `/user?target=<handle-or-url>` displays the User tab. - `/` displays the empty User tab without fetching posts.
- `/:handle` displays the User tab for the normalized handle.
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab. - `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
- `/` redirects to `/user` without a target. - `/i/lists` displays the authenticated account's lists.
- `/i/lists/:listId` displays one selected list timeline.
- `/:handle/status/:tweetId` displays one selected post and its visible conversation.
The target, query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting. The handle, list ID, and tweet ID are validated typed path parameters. The query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting.
Route preloading never fetches posts. Data retrieval begins only when a valid submitted target or query is present. Route preloading never fetches posts. Data retrieval begins only when a valid submitted handle or query is present, a list is deliberately selected, or a valid `/:handle/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
### Server Boundary ### Server Boundary
@@ -120,12 +126,12 @@ new TwitterClient({
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
profileName: process.env.BIRD_PROFILE_NAME, profileName: process.env.BIRD_PROFILE_NAME,
timeoutMs: 20_000, timeoutMs: 20_000,
}) });
``` ```
`TWITTER_RELAY_BASE_URL` is required. `BIRD_PROFILE_NAME` is optional but must be passed explicitly because the Bird library does not read that environment variable itself. `TWITTER_RELAY_BASE_URL` is required. `BIRD_PROFILE_NAME` is optional but must be passed explicitly because the Bird library does not read that environment variable itself.
Only two server functions are callable by the application: Only three server functions are callable by the application:
```ts ```ts
type PostPage = { type PostPage = {
@@ -144,9 +150,19 @@ searchPosts(input: {
following: boolean following: boolean
cursor?: string cursor?: string
}): Promise<PostPage> }): Promise<PostPage>
loadThreadPosts(input:
| { tweetId: string }
| { tweetId: string; conversationId: string; cursor: string }
): Promise<ThreadPage>
``` ```
Neither function exposes the Bird client, arbitrary method names, arbitrary relay URLs, or mutation operations. The status route and `loadThreadPosts` contract are specified in
`2026-07-13-tweet-detail-thread-design.md`. Conversation reads compose Bird's
existing `getTweet()` and `getThreadPaged()` methods; they add no Bird or relay
operation.
None of these functions exposes the Bird client, arbitrary method names, arbitrary relay URLs, or mutation operations.
### User Timeline Flow ### User Timeline Flow
@@ -159,10 +175,14 @@ Neither function exposes the Bird client, arbitrary method names, arbitrary rela
### Search Flow ### Search Flow
1. Trim and validate the raw query. 1. Keep the raw query and structured author, period, language, content, and
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present. exclusion controls in typed URL search parameters.
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`. 2. Compile selected controls into a deterministic advanced search expression
4. Return normalized tweets and the next cursor. using `from:`, `since:`, `until:`, `lang:`, and `filter:` operators.
3. Trim and validate the compiled query, including its 512-character limit.
4. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
5. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
6. Return normalized tweets and the next cursor.
Advanced X search grammar remains available because the raw query is otherwise passed unchanged. Advanced X search grammar remains available because the raw query is otherwise passed unchanged.
@@ -174,6 +194,10 @@ The first request fetches 20 posts. An IntersectionObserver sentinel near the li
Flattened pages are deduplicated by post ID while preserving API order. A failed later page leaves all previously loaded posts visible and presents one explicit Retry control. Requests do not retry automatically. Flattened pages are deduplicated by post ID while preserving API order. A failed later page leaves all previously loaded posts visible and presents one explicit Retry control. Requests do not retry automatically.
User and search pages preserve API order. Conversation pages preserve Bird's
current order: chronological within a fetched page, with cursor pages appended
in retrieval order.
## Interface Design ## Interface Design
### Layout ### Layout
@@ -187,9 +211,15 @@ The selected layout is **Focused Tabs**:
- no content on first load; - no content on first load;
- results directly below the submitted controls. - results directly below the submitted controls.
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button. The User tab contains one target input and a Display button. The Search tab
contains a query input; buttons that open inline author, period, language,
content-type, and exclusion controls; removable active-condition chips; a
collapsible compiled-query preview; a Popular/Latest segmented control; a
Following only checkbox; and a Search button.
Submitting updates the route's typed search parameters. Changing tabs does not preserve or surface the other tab's previous value. Submitting a user target normalizes it to a handle and navigates to `/:handle`.
Submitting a search updates the route's typed search parameters. Changing tabs
does not preserve or surface the other tab's previous value.
### Visual Direction ### Visual Direction
@@ -239,6 +269,8 @@ External links open in a new tab with `rel="noreferrer noopener"`. The app never
Server functions translate library failures into a small discriminated error model: Server functions translate library failures into a small discriminated error model:
- `invalid-input`; - `invalid-input`;
- `post-not-found`;
- `post-unavailable`;
- `user-not-found`; - `user-not-found`;
- `user-unavailable`; - `user-unavailable`;
- `relay-config`; - `relay-config`;
@@ -316,9 +348,13 @@ Bird's README and changelog document the search product option. No AGENTS.md or
The work is complete when: The work is complete when:
1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default. 1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default.
2. Twitter Lite shows no posts before an explicit user submission. 2. Twitter Lite shows no posts before an explicit user action: submitting a
target or query, following a post detail link, or manually opening a valid
status URL.
3. A valid handle or profile URL loads that user's posts. 3. A valid handle or profile URL loads that user's posts.
4. Search supports Top, Latest, and the follows filter. 4. Search supports Top, Latest, the follows filter, and button-driven author,
period, language, content-type, reply, and repost conditions while preserving
raw advanced search syntax.
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts. 5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
6. Relay credentials and Bird remain server-only. 6. Relay credentials and Bird remain server-only.
7. No excluded discovery or mutation feature is reachable. 7. No excluded discovery or mutation feature is reachable.
+23
View File
@@ -0,0 +1,23 @@
# Vitals reference preview
`/vitals` recreates the Body and Environment screenshots attached to
[Alan Shiflett’s post](https://x.com/AlanShiflett/status/2103524300158366183).
The shared sidebar opens it as Vitals. Body categories and history, environment
metric details, chart period/series selection, and simulated climate controls
are interactive. Other screenshot navigation entries open a preview explanation.
All scores, classifications, dates, readings, and connection indicators are
reference/demo values, not the user's health or device data. No clinical
assessment, data sync, or device command is performed. Obscured reference text
remains a visual placeholder, not inferred medical content.
`public/vitals/body-reference.jpg` is the post's second public image, retained
as the anatomy illustration source and clipped in CSS. Source media:
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
colors use `--color-vital-*` Tailwind theme tokens in `src/styles.css`.
Layout, responsive states, and chart styling use utilities in the components.
Sections, tabs, history, chart filters, and detail dialogs are URL state and
restore on reload or browser Back/Forward. Simulated climate control values
remain local; opening a URL never issues a device command.
+128
View File
@@ -0,0 +1,128 @@
# WebMCP prototype
## Registration
The deck workspace exposes nine React-owned tools on `/deck`.
`usewebmcp` handles native browser registration and cleanup. Tools become
available after saved decks load. Connection discovery may still be pending;
`list_connections` then returns `connections: null`.
Registration uses `document.modelContext`; there is no polyfill or external
MCP transport. Unsupported browsers retain the manual interface. All server
operations use the same access controls and persistence rules as the UI.
## Workspace tools
| Tool | Input | Behavior |
| ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials |
| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` |
| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` |
| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view |
| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged |
| `select_deck` | `deckId` | Activate a view and update the URL; other tabs remain unchanged |
| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices |
Read tools return the workspace's current client snapshot, not a fresh server
request. Saved definitions refresh on focus and while visible, except during
editing. Replacing or deleting a saved deck requires `expectedRevision` from
the definition being edited. A stale revision fails without overwriting the
server. Use the UI's reload action after a conflict when a fresh definition
is needed.
`set_deck` accepts at most six columns, each with a title, `connectionId` from
discovery, and a source. Connections must be connected and match the source
platform. Optional column IDs preserve identity; omitted IDs are generated.
Include every retained column: omitted columns are removed, and an empty array
clears the view. A supplied deck ID must already exist.
Twitter sources support `search`, `user`, and `list`. `platform` defaults to
`twitter`; search requires `query` and defaults `product` to `Latest` and
`following` to false. User/list targets accept handles or profile URLs and
numeric list IDs or list URLs, respectively.
Mastodon requires `platform: "mastodon"` and supports `search` (`query`),
`user` (handle or instance-local account ID), `list` (account-local numeric
list ID), and `hashtag` (tag without `#`). It does not accept Twitter ranking
or following controls. Search availability and coverage depend on the
instance; an empty successful result does not prove full-text support.
## Mutation results and persistence
`set_deck` returns `deck`, its actual `persisted` flag, and
`posts: "loading-asynchronously"`. A new view exists only in tab memory until
`save_deck` succeeds. Temporary views disappear on reload or navigation,
including OAuth redirects. Saving keeps the ID, allowing an identical create
request to be retried without duplicate decks. Failed saves keep temporary
content; rejected updates do not replace the accepted saved definition.
Successful mutations close unsaved editor forms. Deleting the active view
selects a remaining one; deleting the last opens an empty temporary view.
There is no tool-level deletion undo. Column requests may fail independently
after a definition is applied or saved. Existing browser decks are imported
only through the explicit UI import action, not through a tool side effect.
## Column tools
`get_column_posts` accepts `columnId`, `offset` (default 0, nonnegative integer),
and `limit` (default 20, integer 1–50). It reads already loaded posts without a
network request. Only columns mounted in the active view are available;
select the view and allow it to render first.
`load_more_column` accepts `columnId` and fetches or retries one continuation
using the column's connection. Wait for initial loading or refresh to finish.
Concurrent pagination joins the existing request. If the view or column
changes during pagination, execution fails rather than returning results under
the changed identity. At the end, no posts are appended and `hasMore` is false.
Both return:
- `column`: ID, title, connection ID, and source
- `status`: `loading`, `ready`, or `error`, with `loading` and `error` details
- `posts`: normalized records with original URLs, text, author, and available
media, quotes, content warnings, or boost information
- `loadedCount`, `offset`, and `nextOffset` for slices of deduplicated posts
- `hasMore`: whether the feed has an upstream continuation
Continuation returns up to 20 newly appended posts. Use `nextOffset` with
`get_column_posts` for additional already loaded records; use
`load_more_column` for another upstream page. These are cache snapshots, not
archived evidence.
## Errors and annotations
Results are JSON in an MCP text content block. Execution failures set
`isError: true` with `code`, `message`, and `retryable: false`. Schema and
connection-validation failures use `invalid-input`; other execution failures
use `tool-error`. Column snapshots expose upstream errors separately in their
`error` field. Empty successful queries are not errors.
`list_connections`, `list_decks`, `get_deck`, and `get_column_posts` have
`readOnlyHint: true`. `delete_deck` has `destructiveHint: true`; tools returning
external posts mark them untrusted. Annotations describe behavior and do not
grant authorization. No tool posts to or modifies an SNS account.
## Browser setup and verification
```sh
nix develop -c pnpm test
nix develop -c pnpm typecheck
nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts
```
E2E tests enable Chromium's `WebMCP` feature and invoke actual registered
tools through `document.modelContext.getTools()` and `executeTool()`. The pinned
Chromium accepts the registered tool object and JSON-serialized arguments.
Mock relay responses pass
through real server functions and isolated database state.
For interactive testing, enable `chrome://flags/#enable-webmcp-testing`,
restart Chrome, and use Model Context Tool Inspector. Reload after changing
browser support. Native WebMCP requires a secure context; use the configured
Tailscale Serve HTTPS origin for remote access. Development also requires
allowing its exact hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`.
The app's owner check still applies; direct localhost access does not supply
Serve identity. See [runtime access configuration](storage-and-oauth.md).
No production origin-trial token or external MCP-client bridge is configured.
Browser cancellation does not guarantee cancellation of a shared feed request.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/features/storage/schema.ts",
out: "./drizzle",
});
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE `connection_credentials` (
`connection_id` text PRIMARY KEY NOT NULL,
`encrypted_token` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `connections` (
`id` text PRIMARY KEY NOT NULL,
`platform` text NOT NULL,
`origin` text NOT NULL,
`account_id` text,
`relay_profile` text,
`display_name` text NOT NULL,
`status` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `connections_account` ON `connections` (`platform`,`origin`,`account_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `connections_relay_profile` ON `connections` (`origin`,`relay_profile`);--> statement-breakpoint
CREATE TABLE `deck_columns` (
`id` text NOT NULL,
`deck_id` text NOT NULL,
`position` integer NOT NULL,
`connection_id` text NOT NULL,
`title` text NOT NULL,
`source` text NOT NULL,
PRIMARY KEY(`deck_id`, `id`),
FOREIGN KEY (`deck_id`) REFERENCES `decks`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE restrict,
CONSTRAINT "deck_columns_valid_position" CHECK("deck_columns"."position" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `deck_columns_position` ON `deck_columns` (`deck_id`,`position`);--> statement-breakpoint
CREATE TABLE `decks` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`revision` integer DEFAULT 1 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT "decks_positive_revision" CHECK("decks"."revision" >= 1)
);
--> statement-breakpoint
CREATE TABLE `oauth_apps` (
`id` text PRIMARY KEY NOT NULL,
`origin` text NOT NULL,
`redirect_uri` text NOT NULL,
`scopes` text NOT NULL,
`client_id` text NOT NULL,
`encrypted_client_secret` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_apps_configuration` ON `oauth_apps` (`origin`,`redirect_uri`,`scopes`);--> statement-breakpoint
CREATE TABLE `oauth_attempts` (
`state_hash` text PRIMARY KEY NOT NULL,
`browser_hash` text NOT NULL,
`app_id` text NOT NULL,
`encrypted_verifier` text NOT NULL,
`connection_id` text,
`expires_at` integer NOT NULL,
`consumed_at` integer,
FOREIGN KEY (`app_id`) REFERENCES `oauth_apps`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade
);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id);
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE `legacy_imports` (
`id` text PRIMARY KEY NOT NULL,
`payload_hash` text NOT NULL,
`deck_ids` text NOT NULL,
`created_at` integer NOT NULL
);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `research_sessions` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`status` text NOT NULL,
`updated_at` integer NOT NULL,
`snapshot` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `research_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_session_id` text,
FOREIGN KEY (`active_session_id`) REFERENCES `research_sessions`(`id`) ON UPDATE no action ON DELETE set null,
CONSTRAINT "research_state_singleton" CHECK("research_state"."id" = 1)
);
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE `auth_sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`owner_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `auth_throttle` (
`id` integer PRIMARY KEY NOT NULL,
`attempts` integer NOT NULL,
`reset_at` integer NOT NULL,
CONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1)
);
--> statement-breakpoint
CREATE TABLE `workspace_owner` (
`id` integer PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`name` text NOT NULL,
`password_hash` text NOT NULL,
`onboarding_completed_at` integer,
`created_at` integer NOT NULL,
CONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1)
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE `research_state`;
+424
View File
@@ -0,0 +1,424 @@
{
"version": "6",
"dialect": "sqlite",
"id": "42951f9a-6025-4e73-9155-764d25514ba8",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"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
},
"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"
}
},
"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"
}
}
},
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+440
View File
@@ -0,0 +1,440 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1455820b-8aec-4fcb-9790-c7d9f1fd908c",
"prevId": "42951f9a-6025-4e73-9155-764d25514ba8",
"tables": {
"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"
}
}
},
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+478
View File
@@ -0,0 +1,478 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ebfb67e2-a014-4d40-89e3-30c9b55cf047",
"prevId": "1455820b-8aec-4fcb-9790-c7d9f1fd908c",
"tables": {
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+562
View File
@@ -0,0 +1,562 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d021bc5b-2422-467a-ab92-2493da6e642a",
"prevId": "ebfb67e2-a014-4d40-89e3-30c9b55cf047",
"tables": {
"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": {}
},
"research_state": {
"name": "research_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"research_state_active_session_id_research_sessions_id_fk": {
"name": "research_state_active_session_id_research_sessions_id_fk",
"tableFrom": "research_state",
"tableTo": "research_sessions",
"columnsFrom": ["active_session_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"research_state_singleton": {
"name": "research_state_singleton",
"value": "\"research_state\".\"id\" = 1"
}
}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+696
View File
@@ -0,0 +1,696 @@
{
"version": "6",
"dialect": "sqlite",
"id": "52f68a9d-b9da-4739-9635-b0b5e3e8040b",
"prevId": "d021bc5b-2422-467a-ab92-2493da6e642a",
"tables": {
"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": {}
},
"research_state": {
"name": "research_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"research_state_active_session_id_research_sessions_id_fk": {
"name": "research_state_active_session_id_research_sessions_id_fk",
"tableFrom": "research_state",
"tableTo": "research_sessions",
"columnsFrom": ["active_session_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"research_state_singleton": {
"name": "research_state_singleton",
"value": "\"research_state\".\"id\" = 1"
}
}
},
"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": {}
}
}
+657
View File
@@ -0,0 +1,657 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7a44ef34-89ad-4ab4-829d-f6669bd72471",
"prevId": "52f68a9d-b9da-4739-9635-b0b5e3e8040b",
"tables": {
"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": {}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1790234301369,
"tag": "0000_aspiring_bloodstorm",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1790234518415,
"tag": "0001_many_fat_cobra",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1790234736939,
"tag": "0002_fine_master_chief",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1790242505709,
"tag": "0003_romantic_scrambler",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1790589494499,
"tag": "0004_good_natasha_romanoff",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1790591720807,
"tag": "0005_salty_madripoor",
"breakpoints": true
}
]
}
+144
View File
@@ -0,0 +1,144 @@
import { basename, dirname, isAbsolute } from "node:path";
import AxeBuilder from "@axe-core/playwright";
import { test as base } from "@playwright/test";
import {
completeOnboarding,
createOwner,
readAuthState,
signIn,
} from "../src/features/auth/auth.server";
import { openDatabase } from "../src/features/storage/database.server";
import { WorkspacePage } from "./models/WorkspacePage";
import { AuthPage } from "./models/AuthPage";
import { OnboardingPage } from "./models/OnboardingPage";
import { ConnectedAccountsDialog } from "./models/ConnectedAccountsDialog";
import { HomePage } from "./models/HomePage";
import { MessagesPage } from "./models/MessagesPage";
import { JournalPage } from "./models/JournalPage";
import { ReaderPage } from "./models/ReaderPage";
import { VitalsPage } from "./models/VitalsPage";
import { DeckPage } from "./models/DeckPage";
import { ResearchPage } from "./models/ResearchPage";
export const test = base.extend<{
workspacePage: WorkspacePage;
authPage: AuthPage;
onboardingPage: OnboardingPage;
connectedAccounts: ConnectedAccountsDialog;
homePage: HomePage;
messagesPage: MessagesPage;
journalPage: JournalPage;
readerPage: ReaderPage;
vitalsPage: VitalsPage;
deckPage: DeckPage;
researchPage: ResearchPage;
a11y: () => AxeBuilder;
resetDecks: undefined;
sessionToken: string;
}>({
workspacePage: async ({ page }, use) => {
await use(new WorkspacePage(page));
},
authPage: async ({ page }, use) => {
await use(new AuthPage(page));
},
onboardingPage: async ({ page }, use) => {
await use(new OnboardingPage(page));
},
connectedAccounts: async ({ page }, use) => {
await use(new ConnectedAccountsDialog(page));
},
homePage: async ({ page }, use) => {
await use(new HomePage(page));
},
messagesPage: async ({ page }, use) => {
await use(new MessagesPage(page));
},
journalPage: async ({ page }, use) => {
await use(new JournalPage(page));
},
readerPage: async ({ page }, use) => {
await use(new ReaderPage(page));
},
vitalsPage: async ({ page }, use) => {
await use(new VitalsPage(page));
},
deckPage: async ({ page }, use) => {
await use(new DeckPage(page));
},
researchPage: async ({ page }, use) => {
await use(new ResearchPage(page));
},
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
sessionToken: async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (!path || !basename(dirname(path)).startsWith("twitter-lite-e2e-"))
throw new Error("Isolated E2E database is required.");
const database = openDatabase(path);
const credentials = {
email: "[email protected]",
password: "E2E-only-passphrase-2026",
};
process.env.WORKSPACE_SETUP_TOKEN = "isolated-e2e-setup-token-not-for-production";
const result = readAuthState(database).needsSetup
? await createOwner(
{
...credentials,
name: "Yuta",
setupToken: process.env.WORKSPACE_SETUP_TOKEN,
},
database,
)
: await signIn(credentials, database);
if (!result.owner.onboardingCompletedAt)
completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
database.$client.close();
await use(result.sessionToken);
},
storageState: async ({ sessionToken }, use) => {
await use({
cookies: [
{
name: "workspace_session",
value: sessionToken,
domain: "127.0.0.1",
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true,
secure: false,
sameSite: "Lax",
},
],
origins: [],
});
},
resetDecks: [
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
async ({}, use) => {
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).$client;
database.pragma("foreign_keys = ON");
database.exec("DELETE FROM decks; DELETE FROM legacy_imports;");
database.close();
await use(undefined);
},
{ auto: true },
],
a11y: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.disableRules(["meta-viewport"]),
);
},
});
export { expect } from "@playwright/test";
+31
View File
@@ -0,0 +1,31 @@
import type { TweetLegacy } from "../generated/twitter-openapi/client.ts";
interface TweetLegacyFixtureOptions {
conversationId?: string;
inReplyTo?: string;
replyCount?: number;
}
export const tweetLegacyFixture = (
id: string,
text: string,
{ conversationId = id, inReplyTo, replyCount = 1 }: TweetLegacyFixtureOptions = {},
): TweetLegacy => ({
bookmark_count: 0,
bookmarked: false,
conversation_id_str: conversationId,
created_at: "Mon Jul 13 00:00:00 +0000 2026",
display_text_range: [0, text.length],
favorite_count: 3,
favorited: false,
full_text: text,
id_str: id,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
is_quote_status: false,
lang: "ja",
quote_count: 0,
reply_count: replyCount,
retweet_count: 2,
retweeted: false,
user_id_str: "42",
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from "../fixtures";
test("owner can open the app but missing or foreign identity is forbidden", async ({ request }) => {
expect((await request.get("/")).status()).toBe(200);
expect((await request.get("/", { headers: { "Tailscale-User-Login": "" } })).status()).toBe(403);
expect(
(
await request.get("/", {
headers: { "Tailscale-User-Login": "[email protected]" },
})
).status(),
).toBe(403);
});
test("mutations require exact Origin even with same-origin Fetch Metadata", async ({ request }) => {
expect((await request.post("/")).status()).toBe(403);
expect(
(
await request.post("/", {
headers: {
Origin: "https://other.invalid",
"Sec-Fetch-Site": "same-origin",
},
})
).status(),
).toBe(403);
});
test("server functions also reject foreign identity and cross-site calls", async ({
page,
deckPage,
request,
}) => {
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await deckPage.goTo();
const url = (await serverRequest).url();
expect(
(
await request.get(url, {
headers: {
"Tailscale-User-Login": "[email protected]",
"Sec-Fetch-Site": "same-origin",
},
})
).status(),
).toBe(403);
expect(
(
await request.get(url, {
headers: { "Sec-Fetch-Site": "cross-site" },
})
).status(),
).toBe(403);
});
+149
View File
@@ -0,0 +1,149 @@
import { openDatabase } from "../../src/features/storage/database.server";
import { expect, test } from "../fixtures";
test("creates the owner once and enters onboarding with a real session", async ({
page,
context,
a11y,
authPage,
onboardingPage,
homePage,
}) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client.exec("DELETE FROM workspace_owner; DELETE FROM auth_throttle;");
db.$client.close();
await context.clearCookies();
await homePage.goTo();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/\/setup$/);
expect((await a11y().analyze()).violations).toEqual([]);
await authPage.createAccount({
name: "Yuta",
email: "[email protected]",
password: "E2E-only-passphrase-2026",
setupCode: "isolated-e2e-setup-token-not-for-production",
});
await expect(page).toHaveURL(/\/onboarding$/);
await page.waitForLoadState("networkidle");
await onboardingPage.complete();
await expect(homePage.greeting("Yuta")).toBeVisible();
});
test("requires login for documents, server functions, and live updates", async ({
page,
context,
authPage,
workspacePage,
deckPage,
journalPage,
baseURL,
}) => {
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await deckPage.goTo();
const serverUrl = (await serverRequest).url();
await context.clearCookies();
for (const path of ["/api/research/events", serverUrl]) {
const result = await context.request.get(path, {
headers: { Origin: new URL("/", baseURL).origin, "Sec-Fetch-Site": "same-origin" },
});
expect(result.status()).toBe(401);
}
await journalPage.goTo();
await expect(page).toHaveURL(/\/login(?:\?|$)/);
await expect(authPage.welcome).toBeVisible();
await expect(workspacePage.navigationLink("Journal")).toHaveCount(0);
});
test("signs in and revokes the session on sign out", async ({
page,
context,
a11y,
authPage,
workspacePage,
connectedAccounts,
}) => {
await context.clearCookies();
await authPage.goTo();
expect((await a11y().analyze()).violations).toEqual([]);
await authPage.signIn("[email protected]", "Wrong-passphrase-2026");
await expect(authPage.error).toContainText("Invalid email or password.");
await authPage.password.fill("E2E-only-passphrase-2026");
await authPage.signInButton.click();
await expect(page).toHaveURL("/");
await page.waitForLoadState("networkidle");
const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session");
expect(token?.httpOnly).toBe(true);
expect(token?.sameSite).toBe("Lax");
await connectedAccounts.open();
await connectedAccounts.signOut.click();
await expect(page).toHaveURL(/\/login(?:\?|$)/);
const replay = await context.request.get("/api/auth", {
headers: { Cookie: `workspace_session=${token?.value}` },
});
expect((await replay.json()).owner).toBeNull();
await page.goBack();
await expect(workspacePage.navigationLink("Home")).toHaveCount(0);
});
test("requires onboarding and remembers its completion and name", async ({
page,
request,
a11y,
onboardingPage,
homePage,
}) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client
.prepare("UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1")
.run();
try {
expect((await request.get("/api/research/events")).status()).toBe(403);
await homePage.goTo();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/\/onboarding$/);
expect((await a11y().analyze()).violations).toEqual([]);
await onboardingPage.name.fill("Yuta Test");
await onboardingPage.complete();
await expect(homePage.greeting("Yuta Test")).toBeVisible();
await page.reload();
await expect(homePage.greeting("Yuta Test")).toBeVisible();
await onboardingPage.goTo();
await expect(page).toHaveURL("/");
} finally {
db.$client
.prepare("UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1")
.run("Yuta", Date.now());
db.$client.close();
}
});
test("rejects cross-origin login and further account registration", async ({
request,
page,
context,
authPage,
baseURL,
}) => {
expect(
(
await request.post("/api/auth", {
headers: { Origin: "https://other.invalid" },
data: { action: "logout" },
})
).status(),
).toBe(403);
const result = await request.post("/api/auth", {
headers: { Origin: new URL("/", baseURL).origin },
data: {
action: "setup",
email: "[email protected]",
name: "Intruder",
password: "Long-enough-password",
setupToken: "arbitrary-token",
},
});
expect(result.status()).toBe(409);
await context.clearCookies();
await authPage.goTo("setup");
await expect(page).toHaveURL(/\/login(?:\?|$)/);
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "../fixtures";
test("opens account management after hydration without inheriting real OAuth configuration", async ({
deckPage,
workspacePage,
connectedAccounts,
}) => {
await deckPage.goTo();
await expect(workspacePage.researchReady).toBeEnabled();
await workspacePage.ensureSidebarOpen();
await expect(workspacePage.manageAccounts).toBeEnabled();
await connectedAccounts.open();
await expect(connectedAccounts.dialog).toBeVisible();
await expect(connectedAccounts.unconfiguredMastodon).toBeVisible();
await expect(connectedAccounts.authorizeMastodon).toHaveCount(0);
await connectedAccounts.close();
await expect(connectedAccounts.dialog).toHaveCount(0);
await expect(workspacePage.manageAccounts).toBeFocused();
});
test("opens account management from the inbox footer and restores focus on close", async ({
readerPage,
workspacePage,
connectedAccounts,
a11y,
}) => {
await readerPage.goTo();
await expect(workspacePage.manageAccounts).toBeVisible();
await connectedAccounts.open();
await expect(connectedAccounts.dialog).toBeVisible();
await expect(connectedAccounts.unconfiguredMastodon).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await connectedAccounts.close();
await expect(connectedAccounts.dialog).toHaveCount(0);
await expect(workspacePage.manageAccounts).toBeFocused();
await expect(readerPage.searchInput).toBeVisible();
});
test("rejects an unbound OAuth callback without contacting an instance", async ({
page,
deckPage,
workspacePage,
}) => {
await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code");
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/);
await expect(deckPage.alert).toContainText("Could not complete Mastodon authorization.");
await expect(workspacePage.researchReady).toBeEnabled();
await workspacePage.ensureSidebarOpen();
await expect(workspacePage.manageAccounts).toBeEnabled();
});
+308
View File
@@ -0,0 +1,308 @@
import { expect, test } from "../fixtures";
import { DeckPage } from "../models/DeckPage";
test.beforeEach(async ({ deckPage, workspacePage }) => {
await deckPage.goTo();
await expect(deckPage.saveAsDeck).toBeEnabled();
await deckPage.saveAsDeck.click();
await workspacePage.showDeckManagement();
await expect(deckPage.savedNotice).toBeVisible();
await workspacePage.closeMobileSidebar();
});
test("binds identical searches to independent profiles through paging, editing and reload", async ({
page,
deckPage,
}) => {
await deckPage.addColumn("通常の接続", "same-query");
await deckPage.addColumn("別の接続", "same-query", "alternate");
await expect(deckPage.columnPostText("通常の接続", "Latest · all page 1", true)).toBeVisible();
await expect(
deckPage.columnPostText("別の接続", "alternate · Latest · all page 1", true),
).toBeVisible();
await deckPage.loadMore("通常の接続");
await expect(deckPage.columnPosts("通常の接続")).toHaveCount(2);
await expect(deckPage.columnPosts("別の接続")).toHaveCount(1);
await deckPage.loadMore("別の接続");
await expect(
deckPage.columnPostText("別の接続", "alternate · Latest · all page 2"),
).toBeVisible();
await deckPage.openColumnActions("別の接続");
await deckPage.editColumn.click();
await deckPage.editorProfile.selectOption({ label: "e2e" });
await deckPage.saveChanges.click();
await expect(deckPage.columnPostText("別の接続", "Latest · all page 1", true)).toBeVisible();
await expect(deckPage.columnPostText("別の接続", "alternate · Latest · all page 1")).toHaveCount(
0,
);
await page.reload();
await expect(deckPage.columnPostText("別の接続", "Latest · all page 1", true)).toBeVisible();
await expect(deckPage.columnPosts("通常の接続")).toHaveCount(1);
});
test("creates, switches, renames and deletes persisted deck profiles", async ({
page,
deckPage,
workspacePage,
}) => {
await deckPage.rename.click();
await deckPage.deckName.fill("最初の調査");
await deckPage.saveName.click();
await deckPage.addColumn("最初の観点", "first-deck");
await workspacePage.showDeckManagement();
await deckPage.createDeck.click();
await deckPage.newDeckName.fill("別の調査");
await deckPage.confirmCreate.click();
await expect(deckPage.columns).toHaveCount(0);
await deckPage.addColumn("別の観点", "second-deck", "alternate");
await workspacePage.showDeckManagement();
await deckPage.deckProfile.selectOption({ label: "最初の調査" });
await workspacePage.closeMobileSidebar();
await expect(deckPage.columnTitles).toHaveText(["最初の観点"]);
await page.reload();
await expect(deckPage.heading).toHaveText("最初の調査");
await workspacePage.showDeckManagement();
await deckPage.deckProfile.selectOption({ label: "別の調査" });
await workspacePage.closeMobileSidebar();
await expect(deckPage.columnTitles).toHaveText(["別の観点"]);
await workspacePage.showDeckManagement();
await deckPage.deleteDeck.click();
await deckPage.confirmDelete.click();
await page.reload();
await expect(deckPage.heading).toHaveText("最初の調査");
await workspacePage.showDeckManagement();
await expect(deckPage.profileOptions).toHaveCount(1);
});
test("reads user and list timelines within columns and reorders, deletes and restores them", async ({
page,
deckPage,
}) => {
await deckPage.addColumn("アカウント", "yuta", "alternate", "user");
await deckPage.addColumn("リストの投稿", "1234567890", "e2e", "list");
await expect(deckPage.columnPostText("アカウント", "alternate profile page 1")).toBeVisible();
await expect(deckPage.columnPostText("リストの投稿", "list page 1")).toBeVisible();
await deckPage.loadMore("リストの投稿");
await expect(deckPage.columnPostText("リストの投稿", "list page 2")).toBeVisible();
await deckPage.openColumnActions("リストの投稿");
await deckPage.columnAction("Move リストの投稿 left").click();
await expect(deckPage.columnTitles).toHaveText(["リストの投稿", "アカウント"]);
await deckPage.openColumnActions("リストの投稿");
await deckPage.columnAction("Delete リストの投稿").click();
await deckPage.undo.click();
await expect(deckPage.columnTitles).toHaveText(["リストの投稿", "アカウント"]);
await page.reload();
await expect(deckPage.columnPostText("アカウント", "alternate profile page 1")).toBeVisible();
await expect(deckPage.columnPostText("リストの投稿", "list page 1")).toBeVisible();
});
test("keeps other columns usable when pagination fails and retries that column", async ({
deckPage,
}, testInfo) => {
await deckPage.addColumn("失敗する検索", `retry-deck-${testInfo.project.name}`);
await deckPage.addColumn("別の検索", "independent-deck");
await deckPage.loadMore("失敗する検索");
await expect(deckPage.columnError("失敗する検索")).toBeVisible();
await expect(deckPage.columnPosts("失敗する検索")).toHaveCount(1);
await deckPage.loadMore("別の検索");
await expect(deckPage.columnPosts("別の検索")).toHaveCount(2);
await deckPage.retryColumn("失敗する検索");
await expect(deckPage.columnPosts("失敗する検索")).toHaveCount(2);
});
test("keeps the deck and editor accessible within narrow viewports", async ({
page,
deckPage,
workspacePage,
a11y,
}, testInfo) => {
await deckPage.addColumn("日本語の反応", "WebMCP lang:ja");
await deckPage.addColumn("海外の反応", "WebMCP lang:en", "alternate");
await deckPage.addColumn("公式アカウント", "OpenAI", "e2e", "user");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 });
expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe(
false,
);
expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
true,
);
const board = await deckPage.board.boundingBox();
expect(board).not.toBeNull();
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
}
await page.setViewportSize({
width: testInfo.project.name === "desktop" ? 1440 : 393,
height: 900,
});
await expect(deckPage.columnNavigation).toHaveCount(0);
await expect(deckPage.columnHeading("公式アカウント")).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({
path: testInfo.outputPath("deck-populated.png"),
fullPage: true,
});
await workspacePage.showDeckManagement();
await deckPage.addColumnButton.click();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({
path: testInfo.outputPath("deck.png"),
fullPage: true,
});
});
test("closes the column editor with Escape and restores focus without applying edits", async ({
page,
deckPage,
workspacePage,
}) => {
await workspacePage.showDeckManagement();
const opener = deckPage.addColumnButton;
await opener.click();
await expect(deckPage.columnDialog).toBeVisible();
await deckPage.columnName.fill("キャンセルする観点");
await page.keyboard.press("Escape");
await expect(deckPage.columnDialog).toHaveCount(0);
await expect(opener).toBeFocused();
await expect(deckPage.columns).toHaveCount(0);
await opener.click();
await expect(deckPage.columnName).toHaveValue("");
});
test("does not overwrite unreadable storage on reload", async ({ page, deckPage }) => {
await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken"));
await page.reload();
await expect(deckPage.alert).toContainText("Unable to load");
expect(await page.evaluate(() => localStorage.getItem("twitter-lite-research-deck"))).toBe(
"{broken",
);
});
test("selects a list from the column profile catalog and refreshes it on profile change", async ({
deckPage,
workspacePage,
}) => {
await workspacePage.showDeckManagement();
await deckPage.addColumnButton.click();
await deckPage.columnName.fill("購読リスト");
await deckPage.editorProfile.selectOption({ label: "e2e" });
await deckPage.columnType.selectOption("list");
await expect(deckPage.primaryList).toBeVisible();
await deckPage.editorProfile.selectOption({ label: "alternate" });
await expect(deckPage.primaryList).toHaveCount(0);
await deckPage.alternateList.click();
await expect(deckPage.listId).toHaveValue("1234567890");
await deckPage.addAndSearch.click();
await workspacePage.closeMobileSidebar();
await expect(deckPage.columnPostText("購読リスト", "list page 1", true)).toBeVisible();
});
test("keeps an open draft through remote edits and rejects its stale save", async ({
page,
deckPage,
browser,
baseURL,
}) => {
const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL,
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const device = await other.newPage();
const deviceDeck = new DeckPage(device);
await deviceDeck.goTo();
await deckPage.rename.click();
await deckPage.deckName.fill("編集中の名前");
await deviceDeck.rename.click();
await deviceDeck.deckName.fill("別の端末の変更");
await deviceDeck.saveName.click();
await expect(deviceDeck.heading).toHaveText("別の端末の変更");
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(deckPage.deckName).toHaveValue("編集中の名前");
await deckPage.saveName.click();
await expect(deckPage.alert).toContainText("This deck changed on another device");
await expect(deckPage.deckName).toHaveValue("編集中の名前");
await page.keyboard.press("Escape");
await deckPage.reloadLatest.click();
await expect(deckPage.heading).toHaveText("別の端末の変更");
} finally {
await other.close();
}
});
test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({
page,
deckPage,
workspacePage,
}) => {
const legacy = {
version: 2,
activeDeckId: "old",
decks: [
{
id: "old",
title: "以前の調査",
columns: [
{
id: "a",
title: "以前の観点",
profileName: "e2e",
source: {
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
],
},
],
};
await page.evaluate(
(legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
legacy,
);
await page.reload();
await workspacePage.showDeckManagement();
const selector = deckPage.deckProfile;
await expect(deckPage.profileOptions).toHaveCount(1);
await workspacePage.closeMobileSidebar();
await deckPage.importLegacy.click();
await workspacePage.showDeckManagement();
await expect(deckPage.profileOptions).toHaveCount(2);
await selector.selectOption({ label: "以前の調査" });
await workspacePage.closeMobileSidebar();
await expect(deckPage.postText("Latest · all page 1")).toBeVisible();
await page.evaluate(
(legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
legacy,
);
await page.reload();
await deckPage.importLegacy.click();
await expect(deckPage.importLegacy).toHaveCount(0);
await workspacePage.showDeckManagement();
await expect(deckPage.profileOptions).toHaveCount(2);
});
test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({
page,
deckPage,
workspacePage,
}) => {
await workspacePage.showDeckManagement();
await deckPage.createDeck.click();
const dialog = deckPage.createDialog;
await deckPage.newDeckName.fill("画面を回転しても残る調査");
for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 });
await expect(dialog).toHaveCount(1);
await expect(dialog).toBeVisible();
await expect(deckPage.newDeckName).toHaveValue("画面を回転しても残る調査");
await expect(deckPage.newDeckTitleInput).toHaveCount(1);
}
await deckPage.confirmCreate.click();
await expect(dialog).toHaveCount(0);
await expect(deckPage.heading).toHaveText("画面を回転しても残る調査");
});
+187
View File
@@ -0,0 +1,187 @@
import { expect, test } from "../fixtures";
test("executes routine steps and records a measurement in the shared journal", async ({
page,
a11y,
homePage,
journalPage,
workspacePage,
}, testInfo) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
await homePage.morningRoutine.click();
await expect(page).toHaveURL(/activity=task/);
await homePage.routineStep(/Drink water/).check();
await expect(homePage.finishRoutine).toBeDisabled();
await homePage.routineStep(/Get ready/).check();
await homePage.routineStep(/Have breakfast/).check();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true });
await homePage.finishRoutine.click();
await homePage.recordMeasurement("65.2");
await expect(homePage.recordedWeight("65.2")).toBeVisible();
await workspacePage.navigateTo("Journal");
await expect(journalPage.weightEntries).toContainText("65.2 kg");
await workspacePage.navigateTo("Home");
await expect(homePage.restoreTask("Morning routine")).toBeVisible();
});
test("shows the day flow and conversation context at desktop width", async ({
page,
homePage,
messagesPage,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1080 });
await homePage.goTo();
await page.waitForLoadState("networkidle");
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 page.screenshot({ path: testInfo.outputPath("messages-desktop.png") });
});
test("previews a natural-language memo and shares the executed record with journal", async ({
page,
homePage,
journalPage,
workspacePage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const memo = "明日のレビューは導線から確認する";
await homePage.previewCommand(`Note: ${memo}`);
const execute = homePage.applyAction;
await expect(execute).toBeVisible();
await execute.click();
await workspacePage.navigateTo("Journal");
await expect(journalPage.noteEntries).toContainText(memo);
await workspacePage.navigateTo("Home");
await expect(homePage.input).toBeVisible();
});
test("keeps the home usable on narrow screens", async ({ page, a11y, homePage }, testInfo) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await expect(homePage.input).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) });
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
}
});
test("adds tasks only after confirmation and can defer and restore them", async ({
page,
homePage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const complete = homePage.completeTask("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 complete.click();
await expect(complete).toHaveCount(0);
await expect(homePage.restoreTask("grocery shopping")).toBeVisible();
});
test("opens a conversation page from home and retains outcomes on return", async ({
page,
a11y,
homePage,
messagesPage,
workspacePage,
}) => {
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();
expect((await a11y().analyze()).violations).toEqual([]);
await page.goBack();
await expect(conversationLink).toBeVisible();
await page.goForward();
await expect(messagesPage.reply).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 messagesPage.showConversationList();
await workspacePage.navigateTo("Home");
await expect(conversationLink).toBeVisible();
});
test("updates the prose brief as priorities change and opens the referenced conversation", async ({
page,
homePage,
messagesPage,
workspacePage,
}) => {
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 workspacePage.navigateTo("Home");
await expect(brief).toContainText("2 conversations");
await expect(brief).not.toContainText("Haruka Aoki");
await homePage.previewCommand("再開地点:資料の2ページ目から");
await homePage.applyAction.click();
await expect(brief).toContainText("資料の2ページ目から");
});
test("keeps completed actions in their original positions with visible outcomes", async ({
page,
a11y,
homePage,
messagesPage,
workspacePage,
}, 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 homePage.recordMeasurement("65.2");
await expect(homePage.recordedWeight("65.2")).toBeVisible();
expect(await titles.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",
);
});
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "../fixtures";
test("selects articles and keeps the reader focused on their content", async ({ readerPage }) => {
await readerPage.goTo();
await expect(readerPage.rows).toHaveCount(9);
const articleTitle = "Context for an agent can be brief";
await readerPage.selectArticle(articleTitle);
const reader = readerPage.article(articleTitle);
await expect(reader).toBeVisible();
await expect(readerPage.articleHeading(articleTitle)).toBeVisible();
await expect(
readerPage.articleText(
articleTitle,
"Start with a one-sentence goal, then add the constraints that must hold.",
),
).toBeVisible();
await expect(readerPage.forYouLabel).toHaveCount(0);
await expect(readerPage.mockLabel).toHaveCount(0);
await expect(readerPage.articleExplanation).toHaveCount(0);
await expect(readerPage.legacyFilters).toHaveCount(0);
await readerPage.backToArticles();
await readerPage.selectArticle("Three columns create a rhythm for reading");
await expect(readerPage.article("Three columns create a rhythm for reading")).toBeVisible();
});
test("recovers from empty search and shares navigation with Research", async ({
page,
readerPage,
workspacePage,
a11y,
}) => {
await readerPage.goTo();
await readerPage.search("nonexistent-query-xyz");
await expect(readerPage.rows).toHaveCount(0);
await expect(readerPage.emptyState).toBeVisible();
await readerPage.clearSearch();
await expect(readerPage.rows).toHaveCount(9);
expect((await a11y().analyze()).violations).toEqual([]);
await expect(readerPage.editInterestsButton).toHaveCount(0);
await workspacePage.navigateTo("Research");
await expect(page).toHaveURL(/\/deck$/);
await workspacePage.navigateTo("Reader");
await expect(page).toHaveURL(/\/inbox$/);
await expect(readerPage.rows).toHaveCount(9);
});
test("keeps article navigation readable without horizontal overflow", async ({
page,
readerPage,
a11y,
}, testInfo) => {
await readerPage.goTo();
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await expect(readerPage.searchInput).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await readerPage.openFirstArticle();
await expect(
readerPage.article("Thinking in the browser: a new workflow with AI agents"),
).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`inbox-reader-${width}.png`) });
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
await readerPage.backToArticles();
}
});
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from "../fixtures";
test("HTTP MCP persists decks visible in Research without browser credentials", async ({
playwright,
baseURL,
page,
deckPage,
}) => {
const client = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: {},
storageState: { cookies: [], origins: [] },
});
async function rpc(method: string, params: unknown = {}) {
const response = await client.post("/mcp", {
headers: { Accept: "application/json, text/event-stream" },
data: { jsonrpc: "2.0", id: 1, method, params },
});
expect(response.status()).toBe(200);
const text = await response.text();
const data = text.startsWith("event:")
? text
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6)
: text;
return JSON.parse(data ?? "null").result;
}
async function tool(name: string, args: unknown = {}) {
const result = await rpc("tools/call", { name, arguments: args });
return JSON.parse(result.content[0].text);
}
try {
// Browser routes still require their existing owner identity and session.
expect((await client.get("/deck", { maxRedirects: 0 })).status()).toBe(403);
const initialized = await rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "e2e", version: "1" },
});
expect(initialized.serverInfo.name).toBe("personal-workspace");
const { connections } = await tool("list_connections");
const connection = connections.find(
(entry: { displayName: string }) => entry.displayName === "e2e",
);
const created = await tool("create_deck", {
title: "HTTP MCP research",
columns: [
{
title: "HTTP posts",
connectionId: connection.id,
source: { kind: "search", query: "WebMCP" },
},
],
});
expect(created.ok).toBe(true);
const fetched = await tool("fetch_column_posts", {
deckId: created.deck.id,
columnId: created.deck.columns[0].id,
});
expect(fetched.ok).toBe(true);
expect(fetched.posts.length).toBeGreaterThan(0);
await deckPage.goTo(`?deck=${created.deck.id}`);
await expect(deckPage.heading).toHaveText("HTTP MCP research");
const updated = await tool("replace_deck", {
deckId: created.deck.id,
expectedRevision: 1,
title: "Updated via HTTP",
columns: created.deck.columns,
});
expect(updated.deck.revision).toBe(2);
expect(
await tool("delete_deck", { deckId: created.deck.id, expectedRevision: 1 }),
).toMatchObject({ ok: false, error: { code: "conflict" } });
await page.reload();
await expect(deckPage.heading).toHaveText("Updated via HTTP");
expect(
await tool("delete_deck", { deckId: created.deck.id, expectedRevision: 2 }),
).toMatchObject({ ok: true });
expect(await tool("get_deck", { deckId: created.deck.id })).toMatchObject({
ok: false,
error: { code: "not-found" },
});
} finally {
await client.dispose();
}
});
+367
View File
@@ -0,0 +1,367 @@
import type { DeckColumn } from "../../src/features/decks/model";
import type { ResearchRun } from "../../src/features/research/model";
import { expect, test } from "../fixtures";
import { ResearchPage } from "../models/ResearchPage";
import { WorkspacePage } from "../models/WorkspacePage";
test("pushes the current research snapshot on each SSE connection", async ({ page, deckPage }) => {
await deckPage.goTo();
const snapshots = await page.evaluate(async () => {
const connect = () =>
new Promise<unknown>((resolve, reject) => {
const events = new EventSource("/api/research/events");
const timer = setTimeout(() => {
events.close();
reject(new Error("SSE timed out"));
}, 5000);
events.onmessage = (event) => {
clearTimeout(timer);
events.close();
resolve(JSON.parse(event.data));
};
events.onerror = () => {
clearTimeout(timer);
events.close();
reject(new Error("SSE failed"));
};
});
return [await connect(), await connect()];
});
expect(snapshots).toEqual([
{ configured: false, run: null, history: [] },
{ configured: false, run: null, history: [] },
]);
});
test("restores the conversation and growing temporary deck from reconnect snapshots", async ({
page,
deckPage,
researchPage,
workspacePage,
}) => {
await deckPage.goTo();
await workspacePage.showDeckManagement();
await deckPage.addColumnButton.click();
const accounts = deckPage.editorProfile;
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await workspacePage.closeMobileSidebar();
const run: ResearchRun = {
id: "36ad8cc7-318c-46ae-94fa-28d30336f027",
threadId: "conversation-test",
connectionIds: [connectionId],
topic: "既存の観点を使って調査して",
status: "running",
startedAt: Date.now(),
deckVersion: 1,
citations: [],
message: "既存の検索条件を再利用しています。",
messages: [
{ id: "user-1", role: "user", text: "既存の観点を使って調査して" },
{ id: "tool-1", role: "tool", text: "list_decks: 完了" },
{
id: "assistant-1",
role: "assistant",
text: "既存の検索条件を再利用しています。",
},
],
deck: {
id: "generated-plan",
title: "会話からの調査",
columns: [
{
id: "one",
title: "最初の観点",
connectionId,
source: {
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
],
},
};
await page.route("**/api/research/events*", (route) =>
route.fulfill({
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
);
await deckPage.goTo(`?run=${run.id}`);
await workspacePage.showResearch();
const conversation = researchPage.conversation;
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("list_decks: 完了");
await expect(conversation).toContainText("既存の検索条件を再利用しています。");
await expect(researchPage.bubbles).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(deckPage.columnTitles).toHaveText(["最初の観点"]);
run.deckVersion = 2;
run.status = "complete";
run.messages.push({
id: "assistant-2",
role: "assistant",
text: "比較する観点を追加しました。",
});
run.deck?.columns.push({
id: "two",
title: "比較の観点",
connectionId,
source: {
platform: "twitter",
kind: "search",
query: "WebMCP lang:ja",
product: "Latest",
following: false,
},
});
await page.reload();
await workspacePage.showResearch();
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("比較する観点を追加しました。");
await expect(deckPage.columnTitles).toHaveText(["最初の観点", "比較の観点"]);
await workspacePage.closeMobileSidebar();
await expect(deckPage.saveAsDeck).toBeEnabled();
await workspacePage.showResearch();
const accountPicker = researchPage.accountPicker(1);
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "true");
await expect(researchPage.account("e2e · Twitter")).toBeChecked();
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "false");
await researchPage.composer.fill("この観点を詳しく");
await expect(researchPage.send).toBeEnabled();
await workspacePage.closeSidebar.click();
run.deckVersion = 3;
if (run.deck) run.deck.title = "閉じている間に更新された調査";
await expect(deckPage.heading).toHaveText("閉じている間に更新された調査", {
timeout: 15000,
});
await workspacePage.showResearch();
await expect(researchPage.composer).toHaveValue("この観点を詳しく");
await page.setViewportSize({ width: 600, height: 900 });
await expect(workspacePage.mobileSidebar).toHaveCount(1);
await workspacePage.showResearch();
await expect(researchPage.composer).toHaveValue("この観点を詳しく");
await researchPage.accountPicker(1).click();
await researchPage.account("e2e · Twitter").uncheck();
await page.setViewportSize({ width: 1100, height: 900 });
await expect(workspacePage.mobileSidebar).toHaveCount(0);
await workspacePage.showResearch();
await expect(researchPage.composer).toHaveValue("この観点を詳しく");
await researchPage.accountPicker(0).click();
await expect(researchPage.account("e2e · Twitter")).not.toBeChecked();
await page.setViewportSize({ width: 600, height: 900 });
await expect(workspacePage.mobileSidebar).toHaveCount(1);
await workspacePage.showResearch();
await expect(researchPage.composer).toHaveValue("この観点を詳しく");
await researchPage.accountPicker(0).click();
await expect(researchPage.account("e2e · Twitter")).not.toBeChecked();
});
test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => {
const response = await request.get("/api/research/events", {
headers: { "Tailscale-User-Login": "[email protected]" },
});
expect(response.status()).toBe(403);
});
test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({
page,
deckPage,
researchPage,
workspacePage,
a11y,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await deckPage.goTo();
await workspacePage.showDeckManagement();
await deckPage.addColumnButton.click();
const accounts = deckPage.editorProfile;
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await workspacePage.closeMobileSidebar();
const source: DeckColumn = {
id: "citation-column-2",
title: "引用の観点",
connectionId,
source: {
platform: "twitter",
kind: "search",
query: "citation-query-2",
product: "Latest",
following: false,
},
};
const columns: DeckColumn[] = ["概要", "比較"].map((title, index) => ({
id: `citation-column-${index}`,
title,
connectionId,
source: {
platform: "twitter",
kind: "search",
query: `citation-query-${index}`,
product: "Latest",
following: false,
},
}));
columns.push(source);
const run: ResearchRun = {
id: "973ae084-18ed-4674-8899-c75851e51d1b",
topic: "引用を確認",
status: "complete",
startedAt: Date.now(),
deckVersion: 1,
message: "",
deck: { id: "citation-deck", title: "引用付きの調査", columns },
messages: [
{
id: "answer",
role: "assistant",
text: "## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。",
},
],
citations: [
{
column: source,
post: {
key: "twitter:123456789",
nativeId: "123456789",
platform: "twitter",
url: "https://twitter.com/author/status/123456789",
text: "AIが調査時に取得した引用本文",
author: { name: "引用元の著者", handle: "author" },
},
},
],
};
await page.route("**/api/research/events*", (route) =>
route.fulfill({
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
);
await deckPage.goTo(`?run=${run.id}`);
await workspacePage.showResearch();
await expect(researchPage.messageHeading("調査結果")).toBeVisible();
await expect(researchPage.messageHeading("調査結果")).toHaveCSS("font-weight", "600");
await expect(researchPage.messageLists).toHaveCSS("list-style-type", "disc");
await expect(researchPage.emphasis("重要な発見")).toBeVisible();
await expect(researchPage.messageTables).toBeVisible();
await expect(deckPage.postText("AIが調査時に取得した引用本文")).toHaveCount(0);
await researchPage.citation("根拠の投稿").click();
const card = deckPage.post("https://twitter.com/author/status/123456789");
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("data-citation-highlight", "true");
await expect(card).toBeFocused();
await expect(card).toContainText("Post saved during research");
await expect(card).toContainText("AIが調査時に取得した引用本文");
expect(await deckPage.board.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
expect(page.context().pages()).toHaveLength(1);
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
const bookmarked = new URL(page.url());
bookmarked.searchParams.set("run", run.id);
bookmarked.searchParams.set("deck", "citation-deck");
const submitted: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST") submitted.push(request.url());
});
await page.goto(bookmarked.toString());
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("data-citation-highlight", "true");
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
await expect(deckPage.columns).toHaveCount(3);
expect(submitted).toEqual([]);
expect((await a11y().analyze()).violations).toEqual([]);
});
test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({
deckPage,
researchPage,
workspacePage,
a11y,
}) => {
await deckPage.goTo();
const heading = deckPage.heading;
await expect(heading).toBeVisible();
const originalTitle = await heading.textContent();
await workspacePage.showResearch();
const panel = researchPage.panel;
await expect(panel).toBeVisible();
await expect(researchPage.unconfiguredNotice).toBeVisible();
await expect(researchPage.send).toBeDisabled();
await expect(researchPage.composer).toBeDisabled();
const accessibility = await a11y().analyze();
expect(accessibility.violations).toEqual([]);
await workspacePage.closeMobileSidebar();
await expect(heading).toHaveText(originalTitle ?? "");
await workspacePage.showDeckManagement();
await deckPage.createDeck.click();
await deckPage.newDeckName.fill("手動の調査");
await deckPage.confirmCreate.click();
await workspacePage.closeMobileSidebar();
await expect(heading).toHaveText("手動の調査");
await workspacePage.showResearch();
await expect(panel).toBeVisible();
});
test("keeps browser conversation selection independent and restores it on reload", async ({
page,
researchPage,
workspacePage,
browser,
context,
}) => {
const { openDatabase } = await import("../../src/features/storage/database.server");
const { createResearchStore } = await import("../../src/features/research/repository.server");
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
const store = createResearchStore(db);
const first = crypto.randomUUID();
const second = crypto.randomUUID();
for (const [id, text] of [
[first, "Browser one conversation"],
[second, "Browser two conversation"],
] as const) {
store.save({
id,
topic: text,
status: "complete",
startedAt: Date.now(),
deckVersion: 0,
message: text,
messages: [{ id: `${id}-message`, role: "assistant", text }],
citations: [],
});
}
const other = await browser.newContext({
storageState: await context.storageState(),
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const secondPage = await other.newPage();
const secondResearch = new ResearchPage(secondPage);
const secondWorkspace = new WorkspacePage(secondPage);
await researchPage.goTo(`?run=${first}`, { waitUntil: "domcontentloaded" });
await workspacePage.showResearch();
await secondResearch.goTo(`?run=${second}`, {
waitUntil: "domcontentloaded",
});
await secondWorkspace.showResearch();
await expect(researchPage.conversation).toContainText("Browser one conversation");
await expect(secondResearch.conversation).toContainText("Browser two conversation");
await secondResearch.newChat.click();
await expect(secondPage).toHaveURL(/run=new/);
await page.reload({ waitUntil: "domcontentloaded" });
await workspacePage.showResearch();
await expect(page).toHaveURL(new RegExp(`run=${first}`));
await expect(researchPage.conversation).toContainText("Browser one conversation");
} finally {
await other.close();
db.$client.prepare("DELETE FROM research_sessions WHERE id IN (?, ?)").run(first, second);
db.$client.close();
}
});
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from "../fixtures";
const reviewSubject = "Schedule next week’s design review";
const coffeeSubject = "Catch up over coffee";
test("reviews an edited reply and keeps the simulated result in history", async ({
page,
a11y,
messagesPage,
}) => {
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/);
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");
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,
messagesPage,
}) => {
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");
});
test("keeps corrections as new records and summarizes only selected current facts", async ({
page,
a11y,
journalPage,
}) => {
await journalPage.goTo();
await page.waitForLoadState("networkidle");
await expect(journalPage.saveEntry).toBeDisabled();
await journalPage.entryText.fill("次はレビュー資料の導線を確認する");
await journalPage.saveEntry.click();
await expect(journalPage.noteEntries).toContainText("Occurred: Unknown");
await journalPage.weightTab.click();
await journalPage.weight.fill("65.2");
await journalPage.source.fill("自宅の体重計");
await journalPage.saveEntry.click();
await journalPage.correctWeight("65.8");
const weights = journalPage.weightEntries;
await expect(weights).toHaveCount(2);
await expect(weights.filter({ hasText: "65.2 kg" })).toContainText("Original");
await expect(weights.filter({ hasText: "65.8 kg" })).toContainText("Correction");
await journalPage.summarizeEntries.click();
const summary = journalPage.summary;
await expect(summary).toHaveValue(/65.8 kg/);
await expect(summary).not.toHaveValue(/65.2 kg/);
await expect(summary).toHaveValue(/Source: 自宅の体重計/);
await expect(summary).toHaveValue(/Entries with an unknown event time: 2 entries/);
await journalPage.excludeFromSummary(/次はレビュー資料/);
await expect(summary).not.toHaveValue(/次はレビュー資料/);
await expect(summary).toHaveValue(
/Medication and weight are unknown for periods without entries/,
);
expect((await a11y().analyze()).violations).toEqual([]);
});
test("fits communication and journal interactions in narrow screens", async ({
page,
a11y,
messagesPage,
journalPage,
workspacePage,
}, testInfo) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await messagesPage.rows.first().click();
await expect(messagesPage.reply).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`) });
await messagesPage.showConversationList();
await workspacePage.navigateTo("Journal");
await journalPage.weightTab.click();
await expect(journalPage.weight).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
}
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from "../fixtures";
test("restores article selection with browser history and reload", async ({ page, readerPage }) => {
await readerPage.goTo();
const rows = readerPage.rows;
const heading = await readerPage.rowTitle(1).innerText();
await rows.nth(1).click();
await expect(readerPage.articleTitle).toHaveText(heading);
const selectedUrl = page.url();
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
await expect(readerPage.articleTitle).toHaveText(heading ?? "");
await page.goBack();
await expect(readerPage.workspace).toHaveAttribute("data-reading", "false");
await page.goForward();
await expect(page).toHaveURL(selectedUrl);
await expect(readerPage.articleTitle).toHaveText(heading ?? "");
});
test("restores search and replaces typing history", async ({
page,
readerPage,
homePage,
workspacePage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
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 expect(search).toHaveValue("browser");
await expect(readerPage.rows).toHaveCount(count);
await page.goBack();
await expect(page).toHaveURL("/");
});
test("restores account dialog without losing underlying article", async ({
page,
readerPage,
connectedAccounts,
}) => {
await readerPage.goTo();
await connectedAccounts.open();
await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true");
await page.reload({ waitUntil: "networkidle" });
await expect(connectedAccounts.dialog).toBeVisible();
await page.goBack();
await expect(connectedAccounts.dialog).toHaveCount(0);
await page.goForward();
await expect(connectedAccounts.dialog).toBeVisible();
});
test("restores a message view and conversation from its URL", async ({ page, messagesPage }) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await messagesPage.selectView("All");
const row = messagesPage.rows.nth(1);
const heading = await messagesPage.rowName(1).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" });
await expect(messagesPage.toolbarName).toHaveText(heading ?? "");
await expect(messagesPage.workspace).toHaveAttribute("data-reading", "true");
});
test("keeps a deep link through login", async ({ page, context, readerPage, authPage }) => {
await context.clearCookies();
await readerPage.goTo("?q=browser");
await expect(page).toHaveURL(/\/login\?returnTo=/);
await authPage.signIn("[email protected]", "E2E-only-passphrase-2026");
await expect(page).toHaveURL(/\/inbox\?q=browser$/);
await expect(readerPage.searchInput).toHaveValue("browser");
});
test("debounces search and cancels pending input on history navigation", async ({
page,
messagesPage,
}) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await messagesPage.selectView("All");
await expect(page).toHaveURL(/view=all/);
await page.clock.install();
const input = messagesPage.searchInput;
await input.fill("not-committed");
await expect(input).toHaveValue("not-committed");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await page.clock.runFor(200);
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await page.goBack();
await expect(page).toHaveURL("/support");
await page.clock.runFor(400);
await expect(input).toHaveValue("");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await input.fill("final search");
await page.clock.runFor(300);
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("final search");
});
test("waits for IME composition to finish before searching", async ({ page, readerPage }) => {
await readerPage.goTo();
await page.clock.install();
const input = readerPage.searchInput;
await input.dispatchEvent("compositionstart");
await input.fill("browser");
await page.clock.runFor(1000);
await expect(input).toHaveValue("browser");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await input.dispatchEvent("compositionend", { data: "browser" });
await page.clock.runFor(300);
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
});
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from "../fixtures";
test("filters body systems and opens lab history", async ({ page, vitalsPage, a11y }) => {
await vitalsPage.goTo();
await expect(vitalsPage.bodyHeading).toBeVisible();
await expect(vitalsPage.systems).toHaveCount(8);
await vitalsPage.selectSystem("Cardiovascular");
await expect(vitalsPage.systems).toHaveCount(1);
await expect(page).toHaveURL(/vitalTab=Cardiovascular/);
await page.reload();
await page.waitForLoadState("networkidle");
await expect(vitalsPage.systems).toHaveCount(1);
await vitalsPage.openFirstSystem();
const dialog = vitalsPage.systemDialog("Cardiovascular");
await expect(dialog).toContainText("68");
await page.goBack();
await expect(dialog).not.toBeVisible();
await page.goForward();
await expect(dialog).toBeVisible();
await vitalsPage.closeSystem("Cardiovascular");
await vitalsPage.showLabHistory();
await expect(vitalsPage.history).toContainText("Jul 28");
await expect(vitalsPage.history).toContainText("93");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(vitalsPage.history).toContainText("Jul 28");
await vitalsPage.showSystems();
await vitalsPage.selectSystem("Overview");
await expect(vitalsPage.systems).toHaveCount(8);
expect((await a11y().analyze()).violations).toEqual([]);
});
test("explores environment charts and simulated climate controls", async ({
page,
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 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 expect(dialog).toContainText("64.0");
await vitalsPage.closeMetric("Room temperature");
await vitalsPage.syncReadings();
await expect(vitalsPage.refreshedStatus).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await vitalsPage.openClimateControl();
await vitalsPage.enableCooling();
await expect(vitalsPage.coolButton).toHaveAttribute("aria-pressed", "true");
const temperature = vitalsPage.targetTemperature;
await temperature.focus();
await temperature.press("ArrowRight");
await expect(temperature).toHaveValue("69");
await vitalsPage.turnOffPurifier();
await expect(vitalsPage.purifierOffButton).toHaveAttribute("aria-pressed", "false");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(temperature).toHaveValue("68");
await expect(vitalsPage.autoButton).toHaveAttribute("aria-pressed", "true");
await expect(vitalsPage.purifierOnButton).toHaveAttribute("aria-pressed", "true");
});
test("keeps both dashboards within the viewport", async ({ page, vitalsPage }, testInfo) => {
await vitalsPage.goTo();
for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 1000 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) });
await vitalsPage.navigateTo("Environment");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`environment-${width}.png`) });
await vitalsPage.navigateTo("Body");
}
});
+288
View File
@@ -0,0 +1,288 @@
import type { Page } from "@playwright/test";
import Database from "better-sqlite3";
import { expect, test } from "../fixtures";
import { DeckPage } from "../models/DeckPage";
type NativeModelContext = {
getTools(): Promise<{ name: string }[]>;
executeTool(tool: { name: string }, input: string): Promise<string | null>;
};
type ToolResult = {
content: { type: string; text: string }[];
isError?: boolean;
};
test.use({
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ["--enable-blink-features=WebMCP"],
},
});
async function toolNames(page: Page) {
return page.evaluate(async () =>
(await (document as Document & { modelContext: NativeModelContext }).modelContext.getTools())
.map((tool) => tool.name)
.sort(),
);
}
async function executeTool(
page: Page,
name: string,
input: Record<string, unknown> = {},
): Promise<ToolResult> {
const result = await page.evaluate(
async ({ name, input }) => {
const model = (document as Document & { modelContext: NativeModelContext }).modelContext;
const tool = (await model.getTools()).find((candidate) => candidate.name === name);
if (!tool) throw new Error(`WebMCP tool not found: ${name}`);
return model.executeTool(tool, JSON.stringify(input));
},
{ name, input },
);
expect(result).not.toBeNull();
return JSON.parse(result ?? "{}");
}
function decoded(result: ToolResult) {
return JSON.parse(result.content[0]?.text ?? "{}");
}
const column = (title: string, connectionId: string) => ({
title,
connectionId,
source: { kind: "search", query: "WebMCP" },
});
function savedCount() {
const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? "", {
readonly: true,
});
try {
return db.prepare("SELECT count(*) AS count FROM decks").get();
} finally {
db.close();
}
}
let accounts: Record<string, string>;
let pageErrors: string[];
test.beforeEach(async ({ page, deckPage }) => {
pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await deckPage.goTo();
await expect
.poll(() => toolNames(page))
.toEqual([
"delete_deck",
"get_column_posts",
"get_deck",
"list_connections",
"list_decks",
"load_more_column",
"save_deck",
"select_deck",
"set_deck",
]);
await expect
.poll(async () => decoded(await executeTool(page, "list_connections")).connections?.length)
.toBe(2);
accounts = Object.fromEntries(
decoded(await executeTool(page, "list_connections")).connections.map(
(connection: { displayName: string; id: string }) => [connection.displayName, connection.id],
),
);
});
test.afterEach(() => expect(pageErrors).toEqual([]));
test("creates temporary research, edits it, persists explicitly and reopens it on another device", async ({
page,
browser,
baseURL,
deckPage,
}) => {
const created = await executeTool(page, "set_deck", {
title: "WebMCPの反応",
columns: [
{ ...column("日本語", accounts.e2e ?? ""), id: "new" },
column("別の接続", accounts.alternate ?? ""),
],
});
expect(created.isError).not.toBe(true);
const deck = decoded(created).deck;
expect(deck.persisted).toBe(false);
expect(savedCount()).toEqual({ count: 0 });
await expect(deckPage.postText("alternate · Latest · all page 1")).toBeVisible();
await deckPage.openColumnActions("日本語");
await deckPage.editColumn.click();
const replaced = await executeTool(page, "set_deck", {
deckId: deck.id,
title: "更新した調査",
columns: [deck.columns[1]],
});
expect(replaced.isError).not.toBe(true);
await expect(deckPage.dialogs).toHaveCount(0);
expect(savedCount()).toEqual({ count: 0 });
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect(savedCount()).toEqual({ count: 1 });
const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL,
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const device = await other.newPage();
const deviceDeck = new DeckPage(device);
await deviceDeck.goTo();
await expect(deviceDeck.heading).toHaveText("更新した調査");
await expect(deviceDeck.postText("alternate · Latest · all page 1")).toBeVisible();
} finally {
await other.close();
}
await page.reload();
await expect(deckPage.heading).toHaveText("更新した調査");
});
test("requires revisions for saved mutations and rejects stale overwrites", async ({
page,
deckPage,
}) => {
const deck = decoded(
await executeTool(page, "set_deck", { title: "保存済み", columns: [] }),
).deck;
await executeTool(page, "save_deck", { deckId: deck.id });
expect(
(
await executeTool(page, "set_deck", {
deckId: deck.id,
title: "変更",
columns: [],
})
).isError,
).toBe(true);
expect(
(
await executeTool(page, "set_deck", {
deckId: deck.id,
expectedRevision: 1,
title: "変更",
columns: [],
})
).isError,
).not.toBe(true);
expect(
(
await executeTool(page, "set_deck", {
deckId: deck.id,
expectedRevision: 1,
title: "古い変更",
columns: [],
})
).isError,
).toBe(true);
await expect(deckPage.heading).toHaveText("変更");
expect(
(
await executeTool(page, "delete_deck", {
deckId: deck.id,
expectedRevision: 1,
})
).isError,
).toBe(true);
expect(
(
await executeTool(page, "delete_deck", {
deckId: deck.id,
expectedRevision: 2,
})
).isError,
).not.toBe(true);
expect(savedCount()).toEqual({ count: 0 });
});
test("keeps temporary views independent and loses only unsaved views on reload", async ({
page,
deckPage,
}) => {
const first = decoded(
await executeTool(page, "set_deck", { title: "一時の調査A", columns: [] }),
).deck;
const second = decoded(
await executeTool(page, "set_deck", { title: "一時の調査B", columns: [] }),
).deck;
await executeTool(page, "select_deck", { deckId: first.id });
await expect(deckPage.heading).toHaveText("一時の調査A");
await executeTool(page, "delete_deck", { deckId: second.id });
expect(
decoded(await executeTool(page, "list_decks")).decks.some(
(deck: { id: string }) => deck.id === second.id,
),
).toBe(false);
expect(savedCount()).toEqual({ count: 0 });
await page.reload();
await expect(deckPage.heading).toHaveText("New research");
});
test("rejects invalid input without changing the active view", async ({ page, deckPage }) => {
await executeTool(page, "set_deck", {
title: "残す調査",
columns: [column("残す観点", accounts.e2e ?? "")],
});
const duplicate = { ...column("不正", accounts.e2e ?? ""), id: "duplicate" };
for (const columns of [
[duplicate, duplicate],
[column("不明", "missing")],
[
{
...column("不正", accounts.e2e ?? ""),
source: { kind: "list", target: "invalid" },
},
],
]) {
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
true,
);
await expect(deckPage.columnTitles).toHaveText(["残す観点"]);
}
expect(savedCount()).toEqual({ count: 0 });
});
test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({
page,
deckPage,
}) => {
const deck = decoded(
await executeTool(page, "set_deck", { title: "未保存の調査", columns: [] }),
).deck;
await page.route("**/_serverFn/**", (route) =>
route.request().method() === "POST" ? route.abort() : route.continue(),
);
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).toBe(true);
await expect(deckPage.heading).toHaveText("未保存の調査");
expect(decoded(await executeTool(page, "get_deck")).deck.persisted).toBe(false);
await page.unroute("**/_serverFn/**");
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect(savedCount()).toEqual({ count: 1 });
});
test("reads and pages bound accounts without mixing sibling columns", async ({
page,
deckPage,
}) => {
const deck = decoded(
await executeTool(page, "set_deck", {
title: "接続を比較",
columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")],
}),
).deck;
await expect(deckPage.postText("alternate · Latest · all page 1")).toBeVisible();
const id = deck.columns[1].id;
const loaded = decoded(await executeTool(page, "get_column_posts", { columnId: id }));
expect(loaded.column.connectionId).toBe(accounts.alternate);
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
"alternate · Latest · all page 1",
]);
const more = decoded(await executeTool(page, "load_more_column", { columnId: id }));
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false });
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
"alternate · Latest · all page 2",
]);
await expect(deckPage.columnPosts("通常")).toHaveCount(1);
await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] });
expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true);
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "../fixtures";
test("keeps the sidebar, composer and deck reachable at compact widths", async ({
page,
deckPage,
researchPage,
workspacePage,
a11y,
}, testInfo) => {
await deckPage.goTo();
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await workspacePage.showResearch();
const chat = researchPage.panel;
await expect(chat).toBeVisible();
const composer = researchPage.composer;
await expect(composer).toBeVisible();
const bounds = await composer.boundingBox();
if (!bounds) throw new Error("Composer must have a visible bounding box");
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({
path: testInfo.outputPath(`workspace-${width}.png`),
});
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
await workspacePage.closeMobileSidebar();
}
await workspacePage.toggleSidebar.click();
await expect(researchPage.panel).toBeHidden();
await workspacePage.researchChat.click();
await expect(researchPage.panel).toBeVisible();
});
+450
View File
@@ -0,0 +1,450 @@
import { createServer } from "node:http";
import { tweetLegacyFixture } from "./fixtures/twitter.ts";
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT;
const port = Number(portValue);
if (!portValue || !Number.isInteger(port) || port < 1) {
throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port");
}
const failedRetryQueries = new Set();
const rootByTarget = new Map([
["1001", "1000"],
["2001", "2000"],
["3001", "3000"],
]);
const targetByRoot = new Map([
["1000", "1001"],
["2000", "2001"],
["3000", "3001"],
]);
const failedThreadRoots = new Set();
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const tweetResult = (
id,
text,
username = "yuta",
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({
rest_id: id,
legacy: tweetLegacyFixture(id, text, {
conversationId,
inReplyTo,
replyCount,
}),
core: {
user_results: {
result: {
rest_id: "42",
legacy: { screen_name: username, name: "Yuta" },
},
},
},
});
const tweet = (id, text, username = "yuta", options) => ({
entryId: `tweet-${id}`,
content: {
itemContent: {
tweet_results: {
result: tweetResult(id, text, username, options),
},
},
},
});
const cursor = (value) => ({
entryId: "cursor-bottom",
content: { cursorType: "Bottom", value },
});
const send = (response, payload, status = 200, headers = {}) => {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
...headers,
});
response.end(JSON.stringify(payload));
};
const fail = (response, status, message, headers) =>
send(response, { errors: [{ message }] }, status, headers);
const variablesFrom = (url) => {
const encoded = url.searchParams.get("variables");
if (!encoded) throw new Error("variables are required");
const variables = JSON.parse(encoded);
if (!isRecord(variables)) throw new Error("variables must be an object");
return variables;
};
const jsonBodyFrom = async (request) => {
let body = "";
for await (const chunk of request) {
body += chunk;
if (body.length > 1_000_000) throw new Error("request body is too large");
}
const parsed = JSON.parse(body);
if (!isRecord(parsed)) throw new Error("JSON body must be an object");
return parsed;
};
const timeline = (entries) => ({ instructions: [{ entries }] });
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
if (request.method === "GET" && url.pathname === "/profiles") {
send(response, { profiles: ["e2e", "alternate"] });
return;
}
const operation = [
"UserByScreenName",
"UserTweets",
"TweetDetail",
"SearchTimeline",
"ListsManagementPageTimeline",
"ListLatestTweetsTimeline",
].find((name) => url.pathname.endsWith(`/${name}`));
if (!operation) {
fail(response, 501, "unsupported read operation");
return;
}
const expectedMethod = operation === "SearchTimeline" ? "POST" : "GET";
if (request.method !== expectedMethod) {
fail(response, 405, `expected ${expectedMethod}`, {
allow: expectedMethod,
});
return;
}
const profileName = request.headers["x-profile-name"];
if (profileName !== "e2e" && profileName !== "alternate") {
fail(response, 403, "invalid profile");
return;
}
let variables;
try {
variables = variablesFrom(url);
} catch {
fail(response, 400, "malformed variables");
return;
}
if (operation === "UserByScreenName") {
if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) {
fail(response, 400, "screen_name is required");
return;
}
send(response, {
data: {
user: {
result: {
rest_id: "42",
legacy: { screen_name: variables.screen_name, name: "Yuta" },
},
},
},
});
return;
}
if (operation === "UserTweets") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next";
if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) {
fail(response, 400, "invalid user timeline variables");
return;
}
const secondPage = variables.cursor === "user-next";
const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
const entries = secondPage
? [tweet("1010", "user page 2")]
: [
tweet("1001", firstPageText, "yuta", {
conversationId: "1000",
}),
cursor("user-next"),
];
send(response, {
data: {
user: { result: { timeline: { timeline: timeline(entries) } } },
},
});
return;
}
if (operation === "ListsManagementPageTimeline") {
if (variables.count !== 100) {
fail(response, 400, "invalid lists management variables");
return;
}
const features = url.searchParams.get("features");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error();
} catch {
fail(response, 400, "invalid lists management feature lock");
return;
}
send(response, {
data: {
viewer: {
list_management_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
clientEventInfo: {
component: "suggest_owned_subscribed_list",
},
items: [
{
item: {
itemContent: {
list: {
id_str: "1234567890",
name:
profileName === "alternate" ? "Alternate List" : "My E2E List",
description: "Accounts selected for testing",
member_count: 10,
mode: "Public",
created_at: 1_704_067_200_000,
user_results: {
result: {
rest_id: "42",
core: {
screen_name: "yuta",
name: "Yuta",
},
},
},
},
},
},
},
],
},
},
],
},
],
},
},
},
},
errors: [{ message: "optional banner field failed" }],
});
return;
}
if (operation === "ListLatestTweetsTimeline") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next";
if (
typeof variables.listId !== "string" ||
!/^\d+$/.test(variables.listId) ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, "invalid list timeline variables");
return;
}
const secondPage = variables.cursor === "list-next";
const entries = secondPage
? [tweet("3010", "list page 2", "listed")]
: [tweet("3001", "list page 1", "listed"), cursor("list-next")];
send(response, {
data: {
list: { tweets_timeline: { timeline: timeline(entries) } },
},
});
return;
}
if (operation === "TweetDetail") {
const focalTweetId = variables.focalTweetId;
if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) {
fail(response, 400, "invalid focalTweetId");
return;
}
const features = url.searchParams.get("features");
const fieldToggles = url.searchParams.get("fieldToggles");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error();
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
throw new Error();
}
} catch {
fail(response, 400, "invalid TweetDetail feature locks");
return;
}
const targetRoot = rootByTarget.get(focalTweetId);
if (targetRoot && variables.cursor === undefined) {
const noReplies = focalTweetId === "3001";
const text = noReplies
? "no replies"
: focalTweetId === "1001"
? "user page 1"
: "retry focal";
const result = tweetResult(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
});
send(response, {
data: {
tweetResult: { result },
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
tweet(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
}),
],
},
],
},
},
});
return;
}
const targetId = targetByRoot.get(focalTweetId);
if (!targetId) {
fail(response, 400, "unsupported TweetDetail fixture");
return;
}
const expectedCursor = `thread-next:${focalTweetId}`;
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
fail(response, 400, "invalid thread cursor");
return;
}
if (variables.cursor && focalTweetId === "3000") {
fail(response, 500, "zero-reply thread must not request a continuation");
return;
}
if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) {
failedThreadRoots.add(focalTweetId);
fail(response, 503, "transient thread fixture failure");
return;
}
const entries = variables.cursor
? [
tweet(`${focalTweetId}3`, "thread page 2", "reply2", {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
]
: focalTweetId === "3000"
? [
tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
tweet(targetId, "no replies", "focus", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
replyCount: 0,
}),
cursor(expectedCursor),
]
: [
tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
}),
tweet(`${focalTweetId}2`, "thread page 1", "reply1", {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
cursor(expectedCursor),
];
send(response, {
data: {
tweetResult: {
result: tweetResult(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
},
threaded_conversation_with_injections_v2: {
instructions: [{ entries }],
},
},
});
if (variables.cursor && focalTweetId === "2000") {
failedThreadRoots.delete(focalTweetId);
}
return;
}
const contentType = request.headers["content-type"] ?? "";
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail(response, 400, "SearchTimeline requires JSON");
return;
}
let body;
try {
body = await jsonBodyFrom(request);
} catch {
fail(response, 400, "malformed JSON body");
return;
}
const rawQuery = variables.rawQuery;
const expectedCursor =
variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`;
if (
typeof rawQuery !== "string" ||
rawQuery.length === 0 ||
variables.count !== 20 ||
variables.querySource !== "typed_query" ||
!["Top", "Latest"].includes(variables.product) ||
!expectedCursor ||
!isRecord(body.features) ||
typeof body.queryId !== "string" ||
body.queryId.length === 0 ||
Object.hasOwn(body, "variables")
) {
fail(response, 400, "invalid search request");
return;
}
const secondPage = variables.cursor !== undefined;
if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) {
failedRetryQueries.add(rawQuery);
fail(response, 503, "transient fixture failure");
return;
}
if (secondPage && rawQuery.startsWith("slow-")) {
await new Promise((resolve) => setTimeout(resolve, 1_200));
}
const follows = rawQuery.includes("filter:follows") ? "follows" : "all";
const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`;
const id = encodeURIComponent(rawQuery);
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)];
send(response, {
data: {
search_by_raw_query: {
search_timeline: { timeline: timeline(entries) },
},
},
});
});
server.listen(port, "127.0.0.1");
+44
View File
@@ -0,0 +1,44 @@
import type { Locator, Page } from "@playwright/test";
export class AuthPage {
readonly name: Locator;
readonly email: Locator;
readonly password: Locator;
readonly confirmPassword: Locator;
readonly setupCode: Locator;
readonly signInButton: Locator;
readonly createAccountButton: Locator;
readonly welcome: Locator;
readonly error: Locator;
constructor(readonly page: Page) {
this.name = page.getByLabel("Your name", { exact: true });
this.email = page.getByLabel("Email address");
this.password = page.getByLabel("Password", { exact: true });
this.confirmPassword = page.getByLabel("Confirm password", { exact: true });
this.setupCode = page.getByLabel("Setup code");
this.signInButton = page.getByRole("button", { name: "Sign in", exact: true });
this.createAccountButton = page.getByRole("button", { name: "Create account", exact: true });
this.welcome = page.getByRole("heading", { name: "Welcome back." });
this.error = page.getByRole("alert");
}
async goTo(mode: "login" | "setup" = "login") {
await this.page.goto(`/${mode}`, { waitUntil: "networkidle" });
}
async signIn(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.signInButton.click();
}
async createAccount(input: { name: string; email: string; password: string; setupCode: string }) {
await this.name.fill(input.name);
await this.email.fill(input.email);
await this.password.fill(input.password);
await this.confirmPassword.fill(input.password);
await this.setupCode.fill(input.setupCode);
await this.createAccountButton.click();
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Locator, Page } from "@playwright/test";
import { WorkspacePage } from "./WorkspacePage";
export class ConnectedAccountsDialog {
readonly dialog: Locator;
readonly unconfiguredMastodon: Locator;
readonly authorizeMastodon: Locator;
readonly signOut: Locator;
constructor(readonly page: Page) {
this.dialog = page.getByRole("dialog", { name: "Connected accounts" });
this.unconfiguredMastodon = this.dialog.getByText(
"No Mastodon servers have been configured yet.",
);
this.authorizeMastodon = this.dialog.getByRole("button", { name: "Authorize with Mastodon" });
this.signOut = this.dialog.getByRole("button", { name: "Sign out", exact: true });
}
async open() {
await new WorkspacePage(this.page).openConnectedAccounts();
}
async close() {
await this.page.keyboard.press("Escape");
}
}
+140
View File
@@ -0,0 +1,140 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { WorkspacePage } from "./WorkspacePage";
export class DeckPage {
get dialogs() {
return this.page.getByRole("dialog");
}
readonly saveAsDeck: Locator;
readonly savedNotice: Locator;
readonly rename: Locator;
readonly deckName: Locator;
readonly saveName: Locator;
readonly createDeck: Locator;
readonly newDeckName: Locator;
readonly confirmCreate: Locator;
readonly deckProfile: Locator;
readonly heading: Locator;
readonly deleteDeck: Locator;
readonly confirmDelete: Locator;
readonly undo: Locator;
readonly columnNavigation: Locator;
readonly addColumnButton: Locator;
readonly columnDialog: Locator;
readonly columnName: Locator;
readonly editorProfile: Locator;
readonly columnType: Locator;
readonly primaryList: Locator;
readonly alternateList: Locator;
readonly listId: Locator;
readonly addAndSearch: Locator;
readonly saveChanges: Locator;
readonly editColumn: Locator;
readonly alert: Locator;
readonly reloadLatest: Locator;
readonly importLegacy: Locator;
readonly createDialog: Locator;
readonly newDeckTitleInput: Locator;
readonly columns: Locator;
readonly profileOptions: Locator;
readonly columnTitles: Locator;
readonly board: Locator;
constructor(readonly page: Page) {
this.saveAsDeck = this.page.getByRole("button", { name: "Save as deck", exact: true });
this.savedNotice = this.page.getByText("Saved to server · Shared across devices");
this.rename = this.page.getByRole("button", { name: "Rename" });
this.deckName = this.page.getByLabel("Research topic / deck name");
this.saveName = this.page.getByRole("button", { name: "Save name" });
this.createDeck = this.page.getByRole("button", { name: "Create deck", exact: true });
this.newDeckName = this.page.getByLabel("New deck name");
this.confirmCreate = this.page.getByRole("button", { name: "Create", exact: true });
this.deckProfile = this.page.getByLabel("Deck profile", { exact: true });
this.profileOptions = this.deckProfile.locator("option");
this.heading = this.page.getByRole("heading", { level: 1 });
this.deleteDeck = this.page.getByRole("button", { name: "Delete deck", exact: true });
this.confirmDelete = this.page.getByRole("button", { name: "Delete", exact: true });
this.undo = this.page.getByRole("button", { name: "Undo" });
this.columnNavigation = this.page.getByRole("navigation", { name: "Columns" });
this.addColumnButton = this.page.getByRole("button", { name: "Add column", exact: true });
this.columnDialog = this.page.getByRole("dialog", { name: "Add column", exact: true });
this.columnName = this.page.getByLabel("Column name", { exact: true });
this.editorProfile = this.page.getByRole("combobox", {
name: "Connection profile",
exact: true,
});
this.columnType = this.page.getByRole("combobox", { name: "Column type", exact: true });
this.primaryList = this.page.getByRole("button", { name: "My E2E List", exact: true });
this.alternateList = this.page.getByRole("button", { name: "Alternate List", exact: true });
this.listId = this.page.getByLabel("List", { exact: true });
this.addAndSearch = this.page.getByRole("button", { name: "Add and search" });
this.saveChanges = this.page.getByRole("button", { name: "Save changes" });
this.editColumn = this.page.getByRole("menuitem", { name: "Edit", exact: true });
this.alert = this.page.getByRole("alert");
this.reloadLatest = this.page.getByRole("button", { name: "Reload latest deck" });
this.importLegacy = this.page.getByRole("button", { name: "Import legacy decks" });
this.createDialog = this.page.getByRole("dialog", { name: "Create deck", exact: true });
this.newDeckTitleInput = this.page.locator("#new-deck-title");
this.columns = this.page.locator("[data-testid='deck-column']");
this.columnTitles = this.page.locator("[data-testid='deck-column'] h2");
this.board = this.page.locator("[data-testid='deck-board']");
}
column(title: string) {
return this.page.getByRole("region", { name: title, exact: true });
}
columnPosts(title: string) {
return this.column(title).locator("article");
}
columnPostText(title: string, text: string, exact = false) {
return this.column(title).getByText(text, { exact });
}
columnError(title: string) {
return this.column(title).getByRole("alert");
}
async loadMore(title: string) {
await this.column(title).getByRole("button", { name: "Load more" }).click();
}
async retryColumn(title: string) {
await this.column(title).getByRole("button", { name: "Retry" }).click();
}
async openColumnActions(title: string) {
await this.column(title)
.getByRole("button", { name: `Actions for ${title}` })
.click();
}
columnHeading(title: string) {
return this.columns.getByRole("heading", { name: title, exact: true });
}
columnAction(name: string) {
return this.page.getByRole("menuitem", { name, exact: true });
}
post(url: string) {
return this.page.locator(`[data-testid="research-post"][data-post-url="${url}"]`);
}
postText(text: string) {
return this.page.getByText(text, { exact: true });
}
async goTo(search = "") {
await this.page.goto(`/deck${search}`);
}
async addColumn(title: string, query: string, profile = "e2e", kind = "search") {
const workspace = new WorkspacePage(this.page);
await workspace.showDeckManagement();
await this.addColumnButton.click();
const editor = this.page.getByRole("form", { name: "Add column", exact: true });
await editor.getByLabel("Column name", { exact: true }).fill(title);
await editor
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: profile });
await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind);
await editor
.getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", {
exact: true,
})
.fill(query);
await editor.getByRole("button", { name: "Add and search" }).click();
await workspace.closeMobileSidebar();
await expect(this.column(title).locator("article").first()).toBeVisible();
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { Locator, Page } from "@playwright/test";
export class HomePage {
readonly page: Page;
readonly input: Locator;
readonly previewAction: Locator;
readonly applyAction: Locator;
readonly morningRoutine: Locator;
readonly finishRoutine: Locator;
readonly recordWeight: Locator;
readonly weight: Locator;
readonly saveMeasurement: Locator;
readonly todayPlan: Locator;
readonly dailyBrief: Locator;
readonly flowTitles: Locator;
constructor(page: Page) {
this.page = page;
this.input = page.getByRole("textbox", { name: "Home input" });
this.previewAction = page.getByRole("button", { name: "Preview action" });
this.applyAction = page.getByRole("button", { name: "Apply", exact: true });
this.morningRoutine = page.getByRole("button", { name: /Morning routine 0 of 3/ });
this.finishRoutine = page.getByRole("button", { name: "Finish routine" });
this.recordWeight = page.getByRole("button", { name: /Record weight A quick check-in/ });
this.weight = page.getByRole("spinbutton", { name: "Weight", exact: true });
this.saveMeasurement = page.getByRole("button", { name: "Save measurement" });
this.todayPlan = page.getByRole("region", { name: "Today’s plan" });
this.dailyBrief = page.getByRole("region", { name: "Daily brief" });
this.flowTitles = page.getByTestId("execution-flow-title");
}
recordedWeight(weight: string) {
return this.page.getByRole("button", { name: `Record weight Recorded · ${weight} kg` });
}
greeting(name: string) {
return this.page.getByRole("heading", { name: `Good morning, ${name}` });
}
async goTo(search = "") {
await this.page.goto(`/${search}`);
}
routineStep(name: string | RegExp) {
return this.page.getByRole("checkbox", { name });
}
completeTask(name: string) {
return this.page.getByRole("button", { name: `Complete ${name}`, exact: true });
}
restoreTask(name: string) {
return this.page.getByRole("button", { name: `Restore ${name}`, exact: true });
}
completedTask(name: string) {
return this.page.getByRole("button", { name: `${name} Completed`, exact: true });
}
conversation(name: string) {
return this.page.getByRole("link", { name: `Open conversation with ${name}` });
}
async openBriefConversation(name: string) {
await this.dailyBrief.getByRole("button", { name: `Reply to ${name}` }).click();
}
async previewCommand(command: string) {
await this.input.fill(command);
await this.previewAction.click();
}
async recordMeasurement(weight: string) {
await this.recordWeight.click();
await this.weight.fill(weight);
await this.saveMeasurement.click();
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Locator, Page } from "@playwright/test";
export class JournalPage {
readonly page: Page;
readonly saveEntry: Locator;
readonly entryText: Locator;
readonly noteEntries: Locator;
readonly weightEntries: Locator;
readonly weightTab: Locator;
readonly weight: Locator;
readonly source: Locator;
readonly correctionDialog: Locator;
readonly summarizeEntries: Locator;
readonly summaryDialog: Locator;
readonly summary: Locator;
constructor(page: Page) {
this.page = page;
this.saveEntry = page.getByRole("button", { name: "Save entry", exact: true });
this.entryText = page.getByRole("textbox", { name: "Entry text" });
this.noteEntries = page.getByRole("article", { name: "Note entry" });
this.weightEntries = page.getByRole("article", { name: "Weight entry" });
this.weightTab = page.getByRole("button", { name: "Weight", exact: true });
this.weight = page.getByRole("spinbutton", { name: "Weight", exact: true });
this.source = page.getByRole("textbox", { name: "Source" });
this.correctionDialog = page.getByRole("dialog", { name: "Correct entry" });
this.summarizeEntries = page.getByRole("button", { name: "Summarize entries", exact: true });
this.summaryDialog = page.getByRole("dialog", { name: "Summarize entries" });
this.summary = this.summaryDialog.getByRole("textbox", { name: "Entry summary" });
}
async goTo(search = "") {
await this.page.goto(`/journal${search}`);
}
async correctWeight(weight: string) {
await this.weightEntries.getByRole("button", { name: "Correct" }).click();
await this.correctionDialog
.getByRole("spinbutton", { name: "Weight", exact: true })
.fill(weight);
await this.correctionDialog.getByRole("button", { name: "Add correction" }).click();
}
async excludeFromSummary(name: string | RegExp) {
await this.summaryDialog.getByRole("checkbox", { name }).uncheck();
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { Locator, Page } from "@playwright/test";
export class MessagesPage {
readonly page: Page;
readonly searchInput: Locator;
readonly toolbarName: Locator;
readonly workspace: Locator;
readonly rows: Locator;
readonly reply: Locator;
readonly suggestions: Locator;
readonly scheduling: Locator;
readonly suggestedTimes: Locator;
readonly reviewReply: Locator;
readonly reviewDialog: Locator;
readonly trySending: Locator;
readonly editReply: Locator;
readonly leaveForLater: Locator;
readonly moveToInbox: Locator;
readonly backToMessages: Locator;
readonly inbox: Locator;
readonly outcome: Locator;
readonly dialogs: Locator;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByRole("textbox", { name: "Search messages" });
this.toolbarName = page.getByTestId("support-toolbar").locator("strong");
this.workspace = page.getByTestId("support-workspace");
this.rows = page.getByTestId("support-row");
this.reply = page.getByRole("textbox", { name: "Reply", exact: true });
this.suggestions = page.locator("[data-testid='conversation-suggestions'] summary:visible");
this.scheduling = page.locator("[data-testid='conversation-scheduling'] summary:visible");
this.suggestedTimes = page.getByRole("combobox", { name: "Suggested times" });
this.reviewReply = page.getByRole("button", { name: "Review reply", exact: true });
this.reviewDialog = page.getByRole("dialog", { name: "Review reply", exact: true });
this.trySending = this.reviewDialog.getByRole("button", { name: "Try sending" });
this.editReply = this.reviewDialog.getByRole("button", { name: "Back", exact: true });
this.leaveForLater = page.getByRole("button", { name: "Leave for later" });
this.moveToInbox = page.getByRole("button", { name: "Move to Inbox" });
this.backToMessages = page.getByRole("button", { name: "Back to messages" });
this.inbox = page.getByRole("button", { name: "Inbox", exact: true });
this.outcome = page.getByTestId("support-outcome");
this.dialogs = page.getByRole("dialog");
}
rowName(index: number) {
return this.rows.nth(index).locator("strong");
}
filter(view: "Inbox" | "Archive" | "Set aside" | "All" | "Awaiting reply") {
return this.page.getByRole("button", { name: view, exact: true });
}
async goTo(search = "") {
await this.page.goto(`/support${search}`);
}
conversation(subject: string) {
return this.rows.filter({ hasText: subject });
}
async chooseReplySuggestion(tone: string | RegExp) {
await this.suggestions.click();
await this.page.getByRole("button", { name: tone }).click();
}
async addSuggestedTime(slot: string) {
await this.scheduling.click();
await this.suggestedTimes.selectOption(slot);
await this.page.getByRole("button", { name: "Add to reply" }).click();
}
async selectView(view: "Inbox" | "Archive" | "Set aside" | "All" | "Awaiting reply") {
await this.filter(view).click();
}
async showConversationList() {
if (await this.backToMessages.isVisible()) await this.backToMessages.click();
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Locator, Page } from "@playwright/test";
export class OnboardingPage {
readonly name: Locator;
readonly continueButton: Locator;
readonly openWorkspace: Locator;
constructor(readonly page: Page) {
this.name = page.getByLabel("What should we call you?");
this.continueButton = page.getByRole("button", { name: "Continue", exact: true });
this.openWorkspace = page.getByRole("button", { name: "Open workspace" });
}
async goTo() {
await this.page.goto("/onboarding", { waitUntil: "networkidle" });
}
async complete() {
await this.page.waitForLoadState("networkidle");
await this.continueButton.click();
await this.openWorkspace.click();
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { Locator, Page } from "@playwright/test";
export class ReaderPage {
readonly rows: Locator;
readonly searchInput: Locator;
readonly articleTitle: Locator;
readonly workspace: Locator;
readonly emptyState: Locator;
readonly forYouLabel: Locator;
readonly mockLabel: Locator;
readonly articleExplanation: Locator;
readonly legacyFilters: Locator;
readonly editInterestsButton: Locator;
readonly backButton: Locator;
readonly clearSearchButton: Locator;
constructor(readonly page: Page) {
this.rows = page.getByTestId("inbox-article-row");
this.searchInput = page.getByRole("textbox", { name: "Search articles" });
this.articleTitle = page.getByTestId("inbox-article-title");
this.workspace = page.getByTestId("inbox-workspace");
this.emptyState = page.getByText("No articles found", { exact: true });
this.forYouLabel = page.getByText("FOR YOU", { exact: true });
this.mockLabel = page.getByText("Mock", { exact: true });
this.articleExplanation = page.getByText("Why this article", { exact: true });
this.legacyFilters = page.getByRole("button", { name: /For you|Read later|Unread only/ });
this.editInterestsButton = page.getByRole("button", { name: "Edit interests", exact: true });
this.backButton = page.getByRole("button", { name: "Back to articles" });
this.clearSearchButton = page.getByRole("button", { name: "Clear search" });
}
async goTo(search = "") {
await this.page.goto(`/inbox${search}`, { waitUntil: "networkidle" });
}
article(title: string) {
return this.page.getByRole("article", { name: title, exact: true });
}
articleHeading(title: string) {
return this.article(title).getByRole("heading", { name: title, exact: true });
}
articleText(title: string, text: string) {
return this.article(title).getByText(text);
}
rowTitle(index: number) {
return this.rows.nth(index).getByTestId("inbox-row-title");
}
async selectArticle(title: string) {
await this.rows.filter({ hasText: title }).click();
}
async openFirstArticle() {
await this.rows.first().click();
}
async backToArticles() {
if (await this.backButton.isVisible()) await this.backButton.click();
}
async search(query: string) {
await this.searchInput.fill(query);
}
async clearSearch() {
await this.clearSearchButton.click();
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Locator, Page } from "@playwright/test";
export class ResearchPage {
readonly panel: Locator;
readonly conversation: Locator;
readonly composer: Locator;
readonly send: Locator;
readonly newChat: Locator;
readonly unconfiguredNotice: Locator;
readonly bubbles: Locator;
readonly messageLists: Locator;
readonly messageTables: Locator;
constructor(readonly page: Page) {
this.panel = this.page.getByRole("complementary", { name: "Research chat" });
this.conversation = this.page.getByRole("log", { name: "Research conversation" });
this.composer = this.panel.getByRole("textbox", { name: "Message", exact: true });
this.send = this.panel.getByRole("button", { name: "Send", exact: true });
this.newChat = this.page.getByRole("button", { name: "New chat", exact: true });
this.unconfiguredNotice = this.panel.getByText(
"The connection to Codex on your home server is not configured.",
);
this.bubbles = this.conversation.locator('[data-slot="bubble-content"]');
this.messageLists = this.conversation.locator("ul");
this.messageTables = this.conversation.getByRole("table");
}
async goTo(search = "", options?: Parameters<Page["goto"]>[1]) {
await this.page.goto(`/deck${search}`, options);
}
accountPicker(count: number) {
return this.page.getByRole("button", { name: `Connected accounts · ${count}` });
}
account(name: string) {
return this.page.getByRole("checkbox", { name, exact: true });
}
messageHeading(name: string) {
return this.conversation.getByRole("heading", { name, exact: true });
}
emphasis(text: string) {
return this.conversation.locator("strong").filter({ hasText: text });
}
citation(name: string) {
return this.conversation.getByRole("link", { name, exact: true });
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { Locator, Page } from "@playwright/test";
export class VitalsPage {
readonly bodyHeading: Locator;
readonly environmentHeading: Locator;
readonly systems: Locator;
readonly history: Locator;
readonly healthMenuButton: Locator;
readonly healthNavigation: Locator;
readonly bodySystems: Locator;
readonly trendMetrics: Locator;
readonly refreshedStatus: Locator;
readonly targetTemperature: Locator;
readonly coolButton: Locator;
readonly autoButton: Locator;
readonly purifierOnButton: Locator;
readonly purifierOffButton: Locator;
constructor(readonly page: Page) {
this.bodyHeading = page.getByRole("heading", { name: "Body", exact: true });
this.environmentHeading = page.getByRole("heading", { name: "Environment", exact: true });
this.systems = page.getByTestId("vital-system-card");
this.history = page.getByTestId("vital-history");
this.healthMenuButton = page.getByRole("button", { name: "Open Health menu" });
this.healthNavigation = page.getByRole("complementary", { name: "Health navigation" });
this.bodySystems = page.getByRole("group", { name: "Body systems", exact: true });
this.trendMetrics = page.getByRole("group", { name: "Trend metric" });
this.refreshedStatus = page
.getByRole("status")
.filter({ hasText: "Sample readings refreshed" });
this.targetTemperature = page.getByRole("slider", { name: "Target temperature" });
this.coolButton = page.getByRole("button", { name: "Cool", exact: true });
this.autoButton = page.getByRole("button", { name: "Auto", exact: true });
this.purifierOnButton = page.getByRole("button", { name: "Air purifier · On" });
this.purifierOffButton = page.getByRole("button", { name: "Air purifier · Off" });
}
async goTo(search = "") {
await this.page.goto(`/vitals${search}`, { waitUntil: "networkidle" });
}
async navigateTo(section: "Body" | "Environment") {
if (await this.healthMenuButton.isVisible()) await this.healthMenuButton.click();
await this.healthNavigation.getByRole("button", { name: section, exact: true }).click();
}
async selectSystem(name: string) {
await this.bodySystems.getByRole("button", { name, exact: true }).click();
}
async openFirstSystem() {
await this.systems.first().click();
}
systemDialog(name: string) {
return this.page.getByRole("dialog", { name });
}
async closeSystem(name: string) {
await this.systemDialog(name)
.getByRole("button", { name: "Close", exact: true })
.last()
.click();
}
async showLabHistory() {
await this.page.getByRole("button", { name: "Lab history", exact: true }).click();
}
async showSystems() {
await this.page.getByRole("button", { name: "Systems", exact: true }).click();
}
async selectTrendPeriod(period: string) {
await this.page.getByRole("button", { name: period, exact: true }).click();
}
async selectTrendMetric(name: string) {
await this.trendMetrics.getByRole("button", { name, exact: true }).click();
}
trendChart(period: string, metric: string) {
return this.page.getByRole("img", {
name: `${period} ${metric} simulated environmental readings`,
});
}
async openMetric(name: string) {
await this.page.getByTestId("vital-metric").filter({ hasText: name }).click();
}
metricDialog(name: string) {
return this.page.getByRole("dialog", { name });
}
async closeMetric(name: string) {
await this.metricDialog(name).getByRole("button", { name: "Back to overview" }).click();
}
async syncReadings() {
await this.page.getByRole("button", { name: "Sync now", exact: true }).click();
}
async openClimateControl() {
await this.page.getByRole("button", { name: "Climate control", exact: true }).click();
}
async enableCooling() {
await this.coolButton.click();
}
async turnOffPurifier() {
await this.purifierOnButton.click();
}
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, type Locator, type Page } from "@playwright/test";
type Destination = "Home" | "Vitals" | "Messages" | "Journal" | "Reader" | "Research";
export class WorkspacePage {
readonly manageAccounts: Locator;
readonly toggleSidebar: Locator;
readonly mobileSidebar: Locator;
readonly closeSidebar: Locator;
readonly deckManager: Locator;
readonly researchChat: Locator;
readonly researchReady: Locator;
constructor(readonly page: Page) {
this.manageAccounts = page.getByRole("button", { name: "Manage connected accounts" });
this.toggleSidebar = page.getByRole("button", { name: "Toggle sidebar" });
this.mobileSidebar = page.locator('[data-mobile="true"][data-sidebar="sidebar"]');
this.closeSidebar = page.getByRole("button", { name: "Close sidebar", exact: true });
this.deckManager = page.getByRole("button", { name: "Deck manager", exact: true });
this.researchChat = page.getByRole("button", { name: "Research chat", exact: true });
this.researchReady = page.getByRole("button", { name: "Rename", includeHidden: true });
}
navigationLink(destination: Destination) {
return this.page.getByRole("link", { name: destination, exact: true }).first();
}
async navigateTo(destination: Destination) {
const link = this.navigationLink(destination);
if (!(await link.isVisible())) await this.ensureSidebarOpen();
await link.click();
}
async ensureSidebarOpen() {
if (!(await this.manageAccounts.isVisible())) await this.toggleSidebar.click();
}
async showDeckManagement() {
await expect(this.researchReady).toBeEnabled();
await this.ensureSidebarOpen();
await this.deckManager.click();
}
async showResearch() {
await expect(this.researchReady).toBeEnabled();
await this.ensureSidebarOpen();
await this.researchChat.click();
}
async closeMobileSidebar() {
if (await this.mobileSidebar.isVisible()) {
await this.closeSidebar.click();
await expect(this.mobileSidebar).toBeHidden();
}
}
async openConnectedAccounts() {
await this.ensureSidebarOpen();
await this.manageAccounts.click();
}
}
Generated
+78 -4
View File
@@ -1,12 +1,84 @@
{ {
"nodes": { "nodes": {
"agent-skills-nix": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1783839654,
"narHash": "sha256-rn5DkeQ9PBaJTZl9juBtPnVawyN4B83tyz1VpiinQFo=",
"owner": "Kyure-A",
"repo": "agent-skills-nix",
"rev": "8326df98cf494dc515db99affa9ea9d2bd6666b6",
"type": "github"
},
"original": {
"owner": "Kyure-A",
"repo": "agent-skills-nix",
"type": "github"
}
},
"hallmark": {
"flake": false,
"locked": {
"lastModified": 1780605762,
"narHash": "sha256-+yPIG1XdI6hhyOH48rd20+YlFrA9Gr416tPt1OoxfwQ=",
"owner": "Nutlope",
"repo": "hallmark",
"rev": "aeb42fb354ff4efa36ab475773a082315a3af2ce",
"type": "github"
},
"original": {
"owner": "Nutlope",
"repo": "hallmark",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"agent-skills-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1783222121,
"narHash": "sha256-E/ElL373TO8lQ2aMvYyzN+k4xkVaUGoRqoa8AtM71tI=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "a1645f40777620c4bd2b6d854b290c2fc354a266",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1783776592, "lastModified": 1783224372,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", "rev": "d407951447dcd00442e97087bf374aad70c04cea",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1790945372,
"narHash": "sha256-DkojfMkyIfG7Hu8oPDJoEwnq+biEDGVV61kv4ePv6SA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "168543c3491074de451852748ac9b310dad0021a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -18,7 +90,9 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "agent-skills-nix": "agent-skills-nix",
"hallmark": "hallmark",
"nixpkgs": "nixpkgs_2"
} }
} }
}, },
+162 -2
View File
@@ -2,21 +2,181 @@
description = "Intentional X reader"; description = "Intentional X reader";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.agent-skills-nix.url = "github:Kyure-A/agent-skills-nix";
inputs.hallmark = {
url = "github:Nutlope/hallmark";
flake = false;
};
outputs = { nixpkgs, ... }: outputs = inputs@{ self, nixpkgs, ... }:
let let
systems = [ "x86_64-linux" "aarch64-linux" ]; systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems; forAllSystems = nixpkgs.lib.genAttrs systems;
mkTwitterLite = pkgs:
pkgs.stdenvNoCC.mkDerivation (finalAttrs: {
pname = "twitter-lite";
version = "0.0.0";
src = self;
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11;
fetcherVersion = 4;
hash = "sha256-Rb6im7siGqmP7woz/kHalfPygY3dpmLbFfyOcOx9/+w=";
};
nativeBuildInputs = with pkgs; [
makeWrapper
nodejs_22
pnpm_11
pnpmConfigHook
];
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/twitter-lite
cp -r .output/. $out/lib/twitter-lite/
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite \
--add-flags "$out/lib/twitter-lite/server/index.mjs"
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \
--add-flags "$out/lib/twitter-lite/server/tools/backup-database.js"
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-setup \
--add-flags "$out/lib/twitter-lite/server/tools/account-setup.js"
runHook postInstall
'';
meta.mainProgram = "twitter-lite";
});
in { in {
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
twitter-lite = mkTwitterLite pkgs;
in {
inherit twitter-lite;
default = twitter-lite;
});
devShells = forAllSystems (system: devShells = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; }; let
pkgs = import nixpkgs { inherit system; };
agentLib = inputs.agent-skills-nix.lib.agent-skills;
sources.hallmark = {
path = inputs.hallmark;
subdir = "skills";
};
catalog = agentLib.discoverCatalog sources;
allowlist = agentLib.allowlistFor {
inherit catalog sources;
enableAll = true;
};
selection = agentLib.selectSkills {
inherit catalog allowlist sources;
skills = { };
};
bundle = agentLib.mkBundle { inherit pkgs selection; };
localTargets = builtins.mapAttrs (_: target:
target // {
enable = true;
}) agentLib.defaultLocalTargets;
in { in {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = with pkgs; [ nodejs_22 pnpm chromium ]; packages = with pkgs; [ nodejs_22 pnpm chromium ];
shellHook = agentLib.mkShellHook {
inherit pkgs bundle;
targets = localTargets;
};
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
PLAYWRIGHT_CHROMIUM_EXECUTABLE = PLAYWRIGHT_CHROMIUM_EXECUTABLE =
"${pkgs.chromium}/bin/chromium"; "${pkgs.chromium}/bin/chromium";
}; };
}); });
nixosModules.default = { config, lib, pkgs, ... }:
let cfg = config.services.twitter-lite;
in {
options.services.twitter-lite = {
enable = lib.mkEnableOption "Personal Workspace";
package = lib.mkOption {
type = lib.types.package;
default = mkTwitterLite pkgs;
defaultText = lib.literalExpression "twitter-lite.packages.\${pkgs.system}.default";
description = "Personal Workspace package to run.";
};
relayBaseUrl = lib.mkOption {
type = lib.types.nonEmptyStr;
example = "http://127.0.0.1:6900";
description = "Base URL of the Twitter relay.";
};
publicOrigin = lib.mkOption {
type = lib.types.nonEmptyStr;
example = "https://home.example-tailnet.ts.net";
description = "Exact Tailscale Serve HTTPS origin, without a trailing slash.";
};
allowedLogin = lib.mkOption {
type = lib.types.nonEmptyStr;
description = "Tailscale login allowed to access this personal workspace.";
};
credentialKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/secrets/twitter-lite-key";
description = "Runtime file containing the base64-encoded 32-byte credential encryption key. Never put this key in the Nix store.";
};
mastodonOrigins = lib.mkOption {
type = lib.types.listOf lib.types.nonEmptyStr;
default = [ ];
example = [ "https://mastodon.social" ];
description = "Allowed Mastodon HTTPS origins, without trailing slashes.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port on which Personal Workspace listens.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.twitter-lite = {
description = "Personal Workspace";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HOST = "127.0.0.1";
PORT = toString cfg.port;
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
TWITTER_LITE_ORIGIN = cfg.publicOrigin;
TWITTER_LITE_ALLOWED_LOGIN = cfg.allowedLogin;
TWITTER_LITE_DB_PATH = "/var/lib/twitter-lite/workspace.sqlite";
TWITTER_LITE_MASTODON_ORIGINS = lib.concatStringsSep "," cfg.mastodonOrigins;
} // lib.optionalAttrs (cfg.credentialKeyFile != null) {
TWITTER_LITE_CREDENTIAL_KEY_FILE = "%d/credential-key";
};
serviceConfig = {
DynamicUser = true;
StateDirectory = "twitter-lite";
StateDirectoryMode = "0700";
UMask = "0077";
LoadCredential = lib.optional (cfg.credentialKeyFile != null)
"credential-key:${cfg.credentialKeyFile}";
ExecStart = lib.getExe cfg.package;
Restart = "on-failure";
};
};
};
};
}; };
} }
+6
View File
@@ -0,0 +1,6 @@
import type { KnipConfig } from "knip";
export default {
entry: ["playwright.config.ts", "e2e/**/*.test.ts", "e2e/mock-relay.mjs"],
playwright: false,
} satisfies KnipConfig;
+72
View File
@@ -0,0 +1,72 @@
import { defineConfig, defineTransformer } from "orval";
const selectedTag = "twitter-lite-e2e";
const selectedOperationIds = new Set([
"getUserByScreenName",
"getUserTweets",
"getTweetDetail",
"getSearchTimeline",
"getListLatestTweetsTimeline",
]);
const selectE2eOperations = defineTransformer((document) => {
const foundOperationIds = new Set<string>();
for (const pathItem of Object.values(document.paths ?? {})) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const candidate of Object.values(pathItem)) {
if (
!candidate ||
typeof candidate !== "object" ||
Array.isArray(candidate) ||
!("operationId" in candidate)
) {
continue;
}
const operation = candidate as {
operationId?: string;
tags?: string[];
};
if (operation.operationId && selectedOperationIds.has(operation.operationId)) {
foundOperationIds.add(operation.operationId);
operation.tags = [...(operation.tags ?? []), selectedTag];
}
}
}
const missingOperationIds = [...selectedOperationIds].filter(
(operationId) => !foundOperationIds.has(operationId),
);
if (missingOperationIds.length > 0) {
throw new Error(
`twitter-openapi is missing selected operations: ${missingOperationIds.join(", ")}`,
);
}
return document;
});
export default defineConfig({
twitterOpenApi: {
input: {
target:
"https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml",
filters: {
mode: "include",
tags: [selectedTag],
},
override: {
transformer: selectE2eOperations,
},
},
output: {
clean: true,
client: "fetch",
mode: "single",
target: "./e2e/generated/twitter-openapi/client.ts",
},
},
});
+57 -12
View File
@@ -2,53 +2,98 @@
"name": "twitter-lite", "name": "twitter-lite",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=22.12.0"
},
"imports": { "imports": {
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && oxfmt --write src/features/storage/migrations.generated.ts",
"db:backup": "tsx scripts/backup-database.ts",
"dev": "vite dev --host 127.0.0.1 --port 3000", "dev": "vite dev --host 127.0.0.1 --port 3000",
"dev:tailscale": "vite dev --host 0.0.0.0 --port 3000", "dev:tailscale": "vite dev --host 127.0.0.1 --port 3000",
"generate:e2e-openapi": "orval --config orval.config.ts --fail-on-warnings && oxfmt --write e2e/generated",
"generate-routes": "node scripts/generate-routes.mjs", "generate-routes": "node scripts/generate-routes.mjs",
"check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts", "check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts",
"build": "vite build", "build": "vite build && node scripts/build-tools.mjs",
"start": "HOST=127.0.0.1 node .output/server/index.mjs", "start": "HOST=127.0.0.1 node .output/server/index.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check .", "lint": "oxlint",
"format": "biome check --write .", "knip": "knip",
"format": "oxfmt --write .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts" "test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
"account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts",
"format:check": "oxfmt --check ."
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0",
"@modelcontextprotocol/server": "2.0.0",
"@shadcn/react": "^0.3.1",
"@tanstack/react-pacer": "^0.23.0",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.17", "@tanstack/react-router": "1.170.17",
"@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27", "@tanstack/react-start": "1.168.27",
"@yuta/bird": "link:../bird", "@yuta/bird": "0.10.1",
"ai": "7.0.118",
"ai-sdk-provider-codex-cli": "2.3.0",
"better-sqlite3": "13.0.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"drizzle-orm": "0.45.3",
"lucide-react": "1.48.0",
"nitro": "3.0.260610-beta", "nitro": "3.0.260610-beta",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.7",
"shadcn": "4.21.0",
"streamdown": "^2.6.0",
"tw-animate-css": "1.4.0",
"usewebmcp": "5.1.0",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@axe-core/playwright": "4.11.1",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@tanstack/router-cli": "1.167.18", "@shadcn/helpers": "0.2.0",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "10.4.1", "@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@types/better-sqlite3": "9.6.0",
"@types/node": "26.1.1", "@types/node": "26.1.1",
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/sanitize-html": "2.16.1",
"@vitejs/plugin-react": "6.0.3", "@vitejs/plugin-react": "6.0.3",
"drizzle-kit": "0.31.11",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"knip": "6.27.0",
"orval": "8.24.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0",
"oxlint-tsgolint": "^7.0.2003",
"tailwindcss": "^4.3.3",
"tsx": "4.23.12",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.1.4", "vite": "8.1.4",
"vitest": "4.1.10" "vitest": "4.1.10"
},
"engines": {
"node": ">=22.12.0"
},
"packageManager": "[email protected]",
"knip": {
"ignore": [
"e2e/generated/**"
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
]
}
} }
} }
+23 -15
View File
@@ -1,41 +1,49 @@
import { defineConfig, devices } from '@playwright/test' import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig, devices } from "@playwright/test";
const appPort = 4173 const appPort = 4173;
const relayPort = 6911 const relayPort = 6911;
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE;
const databasePath =
process.env.TWITTER_LITE_E2E_DB_PATH ??
join(mkdtempSync(join(tmpdir(), "twitter-lite-e2e-")), "workspace.sqlite");
process.env.TWITTER_LITE_E2E_DB_PATH = databasePath;
if (!chromiumExecutable) { if (!chromiumExecutable) {
throw new Error( throw new Error(
'PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.', "PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.",
) );
} }
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: "./e2e",
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
use: { use: {
baseURL: `http://127.0.0.1:${appPort}`, baseURL: `http://127.0.0.1:${appPort}`,
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
launchOptions: { executablePath: chromiumExecutable }, launchOptions: { executablePath: chromiumExecutable },
screenshot: 'only-on-failure', screenshot: "only-on-failure",
trace: 'retain-on-failure', trace: "retain-on-failure",
}, },
webServer: [ webServer: [
{ {
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`, command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} pnpm exec tsx e2e/mock-relay.mjs`,
port: relayPort, port: relayPort,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
}, },
{ {
command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_PATH= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
url: `http://127.0.0.1:${appPort}`, port: appPort,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
}, },
], ],
projects: [ projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: "desktop", use: { ...devices["Desktop Chrome"] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } }, { name: "mobile", use: { ...devices["Pixel 7"] } },
], ],
}) });
+6701 -218
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,2 +1,3 @@
allowBuilds: allowBuilds:
better-sqlite3: false
esbuild: true esbuild: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+9
View File
@@ -0,0 +1,9 @@
import { getSetupToken, readAuthState } from "../src/features/auth/auth.server";
if (!readAuthState().needsSetup) {
console.error("This workspace already has an owner. Sign in with your existing account.");
process.exitCode = 1;
} else {
console.log("Open /setup in your workspace and enter this one-time setup code:");
console.log(getSetupToken());
}
+11
View File
@@ -0,0 +1,11 @@
import { backupDatabase } from "../src/features/storage/backup.server";
const source = process.env.TWITTER_LITE_DB_PATH;
const destination = process.argv[2];
if (!source || !destination || process.argv.length !== 3) {
console.error("Usage: TWITTER_LITE_DB_PATH=/absolute/app.db pnpm db:backup /absolute/backup.db");
process.exitCode = 1;
} else {
await backupDatabase(source, destination);
console.log(`Backup verified: ${destination}`);
}
+13
View File
@@ -0,0 +1,13 @@
import { build } from "vite";
for (const entry of ["backup-database", "account-setup"]) {
await build({
configFile: false,
build: {
ssr: `scripts/${entry}.ts`,
outDir: ".output/server/tools",
emptyOutDir: false,
rollupOptions: { external: ["better-sqlite3"] },
},
});
}
+10
View File
@@ -0,0 +1,10 @@
import { writeFileSync } from "node:fs";
import { readMigrationFiles } from "drizzle-orm/migrator";
const migrations = readMigrationFiles({
migrationsFolder: new URL("../drizzle", import.meta.url).pathname,
});
writeFileSync(
new URL("../src/features/storage/migrations.generated.ts", import.meta.url),
`// Generated by pnpm db:generate. Do not edit.\nexport const migrations = ${JSON.stringify(migrations, null, 2)}\n`,
);
+4 -4
View File
@@ -1,9 +1,9 @@
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { createServer } from 'vite' import { createServer } from "vite";
const server = await createServer({ const server = await createServer({
configFile: false, configFile: false,
plugins: [tanstackStart()], plugins: [tanstackStart()],
server: { middlewareMode: true }, server: { middlewareMode: true },
}) });
await server.close() await server.close();
+104 -28
View File
@@ -1,34 +1,110 @@
import { Link } from '@tanstack/react-router' import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type ReactNode, useState } from "react";
import {
Sidebar,
SidebarInset,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "#/components/ui/sidebar";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
export function AppShell({ type ShellProps = {
active, hasTemporaryDecks: boolean;
children, sidebar: ReactNode;
}: { researchChat: (closeMobileSidebar: () => void) => ReactNode;
active: 'user' | 'search' children: ReactNode;
children: React.ReactNode };
}) {
export function AppShell(props: ShellProps) {
return ( return (
<main className="app"> <TooltipProvider delay={800}>
<header className="app-header"> <SidebarProvider className="h-dvh min-h-0 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
<p className="brand">TWITTER LITE</p> <Workspace {...props} />
<nav aria-label="閲覧方法" className="tabs"> </SidebarProvider>
<Link </TooltipProvider>
aria-current={active === 'user' ? 'page' : undefined} );
search={{ target: '' }} }
to="/user"
function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) {
const [panel, setPanel] = useState<"chat" | "decks">("chat");
const { setOpen, setOpenMobile, isMobile } = useSidebar();
const navigation = [
{ id: "chat", title: "Research chat", icon: MessageSquare },
{ id: "decks", title: "Deck manager", icon: Columns3 },
] as const;
return (
<>
<Sidebar
collapsible="icon"
className="overflow-hidden transition-none *:data-[sidebar=sidebar]:flex-row"
> >
ユーザー <WorkspaceNavigation
</Link> hasTemporaryDecks={hasTemporaryDecks}
<Link footer={
aria-current={active === 'search' ? 'page' : undefined} <SidebarMenuItem>
search={{ q: '', product: 'Latest', following: false }} <SidebarMenuButton
to="/search" aria-label="Close sidebar"
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
> >
検索 <PanelLeftClose />
</Link> </SidebarMenuButton>
</nav> </SidebarMenuItem>
}
>
{navigation.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-label={item.title}
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
onClick={() => {
setPanel(item.id);
if (isMobile) setOpenMobile(true);
else setOpen(true);
}}
className="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar collapsible="none" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="Deck manager"
className={
panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
}
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
<div className="flex flex-col gap-4">{sidebar}</div>
</section>
</Sidebar>
</Sidebar>
<SidebarInset className="min-w-0 overflow-hidden">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header> </header>
<section className="reader">{children}</section> <section
</main> className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
) aria-label="Decks"
>
{children}
</section>
</SidebarInset>
</>
);
} }
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "cn";
import { Icon } from "./icon";
import { Button } from "./ui/button";
import {
DialogClose,
DialogContent,
DialogHeader,
Dialog as DialogRoot,
DialogTitle,
} from "./ui/dialog";
export function Dialog({
title,
onClose,
children,
className,
}: {
title: string;
className?: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<DialogRoot
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className={cn("max-h-[calc(100dvh-2rem)] overflow-y-auto", className)}
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogClose
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-2"
aria-label="Close"
/>
}
>
<Icon name="close" />
</DialogClose>
{children}
</DialogContent>
</DialogRoot>
);
}
+32
View File
@@ -0,0 +1,32 @@
const paths = {
columns: "M3 4h7v16H3z M14 4h7v16h-7z",
hashtag: "M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16",
search: "M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0",
user: "M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0",
list: "M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01",
plus: "M12 5v14 M5 12h14",
close: "M6 6l12 12 M18 6 6 18",
refresh: "M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6",
more: "M5 12h.01 M12 12h.01 M19 12h.01",
arrow: "M7 17 17 7 M7 7h10v10",
edit: "m16 3 5 5-12 12H4v-5z M14 5l5 5",
trash: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7",
} as const;
export function Icon({ name }: { name: keyof typeof paths }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={paths[name]} />
</svg>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
const URL = /(https?:\/\/[^\s]+)/g;
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi;
const NAMED_ENTITIES: Record<string, string> = {
"&amp;": "&",
"&apos;": "'",
"&gt;": ">",
"&lt;": "<",
"&quot;": '"',
};
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity;
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16);
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
});
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text);
const parts: ReactNode[] = [];
let cursor = 0;
for (const match of decoded.matchAll(URL)) {
const href = match[0];
const start = match.index;
if (start > cursor) parts.push(decoded.slice(cursor, start));
parts.push(
<a href={href} key={`link-${start}`} rel="noreferrer noopener" target="_blank">
{href}
</a>,
);
cursor = start + href.length;
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor));
return parts;
}
export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</>;
}
+90
View File
@@ -0,0 +1,90 @@
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "cn";
import type * as React from "react";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };

Some files were not shown because too many files have changed in this diff Show More