Author SHA1 Message Date
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
315 changed files with 43366 additions and 6586 deletions
+18 -1
View File
@@ -1,2 +1,19 @@
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
# 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=
+3
View File
@@ -4,7 +4,10 @@
.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
+40
View File
@@ -1,4 +1,44 @@
[ [
{
"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", "date": "2026-07-26",
"macrostructure": "Workbench", "macrostructure": "Workbench",
+7 -6
View File
@@ -1,9 +1,10 @@
{ {
"date": "2026-07-26", "date": "2026-09-24",
"font_stack": "Inter / Noto Sans JP / system sans declared in src/styles.css", "font_stack": "System sans; tokens.css",
"palette": "CSS custom properties with muted teal hex values", "palette": "Dark navy and blue OKLCH tokens",
"motion": "motion-cut; no motion library", "motion": "motion-cut",
"spacing": "no named scale", "spacing": "named token scale",
"framework": "React 19 + TanStack Start + Vite 8", "framework": "React 19 + TanStack Start + Vite 8",
"preserve": ["routes", "information architecture", "read-only behavior"] "preserve": ["deck data model", "profile binding", "WebMCP contracts"],
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
} }
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [".output/**", ".tanstack/**", "src/routeTree.gen.ts"]
}
+206
View File
@@ -0,0 +1,206 @@
{
"$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",
"transition-none",
"inbox-sidebar",
"inbox-list-panel",
"support-sidebar",
"support-list"
]
},
{
"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", "deck-checkbox"]
},
{
"pattern": "^Textarea$",
"allow": ["layout", "text-sm", "leading-7", "journal-summary"]
},
{
"pattern": "^MessageScroller$",
"allow": ["layout", "research-conversation"]
},
{
"pattern": "^MessageScrollerContent$",
"allow": ["layout", "gap-5", "p-4"]
},
{
"pattern": "^Marker$",
"allow": ["layout", "research-tool-message"]
},
{
"pattern": "^Message$",
"allow": ["layout", "research-message"]
},
{
"pattern": "^Collapsible$",
"allow": ["layout", "research-account-picker"]
},
{
"pattern": "^SidebarProvider$",
"allow": [
"layout",
"workspace",
"home-workspace",
"inbox-workspace",
"vital-workspace",
"support-workspace",
"journal-workspace"
]
},
{
"pattern": "^Button$",
"allow": ["layout", "auth-submit", "inbox-back", "support-back"]
},
{
"pattern": "^SidebarInset$",
"allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"]
},
{
"pattern": "^Input$",
"allow": ["layout", "pl-9"]
},
{
"pattern": "^SidebarContent$",
"allow": ["layout", "gap-0"]
},
{
"pattern": "^Badge$",
"allow": ["layout", "deck-source-label"]
}
]
}
],
"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"
]
}
+315 -54
View File
@@ -1,41 +1,105 @@
# Twitter Lite # Personal Workspace
Twitter Lite is an intentional, read-only X reader. It shows content only after A personal workspace for understanding the day, choosing the next action,
you enter a user handle, profile URL, search query, or list URL, follow a keeping context, and exploring information. Home brings together a short brief,
deliberate post detail link, or manually open a valid flexible tasks and routines, messages, notes, and reading. A shared sidebar opens
`/:handle/status/:tweetId` URL. 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
- Authenticated account list selection and list timelines from a deliberate URL or ID drafts and a contextual side panel. These execution surfaces remain mocks;
- Infinite cursor pagination with explicit retry their records share browser-session state and external sending is simulated.
- Deliberate post detail pages with the visible conversation Reply review can call Jev through Vercel AI Gateway when the server has
- Infinite conversation loading with explicit continuation retry `AI_GATEWAY_API_KEY`. It checks the draft against the sample conversation;
- Read-only cards for text, media, quotes, articles, and quiet engagement counts 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.
Post detail pages show only the selected post and its visible conversation; | Workspace | Route | Current implementation |
they do not add related-post recommendations or an account-discovery surface. | --------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| 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. |
## Routes 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.
- `/` shows the empty user input. See [execution support](docs/execution-support.md) and the
- `/:handle` shows a user timeline. [vitals preview](docs/vitals-preview.md) for prototype scope.
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
- `/i/lists` shows the authenticated account's lists. ## URL state
- `/i/lists/:listId` shows a list timeline.
- `/:handle/status/:tweetId` shows one selected post and its visible conversation. 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
- 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 committed `.npmrc` Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
routes the `@yuta` scope to the public Gitea Packages registry, where Bird routes the `@yuta` scope to the public Gitea Packages registry.
`0.10.0` provides the required Top and Latest search interface.
```bash ```bash
nix develop -c pnpm install --frozen-lockfile nix develop -c pnpm install --frozen-lockfile
@@ -43,48 +107,242 @@ cp .env.example .env.local
``` ```
The Nix development shell installs Hallmark's agent skill for the supported The Nix development shell installs Hallmark's agent skill for the supported
local agent targets. local agent targets. Set the runtime configuration in `.env.local` for Vite
development, or export it before running the production server. In addition to
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set the relay URL, configure the Serve origin, owner login, and an absolute SQLite
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same path from `.env.example`. Mastodon needs an allowed instance and a runtime
values in the process environment before `start` or `test:live`: 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 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. Browser tests are organized under `e2e/`: page object models
and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1
A and AA. `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 existing validated, account-scoped research functions are exposed through
its in-process `createSdkMcpServer` bridge instead. 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.
`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. Chat uses MessageScroller, Message, Bubble,
Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved
conversations. 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. Theme colors are declared in
`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens,
base styles and feature styles in that order, so the UI lint rules can resolve
application classes through the same stylesheet used by the app.
`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 existing workspace
styles; 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).
## 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 ## NixOS service
The flake provides both a production package and a NixOS module. Import the The flake provides a production package and a NixOS module:
module and configure the relay:
```nix ```nix
{ {
@@ -100,7 +358,10 @@ module and configure the relay:
services.twitter-lite = { services.twitter-lite = {
enable = true; enable = true;
relayBaseUrl = "http://127.0.0.1:6900"; relayBaseUrl = "http://127.0.0.1:6900";
# profileName = "default"; publicOrigin = "https://home.example-tailnet.ts.net";
allowedLogin = "your-tailscale-login";
mastodonOrigins = [ "https://fedi.yutakobayashi.com" ];
credentialKeyFile = "/var/lib/secrets/twitter-lite-key";
}; };
} }
]; ];
@@ -109,15 +370,15 @@ module and configure the relay:
} }
``` ```
The service listens on `127.0.0.1:3000` by default. Set The service listens on `127.0.0.1:3000`. Set
`services.twitter-lite.host` or `services.twitter-lite.port` to change the `services.twitter-lite.port` to change the port; the host remains loopback.
listener. The package can also be built directly with `nix build`. 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
Bird uses X's internal GraphQL operations through the configured relay. Query Bird uses X's internal GraphQL operations through the configured relay. Query
IDs and response shapes can change without notice. IDs and response shapes can change without notice.
Conversation pages use Bird's current per-page chronological ordering. Keeping
X's original ranked branch order is deferred until Bird exposes that order
without expanding the relay surface.
-16
View File
@@ -1,16 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"files": {
"includes": [
"**",
"!src/routeTree.gen.ts",
"!!**/.output",
"!!**/test-results"
]
},
"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.
+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
@@ -89,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.
@@ -106,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
@@ -149,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
@@ -68,16 +68,16 @@ Work spans two repositories and the Gitea package registry:
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;
} }
``` ```
@@ -126,7 +126,7 @@ 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.
@@ -175,10 +175,14 @@ None of these functions exposes the Bird client, arbitrary method names, arbitra
### 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.
@@ -207,7 +211,11 @@ 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 a user target normalizes it to a handle and navigates to `/:handle`. 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 Submitting a search updates the route's typed search parameters. Changing tabs
@@ -344,7 +352,9 @@ The work is complete when:
target or query, following a post detail link, or manually opening a valid target or query, following a post detail link, or manually opening a valid
status URL. 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.
+22
View File
@@ -0,0 +1,22 @@
# 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 are scoped to `--vital-*` tokens in `vitals.css`.
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.
+126
View File
@@ -0,0 +1,126 @@
# WebMCP prototype
## Registration
The deck workspace exposes nine React-owned tools on `/` and `/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 WebMCP/testing flags and invoke actual registered
tools through `navigator.modelContextTesting`. 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
}
]
}
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('home page has no accessibility violations', async ({
a11y,
homePage,
}) => {
await homePage.goTo()
await expect(homePage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from '../fixtures'
test('list picker has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('list timeline has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(listsPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from '../fixtures'
test('empty search page has no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('search results have no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await searchPage.search('AI lang:ja')
await expect(searchPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('conversation has no accessibility violations', async ({
a11y,
threadPage,
}) => {
await threadPage.goTo('yuta', '1001')
await expect(threadPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('user timeline has no accessibility violations', async ({
a11y,
userPage,
}) => {
await userPage.goTo('yuta')
await expect(userPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
+85 -31
View File
@@ -1,34 +1,88 @@
import AxeBuilder from '@axe-core/playwright' import { basename, dirname, isAbsolute } from "node:path";
import { test as base } from '@playwright/test' import AxeBuilder from "@axe-core/playwright";
import { HomePage } from './models/HomePage' import { test as base } from "@playwright/test";
import { ListsPage } from './models/ListsPage' import {
import { SearchPage } from './models/SearchPage' completeOnboarding,
import { ThreadPage } from './models/ThreadPage' createOwner,
import { UserPage } from './models/UserPage' readAuthState,
signIn,
} from "../src/features/auth/auth.server";
import { openDatabase } from "../src/features/storage/database.server";
type Fixtures = { export const test = base.extend<{
a11y: () => AxeBuilder a11y: () => AxeBuilder;
homePage: HomePage resetDecks: undefined;
listsPage: ListsPage sessionToken: string;
searchPage: SearchPage }>({
threadPage: ThreadPage // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
userPage: UserPage sessionToken: async ({}, use) => {
} const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (!path || !basename(dirname(path)).startsWith("twitter-lite-e2e-"))
export const test = base.extend<Fixtures>({ throw new Error("Isolated E2E database is required.");
a11y: async ({ page }, use) => { const database = openDatabase(path);
const makeAxeBuilder = () => const credentials = {
new AxeBuilder({ page }) email: "[email protected]",
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) password: "E2E-only-passphrase-2026",
.disableRules(['meta-viewport']) };
process.env.WORKSPACE_SETUP_TOKEN = "isolated-e2e-setup-token-not-for-production";
await use(makeAxeBuilder) const result = readAuthState(database).needsSetup
? await createOwner(
{
...credentials,
name: "Yuta",
setupToken: process.env.WORKSPACE_SETUP_TOKEN,
}, },
homePage: ({ page }, use) => use(new HomePage(page)), database,
listsPage: ({ page }, use) => use(new ListsPage(page)), )
searchPage: ({ page }, use) => use(new SearchPage(page)), : await signIn(credentials, database);
threadPage: ({ page }, use) => use(new ThreadPage(page)), if (!result.owner.onboardingCompletedAt)
userPage: ({ page }, use) => use(new UserPage(page)), 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' 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
+53
View File
@@ -0,0 +1,53 @@
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,
request,
}) => {
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await page.goto("/deck");
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);
});
+134
View File
@@ -0,0 +1,134 @@
import { openDatabase } from "../../src/features/storage/database.server";
import { expect, test } from "../fixtures";
const origin = "http://127.0.0.1:4173";
test("creates the owner once and enters onboarding with a real session", async ({
page,
context,
a11y,
}) => {
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 page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/setup$/);
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("Your name", { exact: true }).fill("Yuta");
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByLabel("Confirm password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByLabel("Setup code").fill("isolated-e2e-setup-token-not-for-production");
await page.getByRole("button", { name: "Create account", exact: true }).click();
await expect(page).toHaveURL(/\/onboarding$/);
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Open workspace" }).click();
await expect(page.getByRole("heading", { name: "Good morning, Yuta" })).toBeVisible();
});
test("requires login for documents, server functions, and live updates", async ({
page,
context,
}) => {
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await page.goto("/deck");
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: origin, "Sec-Fetch-Site": "same-origin" },
});
expect(result.status()).toBe(401);
}
await page.goto("/journal");
await expect(page).toHaveURL(/\/login(?:\?|$)/);
await expect(page.getByRole("heading", { name: "Welcome back." })).toBeVisible();
await expect(page.getByRole("link", { name: "Journal", exact: true })).toHaveCount(0);
});
test("signs in and revokes the session on sign out", async ({ page, context, a11y }) => {
await context.clearCookies();
await page.goto("/login", { waitUntil: "networkidle" });
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("Wrong-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Invalid email or password.");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL(`${origin}/`);
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 page.getByRole("button", { name: "Manage connected accounts" }).click();
await page.getByRole("button", { name: "Sign out", exact: true }).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(page.getByRole("link", { name: "Home", exact: true })).toHaveCount(0);
});
test("requires onboarding and remembers its completion and name", async ({
page,
request,
a11y,
}) => {
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 page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/onboarding$/);
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("What should we call you?").fill("Yuta Test");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Open workspace" }).click();
await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
await page.reload();
await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
await page.goto("/onboarding");
await expect(page).toHaveURL(`${origin}/`);
} 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,
}) => {
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: 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 page.goto("/setup");
await expect(page).toHaveURL(/\/login(?:\?|$)/);
});
+52
View File
@@ -0,0 +1,52 @@
import { expect, test } from "../fixtures";
test("opens account management after hydration without inheriting real OAuth configuration", async ({
page,
}) => {
await page.goto("/deck");
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const manage = page.getByRole("button", { name: "Manage connected accounts" });
if (!(await manage.isVisible())) {
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await expect(manage).toBeEnabled();
await manage.click();
const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Authorize with Mastodon" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(manage).toBeFocused();
});
test("opens account management from the inbox footer and restores focus on close", async ({
page,
a11y,
}) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const manage = page.getByRole("button", { name: "Manage connected accounts" });
await expect(manage).toBeVisible();
await manage.click();
const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(manage).toBeFocused();
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
});
test("rejects an unbound OAuth callback without contacting an instance", async ({ page }) => {
await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code");
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/);
await expect(page.getByRole("alert")).toContainText("Could not complete Mastodon authorization.");
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const manage = page.getByRole("button", { name: "Manage connected accounts" });
if (!(await manage.isVisible())) {
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await expect(page.getByRole("button", { name: "Manage connected accounts" })).toBeEnabled();
});
+327
View File
@@ -0,0 +1,327 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showDeckManagement } from "../workspace";
async function addColumn(
page: Page,
title: string,
query: string,
profile = "e2e",
kind = "search",
) {
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const editor = 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 closeMobileSidebar(page);
await expect(
page.getByRole("region", { name: title, exact: true }).locator("article").first(),
).toBeVisible();
}
test.beforeEach(async ({ page }) => {
await page.goto("/deck");
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Save as deck", exact: true }).click();
await showDeckManagement(page);
await expect(page.getByText("Saved to server · Shared across devices")).toBeVisible();
await closeMobileSidebar(page);
});
test("binds identical searches to independent profiles through paging, editing and reload", async ({
page,
}) => {
await addColumn(page, "通常の接続", "same-query");
await addColumn(page, "別の接続", "same-query", "alternate");
const first = page.getByRole("region", { name: "通常の接続", exact: true });
const second = page.getByRole("region", { name: "別の接続", exact: true });
await expect(first.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(second.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
await first.getByRole("button", { name: "Load more" }).click();
await expect(first.locator("article")).toHaveCount(2);
await expect(second.locator("article")).toHaveCount(1);
await second.getByRole("button", { name: "Load more" }).click();
await expect(second.getByText("alternate · Latest · all page 2")).toBeVisible();
await second.getByRole("button", { name: "Actions for 別の接続" }).click();
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
await page
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "e2e" });
await page.getByRole("button", { name: "Save changes" }).click();
await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(second.getByText("alternate · Latest · all page 1")).toHaveCount(0);
await page.reload();
await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(first.locator("article")).toHaveCount(1);
});
test("creates, switches, renames and deletes persisted deck profiles", async ({ page }) => {
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("Research topic / deck name").fill("最初の調査");
await page.getByRole("button", { name: "Save name" }).click();
await addColumn(page, "最初の観点", "first-deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
await page.getByLabel("New deck name").fill("別の調査");
await page.getByRole("button", { name: "Create", exact: true }).click();
await expect(page.locator(".deck-column")).toHaveCount(0);
await addColumn(page, "別の観点", "second-deck", "alternate");
await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
await closeMobileSidebar(page);
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
await closeMobileSidebar(page);
await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]);
await showDeckManagement(page);
await page.getByRole("button", { name: "Delete deck", exact: true }).click();
await page.getByRole("button", { name: "Delete", exact: true }).click();
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
await showDeckManagement(page);
await expect(page.getByLabel("Deck profile", { exact: true }).locator("option")).toHaveCount(1);
});
test("reads user and list timelines within columns and reorders, deletes and restores them", async ({
page,
}) => {
await addColumn(page, "アカウント", "yuta", "alternate", "user");
await addColumn(page, "リストの投稿", "1234567890", "e2e", "list");
const user = page.getByRole("region", { name: "アカウント", exact: true });
const list = page.getByRole("region", { name: "リストの投稿", exact: true });
await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(list.getByText("list page 1")).toBeVisible();
await list.getByRole("button", { name: "Load more" }).click();
await expect(list.getByText("list page 2")).toBeVisible();
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await page.reload();
await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(list.getByText("list page 1")).toBeVisible();
});
test("keeps other columns usable when pagination fails and retries that column", async ({
page,
}, testInfo) => {
await addColumn(page, "失敗する検索", `retry-deck-${testInfo.project.name}`);
await addColumn(page, "別の検索", "independent-deck");
const failed = page.getByRole("region", { name: "失敗する検索", exact: true });
const other = page.getByRole("region", { name: "別の検索", exact: true });
await failed.getByRole("button", { name: "Load more" }).click();
await expect(failed.getByRole("alert")).toBeVisible();
await expect(failed.locator("article")).toHaveCount(1);
await other.getByRole("button", { name: "Load more" }).click();
await expect(other.locator("article")).toHaveCount(2);
await failed.getByRole("button", { name: "Retry" }).click();
await expect(failed.locator("article")).toHaveCount(2);
});
test("keeps the deck and editor accessible within narrow viewports", async ({
page,
a11y,
}, testInfo) => {
await addColumn(page, "日本語の反応", "WebMCP lang:ja");
await addColumn(page, "海外の反応", "WebMCP lang:en", "alternate");
await addColumn(page, "公式アカウント", "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 page.locator(".deck-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(page.getByRole("navigation", { name: "Columns" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "公式アカウント", exact: true })).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({
path: testInfo.outputPath("deck-populated.png"),
fullPage: true,
});
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).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,
}) => {
await showDeckManagement(page);
const opener = page.getByRole("button", { name: "Add column", exact: true });
await opener.click();
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toBeVisible();
await page.getByLabel("Column name", { exact: true }).fill("キャンセルする観点");
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
await expect(opener).toBeFocused();
await expect(page.locator(".deck-column")).toHaveCount(0);
await opener.click();
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
});
test("does not overwrite unreadable storage on reload", async ({ page }) => {
await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken"));
await page.reload();
await expect(page.getByRole("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 ({
page,
}) => {
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
await page.getByLabel("Column name", { exact: true }).fill("購読リスト");
await page
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "e2e" });
await page.getByRole("combobox", { name: "Column type", exact: true }).selectOption("list");
await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toBeVisible();
await page
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "alternate" });
await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Alternate List", exact: true }).click();
await expect(page.getByLabel("List", { exact: true })).toHaveValue("1234567890");
await page.getByRole("button", { name: "Add and search" }).click();
await closeMobileSidebar(page);
await expect(
page
.getByRole("region", { name: "購読リスト", exact: true })
.getByText("list page 1", { exact: true }),
).toBeVisible();
});
test("keeps an open draft through remote edits and rejects its stale save", async ({
page,
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();
await device.goto("/deck");
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("Research topic / deck name").fill("編集中の名前");
await device.getByRole("button", { name: "Rename" }).click();
await device.getByLabel("Research topic / deck name").fill("別の端末の変更");
await device.getByRole("button", { name: "Save name" }).click();
await expect(device.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更");
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前");
await page.getByRole("button", { name: "Save name" }).click();
await expect(page.getByRole("alert")).toContainText("This deck changed on another device");
await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Reload latest deck" }).click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更");
} finally {
await other.close();
}
});
test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({
page,
}) => {
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 showDeckManagement(page);
const selector = page.getByLabel("Deck profile", { exact: true });
await expect(selector.locator("option")).toHaveCount(1);
await closeMobileSidebar(page);
await page.getByRole("button", { name: "Import legacy decks" }).click();
await showDeckManagement(page);
await expect(selector.locator("option")).toHaveCount(2);
await selector.selectOption({ label: "以前の調査" });
await closeMobileSidebar(page);
await expect(page.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await page.evaluate(
(legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
legacy,
);
await page.reload();
await page.getByRole("button", { name: "Import legacy decks" }).click();
await expect(page.getByRole("button", { name: "Import legacy decks" })).toHaveCount(0);
await showDeckManagement(page);
await expect(selector.locator("option")).toHaveCount(2);
});
test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ page }) => {
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Create deck", exact: true });
await dialog.getByLabel("New deck name").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(dialog.getByLabel("New deck name")).toHaveValue("画面を回転しても残る調査");
await expect(page.locator("#new-deck-title")).toHaveCount(1);
}
await dialog.getByRole("button", { name: "Create", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("画面を回転しても残る調査");
});
+189
View File
@@ -0,0 +1,189 @@
import { expect, test } from "../fixtures";
test("executes routine steps and records a measurement in the shared journal", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Morning routine 0 of 3/ }).click();
await expect(page).toHaveURL(/activity=task/);
await page.getByRole("checkbox", { name: /Drink water/ }).check();
await expect(page.getByRole("button", { name: "Finish routine" })).toBeDisabled();
await page.getByRole("checkbox", { name: /Get ready/ }).check();
await page.getByRole("checkbox", { name: /Have breakfast/ }).check();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true });
await page.getByRole("button", { name: "Finish routine" }).click();
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("button", { name: "Save measurement" }).click();
await expect(
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
).toBeVisible();
await page.getByRole("link", { name: "Journal", exact: true }).first().click();
await expect(page.getByRole("article", { name: "Weight entry" })).toContainText("65.2 kg");
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(page.getByRole("button", { name: "Restore Morning routine" })).toBeVisible();
});
test("shows the day flow and conversation context at desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1080 });
await page.goto("/");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("region", { name: "Today’s plan" })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("home-desktop.png") });
await page.goto("/support?conversation=design-review");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).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,
}) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const memo = "明日のレビューは導線から確認する";
await page.getByRole("textbox", { name: "Home input" }).fill(`Note: ${memo}`);
await page.getByRole("button", { name: "Preview action" }).click();
const execute = page.getByRole("button", { name: "Apply", exact: true });
await expect(execute).toBeVisible();
await execute.click();
await page.getByRole("link", { name: "Journal", exact: true }).first().click();
await expect(page.getByRole("article", { name: "Note entry" })).toContainText(memo);
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
});
test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Home 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 }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const input = page.getByRole("textbox", { name: "Home input" });
const complete = page.getByRole("button", {
name: "Complete grocery shopping",
exact: true,
});
await input.fill("Add grocery shopping");
await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toHaveCount(0);
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toBeVisible();
await input.fill("Defer grocery shopping");
await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toBeVisible();
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toHaveCount(0);
await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
await complete.click();
await expect(complete).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
).toBeVisible();
});
test("opens a conversation page from home and retains outcomes on return", async ({
page,
a11y,
}) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const conversationLink = page.getByRole("link", { name: "Open conversation with Haruka Aoki" });
await expect(conversationLink).toHaveAttribute("href", /conversation=design-review/);
await conversationLink.click();
await expect(page).toHaveURL(/\/support\?.*conversation=design-review/);
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.goBack();
await expect(conversationLink).toBeVisible();
await page.goForward();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Leave for later" }).click();
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(conversationLink).toContainText("Set aside");
await conversationLink.click();
await expect(page).toHaveURL(/conversation=design-review/);
await expect(page.getByRole("button", { name: "Move to Inbox" })).toBeVisible();
await page.getByRole("button", { name: "Move to Inbox" }).click();
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(conversationLink).toBeVisible();
});
test("updates the prose brief as priorities change and opens the referenced conversation", async ({
page,
}) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const brief = page.getByRole("region", { name: "Daily brief" });
await expect(brief).toContainText("Review the documents");
await expect(brief).toContainText("3 conversations");
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
await expect(brief).not.toContainText("Review the documents");
await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
await page.getByRole("button", { name: "Leave for later" }).click();
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(brief).toContainText("2 conversations");
await expect(brief).not.toContainText("Haruka Aoki");
await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
await page.getByRole("button", { name: "Preview action" }).click();
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(brief).toContainText("資料の2ページ目から");
});
test("keeps completed actions in their original positions with visible outcomes", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const titles = page.locator(".execution-flow-title");
const before = await titles.allTextContents();
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
await expect(
page.getByRole("button", { name: "Restore Review the documents", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByRole("button", { name: "Review the documents Completed", exact: true }),
).toBeVisible();
await page.getByRole("link", { name: "Open conversation with Haruka Aoki" }).click();
await page.getByRole("button", { name: "Review reply", exact: true }).click();
await page
.getByRole("dialog", { name: "Review reply", exact: true })
.getByRole("button", { name: "Try sending" })
.click();
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(
page.getByRole("link", { name: "Open conversation with Haruka Aoki" }),
).toContainText("Reply recorded");
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("button", { name: "Save measurement" }).click();
await expect(
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
).toBeVisible();
expect(await titles.allTextContents()).toEqual(before);
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") });
await page.getByRole("button", { name: "Restore Review the documents", exact: true }).click();
await expect(
page.getByRole("button", { name: "Complete Review the documents", exact: true }),
).toHaveAttribute("aria-pressed", "false");
});
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from "../fixtures";
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const rows = page.locator(".inbox-article-row");
await expect(rows).toHaveCount(9);
const articleTitle = "Context for an agent can be brief";
await rows.filter({ hasText: articleTitle }).click();
const reader = page.getByRole("article", { name: articleTitle, exact: true });
await expect(reader).toBeVisible();
await expect(reader.getByRole("heading", { name: articleTitle, exact: true })).toBeVisible();
await expect(
reader.getByText("Start with a one-sentence goal, then add the constraints that must hold.", {
exact: false,
}),
).toBeVisible();
await expect(page.getByText("FOR YOU", { exact: true })).toHaveCount(0);
await expect(page.getByText("Mock", { exact: true })).toHaveCount(0);
await expect(page.getByText("Why this article", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: /For you|Read later|Unread only/ })).toHaveCount(0);
const back = page.getByRole("button", { name: "Back to articles" });
if (await back.isVisible()) await back.click();
await rows.filter({ hasText: "Three columns create a rhythm for reading" }).click();
await expect(
page.getByRole("article", {
name: "Three columns create a rhythm for reading",
exact: true,
}),
).toBeVisible();
});
test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const rows = page.locator(".inbox-article-row");
await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
await expect(rows).toHaveCount(0);
await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear search" }).click();
await expect(rows).toHaveCount(9);
expect((await a11y().analyze()).violations).toEqual([]);
await expect(page.getByRole("button", { name: "Edit interests", exact: true })).toHaveCount(0);
await page.getByRole("link", { name: "Research", exact: true }).click();
await expect(page).toHaveURL(/\/deck$/);
await page.getByRole("link", { name: "Reader", exact: true }).click();
await expect(page).toHaveURL(/\/inbox$/);
await expect(rows).toHaveCount(9);
});
test("keeps article navigation readable without horizontal overflow", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.locator(".inbox-article-row").first().click();
const reader = page.getByRole("article", {
name: "Thinking in the browser: a new workflow with AI agents",
exact: true,
});
await expect(reader).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([]);
const back = page.getByRole("button", { name: "Back to articles" });
if (await back.isVisible()) await back.click();
}
});
-192
View File
@@ -1,192 +0,0 @@
import { expect, test } from '../fixtures'
let consoleErrors: string[]
let pageErrors: string[]
test.beforeEach(async ({ page }) => {
consoleErrors = []
pageErrors = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
})
test.afterEach(() => {
expect(consoleErrors, 'browser console errors').toEqual([])
expect(pageErrors, 'uncaught browser errors').toEqual([])
})
test('requires intent and infinitely loads a user timeline', async ({
homePage,
page,
userPage,
}) => {
await homePage.goTo()
await expect(page).toHaveURL(/\/$/)
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
'content',
'no-referrer',
)
await expect(homePage.articlesLocator).toHaveCount(0)
await expect(homePage.ambientLinksLocator).toHaveCount(0)
await homePage.displayUser('@yuta')
await expect(page).toHaveURL(/\/yuta$/)
await expect(page.getByText('user page 1')).toBeVisible()
await expect(page.getByText('user page 2')).toBeVisible()
await expect(userPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-user.png', {
animations: 'disabled',
fullPage: true,
})
})
test('selects an authenticated list and infinitely loads its timeline', async ({
listsPage,
page,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible()
await expect(listsPage.endOfFeedLocator).toBeVisible()
})
test('opens a card detail and infinitely loads its conversation', async ({
page,
threadPage,
userPage,
}) => {
await userPage.goTo('yuta')
await userPage.openPostDetails('user page 1')
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
await expect(threadPage.headingLocator).toBeVisible()
await expect(threadPage.selectedPostLabelLocator).toBeVisible()
await expect(page.getByText('user page 1')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-thread.png', {
animations: 'disabled',
fullPage: true,
})
})
test('resolves a reply root and retries only the failed continuation', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '2001')
await expect(page.getByText('retry focal')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('retry focal')).toHaveCount(1)
await threadPage.retryContinuation()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
})
test('ends a thread without loading a continuation when there are no replies', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '3001')
await expect(page.getByText('no replies')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
})
test('searches Top posts from followed accounts', async ({
page,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.searchTopFromFollowing('AI lang:ja')
await expect(searchPage.followingOnlyLocator).toBeChecked()
await expect(page).toHaveURL(/following=true/)
await expect(page.getByText('Top · follows page 1')).toBeVisible()
await expect(page.getByText('Top · follows page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('retains the first page through a pagination error and retries explicitly', async ({
page,
searchPage,
}, testInfo) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`retry-${testInfo.project.name}`)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
await searchPage.retryContinuation()
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
searchPage,
}) => {
await searchPage.goTo()
await searchPage.tabTo(
searchPage.page.getByRole('link', { name: 'ユーザー' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator)
await expect(searchPage.latestRankingLocator).toBeChecked()
await searchPage.tabTo(searchPage.latestRankingLocator)
await searchPage.tabTo(searchPage.followingOnlyLocator)
await searchPage.tabTo(searchPage.searchButtonLocator)
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
})
test('removes loading rail animation for reduced motion', async ({
page,
searchPage,
}, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`slow-${testInfo.project.name}`)
const rail = searchPage.loadingRailLocator
await expect(rail).toBeVisible()
await expect
.poll(() =>
rail.evaluate((element) =>
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
),
)
.toBe('none')
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

+378
View File
@@ -0,0 +1,378 @@
import type { DeckColumn } from "../../src/features/decks/model";
import type { ResearchRun } from "../../src/features/research/model";
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace";
test("pushes the current research snapshot on each SSE connection", async ({ page }) => {
await page.goto("/deck");
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,
}) => {
await page.goto("/deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole("combobox", {
name: "Connection profile",
exact: true,
});
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await closeMobileSidebar(page);
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 page.goto(`/deck?run=${run.id}`);
await showResearch(page);
const conversation = page.getByRole("log", { name: "Research conversation" });
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("list_decks: 完了");
await expect(conversation).toContainText("既存の検索条件を再利用しています。");
await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(page.locator(".deck-column h2")).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 showResearch(page);
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("比較する観点を追加しました。");
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]);
await closeMobileSidebar(page);
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
await showResearch(page);
const accountPicker = page.getByRole("button", {
name: "Connected accounts · 1",
});
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked();
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "false");
await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく");
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
run.deckVersion = 3;
if (run.deck) run.deck.title = "閉じている間に更新された調査";
await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", {
timeout: 15000,
});
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.setViewportSize({ width: 600, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 1" }).click();
await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck();
await page.setViewportSize({ width: 1100, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
await page.setViewportSize({ width: 600, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
await expect(page.getByRole("checkbox", { name: "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,
a11y,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole("combobox", {
name: "Connection profile",
exact: true,
});
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await closeMobileSidebar(page);
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 page.goto(`/deck?run=${run.id}`);
await showResearch(page);
const chat = page.getByRole("log", { name: "Research conversation" });
await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible();
await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600");
await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc");
await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible();
await expect(chat.getByRole("table")).toBeVisible();
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
await chat.getByRole("link", { name: "根拠の投稿" }).click();
const card = page.locator(
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
);
await expect(card).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/);
await expect(card).toBeFocused();
await expect(card).toContainText("Post saved during research");
await expect(card).toContainText("AIが調査時に取得した引用本文");
expect(
await page.locator(".deck-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).toHaveClass(/citation-highlight/);
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
await expect(page.locator(".deck-column")).toHaveCount(3);
expect(submitted).toEqual([]);
expect((await a11y().analyze()).violations).toEqual([]);
});
test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({
page,
a11y,
}) => {
await page.goto("/deck");
const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toBeVisible();
const originalTitle = await heading.textContent();
await showResearch(page);
const panel = page.getByRole("complementary", { name: "Research chat" });
await expect(panel).toBeVisible();
await expect(
panel.getByText("The connection to Codex on your home server is not configured."),
).toBeVisible();
await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled();
const accessibility = await a11y().analyze();
expect(accessibility.violations).toEqual([]);
await closeMobileSidebar(page);
await expect(heading).toHaveText(originalTitle ?? "");
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
await page.getByLabel("New deck name").fill("手動の調査");
await page.getByRole("button", { name: "Create", exact: true }).click();
await closeMobileSidebar(page);
await expect(heading).toHaveText("手動の調査");
await showResearch(page);
await expect(panel).toBeVisible();
});
test("keeps browser conversation selection independent and restores it on reload", async ({
page,
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();
await page.goto(`/deck?run=${first}`, { waitUntil: "domcontentloaded" });
await showResearch(page);
await secondPage.goto(`/deck?run=${second}`, {
waitUntil: "domcontentloaded",
});
await showResearch(secondPage);
await expect(page.getByRole("log", { name: "Research conversation" })).toContainText(
"Browser one conversation",
);
await expect(secondPage.getByRole("log", { name: "Research conversation" })).toContainText(
"Browser two conversation",
);
await secondPage.getByRole("button", { name: "New chat", exact: true }).click();
await expect(secondPage).toHaveURL(/run=new/);
await page.reload({ waitUntil: "domcontentloaded" });
await showResearch(page);
await expect(page).toHaveURL(new RegExp(`run=${first}`));
await expect(page.getByRole("log", { name: "Research 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();
}
});
+133
View File
@@ -0,0 +1,133 @@
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,
}) => {
await page.goto("/support");
await page.waitForLoadState("networkidle");
const rows = page.locator(".support-row");
await expect(rows).toHaveCount(3);
await rows.filter({ hasText: reviewSubject }).click();
await page.locator(".conversation-suggestions summary:visible").click();
await page.getByRole("button", { name: /^Polite/ }).click();
const reply = page.getByRole("textbox", { name: "Reply", exact: true });
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);
await page.locator(".conversation-scheduling summary:visible").click();
const slot = "Mon, Oct 5, 14:00–14:30";
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
await page.getByRole("button", { name: "Add to reply" }).click();
await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`);
await page.getByRole("button", { name: "Review reply" }).click();
const dialog = page.getByRole("dialog", { name: "Review reply" });
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 dialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(reply).toHaveValue(new RegExp(edited));
await page.getByRole("button", { name: "Review reply" }).click();
await dialog.getByRole("button", { name: "Try sending" }).click();
await expect(dialog).toHaveCount(0);
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
await page.getByRole("button", { name: "Archive", exact: true }).click();
await rows.filter({ hasText: reviewSubject }).click();
await expect(page.locator(".support-outcome")).toContainText(edited);
await expect(page.locator(".support-outcome")).toContainText(slot);
});
test("defers a conversation and restores it to the current queue", async ({ page }) => {
await page.goto("/support");
await page.waitForLoadState("networkidle");
const rows = page.locator(".support-row");
await rows.filter({ hasText: coffeeSubject }).click();
await page
.getByRole("textbox", { name: "Reply", exact: true })
.fill("土曜の予定を確認してから返事するね。");
await page.getByRole("button", { name: "Leave for later" }).click();
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
await page.getByRole("button", { name: "Set aside", exact: true }).click();
await rows.filter({ hasText: coffeeSubject }).click();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
"土曜の予定を確認してから返事するね。",
);
await page.getByRole("button", { name: "Move to Inbox" }).click();
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
await expect(page.getByRole("button", { name: "Inbox", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("keeps corrections as new records and summarizes only selected current facts", async ({
page,
a11y,
}) => {
await page.goto("/journal");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: "Save entry", exact: true })).toBeDisabled();
await page.getByRole("textbox", { name: "Entry text" }).fill("次はレビュー資料の導線を確認する");
await page.getByRole("button", { name: "Save entry", exact: true }).click();
await expect(page.getByRole("article", { name: "Note entry" })).toContainText(
"Occurred: Unknown",
);
await page.getByRole("button", { name: "Weight", exact: true }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("textbox", { name: "Source" }).fill("自宅の体重計");
await page.getByRole("button", { name: "Save entry", exact: true }).click();
await page
.getByRole("article", { name: "Weight entry" })
.getByRole("button", { name: "Correct" })
.click();
const correction = page.getByRole("dialog", { name: "Correct entry" });
await correction.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.8");
await correction.getByRole("button", { name: "Add correction" }).click();
const weights = page.getByRole("article", { name: "Weight entry" });
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 page.getByRole("button", { name: "Summarize entries", exact: true }).click();
const review = page.getByRole("dialog", { name: "Summarize entries" });
const summary = review.getByRole("textbox", { name: "Entry 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 review.getByRole("checkbox", { name: /次はレビュー資料/ }).uncheck();
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,
}, testInfo) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await page.goto("/support");
await page.waitForLoadState("networkidle");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.locator(".support-row").first().click();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).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`) });
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await page.getByRole("link", { name: "Journal", exact: true }).click();
await page.getByRole("button", { name: "Weight", exact: true }).click();
await expect(page.getByRole("spinbutton", { name: "Weight", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
}
});
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "../fixtures";
test("restores article selection with browser history and reload", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
const rows = page.locator(".inbox-article-row");
const heading = await rows.nth(1).locator(".inbox-row-title").innerText();
await rows.nth(1).click();
await expect(page.locator(".inbox-article-title")).toHaveText(heading);
const selectedUrl = page.url();
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
await page.goBack();
await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false");
await page.goForward();
await expect(page).toHaveURL(selectedUrl);
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
});
test("restores search and replaces typing history", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.getByRole("link", { name: "Reader", exact: true }).click();
const search = page.getByRole("textbox", { name: "Search articles" });
await search.pressSequentially("browser");
await expect(search).toHaveValue("browser");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
const count = await page.locator(".inbox-article-row").count();
await page.reload({ waitUntil: "networkidle" });
await expect(search).toHaveValue("browser");
await expect(page.locator(".inbox-article-row")).toHaveCount(count);
await page.goBack();
await expect(page).toHaveURL("http://127.0.0.1:4173/");
});
test("restores account dialog without losing underlying article", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Manage connected accounts" }).click();
await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true");
await page.reload({ waitUntil: "networkidle" });
await expect(page.getByRole("dialog")).toBeVisible();
await page.goBack();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goForward();
await expect(page.getByRole("dialog")).toBeVisible();
});
test("restores a message view and conversation from its URL", async ({ page }) => {
await page.goto("/support", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "All", exact: true }).click();
const row = page.locator(".support-row").nth(1);
const heading = await row.locator("span").first().innerText();
await row.click();
await expect(page.locator(".support-toolbar > span").first()).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(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? "");
await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true");
});
test("keeps a deep link through login", async ({ page, context }) => {
await context.clearCookies();
await page.goto("/inbox?q=browser", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/login\?returnTo=/);
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL(/\/inbox\?q=browser$/);
await expect(page.getByRole("textbox", { name: "Search articles" })).toHaveValue("browser");
});
test("debounces search and cancels pending input on history navigation", async ({ page }) => {
await page.goto("/support", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "All", exact: true }).click();
await expect(page).toHaveURL(/view=all/);
await page.clock.install();
const input = page.getByRole("textbox", { name: "Search messages" });
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("http://127.0.0.1:4173/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 }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
await page.clock.install();
const input = page.getByRole("textbox", { name: "Search articles" });
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");
});
+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from "../fixtures";
test("filters body systems and opens lab history", async ({ page, a11y }) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
const systems = page.locator(".vital-system-card");
await expect(systems).toHaveCount(8);
await page
.getByRole("group", { name: "Body systems", exact: true })
.getByRole("button", { name: "Cardiovascular", exact: true })
.click();
await expect(systems).toHaveCount(1);
await expect(page).toHaveURL(/vitalTab=Cardiovascular/);
await page.reload();
await page.waitForLoadState("networkidle");
await expect(systems).toHaveCount(1);
await systems.first().click();
const dialog = page.getByRole("dialog", { name: "Cardiovascular" });
await expect(dialog).toContainText("68");
await page.goBack();
await expect(dialog).not.toBeVisible();
await page.goForward();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Lab history", exact: true }).click();
await expect(page.locator(".vital-history")).toContainText("Jul 28");
await expect(page.locator(".vital-history")).toContainText("93");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".vital-history")).toContainText("Jul 28");
await page.getByRole("button", { name: "Systems", exact: true }).click();
await page
.getByRole("group", { name: "Body systems", exact: true })
.getByRole("button", { name: "Overview", exact: true })
.click();
await expect(systems).toHaveCount(8);
expect((await a11y().analyze()).violations).toEqual([]);
});
test("explores environment charts and simulated climate controls", async ({ page, a11y }) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
const menu = page.getByRole("button", { name: "Open Health menu" });
if (await menu.isVisible()) await menu.click();
await page
.getByRole("complementary", { name: "Health navigation" })
.getByRole("button", { name: "Environment", exact: true })
.click();
await expect(page.getByRole("heading", { name: "Environment", exact: true })).toBeVisible();
await page.getByRole("button", { name: "7D", exact: true }).click();
await page
.getByRole("group", { name: "Trend metric" })
.getByRole("button", { name: "CO₂", exact: true })
.click();
await expect(
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible();
await page.reload();
await page.waitForLoadState("networkidle");
await expect(
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible();
await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click();
const dialog = page.getByRole("dialog", { name: "Room temperature" });
await expect(dialog).toContainText("64.0");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(dialog).toContainText("64.0");
await dialog.getByRole("button", { name: "Back to overview" }).click();
await page.getByRole("button", { name: "Sync now", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "Sample readings refreshed" }),
).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByRole("button", { name: "Climate control", exact: true }).click();
await page.getByRole("button", { name: "Cool", exact: true }).click();
await expect(page.getByRole("button", { name: "Cool", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
const temperature = page.getByRole("slider", { name: "Target temperature" });
await temperature.focus();
await temperature.press("ArrowRight");
await expect(temperature).toHaveValue("69");
await page.getByRole("button", { name: "Air purifier · On" }).click();
await expect(page.getByRole("button", { name: "Air purifier · Off" })).toHaveAttribute(
"aria-pressed",
"false",
);
await page.reload();
await page.waitForLoadState("networkidle");
await expect(temperature).toHaveValue("68");
await expect(page.getByRole("button", { name: "Auto", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Air purifier · On" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("keeps both dashboards within the viewport", async ({ page }, testInfo) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
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`) });
const menu = page.getByRole("button", { name: "Open Health menu" });
if (await menu.isVisible()) await menu.click();
const navigation = page.getByRole("complementary", {
name: "Health navigation",
});
await navigation.getByRole("button", { name: "Environment", exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({
path: testInfo.outputPath(`environment-${width}.png`),
});
if (await menu.isVisible()) await menu.click();
await navigation.getByRole("button", { name: "Body", exact: true }).click();
}
});
+283
View File
@@ -0,0 +1,283 @@
import type { Page } from "@playwright/test";
import Database from "better-sqlite3";
import { expect, test } from "../fixtures";
type NativeTesting = {
listTools(): { name: string }[];
executeTool(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,WebMCPTesting"],
},
});
async function toolNames(page: Page) {
return page.evaluate(() =>
(navigator as Navigator & { modelContextTesting: NativeTesting }).modelContextTesting
.listTools()
.map((tool) => tool.name)
.sort(),
);
}
async function executeTool(
page: Page,
name: string,
input: Record<string, unknown> = {},
): Promise<ToolResult> {
const result = await page.evaluate(
({ name, input }) =>
(
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting.executeTool(name, 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 }) => {
pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/deck");
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,
}) => {
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(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
await page
.getByRole("region", { name: "日本語", exact: true })
.getByRole("button", { name: "Actions for 日本語" })
.click();
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
const replaced = await executeTool(page, "set_deck", {
deckId: deck.id,
title: "更新した調査",
columns: [deck.columns[1]],
});
expect(replaced.isError).not.toBe(true);
await expect(page.getByRole("dialog")).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();
await device.goto("/deck");
await expect(device.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
await expect(
device.getByText("alternate · Latest · all page 1", { exact: true }),
).toBeVisible();
} finally {
await other.close();
}
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
});
test("requires revisions for saved mutations and rejects stale overwrites", async ({ page }) => {
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(page.getByRole("heading", { level: 1 })).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,
}) => {
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(page.getByRole("heading", { level: 1 })).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(page.getByRole("heading", { level: 1 })).toHaveText("New research");
});
test("rejects invalid input without changing the active view", async ({ page }) => {
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(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
}
expect(savedCount()).toEqual({ count: 0 });
});
test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({
page,
}) => {
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(page.getByRole("heading", { level: 1 })).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 }) => {
const deck = decoded(
await executeTool(page, "set_deck", {
title: "接続を比較",
columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")],
}),
).deck;
await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).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(
page.getByRole("region", { name: "通常", exact: true }).locator("article"),
).toHaveCount(1);
await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] });
expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true);
});
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showResearch } from "../workspace";
test("keeps the sidebar, composer and deck reachable at compact widths", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/deck");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await showResearch(page);
const chat = page.getByRole("complementary", { name: "Research chat" });
await expect(chat).toBeVisible();
const composer = chat.getByRole("textbox", {
name: "Message",
exact: true,
});
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 closeMobileSidebar(page);
}
await page.getByRole("button", { name: "Toggle sidebar" }).click();
await expect(page.getByRole("complementary", { name: "Research chat" })).toBeHidden();
await page.getByRole("button", { name: "Research chat", exact: true }).click();
await expect(page.getByRole("complementary", { name: "Research chat" })).toBeVisible();
});
+208 -227
View File
@@ -1,55 +1,51 @@
import { createServer } from 'node:http' import { createServer } from "node:http";
import { tweetLegacyFixture } from "./fixtures/twitter.ts";
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT;
const port = Number(portValue) const port = Number(portValue);
if (!portValue || !Number.isInteger(port) || port < 1) { if (!portValue || !Number.isInteger(port) || port < 1) {
throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port') throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port");
} }
const failedRetryQueries = new Set() const failedRetryQueries = new Set();
const rootByTarget = new Map([ const rootByTarget = new Map([
['1001', '1000'], ["1001", "1000"],
['2001', '2000'], ["2001", "2000"],
['3001', '3000'], ["3001", "3000"],
]) ]);
const targetByRoot = new Map([ const targetByRoot = new Map([
['1000', '1001'], ["1000", "1001"],
['2000', '2001'], ["2000", "2001"],
['3000', '3001'], ["3000", "3001"],
]) ]);
const failedThreadRoots = new Set() const failedThreadRoots = new Set();
const isRecord = (value) => const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
value !== null && typeof value === 'object' && !Array.isArray(value)
const tweetResult = ( const tweetResult = (
id, id,
text, text,
username = 'yuta', username = "yuta",
{ conversationId = id, inReplyTo, replyCount = 1 } = {}, { conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({ ) => ({
rest_id: id, rest_id: id,
legacy: { legacy: tweetLegacyFixture(id, text, {
full_text: text, conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026', inReplyTo,
reply_count: replyCount, replyCount,
retweet_count: 2, }),
favorite_count: 3,
conversation_id_str: conversationId,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
},
core: { core: {
user_results: { user_results: {
result: { result: {
rest_id: '42', rest_id: "42",
legacy: { screen_name: username, name: 'Yuta' }, legacy: { screen_name: username, name: "Yuta" },
}, },
}, },
}, },
}) });
const tweet = (id, text, username = 'yuta', options) => ({ const tweet = (id, text, username = "yuta", options) => ({
entryId: `tweet-${id}`, entryId: `tweet-${id}`,
content: { content: {
itemContent: { itemContent: {
@@ -58,141 +54,140 @@ const tweet = (id, text, username = 'yuta', options) => ({
}, },
}, },
}, },
}) });
const cursor = (value) => ({ const cursor = (value) => ({
entryId: 'cursor-bottom', entryId: "cursor-bottom",
content: { cursorType: 'Bottom', value }, content: { cursorType: "Bottom", value },
}) });
const send = (response, payload, status = 200, headers = {}) => { const send = (response, payload, status = 200, headers = {}) => {
response.writeHead(status, { response.writeHead(status, {
'content-type': 'application/json; charset=utf-8', "content-type": "application/json; charset=utf-8",
...headers, ...headers,
}) });
response.end(JSON.stringify(payload)) response.end(JSON.stringify(payload));
} };
const fail = (response, status, message, headers) => const fail = (response, status, message, headers) =>
send(response, { errors: [{ message }] }, status, headers) send(response, { errors: [{ message }] }, status, headers);
const variablesFrom = (url) => { const variablesFrom = (url) => {
const encoded = url.searchParams.get('variables') const encoded = url.searchParams.get("variables");
if (!encoded) throw new Error('variables are required') if (!encoded) throw new Error("variables are required");
const variables = JSON.parse(encoded) const variables = JSON.parse(encoded);
if (!isRecord(variables)) throw new Error('variables must be an object') if (!isRecord(variables)) throw new Error("variables must be an object");
return variables return variables;
} };
const jsonBodyFrom = async (request) => { const jsonBodyFrom = async (request) => {
let body = '' let body = "";
for await (const chunk of request) { for await (const chunk of request) {
body += chunk body += chunk;
if (body.length > 1_000_000) throw new Error('request body is too large') 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 parsed = JSON.parse(body);
if (!isRecord(parsed)) throw new Error("JSON body must be an object");
return parsed;
};
const timeline = (entries) => ({ instructions: [{ entries }] }) const timeline = (entries) => ({ instructions: [{ entries }] });
const server = createServer(async (request, response) => { const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`) const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
const operation = [ if (request.method === "GET" && url.pathname === "/profiles") {
'UserByScreenName', send(response, { profiles: ["e2e", "alternate"] });
'UserTweets', return;
'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' 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) { if (request.method !== expectedMethod) {
fail(response, 405, `expected ${expectedMethod}`, { fail(response, 405, `expected ${expectedMethod}`, {
allow: expectedMethod, allow: expectedMethod,
}) });
return return;
} }
if (request.headers['x-profile-name'] !== 'e2e') { const profileName = request.headers["x-profile-name"];
fail(response, 403, 'invalid profile') if (profileName !== "e2e" && profileName !== "alternate") {
return fail(response, 403, "invalid profile");
return;
} }
let variables let variables;
try { try {
variables = variablesFrom(url) variables = variablesFrom(url);
} catch { } catch {
fail(response, 400, 'malformed variables') fail(response, 400, "malformed variables");
return return;
} }
if (operation === 'UserByScreenName') { if (operation === "UserByScreenName") {
if ( if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) {
typeof variables.screen_name !== 'string' || fail(response, 400, "screen_name is required");
variables.screen_name.length === 0 return;
) {
fail(response, 400, 'screen_name is required')
return
} }
send(response, { send(response, {
data: { data: {
user: { user: {
result: { result: {
rest_id: '42', rest_id: "42",
legacy: { screen_name: variables.screen_name, name: 'Yuta' }, legacy: { screen_name: variables.screen_name, name: "Yuta" },
}, },
}, },
}, },
}) });
return return;
} }
if (operation === 'UserTweets') { if (operation === "UserTweets") {
const expectedCursor = const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next";
variables.cursor === undefined || variables.cursor === 'user-next' if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) {
if ( fail(response, 400, "invalid user timeline variables");
variables.userId !== '42' || return;
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, 'invalid user timeline variables')
return
} }
const secondPage = variables.cursor === 'user-next' const secondPage = variables.cursor === "user-next";
const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
const entries = secondPage const entries = secondPage
? [tweet('1010', 'user page 2')] ? [tweet("1010", "user page 2")]
: [ : [
tweet('1001', 'user page 1', 'yuta', { tweet("1001", firstPageText, "yuta", {
conversationId: '1000', conversationId: "1000",
}), }),
cursor('user-next'), cursor("user-next"),
] ];
send(response, { send(response, {
data: { data: {
user: { result: { timeline: { timeline: timeline(entries) } } }, user: { result: { timeline: { timeline: timeline(entries) } } },
}, },
}) });
return return;
} }
if (operation === 'ListsManagementPageTimeline') { if (operation === "ListsManagementPageTimeline") {
if (variables.count !== 100) { if (variables.count !== 100) {
fail(response, 400, 'invalid lists management variables') fail(response, 400, "invalid lists management variables");
return return;
} }
const features = url.searchParams.get('features') const features = url.searchParams.get("features");
try { try {
if (!features || !isRecord(JSON.parse(features))) throw new Error() if (!features || !isRecord(JSON.parse(features))) throw new Error();
} catch { } catch {
fail(response, 400, 'invalid lists management feature lock') fail(response, 400, "invalid lists management feature lock");
return return;
} }
send(response, { send(response, {
data: { data: {
@@ -205,25 +200,26 @@ const server = createServer(async (request, response) => {
{ {
content: { content: {
clientEventInfo: { clientEventInfo: {
component: 'suggest_owned_subscribed_list', component: "suggest_owned_subscribed_list",
}, },
items: [ items: [
{ {
item: { item: {
itemContent: { itemContent: {
list: { list: {
id_str: '1234567890', id_str: "1234567890",
name: 'My E2E List', name:
description: 'Accounts selected for testing', profileName === "alternate" ? "Alternate List" : "My E2E List",
description: "Accounts selected for testing",
member_count: 10, member_count: 10,
mode: 'Public', mode: "Public",
created_at: 1_704_067_200_000, created_at: 1_704_067_200_000,
user_results: { user_results: {
result: { result: {
rest_id: '42', rest_id: "42",
core: { core: {
screen_name: 'yuta', screen_name: "yuta",
name: 'Yuta', name: "Yuta",
}, },
}, },
}, },
@@ -241,66 +237,65 @@ const server = createServer(async (request, response) => {
}, },
}, },
}, },
errors: [{ message: 'optional banner field failed' }], errors: [{ message: "optional banner field failed" }],
}) });
return return;
} }
if (operation === 'ListLatestTweetsTimeline') { if (operation === "ListLatestTweetsTimeline") {
const expectedCursor = const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next";
variables.cursor === undefined || variables.cursor === 'list-next'
if ( if (
typeof variables.listId !== 'string' || typeof variables.listId !== "string" ||
!/^\d+$/.test(variables.listId) || !/^\d+$/.test(variables.listId) ||
variables.count !== 20 || variables.count !== 20 ||
!expectedCursor !expectedCursor
) { ) {
fail(response, 400, 'invalid list timeline variables') fail(response, 400, "invalid list timeline variables");
return return;
} }
const secondPage = variables.cursor === 'list-next' const secondPage = variables.cursor === "list-next";
const entries = secondPage const entries = secondPage
? [tweet('3010', 'list page 2', 'listed')] ? [tweet("3010", "list page 2", "listed")]
: [tweet('3001', 'list page 1', 'listed'), cursor('list-next')] : [tweet("3001", "list page 1", "listed"), cursor("list-next")];
send(response, { send(response, {
data: { data: {
list: { tweets_timeline: { timeline: timeline(entries) } }, list: { tweets_timeline: { timeline: timeline(entries) } },
}, },
}) });
return return;
} }
if (operation === 'TweetDetail') { if (operation === "TweetDetail") {
const focalTweetId = variables.focalTweetId const focalTweetId = variables.focalTweetId;
if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) { if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) {
fail(response, 400, 'invalid focalTweetId') fail(response, 400, "invalid focalTweetId");
return return;
} }
const features = url.searchParams.get('features') const features = url.searchParams.get("features");
const fieldToggles = url.searchParams.get('fieldToggles') const fieldToggles = url.searchParams.get("fieldToggles");
try { try {
if (!features || !isRecord(JSON.parse(features))) throw new Error() if (!features || !isRecord(JSON.parse(features))) throw new Error();
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) { if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
throw new Error() throw new Error();
} }
} catch { } catch {
fail(response, 400, 'invalid TweetDetail feature locks') fail(response, 400, "invalid TweetDetail feature locks");
return return;
} }
const targetRoot = rootByTarget.get(focalTweetId) const targetRoot = rootByTarget.get(focalTweetId);
if (targetRoot && variables.cursor === undefined) { if (targetRoot && variables.cursor === undefined) {
const noReplies = focalTweetId === '3001' const noReplies = focalTweetId === "3001";
const text = noReplies const text = noReplies
? 'no replies' ? "no replies"
: focalTweetId === '1001' : focalTweetId === "1001"
? 'user page 1' ? "user page 1"
: 'retry focal' : "retry focal";
const result = tweetResult(focalTweetId, text, 'focus', { const result = tweetResult(focalTweetId, text, "focus", {
conversationId: targetRoot, conversationId: targetRoot,
inReplyTo: targetRoot, inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1, replyCount: noReplies ? 0 : 1,
}) });
send(response, { send(response, {
data: { data: {
tweetResult: { result }, tweetResult: { result },
@@ -308,7 +303,7 @@ const server = createServer(async (request, response) => {
instructions: [ instructions: [
{ {
entries: [ entries: [
tweet(focalTweetId, text, 'focus', { tweet(focalTweetId, text, "focus", {
conversationId: targetRoot, conversationId: targetRoot,
inReplyTo: targetRoot, inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1, replyCount: noReplies ? 0 : 1,
@@ -318,47 +313,43 @@ const server = createServer(async (request, response) => {
], ],
}, },
}, },
}) });
return return;
} }
const targetId = targetByRoot.get(focalTweetId) const targetId = targetByRoot.get(focalTweetId);
if (!targetId) { if (!targetId) {
fail(response, 400, 'unsupported TweetDetail fixture') fail(response, 400, "unsupported TweetDetail fixture");
return return;
} }
const expectedCursor = `thread-next:${focalTweetId}` const expectedCursor = `thread-next:${focalTweetId}`;
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) { if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
fail(response, 400, 'invalid thread cursor') fail(response, 400, "invalid thread cursor");
return return;
} }
if (variables.cursor && focalTweetId === '3000') { if (variables.cursor && focalTweetId === "3000") {
fail(response, 500, 'zero-reply thread must not request a continuation') fail(response, 500, "zero-reply thread must not request a continuation");
return return;
} }
if ( if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) {
variables.cursor && failedThreadRoots.add(focalTweetId);
focalTweetId === '2000' && fail(response, 503, "transient thread fixture failure");
!failedThreadRoots.has(focalTweetId) return;
) {
failedThreadRoots.add(focalTweetId)
fail(response, 503, 'transient thread fixture failure')
return
} }
const entries = variables.cursor const entries = variables.cursor
? [ ? [
tweet(`${focalTweetId}3`, 'thread page 2', 'reply2', { tweet(`${focalTweetId}3`, "thread page 2", "reply2", {
conversationId: focalTweetId, conversationId: focalTweetId,
inReplyTo: targetId, inReplyTo: targetId,
}), }),
] ]
: focalTweetId === '3000' : focalTweetId === "3000"
? [ ? [
tweet(focalTweetId, 'thread root', 'root', { tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId, conversationId: focalTweetId,
}), }),
tweet(targetId, 'no replies', 'focus', { tweet(targetId, "no replies", "focus", {
conversationId: focalTweetId, conversationId: focalTweetId,
inReplyTo: focalTweetId, inReplyTo: focalTweetId,
replyCount: 0, replyCount: 0,
@@ -366,28 +357,23 @@ const server = createServer(async (request, response) => {
cursor(expectedCursor), cursor(expectedCursor),
] ]
: [ : [
tweet(focalTweetId, 'thread root', 'root', { tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId, conversationId: focalTweetId,
}), }),
tweet( tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", {
targetId,
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
'focus',
{
conversationId: focalTweetId, conversationId: focalTweetId,
inReplyTo: focalTweetId, inReplyTo: focalTweetId,
}, }),
), tweet(`${focalTweetId}2`, "thread page 1", "reply1", {
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
conversationId: focalTweetId, conversationId: focalTweetId,
inReplyTo: targetId, inReplyTo: targetId,
}), }),
cursor(expectedCursor), cursor(expectedCursor),
] ];
send(response, { send(response, {
data: { data: {
tweetResult: { tweetResult: {
result: tweetResult(focalTweetId, 'thread root', 'root', { result: tweetResult(focalTweetId, "thread root", "root", {
conversationId: focalTweetId, conversationId: focalTweetId,
}), }),
}, },
@@ -395,67 +381,62 @@ const server = createServer(async (request, response) => {
instructions: [{ entries }], instructions: [{ entries }],
}, },
}, },
}) });
if (variables.cursor && focalTweetId === '2000') { if (variables.cursor && focalTweetId === "2000") {
failedThreadRoots.delete(focalTweetId) failedThreadRoots.delete(focalTweetId);
} }
return return;
} }
const contentType = request.headers['content-type'] ?? '' const contentType = request.headers["content-type"] ?? "";
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail(response, 400, 'SearchTimeline requires JSON') fail(response, 400, "SearchTimeline requires JSON");
return return;
} }
let body let body;
try { try {
body = await jsonBodyFrom(request) body = await jsonBodyFrom(request);
} catch { } catch {
fail(response, 400, 'malformed JSON body') fail(response, 400, "malformed JSON body");
return return;
} }
const rawQuery = variables.rawQuery const rawQuery = variables.rawQuery;
const expectedCursor = const expectedCursor =
variables.cursor === undefined || variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`;
variables.cursor === `search-next:${rawQuery}`
if ( if (
typeof rawQuery !== 'string' || typeof rawQuery !== "string" ||
rawQuery.length === 0 || rawQuery.length === 0 ||
variables.count !== 20 || variables.count !== 20 ||
variables.querySource !== 'typed_query' || variables.querySource !== "typed_query" ||
!['Top', 'Latest'].includes(variables.product) || !["Top", "Latest"].includes(variables.product) ||
!expectedCursor || !expectedCursor ||
!isRecord(body.features) || !isRecord(body.features) ||
typeof body.queryId !== 'string' || typeof body.queryId !== "string" ||
body.queryId.length === 0 || body.queryId.length === 0 ||
Object.hasOwn(body, 'variables') Object.hasOwn(body, "variables")
) { ) {
fail(response, 400, 'invalid search request') fail(response, 400, "invalid search request");
return return;
} }
const secondPage = variables.cursor !== undefined const secondPage = variables.cursor !== undefined;
if ( if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) {
secondPage && failedRetryQueries.add(rawQuery);
rawQuery.startsWith('retry-') && fail(response, 503, "transient fixture failure");
!failedRetryQueries.has(rawQuery) return;
) {
failedRetryQueries.add(rawQuery)
fail(response, 503, 'transient fixture failure')
return
} }
if (secondPage && rawQuery.startsWith('slow-')) { if (secondPage && rawQuery.startsWith("slow-")) {
await new Promise((resolve) => setTimeout(resolve, 1_200)) await new Promise((resolve) => setTimeout(resolve, 1_200));
} }
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all' const follows = rawQuery.includes("filter:follows") ? "follows" : "all";
const label = `${variables.product} · ${follows}` const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`;
const id = encodeURIComponent(rawQuery) const id = encodeURIComponent(rawQuery);
const entries = secondPage const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)] ? [tweet(`s-${id}-2`, `${label} page 2`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)] : [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)];
send(response, { send(response, {
data: { data: {
@@ -463,7 +444,7 @@ const server = createServer(async (request, response) => {
search_timeline: { timeline: timeline(entries) }, search_timeline: { timeline: timeline(entries) },
}, },
}, },
}) });
}) });
server.listen(port, '127.0.0.1') server.listen(port, "127.0.0.1");
-49
View File
@@ -1,49 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test'
export class Base {
readonly ambientLinksLocator: Locator
readonly articlesLocator: Locator
readonly continuationErrorLocator: Locator
readonly endOfFeedLocator: Locator
readonly loadingRailLocator: Locator
readonly mutationButtonsLocator: Locator
readonly mutationLinksLocator: Locator
constructor(readonly page: Page) {
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
const mutationControlNames =
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
this.ambientLinksLocator = page.getByRole('link', {
name: ambientLinkNames,
})
this.articlesLocator = page.locator('article')
this.continuationErrorLocator = page
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
this.loadingRailLocator = page.locator('.loading-rail')
this.mutationButtonsLocator = page.getByRole('button', {
name: mutationControlNames,
})
this.mutationLinksLocator = page.getByRole('link', {
name: mutationControlNames,
})
}
protected async navigateTo(path: string) {
await this.page.goto(path)
await this.page.waitForLoadState('networkidle')
}
async tabTo(target: Locator) {
await this.page.keyboard.press('Tab')
await expect(target).toBeFocused()
}
async retryContinuation() {
await this.continuationErrorLocator
.getByRole('button', { name: '再試行' })
.click()
}
}
-22
View File
@@ -1,22 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class HomePage extends Base {
readonly handleInputLocator: Locator
readonly submitButtonLocator: Locator
constructor(page: Page) {
super(page)
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
}
async goTo() {
await this.navigateTo('/')
}
async displayUser(handle: string) {
await this.handleInputLocator.fill(handle)
await this.submitButtonLocator.click()
}
}
-11
View File
@@ -1,11 +0,0 @@
import { Base } from './Base'
export class ListsPage extends Base {
async goTo() {
await this.navigateTo('/i/lists')
}
async openList(name: RegExp | string) {
await this.page.getByRole('link', { name }).click()
}
}
-35
View File
@@ -1,35 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class SearchPage extends Base {
readonly followingOnlyLocator: Locator
readonly latestRankingLocator: Locator
readonly queryInputLocator: Locator
readonly searchButtonLocator: Locator
readonly topRankingLocator: Locator
constructor(page: Page) {
super(page)
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
this.latestRankingLocator = page.getByLabel('最新順')
this.queryInputLocator = page.getByLabel('検索語')
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
this.topRankingLocator = page.getByLabel('人気順')
}
async goTo() {
await this.navigateTo('/search?q=&product=Latest&following=false')
}
async search(query: string) {
await this.queryInputLocator.fill(query)
await this.searchButtonLocator.click()
}
async searchTopFromFollowing(query: string) {
await this.queryInputLocator.fill(query)
await this.topRankingLocator.check()
await this.followingOnlyLocator.check()
await this.searchButtonLocator.click()
}
}
-17
View File
@@ -1,17 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class ThreadPage extends Base {
readonly headingLocator: Locator
readonly selectedPostLabelLocator: Locator
constructor(page: Page) {
super(page)
this.headingLocator = page.getByRole('heading', { name: '会話' })
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
}
async goTo(handle: string, tweetId: string) {
await this.navigateTo(`/${handle}/status/${tweetId}`)
}
}
-15
View File
@@ -1,15 +0,0 @@
import { Base } from './Base'
export class UserPage extends Base {
async goTo(handle: string) {
await this.navigateTo(`/${handle}`)
}
async openPostDetails(postText: string) {
await this.page
.getByRole('article')
.filter({ hasText: postText })
.getByRole('link', { name: '詳細・スレッド' })
.click()
}
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, type Page } from "@playwright/test";
export async function showDeckManagement(page: Page) {
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const navigation = page.getByRole("button", {
name: "Deck manager",
exact: true,
});
if (!(await navigation.isVisible())) {
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await navigation.click();
}
export async function showResearch(page: Page) {
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const navigation = page.getByRole("button", {
name: "Research chat",
exact: true,
});
if (!(await navigation.isVisible())) {
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await navigation.click();
}
export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]');
if (await sheet.isVisible()) {
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
await expect(sheet).toBeHidden();
}
}
Generated
+3 -3
View File
@@ -74,11 +74,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1784796856, "lastModified": 1783776592,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d", "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github" "type": "github"
}, },
"original": { "original": {
+41 -16
View File
@@ -22,7 +22,7 @@
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11; pnpm = pkgs.pnpm_11;
fetcherVersion = 4; fetcherVersion = 4;
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A="; hash = "sha256-60nQAoDWoO15P7TFLhABqGNYpJy/R+w+Zz3oo7qD8dM=";
}; };
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
@@ -44,6 +44,10 @@
cp -r .output/. $out/lib/twitter-lite/ cp -r .output/. $out/lib/twitter-lite/
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite \ makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite \
--add-flags "$out/lib/twitter-lite/server/index.mjs" --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 runHook postInstall
''; '';
@@ -98,13 +102,13 @@
let cfg = config.services.twitter-lite; let cfg = config.services.twitter-lite;
in { in {
options.services.twitter-lite = { options.services.twitter-lite = {
enable = lib.mkEnableOption "Twitter Lite"; enable = lib.mkEnableOption "Personal Workspace";
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = mkTwitterLite pkgs; default = mkTwitterLite pkgs;
defaultText = lib.literalExpression "twitter-lite.packages.\${pkgs.system}.default"; defaultText = lib.literalExpression "twitter-lite.packages.\${pkgs.system}.default";
description = "Twitter Lite package to run."; description = "Personal Workspace package to run.";
}; };
relayBaseUrl = lib.mkOption { relayBaseUrl = lib.mkOption {
@@ -113,40 +117,61 @@
description = "Base URL of the Twitter relay."; description = "Base URL of the Twitter relay.";
}; };
profileName = lib.mkOption { publicOrigin = lib.mkOption {
type = lib.types.nullOr lib.types.nonEmptyStr; type = lib.types.nonEmptyStr;
default = null; example = "https://home.example-tailnet.ts.net";
example = "default"; description = "Exact Tailscale Serve HTTPS origin, without a trailing slash.";
description = "Relay profile to use when the relay has multiple profiles.";
}; };
host = lib.mkOption { allowedLogin = lib.mkOption {
type = lib.types.nonEmptyStr; type = lib.types.nonEmptyStr;
default = "127.0.0.1"; description = "Tailscale login allowed to access this personal workspace.";
description = "Address on which Twitter Lite listens."; };
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 { port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
default = 3000; default = 3000;
description = "Port on which Twitter Lite listens."; description = "Port on which Personal Workspace listens.";
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
systemd.services.twitter-lite = { systemd.services.twitter-lite = {
description = "Twitter Lite"; description = "Personal Workspace";
after = [ "network.target" ]; after = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
environment = { environment = {
HOST = cfg.host; HOST = "127.0.0.1";
PORT = toString cfg.port; PORT = toString cfg.port;
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl; TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
} // lib.optionalAttrs (cfg.profileName != null) { TWITTER_LITE_ORIGIN = cfg.publicOrigin;
BIRD_PROFILE_NAME = cfg.profileName; 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 = { serviceConfig = {
DynamicUser = true; DynamicUser = true;
StateDirectory = "twitter-lite";
StateDirectoryMode = "0700";
UMask = "0077";
LoadCredential = lib.optional (cfg.credentialKeyFile != null)
"credential-key:${cfg.credentialKeyFile}";
ExecStart = lib.getExe cfg.package; ExecStart = lib.getExe cfg.package;
Restart = "on-failure"; Restart = "on-failure";
}; };
+3 -3
View File
@@ -1,6 +1,6 @@
import type { KnipConfig } from 'knip' import type { KnipConfig } from "knip";
export default { export default {
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'], entry: ["playwright.config.ts", "e2e/**/*.test.ts", "e2e/mock-relay.mjs"],
playwright: false, playwright: false,
} satisfies KnipConfig } 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",
},
},
});
+52 -10
View File
@@ -2,55 +2,97 @@
"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",
"knip": "knip", "knip": "knip",
"format": "biome check --write .", "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",
"@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": "0.10.1", "@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": {
"@axe-core/playwright": "4.11.1", "@axe-core/playwright": "4.11.1",
"@biomejs/biome": "2.5.3",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@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", "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: './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 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"] } },
], ],
}) });
+6170 -129
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();
+75
View File
@@ -0,0 +1,75 @@
:root {
--canvas: var(--color-canvas);
--surface: var(--color-paper-2);
--paper: var(--color-paper);
--ink: var(--color-ink);
--accent-hover: var(--color-accent-hover);
--control-border: var(--color-control-rule);
--error: var(--color-error);
color: var(--ink);
background: var(--canvas);
color-scheme: dark;
font-family: var(--font-body);
font-size: 14px;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
overflow-x: clip;
background: var(--canvas);
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
}
a {
color: var(--primary);
text-underline-offset: 0.18em;
}
a:active {
color: var(--accent-hover);
}
:focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 2px;
}
h1 {
margin: 0;
min-width: 0;
font-family: var(--font-display);
font-size: var(--text-display-s);
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
}
.handle {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.workspace .reader {
overflow: hidden;
}
.sidebar-note {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* Respect reduced motion over registry component animation utilities. */
animation-duration: 0.01ms !important;
/* Respect reduced motion over registry component transition utilities. */
transition-duration: 0.01ms !important;
}
}
+109 -30
View File
@@ -1,36 +1,115 @@
import { Link } from '@tanstack/react-router' import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type CSSProperties, 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' | 'list' children: ReactNode;
children: React.ReactNode };
}) {
export function AppShell(props: ShellProps) {
return ( return (
<main className="app"> <TooltipProvider delay={800}>
<header className="app-header"> <SidebarProvider
<p className="brand">Twitter Lite</p> className="workspace h-dvh min-h-0 overflow-hidden"
<nav aria-label="閲覧方法" className="tabs"> style={
<Link aria-current={active === 'user' ? 'page' : undefined} to="/"> {
ユーザー "--sidebar-width": "25rem",
</Link> "--sidebar-width-icon": "4rem",
<Link } as CSSProperties
aria-current={active === 'search' ? 'page' : undefined} }
search={{ q: '', product: 'Latest', following: false }}
to="/search"
> >
検索 <Workspace {...props} />
</Link> </SidebarProvider>
<Link </TooltipProvider>
aria-current={active === 'list' ? 'page' : undefined} );
to="/i/lists" }
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}
</nav> footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Close sidebar"
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
</SidebarMenuButton>
</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 className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks">
</main> {children}
) </section>
</SidebarInset>
</>
);
} }

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