feat: add URL-driven workspace state and streamed Codex research

This commit is contained in:
2026-09-28 20:08:28 +09:00
parent 85d23a328b
commit 8085ad2f90
207 changed files with 15287 additions and 16026 deletions
+2 -2
View File
@@ -5,8 +5,8 @@ TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
# Required when connecting Mastodon; keep this runtime file outside Git. # Required when connecting Mastodon; keep this runtime file outside Git.
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key # TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
# Optional home Codex research prototype. Start pnpm codex:serve separately. # Optional Codex research. The AI SDK provider manages its local app-server.
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500 # TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra # TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research # TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
@@ -1,5 +1,8 @@
# Home-agent research # 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 ## Product boundary
The user starts a research task from the deck workspace. A home agent chooses The user starts a research task from the deck workspace. A home agent chooses
+19 -12
View File
@@ -24,15 +24,15 @@ list, user-profile, and conversation pages are not provided.
unique column IDs within each deck. Each column contains `id`, `title`, unique column IDs within each deck. Each column contains `id`, `title`,
`connectionId`, and a platform-specific `source`. `connectionId`, and a platform-specific `source`.
| Platform | Source kind | Conditions | | Platform | Source kind | Conditions |
| --- | --- | --- | | -------- | ----------- | ----------------------------------------------------------------------- |
| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` | | Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` |
| Twitter | `user` | `target`: handle or X/Twitter profile URL | | Twitter | `user` | `target`: handle or X/Twitter profile URL |
| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL | | Twitter | `list` | `target`: numeric list ID or X/Twitter list URL |
| Mastodon | `search` | `query`; results depend on the instance's search configuration | | 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 | `user` | `target`: account handle or the connected instance's numeric account ID |
| Mastodon | `list` | `target`: numeric list ID for the connected account | | Mastodon | `list` | `target`: numeric list ID for the connected account |
| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores | | Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores |
`connectionId` is the account binding, distinct from a deck's name. Twitter `connectionId` is the account binding, distinct from a deck's name. Twitter
connections resolve to profiles discovered from the configured relay. connections resolve to profiles discovered from the configured relay.
@@ -76,9 +76,9 @@ in a transaction. A stale revision is rejected. Save failures remain visible
and do not report unsaved edits as persisted. and do not report unsaved edits as persisted.
Saved decks refresh on focus and every five seconds while visible. Refresh is Saved decks refresh on focus and every five seconds while visible. Refresh is
held while an editor is open. Active selection is a local preference under held while an editor is open. The `deck` URL parameter selects the active view;
`twitter-lite-active-deck`; selecting a deck does not switch another device's selecting a deck does not switch another device's view. Browser history restores
view. Deleting the active deck selects a remaining one. When none remain, the 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. app opens an empty temporary view.
WebMCP-created views are temporary by default. Temporary copies of saved decks WebMCP-created views are temporary by default. Temporary copies of saved decks
@@ -139,3 +139,10 @@ uses an isolated SQLite database and mock relay through real server functions
to exercise shared decks across browser contexts, editing, persistence, to exercise shared decks across browser contexts, editing, persistence,
pagination, and native WebMCP. Deterministic automated tests do not require pagination, and native WebMCP. Deterministic automated tests do not require
live SNS credentials. 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.
+54
View File
@@ -0,0 +1,54 @@
# 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.
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).
+4
View File
@@ -16,3 +16,7 @@ as the anatomy illustration source and clipped in CSS. Source media:
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
colors are scoped to `--vital-*` tokens in `vitals.css`. 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.
+9 -9
View File
@@ -13,15 +13,15 @@ operations use the same access controls and persistence rules as the UI.
## Workspace tools ## Workspace tools
| Tool | Input | Behavior | | Tool | Input | Behavior |
| --- | --- | --- | | ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials | | `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` | | `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` | | `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 | | `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 | | `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged |
| `select_deck` | `deckId` | Activate a view; selection remains device-local | | `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 | | `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 Read tools return the workspace's current client snapshot, not a fresh server
request. Saved definitions refresh on focus and while visible, except during request. Saved definitions refresh on focus and while visible, except during
+1
View File
@@ -0,0 +1 @@
DROP TABLE `research_state`;
+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": {}
}
}
+7
View File
@@ -36,6 +36,13 @@
"when": 1790589494499, "when": 1790589494499,
"tag": "0004_good_natasha_romanoff", "tag": "0004_good_natasha_romanoff",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1790591720807,
"tag": "0005_salty_madripoor",
"breakpoints": true
} }
] ]
} }
+42 -43
View File
@@ -1,89 +1,88 @@
import { basename, dirname, isAbsolute } from 'node:path' import { basename, dirname, isAbsolute } from "node:path";
import AxeBuilder from '@axe-core/playwright' import AxeBuilder from "@axe-core/playwright";
import { test as base } from '@playwright/test' import { test as base } from "@playwright/test";
import { import {
completeOnboarding, completeOnboarding,
createOwner, createOwner,
readAuthState, readAuthState,
signIn, signIn,
} from '../src/features/auth/auth.server' } from "../src/features/auth/auth.server";
import { openDatabase } from '../src/features/storage/database.server' import { openDatabase } from "../src/features/storage/database.server";
export const test = base.extend<{ export const test = base.extend<{
a11y: () => AxeBuilder a11y: () => AxeBuilder;
resetDecks: undefined resetDecks: undefined;
sessionToken: string sessionToken: string;
}>({ }>({
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
sessionToken: async ({}, use) => { sessionToken: async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (!path || !basename(dirname(path)).startsWith('twitter-lite-e2e-')) if (!path || !basename(dirname(path)).startsWith("twitter-lite-e2e-"))
throw new Error('Isolated E2E database is required.') throw new Error("Isolated E2E database is required.");
const database = openDatabase(path) const database = openDatabase(path);
const credentials = { const credentials = {
email: '[email protected]', email: "[email protected]",
password: 'E2E-only-passphrase-2026', password: "E2E-only-passphrase-2026",
} };
process.env.WORKSPACE_SETUP_TOKEN = process.env.WORKSPACE_SETUP_TOKEN = "isolated-e2e-setup-token-not-for-production";
'isolated-e2e-setup-token-not-for-production'
const result = readAuthState(database).needsSetup const result = readAuthState(database).needsSetup
? await createOwner( ? await createOwner(
{ {
...credentials, ...credentials,
name: 'Yuta', name: "Yuta",
setupToken: process.env.WORKSPACE_SETUP_TOKEN, setupToken: process.env.WORKSPACE_SETUP_TOKEN,
}, },
database, database,
) )
: await signIn(credentials, database) : await signIn(credentials, database);
if (!result.owner.onboardingCompletedAt) if (!result.owner.onboardingCompletedAt)
completeOnboarding(result.sessionToken, { name: 'Yuta' }, database) completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
database.$client.close() database.$client.close();
await use(result.sessionToken) await use(result.sessionToken);
}, },
storageState: async ({ sessionToken }, use) => { storageState: async ({ sessionToken }, use) => {
await use({ await use({
cookies: [ cookies: [
{ {
name: 'workspace_session', name: "workspace_session",
value: sessionToken, value: sessionToken,
domain: '127.0.0.1', domain: "127.0.0.1",
path: '/', path: "/",
expires: Math.floor(Date.now() / 1000) + 3600, expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'Lax', sameSite: "Lax",
}, },
], ],
origins: [], origins: [],
}) });
}, },
resetDecks: [ resetDecks: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
async ({}, use) => { async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if ( if (
!path || !path ||
!isAbsolute(path) || !isAbsolute(path) ||
!basename(dirname(path)).startsWith('twitter-lite-e2e-') || !basename(dirname(path)).startsWith("twitter-lite-e2e-") ||
basename(path) !== 'workspace.sqlite' basename(path) !== "workspace.sqlite"
) )
throw new Error('Isolated E2E database is required.') throw new Error("Isolated E2E database is required.");
const database = openDatabase(path).$client const database = openDatabase(path).$client;
database.pragma('foreign_keys = ON') database.pragma("foreign_keys = ON");
database.exec('DELETE FROM decks; DELETE FROM legacy_imports;') database.exec("DELETE FROM decks; DELETE FROM legacy_imports;");
database.close() database.close();
await use(undefined) await use(undefined);
}, },
{ auto: true }, { auto: true },
], ],
a11y: async ({ page }, use) => { a11y: async ({ page }, use) => {
await use(() => await use(() =>
new AxeBuilder({ page }) new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.disableRules(['meta-viewport']), .disableRules(["meta-viewport"]),
) );
}, },
}) });
export { expect } from '@playwright/test' export { expect } from "@playwright/test";
+9 -13
View File
@@ -1,24 +1,20 @@
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts' import type { TweetLegacy } from "../generated/twitter-openapi/client.ts";
interface TweetLegacyFixtureOptions { interface TweetLegacyFixtureOptions {
conversationId?: string conversationId?: string;
inReplyTo?: string inReplyTo?: string;
replyCount?: number replyCount?: number;
} }
export const tweetLegacyFixture = ( export const tweetLegacyFixture = (
id: string, id: string,
text: string, text: string,
{ { conversationId = id, inReplyTo, replyCount = 1 }: TweetLegacyFixtureOptions = {},
conversationId = id,
inReplyTo,
replyCount = 1,
}: TweetLegacyFixtureOptions = {},
): TweetLegacy => ({ ): TweetLegacy => ({
bookmark_count: 0, bookmark_count: 0,
bookmarked: false, bookmarked: false,
conversation_id_str: conversationId, conversation_id_str: conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026', created_at: "Mon Jul 13 00:00:00 +0000 2026",
display_text_range: [0, text.length], display_text_range: [0, text.length],
favorite_count: 3, favorite_count: 3,
favorited: false, favorited: false,
@@ -26,10 +22,10 @@ export const tweetLegacyFixture = (
id_str: id, id_str: id,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}), ...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
is_quote_status: false, is_quote_status: false,
lang: 'ja', lang: "ja",
quote_count: 0, quote_count: 0,
reply_count: replyCount, reply_count: replyCount,
retweet_count: 2, retweet_count: 2,
retweeted: false, retweeted: false,
user_id_str: '42', user_id_str: "42",
}) });
File diff suppressed because it is too large Load Diff
+25 -35
View File
@@ -1,63 +1,53 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
test('owner can open the app but missing or foreign identity is forbidden', async ({ test("owner can open the app but missing or foreign identity is forbidden", async ({ request }) => {
request, expect((await request.get("/")).status()).toBe(200);
}) => { expect((await request.get("/", { headers: { "Tailscale-User-Login": "" } })).status()).toBe(403);
expect((await request.get('/')).status()).toBe(200)
expect( expect(
( (
await request.get('/', { headers: { 'Tailscale-User-Login': '' } }) await request.get("/", {
).status(), headers: { "Tailscale-User-Login": "[email protected]" },
).toBe(403)
expect(
(
await request.get('/', {
headers: { 'Tailscale-User-Login': '[email protected]' },
}) })
).status(), ).status(),
).toBe(403) ).toBe(403);
}) });
test('mutations require exact Origin even with same-origin Fetch Metadata', async ({ test("mutations require exact Origin even with same-origin Fetch Metadata", async ({ request }) => {
request, expect((await request.post("/")).status()).toBe(403);
}) => {
expect((await request.post('/')).status()).toBe(403)
expect( expect(
( (
await request.post('/', { await request.post("/", {
headers: { headers: {
Origin: 'https://other.invalid', Origin: "https://other.invalid",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Site": "same-origin",
}, },
}) })
).status(), ).status(),
).toBe(403) ).toBe(403);
}) });
test('server functions also reject foreign identity and cross-site calls', async ({ test("server functions also reject foreign identity and cross-site calls", async ({
page, page,
request, request,
}) => { }) => {
const serverRequest = page.waitForRequest((request) => const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
request.url().includes('/_serverFn/'), await page.goto("/deck");
) const url = (await serverRequest).url();
await page.goto('/deck')
const url = (await serverRequest).url()
expect( expect(
( (
await request.get(url, { await request.get(url, {
headers: { headers: {
'Tailscale-User-Login': '[email protected]', "Tailscale-User-Login": "[email protected]",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Site": "same-origin",
}, },
}) })
).status(), ).status(),
).toBe(403) ).toBe(403);
expect( expect(
( (
await request.get(url, { await request.get(url, {
headers: { 'Sec-Fetch-Site': 'cross-site' }, headers: { "Sec-Fetch-Site": "cross-site" },
}) })
).status(), ).status(),
).toBe(403) ).toBe(403);
}) });
+98 -136
View File
@@ -1,172 +1,134 @@
import { openDatabase } from '../../src/features/storage/database.server' import { openDatabase } from "../../src/features/storage/database.server";
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
const origin = 'http://127.0.0.1:4173' const origin = "http://127.0.0.1:4173";
test('creates the owner once and enters onboarding with a real session', async ({ test("creates the owner once and enters onboarding with a real session", async ({
page, page,
context, context,
a11y, a11y,
}) => { }) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client.exec('DELETE FROM workspace_owner; DELETE FROM auth_throttle;') db.$client.exec("DELETE FROM workspace_owner; DELETE FROM auth_throttle;");
db.$client.close() db.$client.close();
await context.clearCookies() await context.clearCookies();
await page.goto('/', { waitUntil: 'networkidle' }) await page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/setup$/) await expect(page).toHaveURL(/\/setup$/);
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel('Your name', { exact: true }).fill('Yuta') await page.getByLabel("Your name", { exact: true }).fill("Yuta");
await page.getByLabel('Email address').fill('[email protected]') await page.getByLabel("Email address").fill("[email protected]");
await page await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
.getByLabel('Password', { exact: true }) await page.getByLabel("Confirm password", { exact: true }).fill("E2E-only-passphrase-2026");
.fill('E2E-only-passphrase-2026') await page.getByLabel("Setup code").fill("isolated-e2e-setup-token-not-for-production");
await page await page.getByRole("button", { name: "Create account", exact: true }).click();
.getByLabel('Confirm password', { exact: true }) await expect(page).toHaveURL(/\/onboarding$/);
.fill('E2E-only-passphrase-2026') await page.waitForLoadState("networkidle");
await page await page.getByRole("button", { name: "Continue", exact: true }).click();
.getByLabel('Setup code') await page.getByRole("button", { name: "Open workspace" }).click();
.fill('isolated-e2e-setup-token-not-for-production') await expect(page.getByRole("heading", { name: "Good morning, Yuta" })).toBeVisible();
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 ({ test("requires login for documents, server functions, and live updates", async ({
page, page,
context, context,
}) => { }) => {
const serverRequest = page.waitForRequest((request) => const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
request.url().includes('/_serverFn/'), await page.goto("/deck");
) const serverUrl = (await serverRequest).url();
await page.goto('/deck') await context.clearCookies();
const serverUrl = (await serverRequest).url() for (const path of ["/api/research/events", serverUrl]) {
await context.clearCookies()
for (const path of ['/api/research/events', serverUrl]) {
const result = await context.request.get(path, { const result = await context.request.get(path, {
headers: { Origin: origin, 'Sec-Fetch-Site': 'same-origin' }, headers: { Origin: origin, "Sec-Fetch-Site": "same-origin" },
}) });
expect(result.status()).toBe(401) expect(result.status()).toBe(401);
} }
await page.goto('/journal') await page.goto("/journal");
await expect(page).toHaveURL(/\/login$/) await expect(page).toHaveURL(/\/login(?:\?|$)/);
await expect( await expect(page.getByRole("heading", { name: "Welcome back." })).toBeVisible();
page.getByRole('heading', { name: 'Welcome back.' }), await expect(page.getByRole("link", { name: "Journal", exact: true })).toHaveCount(0);
).toBeVisible() });
await expect(
page.getByRole('link', { name: 'Journal', exact: true }),
).toHaveCount(0)
})
test('signs in and revokes the session on sign out', async ({ test("signs in and revokes the session on sign out", async ({ page, context, a11y }) => {
page, await context.clearCookies();
context, await page.goto("/login", { waitUntil: "networkidle" });
a11y, expect((await a11y().analyze()).violations).toEqual([]);
}) => { await page.getByLabel("Email address").fill("[email protected]");
await context.clearCookies() await page.getByLabel("Password", { exact: true }).fill("Wrong-passphrase-2026");
await page.goto('/login', { waitUntil: 'networkidle' }) await page.getByRole("button", { name: "Sign in", exact: true }).click();
expect((await a11y().analyze()).violations).toEqual([]) await expect(page.getByRole("alert")).toContainText("Invalid email or password.");
await page.getByLabel('Email address').fill('[email protected]') await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page await page.getByRole("button", { name: "Sign in", exact: true }).click();
.getByLabel('Password', { exact: true }) await expect(page).toHaveURL(`${origin}/`);
.fill('Wrong-passphrase-2026') await page.waitForLoadState("networkidle");
await page.getByRole('button', { name: 'Sign in', exact: true }).click() const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session");
await expect(page.getByRole('alert')).toContainText( expect(token?.httpOnly).toBe(true);
'Invalid email or password.', expect(token?.sameSite).toBe("Lax");
) await page.getByRole("button", { name: "Manage connected accounts" }).click();
await page await page.getByRole("button", { name: "Sign out", exact: true }).click();
.getByLabel('Password', { exact: true }) await expect(page).toHaveURL(/\/login(?:\?|$)/);
.fill('E2E-only-passphrase-2026') const replay = await context.request.get("/api/auth", {
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}` }, headers: { Cookie: `workspace_session=${token?.value}` },
}) });
expect((await replay.json()).owner).toBeNull() expect((await replay.json()).owner).toBeNull();
await page.goBack() await page.goBack();
await expect( await expect(page.getByRole("link", { name: "Home", exact: true })).toHaveCount(0);
page.getByRole('link', { name: 'Home', exact: true }), });
).toHaveCount(0)
})
test('requires onboarding and remembers its completion and name', async ({ test("requires onboarding and remembers its completion and name", async ({
page, page,
request, request,
a11y, a11y,
}) => { }) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client db.$client
.prepare( .prepare("UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1")
'UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1', .run();
)
.run()
try { try {
expect((await request.get('/api/research/events')).status()).toBe(403) expect((await request.get("/api/research/events")).status()).toBe(403);
await page.goto('/', { waitUntil: 'networkidle' }) await page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/onboarding$/) await expect(page).toHaveURL(/\/onboarding$/);
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel('What should we call you?').fill('Yuta Test') 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: "Continue", exact: true }).click();
await page.getByRole('button', { name: 'Open workspace' }).click() await page.getByRole("button", { name: "Open workspace" }).click();
await expect( await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
page.getByRole('heading', { name: 'Good morning, Yuta Test' }), await page.reload();
).toBeVisible() await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
await page.reload() await page.goto("/onboarding");
await expect( await expect(page).toHaveURL(`${origin}/`);
page.getByRole('heading', { name: 'Good morning, Yuta Test' }),
).toBeVisible()
await page.goto('/onboarding')
await expect(page).toHaveURL(`${origin}/`)
} finally { } finally {
db.$client db.$client
.prepare( .prepare("UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1")
'UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1', .run("Yuta", Date.now());
) db.$client.close();
.run('Yuta', Date.now())
db.$client.close()
} }
}) });
test('rejects cross-origin login and further account registration', async ({ test("rejects cross-origin login and further account registration", async ({
request, request,
page, page,
context, context,
}) => { }) => {
expect( expect(
( (
await request.post('/api/auth', { await request.post("/api/auth", {
headers: { Origin: 'https://other.invalid' }, headers: { Origin: "https://other.invalid" },
data: { action: 'logout' }, data: { action: "logout" },
}) })
).status(), ).status(),
).toBe(403) ).toBe(403);
const result = await request.post('/api/auth', { const result = await request.post("/api/auth", {
headers: { Origin: origin }, headers: { Origin: origin },
data: { data: {
action: 'setup', action: "setup",
email: '[email protected]', email: "[email protected]",
name: 'Intruder', name: "Intruder",
password: 'Long-enough-password', password: "Long-enough-password",
setupToken: 'arbitrary-token', setupToken: "arbitrary-token",
}, },
}) });
expect(result.status()).toBe(409) expect(result.status()).toBe(409);
await context.clearCookies() await context.clearCookies();
await page.goto('/setup') await page.goto("/setup");
await expect(page).toHaveURL(/\/login$/) await expect(page).toHaveURL(/\/login(?:\?|$)/);
}) });
+40 -58
View File
@@ -1,70 +1,52 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
test('opens account management after hydration without inheriting real OAuth configuration', async ({ test("opens account management after hydration without inheriting real OAuth configuration", async ({
page, page,
}) => { }) => {
await page.goto('/deck') await page.goto("/deck");
await expect( await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
page.getByRole('button', { name: 'Rename', includeHidden: true }), const manage = page.getByRole("button", { name: "Manage connected accounts" });
).toBeEnabled()
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
if (!(await manage.isVisible())) { if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click() await page.getByRole("button", { name: "Toggle sidebar" }).click();
} }
await expect(manage).toBeEnabled() await expect(manage).toBeEnabled();
await manage.click() await manage.click();
const dialog = page.getByRole('dialog', { name: 'Connected accounts' }) const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible();
await expect( await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
dialog.getByText('No Mastodon servers have been configured yet.'), await expect(dialog.getByRole("button", { name: "Authorize with Mastodon" })).toHaveCount(0);
).toBeVisible() await page.keyboard.press("Escape");
await expect( await expect(dialog).toHaveCount(0);
dialog.getByRole('button', { name: 'Authorize with Mastodon' }), await expect(manage).toBeFocused();
).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 ({ test("opens account management from the inbox footer and restores focus on close", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/inbox') await page.goto("/inbox");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const manage = page.getByRole('button', { name: 'Manage connected accounts' }) const manage = page.getByRole("button", { name: "Manage connected accounts" });
await expect(manage).toBeVisible() await expect(manage).toBeVisible();
await manage.click() await manage.click();
const dialog = page.getByRole('dialog', { name: 'Connected accounts' }) const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible();
await expect( await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
dialog.getByText('No Mastodon servers have been configured yet.'), expect((await a11y().analyze()).violations).toEqual([]);
).toBeVisible() await page.keyboard.press("Escape");
expect((await a11y().analyze()).violations).toEqual([]) await expect(dialog).toHaveCount(0);
await page.keyboard.press('Escape') await expect(manage).toBeFocused();
await expect(dialog).toHaveCount(0) await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
await expect(manage).toBeFocused() });
await expect(
page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible()
})
test('rejects an unbound OAuth callback without contacting an instance', async ({ test("rejects an unbound OAuth callback without contacting an instance", async ({ page }) => {
page, await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code");
}) => { await expect(page).toHaveURL(/\/deck\?mastodon=failed$/);
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') await expect(page.getByRole("alert")).toContainText("Could not complete Mastodon authorization.");
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/) await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
await expect(page.getByRole('alert')).toContainText( const manage = page.getByRole("button", { name: "Manage connected accounts" });
'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())) { if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click() await page.getByRole("button", { name: "Toggle sidebar" }).click();
} }
await expect( await expect(page.getByRole("button", { name: "Manage connected accounts" })).toBeEnabled();
page.getByRole('button', { name: 'Manage connected accounts' }), });
).toBeEnabled()
})
+248 -342
View File
@@ -1,296 +1,230 @@
import type { Page } from '@playwright/test' import type { Page } from "@playwright/test";
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
import { closeMobileSidebar, showDeckManagement } from '../workspace' import { closeMobileSidebar, showDeckManagement } from "../workspace";
async function addColumn( async function addColumn(
page: Page, page: Page,
title: string, title: string,
query: string, query: string,
profile = 'e2e', profile = "e2e",
kind = 'search', kind = "search",
) { ) {
await showDeckManagement(page) await showDeckManagement(page);
await page.getByRole('button', { name: 'Add column', exact: true }).click() await page.getByRole("button", { name: "Add column", exact: true }).click();
const editor = page.getByRole('form', { name: 'Add column', exact: true }) const editor = page.getByRole("form", { name: "Add column", exact: true });
await editor.getByLabel('Column name', { exact: true }).fill(title) await editor.getByLabel("Column name", { exact: true }).fill(title);
await editor await editor
.getByRole('combobox', { name: 'Connection profile', exact: true }) .getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: profile }) .selectOption({ label: profile });
await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind);
await editor await editor
.getByRole('combobox', { name: 'Column type', exact: true }) .getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", {
.selectOption(kind) exact: true,
await editor })
.getByLabel( .fill(query);
kind === 'search' await editor.getByRole("button", { name: "Add and search" }).click();
? 'Twitter search query' await closeMobileSidebar(page);
: kind === 'user'
? 'User'
: 'List',
{ exact: true },
)
.fill(query)
await editor.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page)
await expect( await expect(
page page.getByRole("region", { name: title, exact: true }).locator("article").first(),
.getByRole('region', { name: title, exact: true }) ).toBeVisible();
.locator('article')
.first(),
).toBeVisible()
} }
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/deck') await page.goto("/deck");
await expect( await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
page.getByRole('button', { name: 'Save as deck', exact: true }), await page.getByRole("button", { name: "Save as deck", exact: true }).click();
).toBeEnabled() await showDeckManagement(page);
await page.getByRole('button', { name: 'Save as deck', exact: true }).click() await expect(page.getByText("Saved to server · Shared across devices")).toBeVisible();
await showDeckManagement(page) await closeMobileSidebar(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 ({ test("binds identical searches to independent profiles through paging, editing and reload", async ({
page, page,
}) => { }) => {
await addColumn(page, '通常の接続', 'same-query') await addColumn(page, "通常の接続", "same-query");
await addColumn(page, '別の接続', 'same-query', 'alternate') await addColumn(page, "別の接続", "same-query", "alternate");
const first = page.getByRole('region', { name: '通常の接続', exact: true }) const first = page.getByRole("region", { name: "通常の接続", exact: true });
const second = page.getByRole('region', { name: '別の接続', exact: true }) const second = page.getByRole("region", { name: "別の接続", exact: true });
await expect( await expect(first.getByText("Latest · all page 1", { exact: true })).toBeVisible();
first.getByText('Latest · all page 1', { exact: true }), await expect(second.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
).toBeVisible() await first.getByRole("button", { name: "Load more" }).click();
await expect( await expect(first.locator("article")).toHaveCount(2);
second.getByText('alternate · Latest · all page 1', { exact: true }), await expect(second.locator("article")).toHaveCount(1);
).toBeVisible() await second.getByRole("button", { name: "Load more" }).click();
await first.getByRole('button', { name: 'Load more' }).click() await expect(second.getByText("alternate · Latest · all page 2")).toBeVisible();
await expect(first.locator('article')).toHaveCount(2) await second.getByRole("button", { name: "Actions for 別の接続" }).click();
await expect(second.locator('article')).toHaveCount(1) await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
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 await page
.getByRole('combobox', { name: 'Connection profile', exact: true }) .getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: 'e2e' }) .selectOption({ label: "e2e" });
await page.getByRole('button', { name: 'Save changes' }).click() await page.getByRole("button", { name: "Save changes" }).click();
await expect( await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
second.getByText('Latest · all page 1', { exact: true }), await expect(second.getByText("alternate · Latest · all page 1")).toHaveCount(0);
).toBeVisible() await page.reload();
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount( await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
0, await expect(first.locator("article")).toHaveCount(1);
) });
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 ({ 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, page,
}) => { }) => {
await page.getByRole('button', { name: 'Rename' }).click() await addColumn(page, "アカウント", "yuta", "alternate", "user");
await page.getByLabel('Research topic / deck name').fill('最初の調査') await addColumn(page, "リストの投稿", "1234567890", "e2e", "list");
await page.getByRole('button', { name: 'Save name' }).click() const user = page.getByRole("region", { name: "アカウント", exact: true });
await addColumn(page, '最初の観点', 'first-deck') const list = page.getByRole("region", { name: "リストの投稿", exact: true });
await showDeckManagement(page) await expect(user.getByText("alternate profile page 1")).toBeVisible();
await page.getByRole('button', { name: 'Create deck', exact: true }).click() await expect(list.getByText("list page 1")).toBeVisible();
await page.getByLabel('New deck name').fill('別の調査') await list.getByRole("button", { name: "Load more" }).click();
await page.getByRole('button', { name: 'Create', exact: true }).click() await expect(list.getByText("list page 2")).toBeVisible();
await expect(page.locator('.deck-column')).toHaveCount(0) await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await addColumn(page, '別の観点', 'second-deck', 'alternate') await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
await showDeckManagement(page) await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await page await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
.getByLabel('Deck profile', { exact: true }) await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
.selectOption({ label: '最初の調査' }) await page.getByRole("button", { name: "Undo" }).click();
await closeMobileSidebar(page) await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) await page.reload();
await page.reload() await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') await expect(list.getByText("list page 1")).toBeVisible();
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 ({ test("keeps other columns usable when pagination fails and retries that column", 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, page,
}, testInfo) => { }, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`) await addColumn(page, "失敗する検索", `retry-deck-${testInfo.project.name}`);
await addColumn(page, '別の検索', 'independent-deck') await addColumn(page, "別の検索", "independent-deck");
const failed = page.getByRole('region', { name: '失敗する検索', exact: true }) const failed = page.getByRole("region", { name: "失敗する検索", exact: true });
const other = page.getByRole('region', { name: '別の検索', exact: true }) const other = page.getByRole("region", { name: "別の検索", exact: true });
await failed.getByRole('button', { name: 'Load more' }).click() await failed.getByRole("button", { name: "Load more" }).click();
await expect(failed.getByRole('alert')).toBeVisible() await expect(failed.getByRole("alert")).toBeVisible();
await expect(failed.locator('article')).toHaveCount(1) await expect(failed.locator("article")).toHaveCount(1);
await other.getByRole('button', { name: 'Load more' }).click() await other.getByRole("button", { name: "Load more" }).click();
await expect(other.locator('article')).toHaveCount(2) await expect(other.locator("article")).toHaveCount(2);
await failed.getByRole('button', { name: 'Retry' }).click() await failed.getByRole("button", { name: "Retry" }).click();
await expect(failed.locator('article')).toHaveCount(2) await expect(failed.locator("article")).toHaveCount(2);
}) });
test('keeps the deck and editor accessible within narrow viewports', async ({ test("keeps the deck and editor accessible within narrow viewports", async ({
page, page,
a11y, a11y,
}, testInfo) => { }, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja') await addColumn(page, "日本語の反応", "WebMCP lang:ja");
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate') await addColumn(page, "海外の反応", "WebMCP lang:en", "alternate");
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user') await addColumn(page, "公式アカウント", "OpenAI", "e2e", "user");
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 }) await page.setViewportSize({ width, height: 900 });
expect( expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe(
await page.evaluate( false,
() => document.documentElement.scrollWidth > innerWidth, );
), expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
).toBe(false) true,
expect( );
await page.evaluate( const board = await page.locator(".deck-board").boundingBox();
() => document.documentElement.scrollHeight <= innerHeight, expect(board).not.toBeNull();
), expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
).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({ await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393, width: testInfo.project.name === "desktop" ? 1440 : 393,
height: 900, height: 900,
}) });
await expect(page.getByRole('navigation', { name: 'Columns' })).toHaveCount(0) await expect(page.getByRole("navigation", { name: "Columns" })).toHaveCount(0);
await expect( await expect(page.getByRole("heading", { name: "公式アカウント", exact: true })).toBeVisible();
page.getByRole('heading', { name: '公式アカウント', exact: true }), expect((await a11y().analyze()).violations).toEqual([]);
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('deck-populated.png'), path: testInfo.outputPath("deck-populated.png"),
fullPage: true, fullPage: true,
}) });
await showDeckManagement(page) await showDeckManagement(page);
await page.getByRole('button', { name: 'Add column', exact: true }).click() await page.getByRole("button", { name: "Add column", exact: true }).click();
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('deck.png'), path: testInfo.outputPath("deck.png"),
fullPage: true, fullPage: true,
}) });
}) });
test('closes the column editor with Escape and restores focus without applying edits', async ({ test("closes the column editor with Escape and restores focus without applying edits", async ({
page, page,
}) => { }) => {
await showDeckManagement(page) await showDeckManagement(page);
const opener = page.getByRole('button', { name: 'Add column', exact: true }) const opener = page.getByRole("button", { name: "Add column", exact: true });
await opener.click() await opener.click();
await expect( await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toBeVisible();
page.getByRole('dialog', { name: 'Add column', exact: true }), await page.getByLabel("Column name", { exact: true }).fill("キャンセルする観点");
).toBeVisible() await page.keyboard.press("Escape");
await page await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
.getByLabel('Column name', { exact: true }) await expect(opener).toBeFocused();
.fill('キャンセルする観点') await expect(page.locator(".deck-column")).toHaveCount(0);
await page.keyboard.press('Escape') await opener.click();
await expect( await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
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 }) => { test("does not overwrite unreadable storage on reload", async ({ page }) => {
await page.evaluate(() => await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken"));
localStorage.setItem('twitter-lite-research-deck', '{broken'), await page.reload();
) await expect(page.getByRole("alert")).toContainText("Unable to load");
await page.reload() expect(await page.evaluate(() => localStorage.getItem("twitter-lite-research-deck"))).toBe(
await expect(page.getByRole('alert')).toContainText('Unable to load') "{broken",
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 ({ test("selects a list from the column profile catalog and refreshes it on profile change", async ({
page, page,
}) => { }) => {
await showDeckManagement(page) await showDeckManagement(page);
await page.getByRole('button', { name: 'Add column', exact: true }).click() await page.getByRole("button", { name: "Add column", exact: true }).click();
await page.getByLabel('Column name', { exact: true }).fill('購読リスト') await page.getByLabel("Column name", { exact: true }).fill("購読リスト");
await page await page
.getByRole('combobox', { name: 'Connection profile', exact: true }) .getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: 'e2e' }) .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 await page
.getByRole('combobox', { name: 'Column type', exact: true }) .getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption('list') .selectOption({ label: "alternate" });
await expect( await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toHaveCount(0);
page.getByRole('button', { name: 'My E2E List', exact: true }), await page.getByRole("button", { name: "Alternate List", exact: true }).click();
).toBeVisible() await expect(page.getByLabel("List", { exact: true })).toHaveValue("1234567890");
await page await page.getByRole("button", { name: "Add and search" }).click();
.getByRole('combobox', { name: 'Connection profile', exact: true }) await closeMobileSidebar(page);
.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( await expect(
page page
.getByRole('region', { name: '購読リスト', exact: true }) .getByRole("region", { name: "購読リスト", exact: true })
.getByText('list page 1', { exact: true }), .getByText("list page 1", { exact: true }),
).toBeVisible() ).toBeVisible();
}) });
test('keeps an open draft through remote edits and rejects its stale save', async ({ test("keeps an open draft through remote edits and rejects its stale save", async ({
page, page,
browser, browser,
baseURL, baseURL,
@@ -298,124 +232,96 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
const other = await browser.newContext({ const other = await browser.newContext({
storageState: await page.context().storageState(), storageState: await page.context().storageState(),
baseURL, baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' }, extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
}) });
try { try {
const device = await other.newPage() const device = await other.newPage();
await device.goto('/deck') await device.goto("/deck");
await page.getByRole('button', { name: 'Rename' }).click() await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel('Research topic / deck name').fill('編集中の名前') await page.getByLabel("Research topic / deck name").fill("編集中の名前");
await device.getByRole('button', { name: 'Rename' }).click() await device.getByRole("button", { name: "Rename" }).click();
await device.getByLabel('Research topic / deck name').fill('別の端末の変更') await device.getByLabel("Research topic / deck name").fill("別の端末の変更");
await device.getByRole('button', { name: 'Save name' }).click() await device.getByRole("button", { name: "Save name" }).click();
await expect(device.getByRole('heading', { level: 1 })).toHaveText( 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.evaluate(() => window.dispatchEvent(new Event('focus'))) await page.getByRole("button", { name: "Save name" }).click();
await expect(page.getByLabel('Research topic / deck name')).toHaveValue( 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: 'Save name' }).click() await page.getByRole("button", { name: "Reload latest deck" }).click();
await expect(page.getByRole('alert')).toContainText( await expect(page.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更");
'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 { } finally {
await other.close() await other.close();
} }
}) });
test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({ test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({
page, page,
}) => { }) => {
const legacy = { const legacy = {
version: 2, version: 2,
activeDeckId: 'old', activeDeckId: "old",
decks: [ decks: [
{ {
id: 'old', id: "old",
title: '以前の調査', title: "以前の調査",
columns: [ columns: [
{ {
id: 'a', id: "a",
title: '以前の観点', title: "以前の観点",
profileName: 'e2e', profileName: "e2e",
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'WebMCP', query: "WebMCP",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
}, },
], ],
}, },
], ],
} };
await page.evaluate( await page.evaluate(
(legacy) => (legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy, legacy,
) );
await page.reload() await page.reload();
await showDeckManagement(page) await showDeckManagement(page);
const selector = page.getByLabel('Deck profile', { exact: true }) const selector = page.getByLabel("Deck profile", { exact: true });
await expect(selector.locator('option')).toHaveCount(1) await expect(selector.locator("option")).toHaveCount(1);
await closeMobileSidebar(page) await closeMobileSidebar(page);
await page.getByRole('button', { name: 'Import legacy decks' }).click() await page.getByRole("button", { name: "Import legacy decks" }).click();
await showDeckManagement(page) await showDeckManagement(page);
await expect(selector.locator('option')).toHaveCount(2) await expect(selector.locator("option")).toHaveCount(2);
await selector.selectOption({ label: '以前の調査' }) await selector.selectOption({ label: "以前の調査" });
await closeMobileSidebar(page) await closeMobileSidebar(page);
await expect( await expect(page.getByText("Latest · all page 1", { exact: true })).toBeVisible();
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await page.evaluate( await page.evaluate(
(legacy) => (legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy, legacy,
) );
await page.reload() await page.reload();
await page.getByRole('button', { name: 'Import legacy decks' }).click() await page.getByRole("button", { name: "Import legacy decks" }).click();
await expect( await expect(page.getByRole("button", { name: "Import legacy decks" })).toHaveCount(0);
page.getByRole('button', { name: 'Import legacy decks' }), await showDeckManagement(page);
).toHaveCount(0) await expect(selector.locator("option")).toHaveCount(2);
await showDeckManagement(page) });
await expect(selector.locator('option')).toHaveCount(2)
})
test('preserves a new deck dialog and its draft across the mobile breakpoint', async ({ test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ page }) => {
page, await showDeckManagement(page);
}) => { await page.getByRole("button", { name: "Create deck", exact: true }).click();
await showDeckManagement(page) const dialog = page.getByRole("dialog", { name: "Create deck", exact: true });
await page.getByRole('button', { name: 'Create deck', exact: true }).click() await dialog.getByLabel("New deck name").fill("画面を回転しても残る調査");
const dialog = page.getByRole('dialog', { name: 'Create deck', exact: true })
await dialog.getByLabel('New deck name').fill('画面を回転しても残る調査')
for (const width of [1024, 375, 1024]) { for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 }) await page.setViewportSize({ width, height: 900 });
await expect(dialog).toHaveCount(1) await expect(dialog).toHaveCount(1);
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('New deck name')).toHaveValue( await expect(dialog.getByLabel("New deck name")).toHaveValue("画面を回転しても残る調査");
'画面を回転しても残る調査', await expect(page.locator("#new-deck-title")).toHaveCount(1);
)
await expect(page.locator('#new-deck-title')).toHaveCount(1)
} }
await dialog.getByRole('button', { name: 'Create', exact: true }).click() await dialog.getByRole("button", { name: "Create", exact: true }).click();
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0);
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole("heading", { level: 1 })).toHaveText("画面を回転しても残る調査");
'画面を回転しても残る調査', });
)
})
+94 -120
View File
@@ -1,139 +1,113 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
test('previews a natural-language memo and shares the executed record with journal', async ({ test("previews a natural-language memo and shares the executed record with journal", async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto("/");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const memo = '明日のレビューは導線から確認する' const memo = "明日のレビューは導線から確認する";
await page.getByRole('textbox', { name: 'Home input' }).fill(`Note: ${memo}`) await page.getByRole("textbox", { name: "Home input" }).fill(`Note: ${memo}`);
await page.getByRole('button', { name: 'Preview action' }).click() await page.getByRole("button", { name: "Preview action" }).click();
const execute = page.getByRole('button', { name: 'Apply', exact: true }) const execute = page.getByRole("button", { name: "Apply", exact: true });
await expect(execute).toBeVisible() await expect(execute).toBeVisible();
await execute.click() await execute.click();
await page.getByRole('link', { name: 'Journal', exact: true }).first().click() await page.getByRole("link", { name: "Journal", exact: true }).first().click();
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText( await expect(page.getByRole("article", { name: "Note entry" })).toContainText(memo);
memo, await page.getByRole("link", { name: "Home", exact: true }).first().click();
) await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
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 ({ test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
page, await page.goto("/");
a11y, await page.waitForLoadState("networkidle");
}, testInfo) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
for (const width of [320, 375, 768]) { for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 });
await expect( await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
page.getByRole('textbox', { name: 'Home input' }), expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
).toBeVisible() await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) });
expect( if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
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 ({ test("adds tasks only after confirmation and can defer and restore them", async ({ page }) => {
page, await page.goto("/");
}) => { await page.waitForLoadState("networkidle");
await page.goto('/') const input = page.getByRole("textbox", { name: "Home input" });
await page.waitForLoadState('networkidle') const complete = page.getByRole("button", {
const input = page.getByRole('textbox', { name: 'Home input' }) name: "Complete grocery shopping",
const complete = page.getByRole('button', {
name: 'Complete grocery shopping',
exact: true, exact: true,
}) });
await input.fill('Add grocery shopping') await input.fill("Add grocery shopping");
await page.getByRole('button', { name: 'Preview action' }).click() await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toHaveCount(0) await expect(complete).toHaveCount(0);
await page.getByRole('button', { name: 'Apply', exact: true }).click() await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toBeVisible() await expect(complete).toBeVisible();
await input.fill('Defer grocery shopping') await input.fill("Defer grocery shopping");
await page.getByRole('button', { name: 'Preview action' }).click() await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toBeVisible() await expect(complete).toBeVisible();
await page.getByRole('button', { name: 'Apply', exact: true }).click() await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toHaveCount(0) await expect(complete).toHaveCount(0);
await page.locator('.home-rest summary').click() await page.locator(".home-rest summary").click();
await page await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
.getByRole('button', { name: 'Restore grocery shopping', exact: true }) await complete.click();
.click() await expect(complete).toHaveCount(0);
await complete.click() await page.locator(".home-rest summary").click();
await expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click()
await expect( await expect(
page.getByRole('button', { name: 'Restore grocery shopping', exact: true }), page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
).toBeVisible() ).toBeVisible();
}) });
test('resolves communication within home and carries the status to the shared queue', async ({ test("resolves communication within home and carries the status to the shared queue", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/') await page.goto("/");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
await page await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click();
.getByRole('button', { name: 'Open conversation with Haruka Aoki' }) const dialog = page.getByRole("dialog", {
.click() name: "Conversation with Haruka Aoki",
const dialog = page.getByRole('dialog', { });
name: 'Conversation with Haruka Aoki', await expect(dialog.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
}) expect((await a11y().analyze()).violations).toEqual([]);
await dialog.getByRole("button", { name: "Leave for later" }).click();
await expect(dialog).toHaveCount(0);
await expect( await expect(
dialog.getByRole('textbox', { name: 'Reply', exact: true }), page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toBeVisible() ).toHaveCount(0);
expect((await a11y().analyze()).violations).toEqual([]) await page.getByRole("link", { name: "Messages", exact: true }).first().click();
await dialog.getByRole('button', { name: 'Leave for later' }).click() const rows = page.locator(".support-row");
await expect(dialog).toHaveCount(0) await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0);
await page.getByRole("button", { name: "Later", exact: true }).click();
await rows.filter({ hasText: "Schedule next week’s design review" }).click();
await page.getByRole("button", { name: "Move to Now" }).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( await expect(
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }), page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toHaveCount(0) ).toBeVisible();
await page });
.getByRole('link', { name: 'Messages', exact: true })
.first()
.click()
const rows = page.locator('.support-row')
await expect(
rows.filter({ hasText: 'Schedule next week’s design review' }),
).toHaveCount(0)
await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: 'Schedule next week’s design review' }).click()
await page.getByRole('button', { name: 'Move to Now' }).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(
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toBeVisible()
})
test('updates the prose brief as priorities change and opens the referenced conversation', async ({ test("updates the prose brief as priorities change and opens the referenced conversation", async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto("/");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const brief = page.getByRole('region', { name: 'Daily brief' }) const brief = page.getByRole("region", { name: "Daily brief" });
await expect(brief).toContainText('Review the documents') await expect(brief).toContainText("Review the documents");
await expect(brief).toContainText('4 conversations') await expect(brief).toContainText("4 conversations");
await page await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
.getByRole('button', { name: 'Complete Review the documents', exact: true }) await expect(brief).not.toContainText("Review the documents");
.click() await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
await expect(brief).not.toContainText('Review the documents') const dialog = page.getByRole("dialog", {
await brief.getByRole('button', { name: 'Reply to Haruka Aoki' }).click() name: "Conversation with Haruka Aoki",
const dialog = page.getByRole('dialog', {
name: 'Conversation with Haruka Aoki',
exact: true, exact: true,
}) });
await dialog.getByRole('button', { name: 'Leave for later' }).click() await dialog.getByRole("button", { name: "Leave for later" }).click();
await expect(brief).toContainText('3 conversations') await expect(brief).toContainText("3 conversations");
await expect(brief).not.toContainText('Haruka Aoki') await expect(brief).not.toContainText("Haruka Aoki");
await page await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
.getByRole('textbox', { name: 'Home input' }) await page.getByRole("button", { name: "Preview action" }).click();
.fill('再開地点:資料の2ページ目から') await page.getByRole("button", { name: "Apply", exact: true }).click();
await page.getByRole('button', { name: 'Preview action' }).click() await expect(brief).toContainText("資料の2ページ目から");
await page.getByRole('button', { name: 'Apply', exact: true }).click() });
await expect(brief).toContainText('資料の2ページ目から')
})
+60 -86
View File
@@ -1,101 +1,75 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
test('selects articles and keeps the reader focused on their content', async ({ test("selects articles and keeps the reader focused on their content", async ({ page }) => {
page, await page.goto("/inbox");
}) => { await page.waitForLoadState("networkidle");
await page.goto('/inbox') const rows = page.locator(".inbox-article-row");
await page.waitForLoadState('networkidle') await expect(rows).toHaveCount(9);
const rows = page.locator('.inbox-article-row') const articleTitle = "Context for an agent can be brief";
await expect(rows).toHaveCount(9) await rows.filter({ hasText: articleTitle }).click();
const articleTitle = 'Context for an agent can be brief' const reader = page.getByRole("article", { name: articleTitle, exact: true });
await rows.filter({ hasText: articleTitle }).click() await expect(reader).toBeVisible();
const reader = page.getByRole('article', { name: articleTitle, exact: true }) await expect(reader.getByRole("heading", { name: articleTitle, exact: true })).toBeVisible();
await expect(reader).toBeVisible()
await expect( await expect(
reader.getByRole('heading', { name: articleTitle, exact: true }), reader.getByText("Start with a one-sentence goal, then add the constraints that must hold.", {
).toBeVisible() 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( await expect(
reader.getByText( page.getByRole("article", {
'Start with a one-sentence goal, then add the constraints that must hold.', name: "Three columns create a rhythm for reading",
{ 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, exact: true,
}), }),
).toBeVisible() ).toBeVisible();
}) });
test('recovers from empty search and shares navigation with Research', async ({ test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
page, await page.goto("/inbox");
a11y, await page.waitForLoadState("networkidle");
}) => { const rows = page.locator(".inbox-article-row");
await page.goto('/inbox') await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
await page.waitForLoadState('networkidle') await expect(rows).toHaveCount(0);
const rows = page.locator('.inbox-article-row') await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
await page await page.getByRole("button", { name: "Clear search" }).click();
.getByRole('textbox', { name: 'Search articles' }) await expect(rows).toHaveCount(9);
.fill('nonexistent-query-xyz') expect((await a11y().analyze()).violations).toEqual([]);
await expect(rows).toHaveCount(0) await expect(page.getByRole("button", { name: "Edit interests", exact: true })).toHaveCount(0);
await expect( await page.getByRole("link", { name: "Research", exact: true }).click();
page.getByText('No articles found', { exact: true }), await expect(page).toHaveURL(/\/deck$/);
).toBeVisible() await page.getByRole("link", { name: "Reader", exact: true }).click();
await page.getByRole('button', { name: 'Clear search' }).click() await expect(page).toHaveURL(/\/inbox$/);
await expect(rows).toHaveCount(9) 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 ({ test("keeps article navigation readable without horizontal overflow", async ({
page, page,
a11y, a11y,
}, testInfo) => { }, testInfo) => {
await page.goto('/inbox') await page.goto("/inbox");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 });
await expect( await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
page.getByRole('textbox', { name: 'Search articles' }), expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
).toBeVisible() await page.locator(".inbox-article-row").first().click();
expect( const reader = page.getByRole("article", {
await page.evaluate(() => document.documentElement.scrollWidth), name: "Thinking in the browser: a new workflow with AI agents",
).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, exact: true,
}) });
await expect(reader).toBeVisible() await expect(reader).toBeVisible();
expect( expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath(`inbox-reader-${width}.png`), path: testInfo.outputPath(`inbox-reader-${width}.png`),
}) });
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
const back = page.getByRole('button', { name: 'Back to articles' }) const back = page.getByRole("button", { name: "Back to articles" });
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click();
} }
}) });
+293 -266
View File
@@ -1,351 +1,378 @@
import type { DeckColumn } from '../../src/features/decks/model' import type { DeckColumn } from "../../src/features/decks/model";
import type { ResearchRun } from '../../src/features/research/model' import type { ResearchRun } from "../../src/features/research/model";
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
import { import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace";
closeMobileSidebar,
showDeckManagement,
showResearch,
} from '../workspace'
test('pushes the current research snapshot on each SSE connection', async ({ test("pushes the current research snapshot on each SSE connection", async ({ page }) => {
page, await page.goto("/deck");
}) => {
await page.goto('/deck')
const snapshots = await page.evaluate(async () => { const snapshots = await page.evaluate(async () => {
const connect = () => const connect = () =>
new Promise<unknown>((resolve, reject) => { new Promise<unknown>((resolve, reject) => {
const events = new EventSource('/api/research/events') const events = new EventSource("/api/research/events");
const timer = setTimeout(() => { const timer = setTimeout(() => {
events.close() events.close();
reject(new Error('SSE timed out')) reject(new Error("SSE timed out"));
}, 5000) }, 5000);
events.onmessage = (event) => { events.onmessage = (event) => {
clearTimeout(timer) clearTimeout(timer);
events.close() events.close();
resolve(JSON.parse(event.data)) resolve(JSON.parse(event.data));
} };
events.onerror = () => { events.onerror = () => {
clearTimeout(timer) clearTimeout(timer);
events.close() events.close();
reject(new Error('SSE failed')) reject(new Error("SSE failed"));
} };
}) });
return [await connect(), await connect()] return [await connect(), await connect()];
}) });
expect(snapshots).toEqual([ expect(snapshots).toEqual([
{ configured: false, run: null, history: [] }, { configured: false, run: null, history: [] },
{ configured: false, run: null, history: [] }, { configured: false, run: null, history: [] },
]) ]);
}) });
test('restores the conversation and growing temporary deck from reconnect snapshots', async ({ test("restores the conversation and growing temporary deck from reconnect snapshots", async ({
page, page,
}) => { }) => {
await page.goto('/deck') await page.goto("/deck");
await showDeckManagement(page) await showDeckManagement(page);
await page.getByRole('button', { name: 'Add column', exact: true }).click() await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole('combobox', { const accounts = page.getByRole("combobox", {
name: 'Connection profile', name: "Connection profile",
exact: true, exact: true,
}) });
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue() const connectionId = await accounts.inputValue();
await page.keyboard.press('Escape') await page.keyboard.press("Escape");
await closeMobileSidebar(page) await closeMobileSidebar(page);
const run: ResearchRun = { const run: ResearchRun = {
id: '36ad8cc7-318c-46ae-94fa-28d30336f027', id: "36ad8cc7-318c-46ae-94fa-28d30336f027",
threadId: 'conversation-test', threadId: "conversation-test",
connectionIds: [connectionId], connectionIds: [connectionId],
topic: '既存の観点を使って調査して', topic: "既存の観点を使って調査して",
status: 'running', status: "running",
startedAt: Date.now(), startedAt: Date.now(),
deckVersion: 1, deckVersion: 1,
citations: [], citations: [],
message: '既存の検索条件を再利用しています。', message: "既存の検索条件を再利用しています。",
messages: [ messages: [
{ id: 'user-1', role: 'user', text: '既存の観点を使って調査して' }, { id: "user-1", role: "user", text: "既存の観点を使って調査して" },
{ id: 'tool-1', role: 'tool', text: 'list_decks: 完了' }, { id: "tool-1", role: "tool", text: "list_decks: 完了" },
{ {
id: 'assistant-1', id: "assistant-1",
role: 'assistant', role: "assistant",
text: '既存の検索条件を再利用しています。', text: "既存の検索条件を再利用しています。",
}, },
], ],
deck: { deck: {
id: 'generated-plan', id: "generated-plan",
title: '会話からの調査', title: "会話からの調査",
columns: [ columns: [
{ {
id: 'one', id: "one",
title: '最初の観点', title: "最初の観点",
connectionId, connectionId,
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'WebMCP', query: "WebMCP",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
}, },
], ],
}, },
} };
await page.route('**/api/research/events', (route) => await page.route("**/api/research/events*", (route) =>
route.fulfill({ route.fulfill({
contentType: 'text/event-stream', contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}), }),
) );
await page.reload() await page.goto(`/deck?run=${run.id}`);
await showResearch(page) await showResearch(page);
const conversation = page.getByRole('log', { name: 'Research conversation' }) const conversation = page.getByRole("log", { name: "Research conversation" });
await expect(conversation).toContainText('既存の観点を使って調査して') await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText('list_decks: 完了') await expect(conversation).toContainText("list_decks: 完了");
await expect(conversation).toContainText('既存の検索条件を再利用しています。') await expect(conversation).toContainText("既存の検索条件を再利用しています。");
await expect( await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS(
conversation.locator('[data-slot="bubble-content"]'), "background-color",
).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') "rgba(0, 0, 0, 0)",
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) );
run.deckVersion = 2 await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
run.status = 'complete' run.deckVersion = 2;
run.status = "complete";
run.messages.push({ run.messages.push({
id: 'assistant-2', id: "assistant-2",
role: 'assistant', role: "assistant",
text: '比較する観点を追加しました。', text: "比較する観点を追加しました。",
}) });
run.deck?.columns.push({ run.deck?.columns.push({
id: 'two', id: "two",
title: '比較の観点', title: "比較の観点",
connectionId, connectionId,
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'WebMCP lang:ja', query: "WebMCP lang:ja",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
}) });
await page.reload() await page.reload();
await showResearch(page) await showResearch(page);
await expect(conversation).toContainText('既存の観点を使って調査して') await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText('比較する観点を追加しました。') await expect(conversation).toContainText("比較する観点を追加しました。");
await expect(page.locator('.deck-column h2')).toHaveText([ 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);
await closeMobileSidebar(page) const accountPicker = page.getByRole("button", {
await expect( name: "Connected accounts · 1",
page.getByRole('button', { name: 'Save as deck', exact: true }), });
).toBeEnabled() await accountPicker.click();
await showResearch(page) await expect(accountPicker).toHaveAttribute("aria-expanded", "true");
const accountPicker = page.getByRole('button', { await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked();
name: 'Connected accounts · 1', await accountPicker.click();
}) await expect(accountPicker).toHaveAttribute("aria-expanded", "false");
await accountPicker.click() await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく");
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true') await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled();
await expect( await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
page.getByRole('checkbox', { name: 'e2e · Twitter' }), run.deckVersion = 3;
).toBeChecked() if (run.deck) run.deck.title = "閉じている間に更新された調査";
await accountPicker.click() await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", {
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false') timeout: 15000,
await page.getByLabel('Message', { exact: true }).fill('この観点を詳しく') });
await expect( await showResearch(page);
page.getByRole('button', { name: 'Send', exact: true }), await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
).toBeEnabled() await page.setViewportSize({ width: 600, height: 900 });
await page.getByRole('button', { name: 'Close sidebar', exact: true }).click() await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
run.deckVersion = 3 await showResearch(page);
if (run.deck) run.deck.title = '閉じている間に更新された調査' await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await page.getByRole("button", { name: "Connected accounts · 1" }).click();
'閉じている間に更新された調査', await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck();
{ timeout: 15000 }, await page.setViewportSize({ width: 1100, height: 900 });
) await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0);
await showResearch(page) await showResearch(page);
await expect(page.getByLabel('Message', { exact: true })).toHaveValue( 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 page.setViewportSize({ width: 600, height: 900 });
await expect( await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), await showResearch(page);
).toHaveCount(1) await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await showResearch(page) await page.getByRole("button", { name: "Connected accounts · 0" }).click();
await expect(page.getByLabel('Message', { exact: true })).toHaveValue( await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
'この観点を詳しく', });
)
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 ({ test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => {
request, const response = await request.get("/api/research/events", {
}) => { headers: { "Tailscale-User-Login": "[email protected]" },
const response = await request.get('/api/research/events', { });
headers: { 'Tailscale-User-Login': '[email protected]' }, expect(response.status()).toBe(403);
}) });
expect(response.status()).toBe(403)
})
test('renders Markdown and reveals an unloaded citation in an offscreen deck column', async ({ test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto('/deck') await page.goto("/deck");
await showDeckManagement(page) await showDeckManagement(page);
await page.getByRole('button', { name: 'Add column', exact: true }).click() await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole('combobox', { const accounts = page.getByRole("combobox", {
name: 'Connection profile', name: "Connection profile",
exact: true, exact: true,
}) });
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue() const connectionId = await accounts.inputValue();
await page.keyboard.press('Escape') await page.keyboard.press("Escape");
await closeMobileSidebar(page) await closeMobileSidebar(page);
const source: DeckColumn = { const source: DeckColumn = {
id: 'citation-column-2', id: "citation-column-2",
title: '引用の観点', title: "引用の観点",
connectionId, connectionId,
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'citation-query-2', query: "citation-query-2",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
} };
const columns: DeckColumn[] = ['概要', '比較'].map((title, index) => ({ const columns: DeckColumn[] = ["概要", "比較"].map((title, index) => ({
id: `citation-column-${index}`, id: `citation-column-${index}`,
title, title,
connectionId, connectionId,
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: `citation-query-${index}`, query: `citation-query-${index}`,
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
})) }));
columns.push(source) columns.push(source);
const run: ResearchRun = { const run: ResearchRun = {
id: '973ae084-18ed-4674-8899-c75851e51d1b', id: "973ae084-18ed-4674-8899-c75851e51d1b",
topic: '引用を確認', topic: "引用を確認",
status: 'complete', status: "complete",
startedAt: Date.now(), startedAt: Date.now(),
deckVersion: 1, deckVersion: 1,
message: '', message: "",
deck: { id: 'citation-deck', title: '引用付きの調査', columns }, deck: { id: "citation-deck", title: "引用付きの調査", columns },
messages: [ messages: [
{ {
id: 'answer', id: "answer",
role: 'assistant', role: "assistant",
text: '## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。', text: "## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。",
}, },
], ],
citations: [ citations: [
{ {
column: source, column: source,
post: { post: {
key: 'twitter:123456789', key: "twitter:123456789",
nativeId: '123456789', nativeId: "123456789",
platform: 'twitter', platform: "twitter",
url: 'https://twitter.com/author/status/123456789', url: "https://twitter.com/author/status/123456789",
text: 'AIが調査時に取得した引用本文', text: "AIが調査時に取得した引用本文",
author: { name: '引用元の著者', handle: 'author' }, author: { name: "引用元の著者", handle: "author" },
}, },
}, },
], ],
} };
await page.route('**/api/research/events', (route) => await page.route("**/api/research/events*", (route) =>
route.fulfill({ route.fulfill({
contentType: 'text/event-stream', contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}), }),
) );
await page.reload() await page.goto(`/deck?run=${run.id}`);
await showResearch(page) await showResearch(page);
const chat = page.getByRole('log', { name: 'Research conversation' }) const chat = page.getByRole("log", { name: "Research conversation" });
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible() await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible();
await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS( await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600");
'font-weight', await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc");
'600', await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible();
) await expect(chat.getByRole("table")).toBeVisible();
await expect(chat.locator('ul')).toHaveCSS('list-style-type', 'disc') await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
await expect( await chat.getByRole("link", { name: "根拠の投稿" }).click();
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( const card = page.locator(
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]', '.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
) );
await expect(card).toBeVisible() await expect(card).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/) await expect(card).toHaveClass(/citation-highlight/);
await expect(card).toBeFocused() await expect(card).toBeFocused();
await expect(card).toContainText('Post saved during research') await expect(card).toContainText("Post saved during research");
await expect(card).toContainText('AIが調査時に取得した引用本文') await expect(card).toContainText("AIが調査時に取得した引用本文");
expect( expect(
await page.locator('.deck-board').evaluate((element) => element.scrollLeft), await page.locator(".deck-board").evaluate((element) => element.scrollLeft),
).toBeGreaterThan(0) ).toBeGreaterThan(0);
expect(page.context().pages()).toHaveLength(1) expect(page.context().pages()).toHaveLength(1);
expect((await a11y().analyze()).violations).toEqual([]) 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 ({ test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/deck') await page.goto("/deck");
const heading = page.getByRole('heading', { level: 1 }) const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toBeVisible() await expect(heading).toBeVisible();
const originalTitle = await heading.textContent() const originalTitle = await heading.textContent();
await showResearch(page) await showResearch(page);
const panel = page.getByRole('complementary', { name: 'Research chat' }) const panel = page.getByRole("complementary", { name: "Research chat" });
await expect(panel).toBeVisible() await expect(panel).toBeVisible();
await expect( await expect(
panel.getByText( panel.getByText("The connection to Codex on your home server is not configured."),
'The connection to Codex on your home server is not configured.', ).toBeVisible();
), await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
).toBeVisible() await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled();
await expect( const accessibility = await a11y().analyze();
panel.getByRole('button', { name: 'Send', exact: true }), expect(accessibility.violations).toEqual([]);
).toBeDisabled() await closeMobileSidebar(page);
await expect(panel.getByLabel('Message', { exact: true })).toBeDisabled() await expect(heading).toHaveText(originalTitle ?? "");
const accessibility = await a11y().analyze() await showDeckManagement(page);
expect(accessibility.violations).toEqual([]) await page.getByRole("button", { name: "Create deck", exact: true }).click();
await closeMobileSidebar(page) await page.getByLabel("New deck name").fill("手動の調査");
await expect(heading).toHaveText(originalTitle ?? '') await page.getByRole("button", { name: "Create", exact: true }).click();
await showDeckManagement(page) await closeMobileSidebar(page);
await page.getByRole('button', { name: 'Create deck', exact: true }).click() await expect(heading).toHaveText("手動の調査");
await page.getByLabel('New deck name').fill('手動の調査') await showResearch(page);
await page.getByRole('button', { name: 'Create', exact: true }).click() await expect(panel).toBeVisible();
await closeMobileSidebar(page) });
await expect(heading).toHaveText('手動の調査')
await showResearch(page) test("keeps browser conversation selection independent and restores it on reload", async ({
await expect(panel).toBeVisible() 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();
}
});
+110 -141
View File
@@ -1,158 +1,127 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
const reviewSubject = 'Schedule next week’s design review' const reviewSubject = "Schedule next week’s design review";
const coffeeSubject = 'Catch up over coffee' const coffeeSubject = "Catch up over coffee";
test('reviews an edited reply and keeps the simulated result in history', async ({ test("reviews an edited reply and keeps the simulated result in history", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/support') await page.goto("/support");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const rows = page.locator('.support-row') const rows = page.locator(".support-row");
await expect(rows).toHaveCount(3) await expect(rows).toHaveCount(3);
await rows.filter({ hasText: reviewSubject }).click() await rows.filter({ hasText: reviewSubject }).click();
await page.getByRole('button', { name: 'Polite', exact: true }).click() await page.getByRole("button", { name: "Polite", exact: true }).click();
const reply = page.getByRole('textbox', { name: 'Reply', exact: true }) const reply = page.getByRole("textbox", { name: "Reply", exact: true });
await expect(reply).toHaveValue(/Thank you for sharing/) await expect(reply).toHaveValue(/Thank you for sharing/);
const edited = const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
'I reviewed the materials. Let’s discuss the first-time experience.' await reply.fill(edited);
await reply.fill(edited) const slot = "Mon, Oct 5, 14:00–14:30";
const slot = 'Mon, Oct 5, 14:00–14:30' await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
await page await page.getByRole("button", { name: "Add to reply" }).click();
.getByRole('combobox', { name: 'Suggested times' }) await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`);
.selectOption(slot) await page.getByRole("button", { name: "Review reply" }).click();
await page.getByRole('button', { name: 'Add to reply' }).click() const dialog = page.getByRole("dialog", { name: "Review reply" });
await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`) await expect(dialog).toContainText(edited);
await page.getByRole('button', { name: 'Review reply' }).click() await expect(dialog).toContainText(slot);
const dialog = page.getByRole('dialog', { name: 'Review reply' }) await expect(dialog).toContainText("No message will be sent");
await expect(dialog).toContainText(edited) expect((await a11y().analyze()).violations).toEqual([]);
await expect(dialog).toContainText(slot) await dialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(dialog).toContainText('No message will be sent') await expect(reply).toHaveValue(new RegExp(edited));
expect((await a11y().analyze()).violations).toEqual([]) await page.getByRole("button", { name: "Review reply" }).click();
await dialog.getByRole('button', { name: 'Back', exact: true }).click() await dialog.getByRole("button", { name: "Try sending" }).click();
await expect(reply).toHaveValue(new RegExp(edited)) await expect(dialog).toHaveCount(0);
await page.getByRole('button', { name: 'Review reply' }).click() await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
await dialog.getByRole('button', { name: 'Try sending' }).click() await page.getByRole("button", { name: "History", exact: true }).click();
await expect(dialog).toHaveCount(0) await rows.filter({ hasText: reviewSubject }).click();
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0) await expect(page.locator(".support-outcome")).toContainText(edited);
await page.getByRole('button', { name: 'History', exact: true }).click() await expect(page.locator(".support-outcome")).toContainText(slot);
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 ({ test("defers a conversation and restores it to the current queue", async ({ page }) => {
page, await page.goto("/support");
}) => { await page.waitForLoadState("networkidle");
await page.goto('/support') const rows = page.locator(".support-row");
await page.waitForLoadState('networkidle') await rows.filter({ hasText: coffeeSubject }).click();
const rows = page.locator('.support-row')
await rows.filter({ hasText: coffeeSubject }).click()
await page await page
.getByRole('textbox', { name: 'Reply', exact: true }) .getByRole("textbox", { name: "Reply", exact: true })
.fill('土曜の予定を確認してから返事するね。') .fill("土曜の予定を確認してから返事するね。");
await page.getByRole('button', { name: 'Leave for later' }).click() await page.getByRole("button", { name: "Leave for later" }).click();
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0) await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
await page.getByRole('button', { name: 'Later', exact: true }).click() await page.getByRole("button", { name: "Later", exact: true }).click();
await rows.filter({ hasText: coffeeSubject }).click() await rows.filter({ hasText: coffeeSubject }).click();
await expect( await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
page.getByRole('textbox', { name: 'Reply', exact: true }), "土曜の予定を確認してから返事するね。",
).toHaveValue('土曜の予定を確認してから返事するね。') );
await page.getByRole('button', { name: 'Move to Now' }).click() await page.getByRole("button", { name: "Move to Now" }).click();
const back = page.getByRole('button', { name: 'Back to messages' }) const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click();
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible() await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
await expect( await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute(
page.getByRole('button', { name: 'Now', exact: true }), "aria-pressed",
).toHaveAttribute('aria-pressed', 'true') "true",
}) );
});
test('keeps corrections as new records and summarizes only selected current facts', async ({ test("keeps corrections as new records and summarizes only selected current facts", async ({
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/journal') await page.goto("/journal");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
await expect( await expect(page.getByRole("button", { name: "Save entry", exact: true })).toBeDisabled();
page.getByRole('button', { name: 'Save entry', exact: true }), await page.getByRole("textbox", { name: "Entry text" }).fill("次はレビュー資料の導線を確認する");
).toBeDisabled() 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 await page
.getByRole('textbox', { name: 'Entry text' }) .getByRole("article", { name: "Weight entry" })
.fill('次はレビュー資料の導線を確認する') .getByRole("button", { name: "Correct" })
await page.getByRole('button', { name: 'Save entry', exact: true }).click() .click();
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText( const correction = page.getByRole("dialog", { name: "Correct entry" });
'Occurred: Unknown', await correction.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.8");
) await correction.getByRole("button", { name: "Add correction" }).click();
await page.getByRole('button', { name: 'Weight', exact: true }).click() const weights = page.getByRole("article", { name: "Weight entry" });
await page await expect(weights).toHaveCount(2);
.getByRole('spinbutton', { name: 'Weight', exact: true }) await expect(weights.filter({ hasText: "65.2 kg" })).toContainText("Original");
.fill('65.2') await expect(weights.filter({ hasText: "65.8 kg" })).toContainText("Correction");
await page.getByRole('textbox', { name: 'Source' }).fill('自宅の体重計') await page.getByRole("button", { name: "Summarize entries", exact: true }).click();
await page.getByRole('button', { name: 'Save entry', exact: true }).click() const review = page.getByRole("dialog", { name: "Summarize entries" });
await page const summary = review.getByRole("textbox", { name: "Entry summary" });
.getByRole('article', { name: 'Weight entry' }) await expect(summary).toHaveValue(/65.8 kg/);
.getByRole('button', { name: 'Correct' }) await expect(summary).not.toHaveValue(/65.2 kg/);
.click() await expect(summary).toHaveValue(/Source: 自宅の体重計/);
const correction = page.getByRole('dialog', { name: 'Correct entry' }) await expect(summary).toHaveValue(/Entries with an unknown event time: 2 entries/);
await correction await review.getByRole("checkbox", { name: /次はレビュー資料/ }).uncheck();
.getByRole('spinbutton', { name: 'Weight', exact: true }) await expect(summary).not.toHaveValue(/次はレビュー資料/);
.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( await expect(summary).toHaveValue(
/Medication and weight are unknown for periods without entries/, /Medication and weight are unknown for periods without entries/,
) );
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([]);
}) });
test('fits communication and journal interactions in narrow screens', async ({ test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => {
page,
a11y,
}) => {
for (const width of [320, 375, 768]) { for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 });
await page.goto('/support') await page.goto("/support");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
expect( expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.evaluate(() => document.documentElement.scrollWidth), await page.locator(".support-row").first().click();
).toBe(width) await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
await page.locator('.support-row').first().click() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await expect( if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
page.getByRole('textbox', { name: 'Reply', exact: true }), const back = page.getByRole("button", { name: "Back to messages" });
).toBeVisible() if (await back.isVisible()) await back.click();
expect( await page.getByRole("link", { name: "Journal", exact: true }).click();
await page.evaluate(() => document.documentElement.scrollWidth), await page.getByRole("button", { name: "Weight", exact: true }).click();
).toBe(width) await expect(page.getByRole("spinbutton", { name: "Weight", exact: true })).toBeVisible();
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
const back = page.getByRole('button', { name: 'Back to messages' }) if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
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");
});
+110 -101
View File
@@ -1,115 +1,124 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
test('filters body systems and opens lab history', async ({ page, a11y }) => { test("filters body systems and opens lab history", async ({ page, a11y }) => {
await page.goto('/vitals') await page.goto("/vitals");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
await expect( await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
page.getByRole('heading', { name: 'Body', exact: true }), const systems = page.locator(".vital-system-card");
).toBeVisible() await expect(systems).toHaveCount(8);
const systems = page.locator('.vital-system-card')
await expect(systems).toHaveCount(8)
await page await page
.locator('.vital-body-page > .vital-tabs') .getByRole("group", { name: "Body systems", exact: true })
.getByRole('button', { name: 'Cardiovascular', exact: true }) .getByRole("button", { name: "Cardiovascular", exact: true })
.click() .click();
await expect(systems).toHaveCount(1) await expect(systems).toHaveCount(1);
await systems.first().click() await expect(page).toHaveURL(/vitalTab=Cardiovascular/);
const dialog = page.getByRole('dialog', { name: 'Cardiovascular' }) await page.reload();
await expect(dialog).toContainText('68') await page.waitForLoadState("networkidle");
await dialog await expect(systems).toHaveCount(1);
.getByRole('button', { name: 'Close', exact: true }) await systems.first().click();
.last() const dialog = page.getByRole("dialog", { name: "Cardiovascular" });
.click() await expect(dialog).toContainText("68");
await page.getByRole('button', { name: 'Lab history', exact: true }).click() await page.goBack();
await expect(page.locator('.vital-history')).toContainText('Jul 28') await expect(dialog).not.toBeVisible();
await expect(page.locator('.vital-history')).toContainText('93') await page.goForward();
await page.getByRole('button', { name: 'Systems', exact: true }).click() 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 await page
.locator('.vital-body-page > .vital-tabs') .getByRole("group", { name: "Body systems", exact: true })
.getByRole('button', { name: 'Overview', exact: true }) .getByRole("button", { name: "Overview", exact: true })
.click() .click();
await expect(systems).toHaveCount(8) await expect(systems).toHaveCount(8);
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([]);
}) });
test('explores environment charts and simulated climate controls', async ({ test("explores environment charts and simulated climate controls", async ({ page, a11y }) => {
page, await page.goto("/vitals");
a11y, await page.waitForLoadState("networkidle");
}) => { const menu = page.getByRole("button", { name: "Open Health menu" });
await page.goto('/vitals') if (await menu.isVisible()) await menu.click();
await page.waitForLoadState('networkidle')
const menu = page.getByRole('button', { name: 'Open Health menu' })
if (await menu.isVisible()) await menu.click()
await page await page
.getByRole('complementary', { name: 'Health navigation' }) .getByRole("complementary", { name: "Health navigation" })
.getByRole('button', { name: 'Environment', exact: true }) .getByRole("button", { name: "Environment", exact: true })
.click() .click();
await expect( await expect(page.getByRole("heading", { name: "Environment", exact: true })).toBeVisible();
page.getByRole('heading', { name: 'Environment', exact: true }), await page.getByRole("button", { name: "7D", exact: true }).click();
).toBeVisible()
await page.getByRole('button', { name: '7D', exact: true }).click()
await page await page
.getByRole('group', { name: 'Trend metric' }) .getByRole("group", { name: "Trend metric" })
.getByRole('button', { name: 'CO₂', exact: true }) .getByRole("button", { name: "CO₂", exact: true })
.click() .click();
await expect( await expect(
page.getByRole('img', { name: '7D CO₂ simulated environmental readings' }), page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible() ).toBeVisible();
await page await page.reload();
.locator('.vital-metric') await page.waitForLoadState("networkidle");
.filter({ hasText: 'Room temperature' })
.click()
const dialog = page.getByRole('dialog', { name: 'Room temperature' })
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( await expect(
page.getByRole('status').filter({ hasText: 'Sample readings refreshed' }), page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible() ).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]) await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click();
await page const dialog = page.getByRole("dialog", { name: "Room temperature" });
.getByRole('button', { name: 'Climate control', exact: true }) await expect(dialog).toContainText("64.0");
.click() await page.reload();
await page.getByRole('button', { name: 'Cool', exact: true }).click() 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( await expect(
page.getByRole('button', { name: 'Cool', exact: true }), page.getByRole("status").filter({ hasText: "Sample readings refreshed" }),
).toHaveAttribute('aria-pressed', 'true') ).toBeVisible();
const temperature = page.getByRole('slider', { name: 'Target temperature' }) expect((await a11y().analyze()).violations).toEqual([]);
await temperature.focus() await page.getByRole("button", { name: "Climate control", exact: true }).click();
await temperature.press('ArrowRight') await page.getByRole("button", { name: "Cool", exact: true }).click();
await expect(temperature).toHaveValue('69') await expect(page.getByRole("button", { name: "Cool", exact: true })).toHaveAttribute(
await page.getByRole('button', { name: 'Air purifier · On' }).click() "aria-pressed",
await expect( "true",
page.getByRole('button', { name: 'Air purifier · Off' }), );
).toHaveAttribute('aria-pressed', 'false') 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 ({ test("keeps both dashboards within the viewport", async ({ page }, testInfo) => {
page, await page.goto("/vitals");
}, testInfo) => { await page.waitForLoadState("networkidle");
await page.goto('/vitals')
await page.waitForLoadState('networkidle')
for (const width of [375, 768, 1440]) { for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 1000 }) await page.setViewportSize({ width, height: 1000 });
expect( expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.evaluate(() => document.documentElement.scrollWidth), await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) });
).toBe(width) const menu = page.getByRole("button", { name: "Open Health menu" });
await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }) if (await menu.isVisible()) await menu.click();
const menu = page.getByRole('button', { name: 'Open Health menu' }) const navigation = page.getByRole("complementary", {
if (await menu.isVisible()) await menu.click() name: "Health navigation",
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 navigation
.getByRole('button', { name: 'Environment', exact: true })
.click()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath(`environment-${width}.png`), path: testInfo.outputPath(`environment-${width}.png`),
}) });
if (await menu.isVisible()) await menu.click() if (await menu.isVisible()) await menu.click();
await navigation.getByRole('button', { name: 'Body', exact: true }).click() await navigation.getByRole("button", { name: "Body", exact: true }).click();
} }
}) });
+162 -211
View File
@@ -1,30 +1,28 @@
import type { Page } from '@playwright/test' import type { Page } from "@playwright/test";
import Database from 'better-sqlite3' import Database from "better-sqlite3";
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
type NativeTesting = { type NativeTesting = {
listTools(): { name: string }[] listTools(): { name: string }[];
executeTool(name: string, input: string): Promise<string | null> executeTool(name: string, input: string): Promise<string | null>;
} };
type ToolResult = { type ToolResult = {
content: { type: string; text: string }[] content: { type: string; text: string }[];
isError?: boolean isError?: boolean;
} };
test.use({ test.use({
launchOptions: { launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--enable-blink-features=WebMCP,WebMCPTesting'], args: ["--enable-blink-features=WebMCP,WebMCPTesting"],
}, },
}) });
async function toolNames(page: Page) { async function toolNames(page: Page) {
return page.evaluate(() => return page.evaluate(() =>
( (navigator as Navigator & { modelContextTesting: NativeTesting }).modelContextTesting
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting
.listTools() .listTools()
.map((tool) => tool.name) .map((tool) => tool.name)
.sort(), .sort(),
) );
} }
async function executeTool( async function executeTool(
page: Page, page: Page,
@@ -37,296 +35,249 @@ async function executeTool(
navigator as Navigator & { modelContextTesting: NativeTesting } navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting.executeTool(name, JSON.stringify(input)), ).modelContextTesting.executeTool(name, JSON.stringify(input)),
{ name, input }, { name, input },
) );
expect(result).not.toBeNull() expect(result).not.toBeNull();
return JSON.parse(result ?? '{}') return JSON.parse(result ?? "{}");
} }
function decoded(result: ToolResult) { function decoded(result: ToolResult) {
return JSON.parse(result.content[0]?.text ?? '{}') return JSON.parse(result.content[0]?.text ?? "{}");
} }
const column = (title: string, connectionId: string) => ({ const column = (title: string, connectionId: string) => ({
title, title,
connectionId, connectionId,
source: { kind: 'search', query: 'WebMCP' }, source: { kind: "search", query: "WebMCP" },
}) });
function savedCount() { function savedCount() {
const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', { const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? "", {
readonly: true, readonly: true,
}) });
try { try {
return db.prepare('SELECT count(*) AS count FROM decks').get() return db.prepare("SELECT count(*) AS count FROM decks").get();
} finally { } finally {
db.close() db.close();
} }
} }
let accounts: Record<string, string> let accounts: Record<string, string>;
let pageErrors: string[] let pageErrors: string[];
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
pageErrors = [] pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message)) page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto('/deck') await page.goto("/deck");
await expect await expect
.poll(() => toolNames(page)) .poll(() => toolNames(page))
.toEqual([ .toEqual([
'delete_deck', "delete_deck",
'get_column_posts', "get_column_posts",
'get_deck', "get_deck",
'list_connections', "list_connections",
'list_decks', "list_decks",
'load_more_column', "load_more_column",
'save_deck', "save_deck",
'select_deck', "select_deck",
'set_deck', "set_deck",
]) ]);
await expect await expect
.poll( .poll(async () => decoded(await executeTool(page, "list_connections")).connections?.length)
async () => .toBe(2);
decoded(await executeTool(page, 'list_connections')).connections
?.length,
)
.toBe(2)
accounts = Object.fromEntries( accounts = Object.fromEntries(
decoded(await executeTool(page, 'list_connections')).connections.map( decoded(await executeTool(page, "list_connections")).connections.map(
(connection: { displayName: string; id: string }) => [ (connection: { displayName: string; id: string }) => [connection.displayName, connection.id],
connection.displayName,
connection.id,
],
), ),
) );
}) });
test.afterEach(() => expect(pageErrors).toEqual([])) test.afterEach(() => expect(pageErrors).toEqual([]));
test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({ test("creates temporary research, edits it, persists explicitly and reopens it on another device", async ({
page, page,
browser, browser,
baseURL, baseURL,
}) => { }) => {
const created = await executeTool(page, 'set_deck', { const created = await executeTool(page, "set_deck", {
title: 'WebMCPの反応', title: "WebMCPの反応",
columns: [ columns: [
{ ...column('日本語', accounts.e2e ?? ''), id: 'new' }, { ...column("日本語", accounts.e2e ?? ""), id: "new" },
column('別の接続', accounts.alternate ?? ''), column("別の接続", accounts.alternate ?? ""),
], ],
}) });
expect(created.isError).not.toBe(true) expect(created.isError).not.toBe(true);
const deck = decoded(created).deck const deck = decoded(created).deck;
expect(deck.persisted).toBe(false) expect(deck.persisted).toBe(false);
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 });
await expect( await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await page await page
.getByRole('region', { name: '日本語', exact: true }) .getByRole("region", { name: "日本語", exact: true })
.getByRole('button', { name: 'Actions for 日本語' }) .getByRole("button", { name: "Actions for 日本語" })
.click() .click();
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click() await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
const replaced = await executeTool(page, 'set_deck', { const replaced = await executeTool(page, "set_deck", {
deckId: deck.id, deckId: deck.id,
title: '更新した調査', title: "更新した調査",
columns: [deck.columns[1]], columns: [deck.columns[1]],
}) });
expect(replaced.isError).not.toBe(true) expect(replaced.isError).not.toBe(true);
await expect(page.getByRole('dialog')).toHaveCount(0) await expect(page.getByRole("dialog")).toHaveCount(0);
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 });
expect( expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError, expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
).not.toBe(true) expect(savedCount()).toEqual({ count: 1 });
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 })
const other = await browser.newContext({ const other = await browser.newContext({
storageState: await page.context().storageState(), storageState: await page.context().storageState(),
baseURL, baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' }, extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
}) });
try { try {
const device = await other.newPage() const device = await other.newPage();
await device.goto('/deck') await device.goto("/deck");
await expect(device.getByRole('heading', { level: 1 })).toHaveText( await expect(device.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
'更新した調査',
)
await expect( await expect(
device.getByText('alternate · Latest · all page 1', { exact: true }), device.getByText("alternate · Latest · all page 1", { exact: true }),
).toBeVisible() ).toBeVisible();
} finally { } finally {
await other.close() await other.close();
} }
await page.reload() await page.reload();
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
'更新した調査', });
)
})
test('requires revisions for saved mutations and rejects stale overwrites', async ({ test("requires revisions for saved mutations and rejects stale overwrites", async ({ page }) => {
page,
}) => {
const deck = decoded( const deck = decoded(
await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }), await executeTool(page, "set_deck", { title: "保存済み", columns: [] }),
).deck ).deck;
await executeTool(page, 'save_deck', { deckId: deck.id }) await executeTool(page, "save_deck", { deckId: deck.id });
expect( expect(
( (
await executeTool(page, 'set_deck', { await executeTool(page, "set_deck", {
deckId: deck.id, deckId: deck.id,
title: '変更', title: "変更",
columns: [], columns: [],
}) })
).isError, ).isError,
).toBe(true) ).toBe(true);
expect( expect(
( (
await executeTool(page, 'set_deck', { await executeTool(page, "set_deck", {
deckId: deck.id, deckId: deck.id,
expectedRevision: 1, expectedRevision: 1,
title: '変更', title: "変更",
columns: [], columns: [],
}) })
).isError, ).isError,
).not.toBe(true) ).not.toBe(true);
expect( expect(
( (
await executeTool(page, 'set_deck', { await executeTool(page, "set_deck", {
deckId: deck.id, deckId: deck.id,
expectedRevision: 1, expectedRevision: 1,
title: '古い変更', title: "古い変更",
columns: [], columns: [],
}) })
).isError, ).isError,
).toBe(true) ).toBe(true);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更') await expect(page.getByRole("heading", { level: 1 })).toHaveText("変更");
expect( expect(
( (
await executeTool(page, 'delete_deck', { await executeTool(page, "delete_deck", {
deckId: deck.id, deckId: deck.id,
expectedRevision: 1, expectedRevision: 1,
}) })
).isError, ).isError,
).toBe(true) ).toBe(true);
expect( expect(
( (
await executeTool(page, 'delete_deck', { await executeTool(page, "delete_deck", {
deckId: deck.id, deckId: deck.id,
expectedRevision: 2, expectedRevision: 2,
}) })
).isError, ).isError,
).not.toBe(true) ).not.toBe(true);
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 });
}) });
test('keeps temporary views independent and loses only unsaved views on reload', async ({ test("keeps temporary views independent and loses only unsaved views on reload", async ({
page, page,
}) => { }) => {
const first = decoded( const first = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }), await executeTool(page, "set_deck", { title: "一時の調査A", columns: [] }),
).deck ).deck;
const second = decoded( const second = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }), await executeTool(page, "set_deck", { title: "一時の調査B", columns: [] }),
).deck ).deck;
await executeTool(page, 'select_deck', { deckId: first.id }) await executeTool(page, "select_deck", { deckId: first.id });
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole("heading", { level: 1 })).toHaveText("一時の調査A");
'一時の調査A', await executeTool(page, "delete_deck", { deckId: second.id });
)
await executeTool(page, 'delete_deck', { deckId: second.id })
expect( expect(
decoded(await executeTool(page, 'list_decks')).decks.some( decoded(await executeTool(page, "list_decks")).decks.some(
(deck: { id: string }) => deck.id === second.id, (deck: { id: string }) => deck.id === second.id,
), ),
).toBe(false) ).toBe(false);
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 });
await page.reload() await page.reload();
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole("heading", { level: 1 })).toHaveText("New research");
'New research', });
)
})
test('rejects invalid input without changing the active view', async ({ test("rejects invalid input without changing the active view", async ({ page }) => {
page, await executeTool(page, "set_deck", {
}) => { title: "残す調査",
await executeTool(page, 'set_deck', { columns: [column("残す観点", accounts.e2e ?? "")],
title: '残す調査', });
columns: [column('残す観点', accounts.e2e ?? '')], const duplicate = { ...column("不正", accounts.e2e ?? ""), id: "duplicate" };
})
const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' }
for (const columns of [ for (const columns of [
[duplicate, duplicate], [duplicate, duplicate],
[column('不明', 'missing')], [column("不明", "missing")],
[ [
{ {
...column('不正', accounts.e2e ?? ''), ...column("不正", accounts.e2e ?? ""),
source: { kind: 'list', target: 'invalid' }, source: { kind: "list", target: "invalid" },
}, },
], ],
]) { ]) {
expect( expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
(await executeTool(page, 'set_deck', { title: '変更しない', columns })) true,
.isError, );
).toBe(true) await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
} }
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 });
}) });
test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({ test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({
page, page,
}) => { }) => {
const deck = decoded( const deck = decoded(
await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }), await executeTool(page, "set_deck", { title: "未保存の調査", columns: [] }),
).deck ).deck;
await page.route('**/_serverFn/**', (route) => await page.route("**/_serverFn/**", (route) =>
route.request().method() === 'POST' ? route.abort() : route.continue(), route.request().method() === "POST" ? route.abort() : route.continue(),
) );
expect( expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).toBe(true);
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError, await expect(page.getByRole("heading", { level: 1 })).toHaveText("未保存の調査");
).toBe(true) expect(decoded(await executeTool(page, "get_deck")).deck.persisted).toBe(false);
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await page.unroute("**/_serverFn/**");
'未保存の調査', expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
) expect(savedCount()).toEqual({ count: 1 });
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 ({ test("reads and pages bound accounts without mixing sibling columns", async ({ page }) => {
page,
}) => {
const deck = decoded( const deck = decoded(
await executeTool(page, 'set_deck', { await executeTool(page, "set_deck", {
title: '接続を比較', title: "接続を比較",
columns: [ columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")],
column('通常', accounts.e2e ?? ''),
column('別の接続', accounts.alternate ?? ''),
],
}), }),
).deck ).deck;
await expect( await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
page.getByText('alternate · Latest · all page 1', { exact: true }), const id = deck.columns[1].id;
).toBeVisible() const loaded = decoded(await executeTool(page, "get_column_posts", { columnId: id }));
const id = deck.columns[1].id expect(loaded.column.connectionId).toBe(accounts.alternate);
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([ expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 1', "alternate · Latest · all page 1",
]) ]);
const more = decoded( const more = decoded(await executeTool(page, "load_more_column", { columnId: id }));
await executeTool(page, 'load_more_column', { columnId: id }), expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false });
)
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 2', "alternate · Latest · all page 2",
]) ]);
await expect( await expect(
page.getByRole('region', { name: '通常', exact: true }).locator('article'), page.getByRole("region", { name: "通常", exact: true }).locator("article"),
).toHaveCount(1) ).toHaveCount(1);
await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] }) await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] });
expect( expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true);
(await executeTool(page, 'get_column_posts', { columnId: id })).isError, });
).toBe(true)
})
+26 -32
View File
@@ -1,41 +1,35 @@
import { expect, test } from '../fixtures' import { expect, test } from "../fixtures";
import { closeMobileSidebar, showResearch } from '../workspace' import { closeMobileSidebar, showResearch } from "../workspace";
test('keeps the sidebar, composer and deck reachable at compact widths', async ({ test("keeps the sidebar, composer and deck reachable at compact widths", async ({
page, page,
a11y, a11y,
}, testInfo) => { }, testInfo) => {
await page.goto('/deck') await page.goto("/deck");
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 });
await showResearch(page) await showResearch(page);
const chat = page.getByRole('complementary', { name: 'Research chat' }) const chat = page.getByRole("complementary", { name: "Research chat" });
await expect(chat).toBeVisible() await expect(chat).toBeVisible();
const composer = chat.getByRole('textbox', { const composer = chat.getByRole("textbox", {
name: 'Message', name: "Message",
exact: true, exact: true,
}) });
await expect(composer).toBeVisible() await expect(composer).toBeVisible();
const bounds = await composer.boundingBox() const bounds = await composer.boundingBox();
if (!bounds) throw new Error('Composer must have a visible bounding box') if (!bounds) throw new Error("Composer must have a visible bounding box");
expect(bounds.x).toBeGreaterThanOrEqual(0) expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width) expect(bounds.x + bounds.width).toBeLessThanOrEqual(width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850) expect(bounds.y + bounds.height).toBeLessThanOrEqual(850);
expect( expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath(`workspace-${width}.png`), path: testInfo.outputPath(`workspace-${width}.png`),
}) });
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
await closeMobileSidebar(page) await closeMobileSidebar(page);
} }
await page.getByRole('button', { name: 'Toggle sidebar' }).click() await page.getByRole("button", { name: "Toggle sidebar" }).click();
await expect( await expect(page.getByRole("complementary", { name: "Research chat" })).toBeHidden();
page.getByRole('complementary', { name: 'Research chat' }), await page.getByRole("button", { name: "Research chat", exact: true }).click();
).toBeHidden() await expect(page.getByRole("complementary", { name: "Research chat" })).toBeVisible();
await page.getByRole('button', { name: 'Research chat', exact: true }).click() });
await expect(
page.getByRole('complementary', { name: 'Research chat' }),
).toBeVisible()
})
+199 -226
View File
@@ -1,33 +1,32 @@
import { createServer } from 'node:http' import { createServer } from "node:http";
import { tweetLegacyFixture } from './fixtures/twitter.ts' 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,
@@ -39,14 +38,14 @@ const tweetResult = (
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: {
@@ -55,149 +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) const parsed = JSON.parse(body);
if (!isRecord(parsed)) throw new Error('JSON body must be an object') if (!isRecord(parsed)) throw new Error("JSON body must be an object");
return parsed 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}`);
if (request.method === 'GET' && url.pathname === '/profiles') { if (request.method === "GET" && url.pathname === "/profiles") {
send(response, { profiles: ['e2e', 'alternate'] }) send(response, { profiles: ["e2e", "alternate"] });
return return;
} }
const operation = [ const operation = [
'UserByScreenName', "UserByScreenName",
'UserTweets', "UserTweets",
'TweetDetail', "TweetDetail",
'SearchTimeline', "SearchTimeline",
'ListsManagementPageTimeline', "ListsManagementPageTimeline",
'ListLatestTweetsTimeline', "ListLatestTweetsTimeline",
].find((name) => url.pathname.endsWith(`/${name}`)) ].find((name) => url.pathname.endsWith(`/${name}`));
if (!operation) { if (!operation) {
fail(response, 501, 'unsupported read operation') fail(response, 501, "unsupported read operation");
return return;
} }
const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET' 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;
} }
const profileName = request.headers['x-profile-name'] const profileName = request.headers["x-profile-name"];
if (profileName !== 'e2e' && profileName !== 'alternate') { if (profileName !== "e2e" && profileName !== "alternate") {
fail(response, 403, 'invalid profile') fail(response, 403, "invalid profile");
return 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 = const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
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', firstPageText, '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: {
@@ -210,28 +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: name:
profileName === 'alternate' profileName === "alternate" ? "Alternate List" : "My E2E List",
? 'Alternate List' description: "Accounts selected for testing",
: '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",
}, },
}, },
}, },
@@ -249,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 },
@@ -316,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,
@@ -326,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,
@@ -374,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, conversationId: focalTweetId,
focalTweetId === '1000' ? 'user page 1' : 'retry focal', inReplyTo: focalTweetId,
'focus', }),
{ tweet(`${focalTweetId}2`, "thread page 1", "reply1", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
},
),
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,
}), }),
}, },
@@ -403,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 = `${profileName === 'alternate' ? 'alternate · ' : ''}${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: {
@@ -471,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");
+16 -22
View File
@@ -1,39 +1,33 @@
import { expect, type Page } from '@playwright/test' import { expect, type Page } from "@playwright/test";
export async function showDeckManagement(page: Page) { export async function showDeckManagement(page: Page) {
await expect( await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
page.getByRole('button', { name: 'Rename', includeHidden: true }), const navigation = page.getByRole("button", {
).toBeEnabled() name: "Deck manager",
const navigation = page.getByRole('button', {
name: 'Deck manager',
exact: true, exact: true,
}) });
if (!(await navigation.isVisible())) { if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click() await page.getByRole("button", { name: "Toggle sidebar" }).click();
} }
await navigation.click() await navigation.click();
} }
export async function showResearch(page: Page) { export async function showResearch(page: Page) {
await expect( await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
page.getByRole('button', { name: 'Rename', includeHidden: true }), const navigation = page.getByRole("button", {
).toBeEnabled() name: "Research chat",
const navigation = page.getByRole('button', {
name: 'Research chat',
exact: true, exact: true,
}) });
if (!(await navigation.isVisible())) { if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click() await page.getByRole("button", { name: "Toggle sidebar" }).click();
} }
await navigation.click() await navigation.click();
} }
export async function closeMobileSidebar(page: Page) { export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]') const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]');
if (await sheet.isVisible()) { if (await sheet.isVisible()) {
await page await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
.getByRole('button', { name: 'Close sidebar', exact: true }) await expect(sheet).toBeHidden();
.click()
await expect(sheet).toBeHidden()
} }
} }
+27 -26
View File
@@ -2,56 +2,41 @@
"name": "twitter-lite", "name": "twitter-lite",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=22.12.0"
},
"knip": {
"ignoreBinaries": [
"codex"
],
"ignore": [
"e2e/generated/**"
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
]
}
},
"imports": { "imports": {
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"codex:serve": "node scripts/serve-codex.mjs", "db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && oxfmt --write src/features/storage/migrations.generated.ts",
"db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && biome format --write src/features/storage/migrations.generated.ts",
"db:backup": "tsx scripts/backup-database.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 127.0.0.1 --port 3000", "dev:tailscale": "vite dev --host 127.0.0.1 --port 3000",
"generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings", "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 && node scripts/build-tools.mjs", "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 . && pnpm lint:ui", "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",
"lint:ui": "oxlint src", "account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.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", "@base-ui/react": "1.8.0",
"@shadcn/react": "^0.3.1", "@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", "better-sqlite3": "13.0.3",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"cn": "^0.4.0", "cn": "^0.4.0",
@@ -60,18 +45,18 @@
"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",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1", "remark-gfm": "4.0.1",
"sanitize-html": "2.17.7", "sanitize-html": "2.17.7",
"shadcn": "4.21.0", "shadcn": "4.21.0",
"streamdown": "^2.6.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"usewebmcp": "5.1.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", "@shadcn/lint": "^0.2.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "10.4.1", "@testing-library/dom": "10.4.1",
@@ -87,11 +72,27 @@
"jsdom": "29.1.1", "jsdom": "29.1.1",
"knip": "6.27.0", "knip": "6.27.0",
"orval": "8.24.0", "orval": "8.24.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0", "oxlint": "^1.85.0",
"oxlint-tsgolint": "^7.0.2003",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "4.23.12", "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"
]
}
} }
} }
+19 -19
View File
@@ -1,32 +1,32 @@
import { mkdtempSync } from 'node:fs' import { mkdtempSync } from "node:fs";
import { tmpdir } from 'node:os' import { tmpdir } from "node:os";
import { join } from 'node:path' import { join } from "node:path";
import { defineConfig, devices } from '@playwright/test' 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 = const databasePath =
process.env.TWITTER_LITE_E2E_DB_PATH ?? process.env.TWITTER_LITE_E2E_DB_PATH ??
join(mkdtempSync(join(tmpdir(), 'twitter-lite-e2e-')), 'workspace.sqlite') join(mkdtempSync(join(tmpdir(), "twitter-lite-e2e-")), "workspace.sqlite");
process.env.TWITTER_LITE_E2E_DB_PATH = databasePath 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]' }, 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: [
{ {
@@ -36,14 +36,14 @@ export default defineConfig({
timeout: 120_000, timeout: 120_000,
}, },
{ {
command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_URL= 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`, 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`,
port: 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"] } },
], ],
}) });
+706 -124
View File
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
import { spawn, spawnSync } from 'node:child_process'
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
// This process is independent of Vite / the web backend. Keep it running while
// restarting the app. Codex owns its own login and refreshes that login normally.
const reportRoot = resolve(
process.env.TWITTER_LITE_REPORT_ROOT || '.data/research',
)
const endpoint = new URL(
process.env.TWITTER_LITE_CODEX_URL || 'ws://127.0.0.1:4500',
)
if (
endpoint.protocol !== 'ws:' ||
endpoint.hostname !== '127.0.0.1' ||
endpoint.username ||
endpoint.password
) {
throw new Error('The prototype Codex endpoint must be ws://127.0.0.1:<port>.')
}
await mkdir(reportRoot, { recursive: true, mode: 0o700 })
const env = {
HOME: homedir(),
PATH: process.env.PATH,
CODEX_HOME: process.env.CODEX_HOME || resolve(homedir(), '.codex'),
}
for (const name of [
'LANG',
'LC_ALL',
'SSL_CERT_FILE',
'SSL_CERT_DIR',
'NIX_SSL_CERT_FILE',
'TMPDIR',
]) {
if (process.env[name]) env[name] = process.env[name]
}
// Enumerate the effective config, including CLI-wrapper and project settings.
// Keep this output private: MCP transport settings can contain credentials.
const configured = spawnSync('codex', ['mcp', 'list', '--json'], {
cwd: reportRoot,
env,
encoding: 'utf8',
timeout: 15_000,
maxBuffer: 4 * 1024 * 1024,
})
let mcpOverrides
try {
if (configured.error || configured.status !== 0) throw new Error()
const servers = JSON.parse(configured.stdout)
if (!Array.isArray(servers)) throw new Error()
mcpOverrides = servers.flatMap((server) => {
if (
typeof server?.name !== 'string' ||
!/^[A-Za-z0-9_-]+$/.test(server.name)
)
throw new Error()
return ['-c', `mcp_servers.${server.name}.enabled=false`]
})
} catch {
throw new Error(
'Could not inspect Codex MCP configuration safely. Check codex mcp list locally.',
)
}
const child = spawn(
'codex',
[
// Keep overrides before the subcommand so they merge with wrapper settings.
'-c',
'shell_environment_policy.inherit="none"',
'-c',
'features.multi_agent=false',
'-c',
'features.hooks=false',
'-c',
'web_search="disabled"',
...mcpOverrides,
'app-server',
'--listen',
endpoint.origin,
],
{ cwd: reportRoot, env, stdio: 'inherit' },
)
child.on('error', () => {
console.error(
'Could not start Codex. Install codex and run codex login first.',
)
process.exitCode = 1
})
child.on('exit', (code) => {
process.exitCode = code ?? 1
})
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => child.kill(signal))
+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;
}
}
+30 -51
View File
@@ -1,5 +1,5 @@
import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react' import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from 'react' import { type CSSProperties, type ReactNode, useState } from "react";
import { import {
Sidebar, Sidebar,
SidebarInset, SidebarInset,
@@ -8,16 +8,16 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} from '#/components/ui/sidebar' } from "#/components/ui/sidebar";
import { TooltipProvider } from '#/components/ui/tooltip' import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from '#/components/workspace-navigation' import { WorkspaceNavigation } from "#/components/workspace-navigation";
type ShellProps = { type ShellProps = {
hasTemporaryDecks: boolean hasTemporaryDecks: boolean;
sidebar: ReactNode sidebar: ReactNode;
researchChat: (closeMobileSidebar: () => void) => ReactNode researchChat: (closeMobileSidebar: () => void) => ReactNode;
children: ReactNode children: ReactNode;
} };
export function AppShell(props: ShellProps) { export function AppShell(props: ShellProps) {
return ( return (
@@ -26,29 +26,24 @@ export function AppShell(props: ShellProps) {
className="workspace h-dvh min-h-0 overflow-hidden" className="workspace h-dvh min-h-0 overflow-hidden"
style={ style={
{ {
'--sidebar-width': '25rem', "--sidebar-width": "25rem",
'--sidebar-width-icon': '4rem', "--sidebar-width-icon": "4rem",
} as CSSProperties } as CSSProperties
} }
> >
<Workspace {...props} /> <Workspace {...props} />
</SidebarProvider> </SidebarProvider>
</TooltipProvider> </TooltipProvider>
) );
} }
function Workspace({ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) {
sidebar, const [panel, setPanel] = useState<"chat" | "decks">("chat");
researchChat, const { setOpen, setOpenMobile, isMobile } = useSidebar();
children,
hasTemporaryDecks,
}: ShellProps) {
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar()
const navigation = [ const navigation = [
{ id: 'chat', title: 'Research chat', icon: MessageSquare }, { id: "chat", title: "Research chat", icon: MessageSquare },
{ id: 'decks', title: 'Deck manager', icon: Columns3 }, { id: "decks", title: "Deck manager", icon: Columns3 },
] as const ] as const;
return ( return (
<> <>
@@ -62,10 +57,8 @@ function Workspace({
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
aria-label="Close sidebar" aria-label="Close sidebar"
tooltip={{ children: 'Close sidebar', hidden: false }} tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
isMobile ? setOpenMobile(false) : setOpen(false)
}
className="size-10 justify-center p-0" className="size-10 justify-center p-0"
> >
<PanelLeftClose /> <PanelLeftClose />
@@ -80,9 +73,9 @@ function Workspace({
tooltip={{ children: item.title, hidden: false }} tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id} isActive={panel === item.id}
onClick={() => { onClick={() => {
setPanel(item.id) setPanel(item.id);
if (isMobile) setOpenMobile(true) if (isMobile) setOpenMobile(true);
else setOpen(true) else setOpen(true);
}} }}
className="size-10 justify-center p-0" className="size-10 justify-center p-0"
> >
@@ -92,23 +85,14 @@ function Workspace({
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</WorkspaceNavigation> </WorkspaceNavigation>
<Sidebar <Sidebar collapsible="none" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
collapsible="none" <div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
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))} {researchChat(() => setOpenMobile(false))}
</div> </div>
<section <section
aria-label="Deck manager" aria-label="Deck manager"
className={ className={
panel === 'decks' panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: 'hidden'
} }
> >
<h2 className="mb-4 text-base font-semibold">Decks</h2> <h2 className="mb-4 text-base font-semibold">Decks</h2>
@@ -120,17 +104,12 @@ function Workspace({
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3"> <header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" /> <SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span> <span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
Research workspace
</span>
</header> </header>
<section <section className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks">
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
>
{children} {children}
</section> </section>
</SidebarInset> </SidebarInset>
</> </>
) );
} }
+11 -11
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn' import { cn } from "cn";
import { Icon } from './icon' import { Icon } from "./icon";
import { Button } from './ui/button' import { Button } from "./ui/button";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
Dialog as DialogRoot, Dialog as DialogRoot,
DialogTitle, DialogTitle,
} from './ui/dialog' } from "./ui/dialog";
export function Dialog({ export function Dialog({
title, title,
@@ -15,21 +15,21 @@ export function Dialog({
children, children,
className, className,
}: { }: {
title: string title: string;
className?: string className?: string;
onClose: () => void onClose: () => void;
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<DialogRoot <DialogRoot
open open
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) onClose() if (!open) onClose();
}} }}
> >
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)} className={cn("max-h-[calc(100dvh-2rem)] overflow-y-auto", className)}
aria-describedby={undefined} aria-describedby={undefined}
> >
<DialogHeader> <DialogHeader>
@@ -50,5 +50,5 @@ export function Dialog({
{children} {children}
</DialogContent> </DialogContent>
</DialogRoot> </DialogRoot>
) );
} }
+14 -14
View File
@@ -1,17 +1,17 @@
const paths = { const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z', columns: "M3 4h7v16H3z M14 4h7v16h-7z",
hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16', hashtag: "M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16",
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0', search: "M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0",
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0', user: "M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0",
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01', list: "M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01",
plus: 'M12 5v14 M5 12h14', plus: "M12 5v14 M5 12h14",
close: 'M6 6l12 12 M18 6 6 18', close: "M6 6l12 12 M18 6 6 18",
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6', refresh: "M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6",
more: 'M5 12h.01 M12 12h.01 M19 12h.01', more: "M5 12h.01 M12 12h.01 M19 12h.01",
arrow: 'M7 17 17 7 M7 7h10v10', arrow: "M7 17 17 7 M7 7h10v10",
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5', edit: "m16 3 5 5-12 12H4v-5z M14 5l5 5",
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7', trash: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7",
} as const } as const;
export function Icon({ name }: { name: keyof typeof paths }) { export function Icon({ name }: { name: keyof typeof paths }) {
return ( return (
@@ -28,5 +28,5 @@ export function Icon({ name }: { name: keyof typeof paths }) {
> >
<path d={paths[name]} /> <path d={paths[name]} />
</svg> </svg>
) );
} }
+25 -30
View File
@@ -1,49 +1,44 @@
import type { ReactNode } from 'react' import type { ReactNode } from "react";
const URL = /(https?:\/\/[^\s]+)/g const URL = /(https?:\/\/[^\s]+)/g;
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi;
const NAMED_ENTITIES: Record<string, string> = { const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&', "&amp;": "&",
'&apos;': "'", "&apos;": "'",
'&gt;': '>', "&gt;": ">",
'&lt;': '<', "&lt;": "<",
'&quot;': '"', "&quot;": '"',
} };
function decodeHtmlEntities(text: string): string { function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => { return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) { if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity return NAMED_ENTITIES[entity.toLowerCase()] ?? entity;
} }
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16) const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16);
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
}) });
} }
function linkedText(text: string): ReactNode[] { function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text) const decoded = decodeHtmlEntities(text);
const parts: ReactNode[] = [] const parts: ReactNode[] = [];
let cursor = 0 let cursor = 0;
for (const match of decoded.matchAll(URL)) { for (const match of decoded.matchAll(URL)) {
const href = match[0] const href = match[0];
const start = match.index const start = match.index;
if (start > cursor) parts.push(decoded.slice(cursor, start)) if (start > cursor) parts.push(decoded.slice(cursor, start));
parts.push( parts.push(
<a <a href={href} key={`link-${start}`} rel="noreferrer noopener" target="_blank">
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
{href} {href}
</a>, </a>,
) );
cursor = start + href.length cursor = start + href.length;
} }
if (cursor < decoded.length) parts.push(decoded.slice(cursor)) if (cursor < decoded.length) parts.push(decoded.slice(cursor));
return parts return parts;
} }
export function PostText({ text }: { text: string }) { export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</> return <>{linkedText(text)}</>;
} }
+25 -41
View File
@@ -1,106 +1,90 @@
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar' import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function Avatar({ function Avatar({
className, className,
size = 'default', size = "default",
...props ...props
}: AvatarPrimitive.Root.Props & { }: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg' size?: "default" | "sm" | "lg";
}) { }) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn( className={cn("aspect-square size-full rounded-full object-cover", className)}
'aspect-square size-full rounded-full object-cover',
className,
)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs', "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none', "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="avatar-group" data-slot="avatar-group"
className={cn( className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background', "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroupCount({ function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="avatar-group-count" data-slot="avatar-group-count"
className={cn( className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}
+20 -23
View File
@@ -1,40 +1,37 @@
import { mergeProps } from '@base-ui/react/merge-props' import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from '@base-ui/react/use-render' import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
const badgeVariants = cva( const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!', "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
ghost: link: "text-primary underline-offset-4 hover:underline",
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function Badge({ function Badge({
className, className,
variant = 'default', variant = "default",
render, render,
...props ...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) { }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({ return useRender({
defaultTagName: 'span', defaultTagName: "span",
props: mergeProps<'span'>( props: mergeProps<"span">(
{ {
className: cn(badgeVariants({ variant }), className), className: cn(badgeVariants({ variant }), className),
}, },
@@ -42,10 +39,10 @@ function Badge({
), ),
render, render,
state: { state: {
slot: 'badge', slot: "badge",
variant, variant,
}, },
}) });
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+44 -48
View File
@@ -1,54 +1,54 @@
import { mergeProps } from '@base-ui/react/merge-props' import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from '@base-ui/react/use-render' import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) { function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="bubble-group" data-slot="bubble-group"
className={cn('flex min-w-0 flex-col gap-2', className)} className={cn("flex min-w-0 flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
const bubbleVariants = cva( const bubbleVariants = cva(
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full', "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80', "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary: secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]', "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted: muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]', "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted: tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]', "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline: outline:
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30', "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost: ghost:
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50', "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive: destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30', "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function Bubble({ function Bubble({
variant = 'default', variant = "default",
align = 'start', align = "start",
className, className,
...props ...props
}: React.ComponentProps<'div'> & }: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & { VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end' align?: "start" | "end";
}) { }) {
return ( return (
<div <div
@@ -58,20 +58,16 @@ function Bubble({
className={cn(bubbleVariants({ variant }), className)} className={cn(bubbleVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function BubbleContent({ function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
return useRender({ return useRender({
defaultTagName: 'div', defaultTagName: "div",
props: mergeProps<'div'>( props: mergeProps<"div">(
{ {
className: cn( className: cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50', "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className, className,
), ),
}, },
@@ -79,39 +75,39 @@ function BubbleContent({
), ),
render, render,
state: { state: {
slot: 'bubble-content', slot: "bubble-content",
}, },
}) });
} }
const bubbleReactionsVariants = cva( const bubbleReactionsVariants = cva(
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0', "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{ {
variants: { variants: {
side: { side: {
top: 'top-0 -translate-y-3/4', top: "top-0 -translate-y-3/4",
bottom: 'bottom-0 translate-y-3/4', bottom: "bottom-0 translate-y-3/4",
}, },
align: { align: {
start: 'left-3', start: "left-3",
end: 'right-3', end: "right-3",
}, },
}, },
defaultVariants: { defaultVariants: {
side: 'bottom', side: "bottom",
align: 'end', align: "end",
}, },
}, },
) );
function BubbleReactions({ function BubbleReactions({
side = 'bottom', side = "bottom",
align = 'end', align = "end",
className, className,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
align?: 'start' | 'end' align?: "start" | "end";
side?: 'top' | 'bottom' side?: "top" | "bottom";
}) { }) {
return ( return (
<div <div
@@ -121,7 +117,7 @@ function BubbleReactions({
className={cn(bubbleReactionsVariants({ side, align }), className)} className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props} {...props}
/> />
) );
} }
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions } export { Bubble, BubbleContent, BubbleGroup, BubbleReactions };
+23 -23
View File
@@ -1,48 +1,48 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button' import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80', default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: 'text-primary underline-offset-4 hover:underline', link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: 'size-8', icon: "size-8",
'icon-xs': "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': "icon-sm":
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
'icon-lg': 'size-9', "icon-lg": "size-9",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function Button({ function Button({
className, className,
variant = 'default', variant = "default",
size = 'default', size = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return ( return (
@@ -51,7 +51,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };
+6 -6
View File
@@ -1,13 +1,13 @@
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox' import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from 'cn' import { cn } from "cn";
import { CheckIcon } from 'lucide-react' import { CheckIcon } from "lucide-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary', "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className, className,
)} )}
{...props} {...props}
@@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckIcon /> <CheckIcon />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+5 -9
View File
@@ -1,19 +1,15 @@
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible' import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return ( return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
} }
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return ( return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
} }
export { Collapsible, CollapsibleContent, CollapsibleTrigger } export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+30 -48
View File
@@ -1,39 +1,36 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog' import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from 'cn' import { cn } from "cn";
import { XIcon } from 'lucide-react' import { XIcon } from "lucide-react";
import type * as React from 'react' import type * as React from "react";
import { Button } from '#/components/ui/button.tsx' import { Button } from "#/components/ui/button.tsx";
function Dialog({ ...props }: DialogPrimitive.Root.Props) { function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: DialogPrimitive.Close.Props) { function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return ( return (
<DialogPrimitive.Backdrop <DialogPrimitive.Backdrop
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
'fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', "fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -42,7 +39,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: DialogPrimitive.Popup.Props & { }: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -50,7 +47,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className, className,
)} )}
{...props} {...props}
@@ -59,13 +56,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
render={ render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
@@ -73,17 +64,13 @@ function DialogContent({
)} )}
</DialogPrimitive.Popup> </DialogPrimitive.Popup>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
data-slot="dialog-header" );
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
} }
function DialogFooter({ function DialogFooter({
@@ -91,52 +78,47 @@ function DialogFooter({
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end', "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}> <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
Close
</DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn('text-base leading-none font-medium', className)} className={cn("text-base leading-none font-medium", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
className,
...props
}: DialogPrimitive.Description.Props) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground', "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -150,4 +132,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+40 -54
View File
@@ -1,34 +1,31 @@
'use client' "use client";
import { Menu as MenuPrimitive } from '@base-ui/react/menu' import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from 'cn' import { cn } from "cn";
import { CheckIcon, ChevronRightIcon } from 'lucide-react' import { CheckIcon, ChevronRightIcon } from "lucide-react";
import type * as React from 'react' import type * as React from "react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
} }
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
} }
function DropdownMenuContent({ function DropdownMenuContent({
align = 'start', align = "start",
alignOffset = 0, alignOffset = 0,
side = 'bottom', side = "bottom",
sideOffset = 4, sideOffset = 4,
className, className,
...props ...props
}: MenuPrimitive.Popup.Props & }: MenuPrimitive.Popup.Props &
Pick< Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
MenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return ( return (
<MenuPrimitive.Portal> <MenuPrimitive.Portal>
<MenuPrimitive.Positioner <MenuPrimitive.Positioner
@@ -41,18 +38,18 @@ function DropdownMenuContent({
<MenuPrimitive.Popup <MenuPrimitive.Popup
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
className={cn( className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95', "z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className, className,
)} )}
{...props} {...props}
/> />
</MenuPrimitive.Positioner> </MenuPrimitive.Positioner>
</MenuPrimitive.Portal> </MenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -60,29 +57,29 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: MenuPrimitive.GroupLabel.Props & { }: MenuPrimitive.GroupLabel.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.GroupLabel <MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7', "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = 'default', variant = "default",
...props ...props
}: MenuPrimitive.Item.Props & { }: MenuPrimitive.Item.Props & {
inset?: boolean inset?: boolean;
variant?: 'default' | 'destructive' variant?: "default" | "destructive";
}) { }) {
return ( return (
<MenuPrimitive.Item <MenuPrimitive.Item
@@ -95,11 +92,11 @@ function DropdownMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} /> return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -108,7 +105,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: MenuPrimitive.SubmenuTrigger.Props & { }: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.SubmenuTrigger <MenuPrimitive.SubmenuTrigger
@@ -123,13 +120,13 @@ function DropdownMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger> </MenuPrimitive.SubmenuTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
align = 'start', align = "start",
alignOffset = -3, alignOffset = -3,
side = 'right', side = "right",
sideOffset = 0, sideOffset = 0,
className, className,
...props ...props
@@ -138,7 +135,7 @@ function DropdownMenuSubContent({
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', "w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className, className,
)} )}
align={align} align={align}
@@ -147,7 +144,7 @@ function DropdownMenuSubContent({
sideOffset={sideOffset} sideOffset={sideOffset}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -157,7 +154,7 @@ function DropdownMenuCheckboxItem({
inset, inset,
...props ...props
}: MenuPrimitive.CheckboxItem.Props & { }: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.CheckboxItem <MenuPrimitive.CheckboxItem
@@ -180,16 +177,11 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</MenuPrimitive.CheckboxItem> </MenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return ( return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -198,7 +190,7 @@ function DropdownMenuRadioItem({
inset, inset,
...props ...props
}: MenuPrimitive.RadioItem.Props & { }: MenuPrimitive.RadioItem.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.RadioItem <MenuPrimitive.RadioItem
@@ -220,36 +212,30 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</MenuPrimitive.RadioItem> </MenuPrimitive.RadioItem>
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
className,
...props
}: MenuPrimitive.Separator.Props) {
return ( return (
<MenuPrimitive.Separator <MenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
className,
...props
}: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground', "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -268,4 +254,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} };
+25 -32
View File
@@ -1,49 +1,49 @@
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
function Empty({ className, ...props }: React.ComponentProps<'div'>) { function Empty({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty" data-slot="empty"
className={cn( className={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance', "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-header" data-slot="empty-header"
className={cn('flex max-w-sm flex-col items-center gap-2', className)} className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props} {...props}
/> />
) );
} }
const emptyMediaVariants = cva( const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function EmptyMedia({ function EmptyMedia({
className, className,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return ( return (
<div <div
data-slot="empty-icon" data-slot="empty-icon"
@@ -51,50 +51,43 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))} className={cn(emptyMediaVariants({ variant, className }))}
{...props} {...props}
/> />
) );
} }
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-title" data-slot="empty-title"
className={cn('text-sm font-medium tracking-tight', className)} className={cn("text-sm font-medium tracking-tight", className)}
{...props} {...props}
/> />
) );
} }
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<div <div
data-slot="empty-description" data-slot="empty-description"
className={cn( className={cn(
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', "text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-content" data-slot="empty-content"
className={cn( className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance', "flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
}
+51 -64
View File
@@ -1,24 +1,24 @@
'use client' "use client";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
import { Button } from '#/components/ui/button.tsx' import { Button } from "#/components/ui/button.tsx";
import { Input } from '#/components/ui/input.tsx' import { Input } from "#/components/ui/input.tsx";
import { Textarea } from '#/components/ui/textarea.tsx' import { Textarea } from "#/components/ui/textarea.tsx";
function InputGroup({ className, ...props }: React.ComponentProps<'fieldset'>) { function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
<fieldset <fieldset
data-slot="input-group" data-slot="input-group"
className={cn( className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
@@ -26,72 +26,65 @@ const inputGroupAddonVariants = cva(
{ {
variants: { variants: {
align: { align: {
'inline-start': "inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]', "inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
'inline-end': "block-start":
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]', "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
'block-start': "block-end":
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2', "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
}, },
}, },
defaultVariants: { defaultVariants: {
align: 'inline-start', align: "inline-start",
}, },
}, },
) );
function InputGroupAddon({ function InputGroupAddon({
className, className,
align = 'inline-start', align = "inline-start",
...props ...props
}: React.ComponentProps<'fieldset'> & }: React.ComponentProps<"fieldset"> & VariantProps<typeof inputGroupAddonVariants>) {
VariantProps<typeof inputGroupAddonVariants>) {
return ( return (
// biome-ignore lint/a11y/useKeyWithClickEvents: Pointer convenience only; the text control remains independently keyboard-focusable. // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- Pointer convenience only; the text control remains independently keyboard-focusable.
<fieldset <fieldset
data-slot="input-group-addon" data-slot="input-group-addon"
data-align={align} data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)} className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) { if ((e.target as HTMLElement).closest("button")) {
return return;
} }
e.currentTarget.parentElement?.querySelector('input')?.focus() e.currentTarget.parentElement?.querySelector("input")?.focus();
}} }}
{...props} {...props}
/> />
) );
} }
const inputGroupButtonVariants = cva( const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
'flex items-center gap-2 text-sm shadow-none', variants: {
{ size: {
variants: { xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
size: { sm: "",
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", "icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
sm: '', "icon-sm": "size-8 p-0 has-[>svg]:p-0",
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
}, },
}, },
) defaultVariants: {
size: "xs",
},
});
function InputGroupButton({ function InputGroupButton({
className, className,
type = 'button', type = "button",
variant = 'ghost', variant = "ghost",
size = 'xs', size = "xs",
...props ...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> & }: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & { VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset' type?: "button" | "submit" | "reset";
}) { }) {
return ( return (
<Button <Button
@@ -101,10 +94,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)} className={cn(inputGroupButtonVariants({ size }), className)}
{...props} {...props}
/> />
) );
} }
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) { function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
className={cn( className={cn(
@@ -113,39 +106,33 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
)} )}
{...props} {...props}
/> />
) );
} }
function InputGroupInput({ function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
className,
...props
}: React.ComponentProps<'input'>) {
return ( return (
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent', "flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function InputGroupTextarea({ function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
className,
...props
}: React.ComponentProps<'textarea'>) {
return ( return (
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent', "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -155,4 +142,4 @@ export {
InputGroupInput, InputGroupInput,
InputGroupText, InputGroupText,
InputGroupTextarea, InputGroupTextarea,
} };
+7 -7
View File
@@ -1,19 +1,19 @@
import { Input as InputPrimitive } from '@base-ui/react/input' import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function Input({ className, type, ...props }: React.ComponentProps<'input'>) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<InputPrimitive <InputPrimitive
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+8 -8
View File
@@ -1,20 +1,20 @@
'use client' "use client";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function Label({ className, ...props }: React.ComponentProps<'label'>) { function Label({ className, ...props }: React.ComponentProps<"label">) {
return ( return (
// biome-ignore lint/a11y/noLabelWithoutControl: Association and content are supplied by the caller through props. // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- Association and content are supplied by the caller through props.
<label <label
data-slot="label" data-slot="label"
className={cn( className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+22 -25
View File
@@ -1,32 +1,32 @@
import { mergeProps } from '@base-ui/react/merge-props' import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from '@base-ui/react/use-render' import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
const markerVariants = cva( const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground", "group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{ {
variants: { variants: {
variant: { variant: {
default: '', default: "",
separator: separator:
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border', "before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: 'border-b border-border pb-2', border: "border-b border-border pb-2",
}, },
}, },
}, },
) );
function Marker({ function Marker({
className, className,
variant = 'default', variant = "default",
render, render,
...props ...props
}: useRender.ComponentProps<'div'> & VariantProps<typeof markerVariants>) { }: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({ return useRender({
defaultTagName: 'div', defaultTagName: "div",
props: mergeProps<'div'>( props: mergeProps<"div">(
{ {
className: cn(markerVariants({ variant, className })), className: cn(markerVariants({ variant, className })),
}, },
@@ -34,37 +34,34 @@ function Marker({
), ),
render, render,
state: { state: {
slot: 'marker', slot: "marker",
variant, variant,
}, },
}) });
} }
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) { function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="marker-icon" data-slot="marker-icon"
aria-hidden="true" aria-hidden="true"
className={cn( className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props} {...props}
/> />
) );
} }
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) { function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="marker-content" data-slot="marker-content"
className={cn( className={cn(
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground', "min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Marker, MarkerContent, MarkerIcon, markerVariants } export { Marker, MarkerContent, MarkerIcon, markerVariants };
+23 -23
View File
@@ -1,20 +1,20 @@
'use client' "use client";
import { import {
MessageScroller as MessageScrollerPrimitive, MessageScroller as MessageScrollerPrimitive,
useMessageScroller, useMessageScroller,
useMessageScrollerScrollable, useMessageScrollerScrollable,
useMessageScrollerVisibility, useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller' } from "@shadcn/react/message-scroller";
import { cn } from 'cn' import { cn } from "cn";
import { ArrowDownIcon } from 'lucide-react' import { ArrowDownIcon } from "lucide-react";
import type * as React from 'react' import type * as React from "react";
import { Button } from '#/components/ui/button.tsx' import { Button } from "#/components/ui/button.tsx";
function MessageScrollerProvider( function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>, props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) { ) {
return <MessageScrollerPrimitive.Provider {...props} /> return <MessageScrollerPrimitive.Provider {...props} />;
} }
function MessageScroller({ function MessageScroller({
@@ -25,12 +25,12 @@ function MessageScroller({
<MessageScrollerPrimitive.Root <MessageScrollerPrimitive.Root
data-slot="message-scroller" data-slot="message-scroller"
className={cn( className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden', "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageScrollerViewport({ function MessageScrollerViewport({
@@ -41,12 +41,12 @@ function MessageScrollerViewport({
<MessageScrollerPrimitive.Viewport <MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport" data-slot="message-scroller-viewport"
className={cn( className={cn(
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible', "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageScrollerContent({ function MessageScrollerContent({
@@ -56,10 +56,10 @@ function MessageScrollerContent({
return ( return (
<MessageScrollerPrimitive.Content <MessageScrollerPrimitive.Content
data-slot="message-scroller-content" data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col gap-6', className)} className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props} {...props}
/> />
) );
} }
function MessageScrollerItem({ function MessageScrollerItem({
@@ -72,24 +72,24 @@ function MessageScrollerItem({
data-slot="message-scroller-item" data-slot="message-scroller-item"
scrollAnchor={scrollAnchor} scrollAnchor={scrollAnchor}
className={cn( className={cn(
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]', "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageScrollerButton({ function MessageScrollerButton({
direction = 'end', direction = "end",
className, className,
children, children,
render, render,
variant = 'secondary', variant = "secondary",
size = 'icon-sm', size = "icon-sm",
...props ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> & }: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) { Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<MessageScrollerPrimitive.Button <MessageScrollerPrimitive.Button
data-slot="message-scroller-button" data-slot="message-scroller-button"
@@ -98,7 +98,7 @@ function MessageScrollerButton({
data-size={size} data-size={size}
direction={direction} direction={direction}
className={cn( className={cn(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180', "absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className, className,
)} )}
render={render ?? <Button variant={variant} size={size} />} render={render ?? <Button variant={variant} size={size} />}
@@ -108,12 +108,12 @@ function MessageScrollerButton({
<> <>
<ArrowDownIcon /> <ArrowDownIcon />
<span className="sr-only"> <span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'} {direction === "end" ? "Scroll to end" : "Scroll to start"}
</span> </span>
</> </>
)} )}
</MessageScrollerPrimitive.Button> </MessageScrollerPrimitive.Button>
) );
} }
export { export {
@@ -126,4 +126,4 @@ export {
useMessageScroller, useMessageScroller,
useMessageScrollerScrollable, useMessageScrollerScrollable,
useMessageScrollerVisibility, useMessageScrollerVisibility,
} };
+22 -29
View File
@@ -1,91 +1,84 @@
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) { function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="message-group" data-slot="message-group"
className={cn('flex min-w-0 flex-col gap-2', className)} className={cn("flex min-w-0 flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function Message({ function Message({
className, className,
align = 'start', align = "start",
...props ...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) { }: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return ( return (
<div <div
data-slot="message" data-slot="message"
data-align={align} data-align={align}
className={cn( className={cn(
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse', "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) { function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="message-avatar" data-slot="message-avatar"
className={cn( className={cn(
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8', "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) { function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="message-content" data-slot="message-content"
className={cn( className={cn(
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end', "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) { function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="message-header" data-slot="message-header"
className={cn( className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0', "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) { function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="message-footer" data-slot="message-footer"
className={cn( className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end', "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
}
+16 -26
View File
@@ -1,20 +1,16 @@
import { cn } from 'cn' import { cn } from "cn";
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from "lucide-react";
import type * as React from 'react' import type * as React from "react";
type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & { type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: 'sm' | 'default' size?: "sm" | "default";
} };
function NativeSelect({ function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
className,
size = 'default',
...props
}: NativeSelectProps) {
return ( return (
<div <div
className={cn( className={cn(
'group/native-select relative w-fit has-[select:disabled]:opacity-50', "group/native-select relative w-fit has-[select:disabled]:opacity-50",
className, className,
)} )}
data-slot="native-select-wrapper" data-slot="native-select-wrapper"
@@ -32,33 +28,27 @@ function NativeSelect({
data-slot="native-select-icon" data-slot="native-select-icon"
/> />
</div> </div>
) );
} }
function NativeSelectOption({ function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
className,
...props
}: React.ComponentProps<'option'>) {
return ( return (
<option <option
data-slot="native-select-option" data-slot="native-select-option"
className={cn('bg-[Canvas] text-[CanvasText]', className)} className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props} {...props}
/> />
) );
} }
function NativeSelectOptGroup({ function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
className,
...props
}: React.ComponentProps<'optgroup'>) {
return ( return (
<optgroup <optgroup
data-slot="native-select-optgroup" data-slot="native-select-optgroup"
className={cn('bg-[Canvas] text-[CanvasText]', className)} className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props} {...props}
/> />
) );
} }
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption } export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
+6 -10
View File
@@ -1,22 +1,18 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator' import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from 'cn' import { cn } from "cn";
function Separator({ function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
className,
orientation = 'horizontal',
...props
}: SeparatorPrimitive.Props) {
return ( return (
<SeparatorPrimitive <SeparatorPrimitive
data-slot="separator" data-slot="separator"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch', "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+30 -39
View File
@@ -1,23 +1,23 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog' import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from 'cn' import { cn } from "cn";
import { XIcon } from 'lucide-react' import { XIcon } from "lucide-react";
import type * as React from 'react' import type * as React from "react";
import { Button } from '#/components/ui/button.tsx' import { Button } from "#/components/ui/button.tsx";
function Sheet({ ...props }: SheetPrimitive.Root.Props) { function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ ...props }: SheetPrimitive.Close.Props) { function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
@@ -25,25 +25,25 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop <SheetPrimitive.Backdrop
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
'fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs', "fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
className, className,
children, children,
side = 'right', side = "right",
showCloseButton = true, showCloseButton = true,
keepMounted = false, keepMounted = false,
...props ...props
}: SheetPrimitive.Popup.Props & { }: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left' side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean showCloseButton?: boolean;
keepMounted?: boolean keepMounted?: boolean;
}) { }) {
return ( return (
<SheetPortal keepMounted={keepMounted}> <SheetPortal keepMounted={keepMounted}>
@@ -52,7 +52,7 @@ function SheetContent({
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
className={cn( className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm', "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className, className,
)} )}
{...props} {...props}
@@ -61,13 +61,7 @@ function SheetContent({
{showCloseButton && ( {showCloseButton && (
<SheetPrimitive.Close <SheetPrimitive.Close
data-slot="sheet-close" data-slot="sheet-close"
render={ render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
@@ -75,50 +69,47 @@ function SheetContent({
)} )}
</SheetPrimitive.Popup> </SheetPrimitive.Popup>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)} className={cn("flex flex-col gap-0.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn('text-base font-medium text-foreground', className)} className={cn("text-base font-medium text-foreground", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
className,
...props
}: SheetPrimitive.Description.Props) {
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -130,4 +121,4 @@ export {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} };
+205 -238
View File
@@ -1,51 +1,47 @@
import { mergeProps } from '@base-ui/react/merge-props' import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from '@base-ui/react/use-render' import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from 'cn' import { cn } from "cn";
import { PanelLeftIcon } from 'lucide-react' import { PanelLeftIcon } from "lucide-react";
import * as React from 'react' import * as React from "react";
import { Button } from '#/components/ui/button.tsx' import { Button } from "#/components/ui/button.tsx";
import { Input } from '#/components/ui/input.tsx' import { Input } from "#/components/ui/input.tsx";
import { Separator } from '#/components/ui/separator.tsx' import { Separator } from "#/components/ui/separator.tsx";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '#/components/ui/sheet.tsx' } from "#/components/ui/sheet.tsx";
import { Skeleton } from '#/components/ui/skeleton.tsx' import { Skeleton } from "#/components/ui/skeleton.tsx";
import { import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
Tooltip, import { useIsMobile } from "#/hooks/use-mobile.ts";
TooltipContent,
TooltipTrigger,
} from '#/components/ui/tooltip.tsx'
import { useIsMobile } from '#/hooks/use-mobile.ts'
const SIDEBAR_WIDTH = '16rem' const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = 'min(25rem, 100vw)' const SIDEBAR_WIDTH_MOBILE = "min(25rem, 100vw)";
const SIDEBAR_WIDTH_ICON = '3rem' const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = 'b' const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: 'expanded' | 'collapsed' state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.') throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@@ -56,54 +52,51 @@ function SidebarProvider({
style, style,
children, children,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
}, },
[setOpenProp, open], [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen]) }, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.preventDefault();
(event.metaKey || event.ctrlKey) toggleSidebar();
) {
event.preventDefault()
toggleSidebar()
} }
} };
window.addEventListener('keydown', handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed' const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -116,7 +109,7 @@ function SidebarProvider({
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, toggleSidebar], [state, open, setOpen, isMobile, openMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -124,13 +117,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH, "--sidebar-width": SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style, ...style,
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className, className,
)} )}
{...props} {...props}
@@ -138,37 +131,37 @@ function SidebarProvider({
{children} {children}
</div> </div>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
side = 'left', side = "left",
variant = 'sidebar', variant = "sidebar",
collapsible = 'offcanvas', collapsible = "offcanvas",
className, className,
children, children,
dir, dir,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
side?: 'left' | 'right' side?: "left" | "right";
variant?: 'sidebar' | 'floating' | 'inset' variant?: "sidebar" | "floating" | "inset";
collapsible?: 'offcanvas' | 'icon' | 'none' collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') { if (collapsible === "none") {
return ( return (
<div <div
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@@ -183,28 +176,26 @@ function Sidebar({
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH_MOBILE, "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties } as React.CSSProperties
} }
side={side} side={side}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Workspace</SheetTitle> <SheetTitle>Workspace</SheetTitle>
<SheetDescription> <SheetDescription>Research chat and deck management</SheetDescription>
Research chat and deck management
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div> <div className="flex h-full w-full flex-row">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
<div <div
className="group peer hidden text-sidebar-foreground md:block" className="group peer hidden text-sidebar-foreground md:block"
data-state={state} data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''} data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant} data-variant={variant}
data-side={side} data-side={side}
data-slot="sidebar" data-slot="sidebar"
@@ -213,23 +204,23 @@ function Sidebar({
<div <div
data-slot="sidebar-gap" data-slot="sidebar-gap"
className={cn( className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
'group-data-[collapsible=offcanvas]:w-0', "group-data-[collapsible=offcanvas]:w-0",
'group-data-[side=right]:rotate-180', "group-data-[side=right]:rotate-180",
variant === 'floating' || variant === 'inset' variant === "floating" || variant === "inset"
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
data-side={side} data-side={side}
className={cn( className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex', "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset' variant === "floating" || variant === "inset"
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className, className,
)} )}
{...props} {...props}
@@ -243,15 +234,11 @@ function Sidebar({
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
className, const { toggleSidebar } = useSidebar();
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return ( return (
<Button <Button
@@ -261,19 +248,19 @@ function SidebarTrigger({
size="icon-sm" size="icon-sm"
className={cn(className)} className={cn(className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -284,118 +271,112 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2', "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return ( return (
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
className,
...props
}: React.ComponentProps<typeof Input>) {
return ( return (
<Input <Input
data-slot="sidebar-input" data-slot="sidebar-input"
data-sidebar="input" data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)} className={cn("h-8 w-full bg-background shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-header" data-slot="sidebar-header"
data-sidebar="header" data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-footer" data-slot="sidebar-footer"
data-sidebar="footer" data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
className,
...props
}: React.ComponentProps<typeof Separator>) {
return ( return (
<Separator <Separator
data-slot="sidebar-separator" data-slot="sidebar-separator"
data-sidebar="separator" data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)} className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden', "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-group" data-slot="sidebar-group"
data-sidebar="group" data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
className, className,
render, render,
...props ...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) { }: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({ return useRender({
defaultTagName: 'div', defaultTagName: "div",
props: mergeProps<'div'>( props: mergeProps<"div">(
{ {
className: cn( className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className, className,
), ),
}, },
@@ -403,23 +384,23 @@ function SidebarGroupLabel({
), ),
render, render,
state: { state: {
slot: 'sidebar-group-label', slot: "sidebar-group-label",
sidebar: 'group-label', sidebar: "group-label",
}, },
}) });
} }
function SidebarGroupAction({ function SidebarGroupAction({
className, className,
render, render,
...props ...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) { }: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({ return useRender({
defaultTagName: 'button', defaultTagName: "button",
props: mergeProps<'button'>( props: mergeProps<"button">(
{ {
className: cn( className: cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className, className,
), ),
}, },
@@ -427,87 +408,84 @@ function SidebarGroupAction({
), ),
render, render,
state: { state: {
slot: 'sidebar-group-action', slot: "sidebar-group-action",
sidebar: 'group-action', sidebar: "group-action",
}, },
}) });
} }
function SidebarGroupContent({ function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-group-content" data-slot="sidebar-group-content"
data-sidebar="group-content" data-sidebar="group-content"
className={cn('w-full text-sm', className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return ( return (
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-sidebar="menu" data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0', className)} className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="sidebar-menu-item" data-slot="sidebar-menu-item"
data-sidebar="menu-item" data-sidebar="menu-item"
className={cn('group/menu-item relative', className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate', "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{ {
variants: { variants: {
variant: { variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: outline:
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]', "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
}, },
size: { size: {
default: 'h-8 text-sm', default: "h-8 text-sm",
sm: 'h-7 text-xs', sm: "h-7 text-xs",
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!', lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function SidebarMenuButton({ function SidebarMenuButton({
render, render,
isActive = false, isActive = false,
variant = 'default', variant = "default",
size = 'default', size = "default",
tooltip, tooltip,
className, className,
...props ...props
}: useRender.ComponentProps<'button'> & }: useRender.ComponentProps<"button"> &
React.ComponentProps<'button'> & { React.ComponentProps<"button"> & {
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const comp = useRender({ const comp = useRender({
defaultTagName: 'button', defaultTagName: "button",
props: mergeProps<'button'>( props: mergeProps<"button">(
{ {
className: cn(sidebarMenuButtonVariants({ variant, size }), className), className: cn(sidebarMenuButtonVariants({ variant, size }), className),
}, },
@@ -515,21 +493,21 @@ function SidebarMenuButton({
), ),
render: !tooltip ? render : <TooltipTrigger render={render} />, render: !tooltip ? render : <TooltipTrigger render={render} />,
state: { state: {
slot: 'sidebar-menu-button', slot: "sidebar-menu-button",
sidebar: 'menu-button', sidebar: "menu-button",
size, size,
active: isActive, active: isActive,
}, },
}) });
if (!tooltip) { if (!tooltip) {
return comp return comp;
} }
if (typeof tooltip === 'string') { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -538,11 +516,11 @@ function SidebarMenuButton({
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
hidden={state !== 'collapsed' || isMobile} hidden={state !== "collapsed" || isMobile}
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@@ -550,18 +528,18 @@ function SidebarMenuAction({
render, render,
showOnHover = false, showOnHover = false,
...props ...props
}: useRender.ComponentProps<'button'> & }: useRender.ComponentProps<"button"> &
React.ComponentProps<'button'> & { React.ComponentProps<"button"> & {
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
return useRender({ return useRender({
defaultTagName: 'button', defaultTagName: "button",
props: mergeProps<'button'>( props: mergeProps<"button">(
{ {
className: cn( className: cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover && showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0', "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className, className,
), ),
}, },
@@ -569,112 +547,101 @@ function SidebarMenuAction({
), ),
render, render,
state: { state: {
slot: 'sidebar-menu-action', slot: "sidebar-menu-action",
sidebar: 'menu-action', sidebar: "menu-action",
}, },
}) });
} }
function SidebarMenuBadge({ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="sidebar-menu-badge" data-slot="sidebar-menu-badge"
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground', "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
className, className,
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const [width] = React.useState(() => { const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}) });
return ( return (
<div <div
data-slot="sidebar-menu-skeleton" data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton" data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props} {...props}
> >
{showIcon && ( {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton <Skeleton
className="h-4 max-w-(--skeleton-width) flex-1" className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text" data-sidebar="menu-skeleton-text"
style={ style={
{ {
'--skeleton-width': width, "--skeleton-width": width,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return ( return (
<ul <ul
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden', "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
className,
...props
}: React.ComponentProps<'li'>) {
return ( return (
<li <li
data-slot="sidebar-menu-sub-item" data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item" data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
render, render,
size = 'md', size = "md",
isActive = false, isActive = false,
className, className,
...props ...props
}: useRender.ComponentProps<'a'> & }: useRender.ComponentProps<"a"> &
React.ComponentProps<'a'> & { React.ComponentProps<"a"> & {
size?: 'sm' | 'md' size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
return useRender({ return useRender({
defaultTagName: 'a', defaultTagName: "a",
props: mergeProps<'a'>( props: mergeProps<"a">(
{ {
className: cn( className: cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className, className,
), ),
}, },
@@ -682,12 +649,12 @@ function SidebarMenuSubButton({
), ),
render, render,
state: { state: {
slot: 'sidebar-menu-sub-button', slot: "sidebar-menu-sub-button",
sidebar: 'menu-sub-button', sidebar: "menu-sub-button",
size, size,
active: isActive, active: isActive,
}, },
}) });
} }
export { export {
@@ -715,4 +682,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };
+5 -5
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn' import { cn } from "cn";
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+6 -6
View File
@@ -1,17 +1,17 @@
import { cn } from 'cn' import { cn } from "cn";
import type * as React from 'react' import type * as React from "react";
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Textarea } export { Textarea };
+13 -25
View File
@@ -1,42 +1,30 @@
'use client' "use client";
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip' import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from 'cn' import { cn } from "cn";
function TooltipProvider({ function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
delay = 0, return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
} }
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
className, className,
side = 'top', side = "top",
sideOffset = 4, sideOffset = 4,
align = 'center', align = "center",
alignOffset = 0, alignOffset = 0,
children, children,
...props ...props
}: TooltipPrimitive.Popup.Props & }: TooltipPrimitive.Popup.Props &
Pick< Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
TooltipPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner <TooltipPrimitive.Positioner
@@ -49,7 +37,7 @@ function TooltipContent({
<TooltipPrimitive.Popup <TooltipPrimitive.Popup
data-slot="tooltip-content" data-slot="tooltip-content"
className={cn( className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className, className,
)} )}
{...props} {...props}
@@ -59,7 +47,7 @@ function TooltipContent({
</TooltipPrimitive.Popup> </TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner> </TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+38 -48
View File
@@ -1,15 +1,7 @@
import { Link, useLocation, useRouteContext } from '@tanstack/react-router' import { Link, useLocation, useRouteContext } from "@tanstack/react-router";
import { import { Activity, House, Inbox, Layers3, MessagesSquare, NotebookPen, Search } from "lucide-react";
Activity, import type { ReactNode } from "react";
House, import { Avatar, AvatarFallback } from "#/components/ui/avatar";
Inbox,
Layers3,
MessagesSquare,
NotebookPen,
Search,
} from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -21,62 +13,62 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarSeparator, SidebarSeparator,
} from '#/components/ui/sidebar' } from "#/components/ui/sidebar";
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog' import { ConnectionManagerDialog } from "#/features/connections/connection-manager-dialog";
import { useUrlState } from "#/features/navigation/use-url-state";
type WorkspaceNavigationProps = { type WorkspaceNavigationProps = {
children?: ReactNode children?: ReactNode;
footer?: ReactNode footer?: ReactNode;
hasTemporaryDecks?: boolean hasTemporaryDecks?: boolean;
} };
export function WorkspaceNavigation({ export function WorkspaceNavigation({
children, children,
footer, footer,
hasTemporaryDecks, hasTemporaryDecks,
}: WorkspaceNavigationProps) { }: WorkspaceNavigationProps) {
const { auth } = useRouteContext({ from: '__root__' }) const { auth } = useRouteContext({ from: "__root__" });
const [managingAccounts, setManagingAccounts] = useState(false) const [search, updateSearch] = useUrlState();
const pathname = useLocation({ select: (location) => location.pathname }) const managingAccounts = search.accounts;
const setManagingAccounts = (open: boolean) => updateSearch({ accounts: open || undefined });
const pathname = useLocation({ select: (location) => location.pathname });
const navigation = [ const navigation = [
{ title: 'Home', to: '/', icon: House, active: pathname === '/' }, { title: "Home", to: "/", icon: House, active: pathname === "/" },
{ {
title: 'Vitals', title: "Vitals",
to: '/vitals', to: "/vitals",
icon: Activity, icon: Activity,
active: pathname === '/vitals', active: pathname === "/vitals",
}, },
{ {
title: 'Messages', title: "Messages",
to: '/support', to: "/support",
icon: MessagesSquare, icon: MessagesSquare,
active: pathname === '/support', active: pathname === "/support",
}, },
{ {
title: 'Journal', title: "Journal",
to: '/journal', to: "/journal",
icon: NotebookPen, icon: NotebookPen,
active: pathname === '/journal', active: pathname === "/journal",
}, },
{ {
title: 'Reader', title: "Reader",
to: '/inbox', to: "/inbox",
icon: Inbox, icon: Inbox,
active: pathname === '/inbox', active: pathname === "/inbox",
}, },
{ {
title: 'Research', title: "Research",
to: '/deck', to: "/deck",
icon: Search, icon: Search,
active: pathname === '/deck', active: pathname === "/deck",
}, },
] as const ] as const;
return ( return (
<Sidebar <Sidebar collapsible="none" className="w-16! shrink-0 border-r">
collapsible="none"
className="workspace-navigation w-16! shrink-0 border-r"
>
<SidebarHeader className="items-center py-4"> <SidebarHeader className="items-center py-4">
<div <div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground" className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
@@ -94,7 +86,7 @@ export function WorkspaceNavigation({
<SidebarMenuItem key={item.to}> <SidebarMenuItem key={item.to}>
<SidebarMenuButton <SidebarMenuButton
aria-label={item.title} aria-label={item.title}
aria-current={item.active ? 'page' : undefined} aria-current={item.active ? "page" : undefined}
tooltip={{ children: item.title, hidden: false }} tooltip={{ children: item.title, hidden: false }}
isActive={item.active} isActive={item.active}
render={<Link to={item.to} />} render={<Link to={item.to} />}
@@ -125,14 +117,12 @@ export function WorkspaceNavigation({
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
aria-label="Manage connected accounts" aria-label="Manage connected accounts"
tooltip={{ children: 'Manage connected accounts', hidden: false }} tooltip={{ children: "Manage connected accounts", hidden: false }}
className="size-10 justify-center p-0" className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)} onClick={() => setManagingAccounts(true)}
> >
<Avatar className="size-8"> <Avatar className="size-8">
<AvatarFallback> <AvatarFallback>{auth.owner?.name.slice(0, 1).toUpperCase()}</AvatarFallback>
{auth.owner?.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -145,5 +135,5 @@ export function WorkspaceNavigation({
/> />
)} )}
</Sidebar> </Sidebar>
) );
} }
+24 -27
View File
@@ -1,45 +1,42 @@
type AccessConfig = { origin: string } & ( type AccessConfig = { origin: string } & (
| { mode: 'tailscale'; allowedLogin: string } | { mode: "tailscale"; allowedLogin: string }
| { mode: 'none' } | { mode: "none" }
) );
export function readAccessConfig(): AccessConfig | null { export function readAccessConfig(): AccessConfig | null {
const origin = process.env.TWITTER_LITE_ORIGIN const origin = process.env.TWITTER_LITE_ORIGIN;
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale' const mode = process.env.TWITTER_LITE_AUTH_MODE ?? "tailscale";
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN;
if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null if (!origin || (mode !== "tailscale" && mode !== "none")) return null;
try { try {
const url = new URL(origin) const url = new URL(origin);
const secure = url.protocol === 'https:' const secure = url.protocol === "https:";
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1' const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
if ((!secure && !local) || url.origin !== origin) return null if ((!secure && !local) || url.origin !== origin) return null;
if (mode === 'none') return { origin, mode } if (mode === "none") return { origin, mode };
if (!allowedLogin?.trim()) return null if (!allowedLogin?.trim()) return null;
return { origin, mode, allowedLogin } return { origin, mode, allowedLogin };
} catch { } catch {
return null return null;
} }
} }
/** Use loopback behind Serve for identity, or a private network for mode none. */ /** Use loopback behind Serve for identity, or a private network for mode none. */
export function checkAccess( export function checkAccess(request: Request, config: AccessConfig | null): Response | null {
request: Request,
config: AccessConfig | null,
): Response | null {
if (!config) { if (!config) {
return new Response('Access configuration is required.', { status: 503 }) return new Response("Access configuration is required.", { status: 503 });
} }
if ( if (
config.mode === 'tailscale' && config.mode === "tailscale" &&
request.headers.get('Tailscale-User-Login') !== config.allowedLogin request.headers.get("Tailscale-User-Login") !== config.allowedLogin
) { ) {
return new Response('Forbidden', { status: 403 }) return new Response("Forbidden", { status: 403 });
} }
if ( if (
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && !["GET", "HEAD", "OPTIONS"].includes(request.method) &&
request.headers.get('Origin') !== config.origin request.headers.get("Origin") !== config.origin
) { ) {
return new Response('Forbidden', { status: 403 }) return new Response("Forbidden", { status: 403 });
} }
return null return null;
} }
+131 -155
View File
@@ -1,171 +1,147 @@
// @vitest-environment node // @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkAccess, readAccessConfig } from './policy.server' import { checkAccess, readAccessConfig } from "./policy.server";
const config = { const config = {
mode: 'tailscale' as const, mode: "tailscale" as const,
origin: 'https://deck.invalid', origin: "https://deck.invalid",
allowedLogin: '[email protected]', allowedLogin: "[email protected]",
} };
beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined)) beforeEach(() => vi.stubEnv("TWITTER_LITE_AUTH_MODE", undefined));
afterEach(() => vi.unstubAllEnvs()) afterEach(() => vi.unstubAllEnvs());
describe('access boundary', () => { describe("access boundary", () => {
it.each([ it.each([undefined, "tailscale"])("requires an owner in identity mode: %s", (mode) => {
undefined, vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
'tailscale', vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
])('requires an owner in identity mode: %s', (mode) => { vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode) expect(readAccessConfig()).toBeNull();
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin) });
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
})
it.each([ it.each(["", "off", "NONE"])("fails closed for an unknown auth mode: %s", (mode) => {
'', vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
'off', vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
'NONE', vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
])('fails closed for an unknown auth mode: %s', (mode) => { expect(checkAccess(new Request(config.origin), readAccessConfig())?.status).toBe(503);
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode) });
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(
checkAccess(new Request(config.origin), readAccessConfig())?.status,
).toBe(503)
})
it('allows private-proxy navigation without an identity only when explicitly configured', () => { it("allows private-proxy navigation without an identity only when explicitly configured", () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none') vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin) vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '') vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
const access = readAccessConfig() const access = readAccessConfig();
expect(access).toEqual({ origin: config.origin, mode: 'none' }) expect(access).toEqual({ origin: config.origin, mode: "none" });
expect(checkAccess(new Request(config.origin), access)).toBeNull() expect(checkAccess(new Request(config.origin), access)).toBeNull();
}) });
it('still requires a configured origin without identity authentication', () => { it("still requires a configured origin without identity authentication", () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none') vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv('TWITTER_LITE_ORIGIN', '') vi.stubEnv("TWITTER_LITE_ORIGIN", "");
expect(readAccessConfig()).toBeNull() expect(readAccessConfig()).toBeNull();
}) });
it.each([ it.each(["POST", "PUT", "PATCH", "DELETE"])(
'POST', "requires exact Origin without identity authentication for %s",
'PUT', (method) => {
'PATCH', const access = { origin: config.origin, mode: "none" as const };
'DELETE', expect(checkAccess(new Request(config.origin, { method }), access)?.status).toBe(403);
])('requires exact Origin without identity authentication for %s', (method) => { expect(
const access = { origin: config.origin, mode: 'none' as const } checkAccess(
expect( new Request(config.origin, {
checkAccess(new Request(config.origin, { method }), access)?.status, method,
).toBe(403) headers: {
expect( Origin: "https://other.invalid",
checkAccess( "Sec-Fetch-Site": "same-origin",
new Request(config.origin, { },
method, }),
headers: { access,
Origin: 'https://other.invalid', )?.status,
'Sec-Fetch-Site': 'same-origin', ).toBe(403);
}, expect(
}), checkAccess(
access, new Request(config.origin, {
)?.status, method,
).toBe(403) headers: { Origin: config.origin },
expect( }),
checkAccess( access,
new Request(config.origin, { ),
method, ).toBeNull();
headers: { Origin: config.origin }, },
}), );
access,
),
).toBeNull()
})
it('fails closed when the deployment is not configured', () => { it("fails closed when the deployment is not configured", () => {
vi.stubEnv('TWITTER_LITE_ORIGIN', '') vi.stubEnv("TWITTER_LITE_ORIGIN", "");
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '') vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
expect(readAccessConfig()).toBeNull() expect(readAccessConfig()).toBeNull();
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503) expect(checkAccess(new Request(config.origin), null)?.status).toBe(503);
}) });
it.each([ it.each(["", "https://deck.invalid/path", "http://deck.invalid", "not a URL"])(
'', "rejects an invalid configured origin: %s",
'https://deck.invalid/path', (origin) => {
'http://deck.invalid', vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
'not a URL', vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
])('rejects an invalid configured origin: %s', (origin) => { expect(readAccessConfig()).toBeNull();
vi.stubEnv('TWITTER_LITE_ORIGIN', origin) },
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin) );
expect(readAccessConfig()).toBeNull()
})
it.each([ it.each(["https://deck.invalid", "http://127.0.0.1:4173"])(
'https://deck.invalid', "accepts an explicit deployment origin: %s",
'http://127.0.0.1:4173', (origin) => {
])('accepts an explicit deployment origin: %s', (origin) => { vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
vi.stubEnv('TWITTER_LITE_ORIGIN', origin) vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin) expect(readAccessConfig()).toEqual({ ...config, origin });
expect(readAccessConfig()).toEqual({ ...config, origin }) },
}) );
it.each([ it.each([undefined, "[email protected]", "[email protected], [email protected]"])(
undefined, "rejects absent, foreign, or ambiguous identities: %s",
'[email protected]', (login) => {
'[email protected], [email protected]', const headers = new Headers();
])('rejects absent, foreign, or ambiguous identities: %s', (login) => { if (login) headers.set("Tailscale-User-Login", login);
const headers = new Headers() expect(checkAccess(new Request(config.origin, { headers }), config)?.status).toBe(403);
if (login) headers.set('Tailscale-User-Login', login) },
expect( );
checkAccess(new Request(config.origin, { headers }), config)?.status,
).toBe(403)
})
it('permits owner navigation back from an OAuth provider without Origin', () => { it("permits owner navigation back from an OAuth provider without Origin", () => {
const request = new Request( const request = new Request(`${config.origin}/oauth/mastodon/callback?code=code`, {
`${config.origin}/oauth/mastodon/callback?code=code`, headers: {
{ "Tailscale-User-Login": config.allowedLogin,
headers: { "Sec-Fetch-Site": "cross-site",
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'cross-site',
},
}, },
) });
expect(checkAccess(request, config)).toBeNull() expect(checkAccess(request, config)).toBeNull();
}) });
it.each([ it.each(["POST", "PUT", "PATCH", "DELETE"])(
'POST', "requires exact Origin for %s even with same-origin Fetch Metadata",
'PUT', (method) => {
'PATCH', const headers = {
'DELETE', "Tailscale-User-Login": config.allowedLogin,
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => { "Sec-Fetch-Site": "same-origin",
const headers = { };
'Tailscale-User-Login': config.allowedLogin, expect(checkAccess(new Request(config.origin, { method, headers }), config)?.status).toBe(
'Sec-Fetch-Site': 'same-origin', 403,
} );
expect( expect(
checkAccess(new Request(config.origin, { method, headers }), config) checkAccess(
?.status, new Request(config.origin, {
).toBe(403) method,
expect( headers: { ...headers, Origin: "https://other.invalid" },
checkAccess( }),
new Request(config.origin, { config,
method, )?.status,
headers: { ...headers, Origin: 'https://other.invalid' }, ).toBe(403);
}), expect(
config, checkAccess(
)?.status, new Request(config.origin, {
).toBe(403) method,
expect( headers: { ...headers, Origin: config.origin },
checkAccess( }),
new Request(config.origin, { config,
method, ),
headers: { ...headers, Origin: config.origin }, ).toBeNull();
}), },
config, );
), });
).toBeNull()
})
})
+22 -22
View File
@@ -1,34 +1,34 @@
export type SafeOwner = { export type SafeOwner = {
id: number id: number;
email: string email: string;
name: string name: string;
onboardingCompletedAt: number | null onboardingCompletedAt: number | null;
} };
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null } export type AuthState = { needsSetup: boolean; owner: SafeOwner | null };
type AuthInput = type AuthInput =
| { action: 'login'; email: string; password: string } | { action: "login"; email: string; password: string }
| { | {
action: 'setup' action: "setup";
email: string email: string;
password: string password: string;
name: string name: string;
setupToken: string setupToken: string;
} }
| { action: 'logout' } | { action: "logout" }
| { action: 'onboard'; name: string } | { action: "onboard"; name: string };
export async function authRequest(input: AuthInput): Promise<AuthState> { export async function authRequest(input: AuthInput): Promise<AuthState> {
const response = await fetch('/api/auth', { const response = await fetch("/api/auth", {
method: 'POST', method: "POST",
credentials: 'same-origin', credentials: "same-origin",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: JSON.stringify(input),
}) });
const result = await response.json() const result = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(result.error || 'Something went wrong. Please try again.') throw new Error(result.error || "Something went wrong. Please try again.");
} }
return result return result;
} }
+51 -64
View File
@@ -1,10 +1,10 @@
import { ArrowRight, LockKeyhole } from 'lucide-react' import { ArrowRight, LockKeyhole } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from 'react' import { type FormEvent, type ReactNode, useState } from "react";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { authRequest } from './auth-client' import { authRequest } from "./auth-client";
import './auth.css' import { safeReturnTo } from "./return-to";
export function AuthFrame({ children }: { children: ReactNode }) { export function AuthFrame({ children }: { children: ReactNode }) {
return ( return (
@@ -17,78 +17,70 @@ export function AuthFrame({ children }: { children: ReactNode }) {
{children} {children}
</div> </div>
</main> </main>
) );
} }
export function LoginPage() { export function LoginPage() {
return <CredentialsForm setup={false} /> return <CredentialsForm setup={false} />;
} }
export function SetupPage() { export function SetupPage() {
return <CredentialsForm setup /> return <CredentialsForm setup />;
}
function formText(data: FormData, name: string) {
const value = data.get(name);
return typeof value === "string" ? value : "";
} }
function CredentialsForm({ setup }: { setup: boolean }) { function CredentialsForm({ setup }: { setup: boolean }) {
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault();
if (pending) return if (pending) return;
const data = new FormData(event.currentTarget) const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim() const email = formText(data, "email").trim();
const password = String(data.get('password') ?? '') const password = formText(data, "password");
if (setup && password !== data.get('confirmPassword')) { if (setup && password !== data.get("confirmPassword")) {
setError('Passwords do not match.') setError("Passwords do not match.");
return return;
} }
setPending(true) setPending(true);
setError('') setError("");
try { try {
const state = await authRequest( const state = await authRequest(
setup setup
? { ? {
action: 'setup', action: "setup",
email, email,
password, password,
name: String(data.get('name') ?? '').trim(), name: formText(data, "name").trim(),
setupToken: String(data.get('setupToken') ?? '').trim(), setupToken: formText(data, "setupToken").trim(),
} }
: { action: 'login', email, password }, : { action: "login", email, password },
) );
if (!state.owner?.onboardingCompletedAt) { if (!state.owner?.onboardingCompletedAt) {
window.location.assign('/onboarding') window.location.assign("/onboarding");
return return;
} }
const returnTo = new URLSearchParams(window.location.search).get( const returnTo = new URLSearchParams(window.location.search).get("returnTo");
'returnTo', window.location.assign(safeReturnTo(returnTo));
)
const destination =
returnTo &&
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
returnTo,
)
? returnTo
: '/'
window.location.assign(destination)
} catch (cause) { } catch (cause) {
setError( setError(cause instanceof Error ? cause.message : "Unable to sign in. Please try again.");
cause instanceof Error setPending(false);
? cause.message
: 'Unable to sign in. Please try again.',
)
setPending(false)
} }
} }
return ( return (
<AuthFrame> <AuthFrame>
<header className="auth-heading"> <header className="auth-heading">
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1> <h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
<p> <p>
{setup {setup
? 'Create the owner account for your personal workspace.' ? "Create the owner account for your personal workspace."
: 'Sign in to your personal workspace.'} : "Sign in to your personal workspace."}
</p> </p>
</header> </header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}> <form onSubmit={submit} className="auth-form" aria-busy={pending}>
@@ -123,11 +115,11 @@ function CredentialsForm({ setup }: { setup: boolean }) {
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete={setup ? 'new-password' : 'current-password'} autoComplete={setup ? "new-password" : "current-password"}
required required
minLength={setup ? 15 : undefined} minLength={setup ? 15 : undefined}
maxLength={128} maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined} aria-describedby={setup ? "password-hint" : undefined}
disabled={pending} disabled={pending}
/> />
{setup && ( {setup && (
@@ -173,27 +165,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
{error} {error}
</p> </p>
)} )}
<Button <Button type="submit" size="lg" disabled={pending} className="auth-submit">
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
{pending {pending
? setup ? setup
? 'Creating account…' ? "Creating account…"
: 'Signing in…' : "Signing in…"
: setup : setup
? 'Create account' ? "Create account"
: 'Sign in'} : "Sign in"}
<ArrowRight aria-hidden="true" /> <ArrowRight aria-hidden="true" />
</Button> </Button>
</form> </form>
<p className="auth-footnote"> <p className="auth-footnote">
{setup {setup
? 'This workspace has one owner. Connections can be added later.' ? "This workspace has one owner. Connections can be added later."
: 'A private workspace. Access is limited to its owner.'} : "A private workspace. Access is limited to its owner."}
</p> </p>
</AuthFrame> </AuthFrame>
) );
} }
+90 -122
View File
@@ -1,93 +1,83 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto' import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute } from 'node:path' import { dirname, isAbsolute } from "node:path";
import { eq, lte } from 'drizzle-orm' import { eq, lte } from "drizzle-orm";
import { type AppDatabase, getDatabase } from '../storage/database.server' import { type AppDatabase, getDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema' import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import type { SafeOwner } from './auth-client' import type { SafeOwner } from "./auth-client";
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const derive = (password: string, salt: string) => const derive = (password: string, salt: string) =>
new Promise<Buffer>((resolve, reject) => new Promise<Buffer>((resolve, reject) =>
scrypt(password, salt, 64, scryptOptions, (error, key) => scrypt(password, salt, 64, scryptOptions, (error, key) =>
error ? reject(error) : resolve(key), error ? reject(error) : resolve(key),
), ),
) );
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 } const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const throttleWindow = 15 * 60_000 const throttleWindow = 15 * 60_000;
export class AuthError extends Error { export class AuthError extends Error {
constructor( constructor(
public status: number, public status: number,
message: string, message: string,
) { ) {
super(message) super(message);
this.name = 'AuthError' this.name = "AuthError";
} }
} }
const hash = (value: string) => createHash('sha256').update(value).digest('hex') const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const equal = (left: string, right: string) => const equal = (left: string, right: string) =>
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right))) timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)));
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner { function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
return { return {
id: owner.id, id: owner.id,
email: owner.email, email: owner.email,
name: owner.name, name: owner.name,
onboardingCompletedAt: owner.onboardingCompletedAt, onboardingCompletedAt: owner.onboardingCompletedAt,
} };
} }
export function readAuthState(database = getDatabase()) { export function readAuthState(database = getDatabase()) {
return { return {
needsSetup: !database needsSetup: !database.select({ id: workspaceOwner.id }).from(workspaceOwner).get(),
.select({ id: workspaceOwner.id }) };
.from(workspaceOwner)
.get(),
}
} }
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */ /** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
export function getSetupToken() { export function getSetupToken() {
const configured = process.env.WORKSPACE_SETUP_TOKEN const configured = process.env.WORKSPACE_SETUP_TOKEN;
if (configured) { if (configured) {
if (configured.length < 32) if (configured.length < 32)
throw new AuthError( throw new AuthError(503, "The setup token must contain at least 32 characters.");
503, return configured;
'The setup token must contain at least 32 characters.',
)
return configured
} }
const dbPath = process.env.TWITTER_LITE_DB_PATH const dbPath = process.env.TWITTER_LITE_DB_PATH;
if (!dbPath || !isAbsolute(dbPath)) if (!dbPath || !isAbsolute(dbPath))
throw new AuthError(503, 'Configure a workspace database or setup token.') throw new AuthError(503, "Configure a workspace database or setup token.");
const path = `${dbPath}.setup-token` const path = `${dbPath}.setup-token`;
mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try { try {
writeFileSync(path, randomBytes(32).toString('base64url'), { writeFileSync(path, randomBytes(32).toString("base64url"), {
mode: 0o600, mode: 0o600,
flag: 'wx', flag: "wx",
}) });
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
} }
const token = readFileSync(path, 'utf8').trim() const token = readFileSync(path, "utf8").trim();
if (token.length < 32) if (token.length < 32) throw new AuthError(503, "The workspace setup token is invalid.");
throw new AuthError(503, 'The workspace setup token is invalid.') return token;
return token
} }
function consumeAttempt(database: AppDatabase) { function consumeAttempt(database: AppDatabase) {
database.transaction( database.transaction(
(tx) => { (tx) => {
const now = Date.now() const now = Date.now();
const current = tx.select().from(authThrottle).get() const current = tx.select().from(authThrottle).get();
if (current && current.resetAt > now) { if (current && current.resetAt > now) {
if (current.attempts >= 10) if (current.attempts >= 10)
throw new AuthError( throw new AuthError(429, "Too many attempts. Please try again in 15 minutes.");
429,
'Too many attempts. Please try again in 15 minutes.',
)
tx.update(authThrottle) tx.update(authThrottle)
.set({ attempts: current.attempts + 1 }) .set({ attempts: current.attempts + 1 })
.where(eq(authThrottle.id, 1)) .where(eq(authThrottle.id, 1))
.run() .run();
} else { } else {
tx.insert(authThrottle) tx.insert(authThrottle)
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow }) .values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
@@ -95,21 +85,18 @@ function consumeAttempt(database: AppDatabase) {
target: authThrottle.id, target: authThrottle.id,
set: { attempts: 1, resetAt: now + throttleWindow }, set: { attempts: 1, resetAt: now + throttleWindow },
}) })
.run() .run();
} }
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
function session( function session(
database: Pick<AppDatabase, 'insert' | 'delete'>, database: Pick<AppDatabase, "insert" | "delete">,
owner: typeof workspaceOwner.$inferSelect, owner: typeof workspaceOwner.$inferSelect,
) { ) {
const sessionToken = randomBytes(32).toString('base64url') const sessionToken = randomBytes(32).toString("base64url");
database database.delete(authSessions).where(lte(authSessions.expiresAt, Date.now())).run();
.delete(authSessions)
.where(lte(authSessions.expiresAt, Date.now()))
.run()
database database
.insert(authSessions) .insert(authSessions)
.values({ .values({
@@ -117,125 +104,106 @@ function session(
ownerId: owner.id, ownerId: owner.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000, expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
}) })
.run() .run();
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run() database.delete(authThrottle).where(eq(authThrottle.id, 1)).run();
return { sessionToken, owner: safeOwner(owner) } return { sessionToken, owner: safeOwner(owner) };
} }
function cleanName(name: string) { function cleanName(name: string) {
const cleaned = name.trim() const cleaned = name.trim();
if (!cleaned || cleaned.length > 80) if (!cleaned || cleaned.length > 80)
throw new AuthError(400, 'Enter a name of up to 80 characters.') throw new AuthError(400, "Enter a name of up to 80 characters.");
return cleaned return cleaned;
} }
export async function createOwner( export async function createOwner(
input: { email: string; password: string; name: string; setupToken: string }, input: { email: string; password: string; name: string; setupToken: string },
database = getDatabase(), database = getDatabase(),
) { ) {
if (!readAuthState(database).needsSetup) if (!readAuthState(database).needsSetup)
throw new AuthError( throw new AuthError(409, "This workspace is already set up. Please sign in.");
409, consumeAttempt(database);
'This workspace is already set up. Please sign in.',
)
consumeAttempt(database)
if (!equal(input.setupToken, getSetupToken())) if (!equal(input.setupToken, getSetupToken()))
throw new AuthError(401, 'Invalid setup credentials.') throw new AuthError(401, "Invalid setup credentials.");
const email = input.email.trim().toLowerCase() const email = input.email.trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new AuthError(400, 'Enter a valid email address.') throw new AuthError(400, "Enter a valid email address.");
if (input.password.length < 15 || input.password.length > 128) if (input.password.length < 15 || input.password.length > 128)
throw new AuthError(400, 'Use a password between 15 and 128 characters.') throw new AuthError(400, "Use a password between 15 and 128 characters.");
const name = cleanName(input.name) const name = cleanName(input.name);
const salt = randomBytes(16).toString('hex') const salt = randomBytes(16).toString("hex");
const derived = (await derive(input.password, salt)) as Buffer const derived = (await derive(input.password, salt)) as Buffer;
return database.transaction( return database.transaction(
(tx) => { (tx) => {
if (tx.select().from(workspaceOwner).get()) if (tx.select().from(workspaceOwner).get())
throw new AuthError( throw new AuthError(409, "This workspace is already set up. Please sign in.");
409,
'This workspace is already set up. Please sign in.',
)
const owner = { const owner = {
id: 1, id: 1,
email, email,
name, name,
passwordHash: `${salt}:${derived.toString('hex')}`, passwordHash: `${salt}:${derived.toString("hex")}`,
onboardingCompletedAt: null, onboardingCompletedAt: null,
createdAt: Date.now(), createdAt: Date.now(),
} };
tx.insert(workspaceOwner).values(owner).run() tx.insert(workspaceOwner).values(owner).run();
return session(tx, owner) return session(tx, owner);
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
export async function signIn( export async function signIn(input: { email: string; password: string }, database = getDatabase()) {
input: { email: string; password: string }, consumeAttempt(database);
database = getDatabase(), const owner = database.select().from(workspaceOwner).get();
) {
consumeAttempt(database)
const owner = database.select().from(workspaceOwner).get()
if (input.password.length > 128 || input.email.length > 254) if (input.password.length > 128 || input.email.length > 254)
throw new AuthError(401, 'Invalid email or password.') throw new AuthError(401, "Invalid email or password.");
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [ const [salt = "", expected = ""] = owner?.passwordHash.split(":") ?? [
'0'.repeat(32), "0".repeat(32),
'0'.repeat(128), "0".repeat(128),
] ];
const actual = (await derive(input.password, salt)) as Buffer const actual = (await derive(input.password, salt)) as Buffer;
if ( if (
!owner || !owner ||
!equal(input.email.trim().toLowerCase(), owner.email) || !equal(input.email.trim().toLowerCase(), owner.email) ||
!timingSafeEqual(actual, Buffer.from(expected, 'hex')) !timingSafeEqual(actual, Buffer.from(expected, "hex"))
) )
throw new AuthError(401, 'Invalid email or password.') throw new AuthError(401, "Invalid email or password.");
return session(database, owner) return session(database, owner);
} }
export function getSession( export function getSession(token: string | undefined, database = getDatabase()): SafeOwner | null {
token: string | undefined, if (!token || token.length > 128) return null;
database = getDatabase(),
): SafeOwner | null {
if (!token || token.length > 128) return null
const found = database const found = database
.select() .select()
.from(authSessions) .from(authSessions)
.where(eq(authSessions.tokenHash, hash(token))) .where(eq(authSessions.tokenHash, hash(token)))
.get() .get();
if (!found) return null if (!found) return null;
if (found.expiresAt <= Date.now()) { if (found.expiresAt <= Date.now()) {
database database.delete(authSessions).where(eq(authSessions.tokenHash, found.tokenHash)).run();
.delete(authSessions) return null;
.where(eq(authSessions.tokenHash, found.tokenHash))
.run()
return null
} }
const owner = database const owner = database
.select() .select()
.from(workspaceOwner) .from(workspaceOwner)
.where(eq(workspaceOwner.id, found.ownerId)) .where(eq(workspaceOwner.id, found.ownerId))
.get() .get();
return owner ? safeOwner(owner) : null return owner ? safeOwner(owner) : null;
} }
export function signOut(token: string | undefined, database = getDatabase()) { export function signOut(token: string | undefined, database = getDatabase()) {
if (token) if (token)
database database
.delete(authSessions) .delete(authSessions)
.where(eq(authSessions.tokenHash, hash(token))) .where(eq(authSessions.tokenHash, hash(token)))
.run() .run();
} }
export function completeOnboarding( export function completeOnboarding(
token: string, token: string,
input: { name: string }, input: { name: string },
database = getDatabase(), database = getDatabase(),
): SafeOwner { ): SafeOwner {
const owner = getSession(token, database) const owner = getSession(token, database);
if (!owner) throw new AuthError(401, 'Please sign in.') if (!owner) throw new AuthError(401, "Please sign in.");
const update = { const update = {
name: cleanName(input.name), name: cleanName(input.name),
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(), onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
} };
database database.update(workspaceOwner).set(update).where(eq(workspaceOwner.id, owner.id)).run();
.update(workspaceOwner) return { ...owner, ...update };
.set(update)
.where(eq(workspaceOwner.id, owner.id))
.run()
return { ...owner, ...update }
} }
+102 -119
View File
@@ -1,10 +1,10 @@
// @vitest-environment node // @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from 'node:os' import { tmpdir } from "node:os";
import { join } from 'node:path' import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openDatabase } from '../storage/database.server' import { openDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema' import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import { import {
completeOnboarding, completeOnboarding,
createOwner, createOwner,
@@ -14,137 +14,120 @@ import {
SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_SECONDS,
signIn, signIn,
signOut, signOut,
} from './auth.server' } from "./auth.server";
let database: ReturnType<typeof openDatabase> let database: ReturnType<typeof openDatabase>;
const credentials = { const credentials = {
email: '[email protected]', email: "[email protected]",
password: 'correct horse battery staple', password: "correct horse battery staple",
name: 'Owner', name: "Owner",
setupToken: 's'.repeat(32), setupToken: "s".repeat(32),
} };
beforeEach(() => { beforeEach(() => {
database = openDatabase(':memory:') database = openDatabase(":memory:");
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken) vi.stubEnv("WORKSPACE_SETUP_TOKEN", credentials.setupToken);
}) });
afterEach(() => { afterEach(() => {
database.$client.close() database.$client.close();
vi.unstubAllEnvs() vi.unstubAllEnvs();
vi.restoreAllMocks() vi.restoreAllMocks();
}) });
describe('owner authentication', () => { describe("owner authentication", () => {
it('requires a private setup token and stores only derived credentials and session tokens', async () => { it("requires a private setup token and stores only derived credentials and session tokens", async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true }) expect(readAuthState(database)).toEqual({ needsSetup: true });
await expect( await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database), createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 }) ).rejects.toMatchObject({ status: 401 });
expect(readAuthState(database).needsSetup).toBe(true) expect(readAuthState(database).needsSetup).toBe(true);
const result = await createOwner(credentials, database) const result = await createOwner(credentials, database);
expect(readAuthState(database)).toEqual({ needsSetup: false }) expect(readAuthState(database)).toEqual({ needsSetup: false });
expect(result.owner.email).toBe(credentials.email) expect(result.owner.email).toBe(credentials.email);
expect(result.owner).not.toHaveProperty('passwordHash') expect(result.owner).not.toHaveProperty("passwordHash");
expect( expect(database.select().from(workspaceOwner).get()?.passwordHash).not.toContain(
database.select().from(workspaceOwner).get()?.passwordHash, credentials.password,
).not.toContain(credentials.password) );
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual( expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(result.sessionToken);
result.sessionToken, expect(getSession(result.sessionToken, database)).toEqual(result.owner);
)
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
await expect(createOwner(credentials, database)).rejects.toMatchObject({ await expect(createOwner(credentials, database)).rejects.toMatchObject({
status: 409, status: 409,
}) });
}) });
it('only allows one owner even when setup requests race', async () => { it("only allows one owner even when setup requests race", async () => {
const results = await Promise.allSettled([ const results = await Promise.allSettled([
createOwner(credentials, database), createOwner(credentials, database),
createOwner(credentials, database), createOwner(credentials, database),
]) ]);
expect( expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
results.filter((result) => result.status === 'fulfilled'), expect(database.select().from(workspaceOwner).all()).toHaveLength(1);
).toHaveLength(1) });
expect(database.select().from(workspaceOwner).all()).toHaveLength(1) it("checks the email and password and revokes logged-out sessions", async () => {
}) await createOwner(credentials, database);
it('checks the email and password and revokes logged-out sessions', async () => { await expect(signIn({ ...credentials, password: "wrong" }, database)).rejects.toMatchObject({
await createOwner(credentials, database) status: 401,
message: "Invalid email or password.",
});
await expect( await expect(
signIn({ ...credentials, password: 'wrong' }, database), signIn({ ...credentials, email: "[email protected]" }, database),
).rejects.toMatchObject({ ).rejects.toMatchObject({
status: 401, status: 401,
message: 'Invalid email or password.', message: "Invalid email or password.",
}) });
await expect( const result = await signIn({ ...credentials, email: "[email protected]" }, database);
signIn({ ...credentials, email: '[email protected]' }, database), expect(database.select().from(authThrottle).all()).toHaveLength(0);
).rejects.toMatchObject({ expect(getSession("invented", database)).toBeNull();
status: 401, expect(getSession(result.sessionToken, database)).not.toBeNull();
message: 'Invalid email or password.', signOut(result.sessionToken, database);
}) expect(getSession(result.sessionToken, database)).toBeNull();
const result = await signIn( });
{ ...credentials, email: '[email protected]' }, it("expires sessions and stores onboarding completion", async () => {
database, const result = await createOwner(credentials, database);
) expect(result.owner.onboardingCompletedAt).toBeNull();
expect(database.select().from(authThrottle).all()).toHaveLength(0) const owner = completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
expect(getSession('invented', database)).toBeNull() expect(owner.name).toBe("Yuta");
expect(getSession(result.sessionToken, database)).not.toBeNull() expect(owner.onboardingCompletedAt).toBeTypeOf("number");
signOut(result.sessionToken, database) expect(getSession(result.sessionToken, database)).toEqual(owner);
expect(getSession(result.sessionToken, database)).toBeNull() expect(() => completeOnboarding("invalid", { name: "Other" }, database)).toThrow(
}) "Please sign in.",
it('expires sessions and stores onboarding completion', async () => { );
const result = await createOwner(credentials, database) vi.spyOn(Date, "now").mockReturnValue(Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1);
expect(result.owner.onboardingCompletedAt).toBeNull() expect(getSession(result.sessionToken, database)).toBeNull();
const owner = completeOnboarding( expect(database.select().from(authSessions).all()).toHaveLength(0);
result.sessionToken, });
{ name: 'Yuta' }, it("bounds setup and login attempts persistently and permits retry after cooldown", async () => {
database,
)
expect(owner.name).toBe('Yuta')
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
expect(getSession(result.sessionToken, database)).toEqual(owner)
expect(() =>
completeOnboarding('invalid', { name: 'Other' }, database),
).toThrow('Please sign in.')
vi.spyOn(Date, 'now').mockReturnValue(
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
)
expect(getSession(result.sessionToken, database)).toBeNull()
expect(database.select().from(authSessions).all()).toHaveLength(0)
})
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
for (let index = 0; index < 10; index++) for (let index = 0; index < 10; index++)
await expect( await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database), createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 }) ).rejects.toMatchObject({ status: 401 });
await expect(signIn(credentials, database)).rejects.toMatchObject({ await expect(signIn(credentials, database)).rejects.toMatchObject({
status: 429, status: 429,
}) });
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1) vi.spyOn(Date, "now").mockReturnValue(Date.now() + 15 * 60_000 + 1);
await expect(createOwner(credentials, database)).resolves.toHaveProperty( await expect(createOwner(credentials, database)).resolves.toHaveProperty("sessionToken");
'sessionToken', });
) it("rejects short passwords and invalid identity fields", async () => {
})
it('rejects short passwords and invalid identity fields', async () => {
await expect( await expect(
createOwner({ ...credentials, password: 'short' }, database), createOwner({ ...credentials, password: "short" }, database),
).rejects.toMatchObject({ status: 400 }) ).rejects.toMatchObject({ status: 400 });
await expect( await expect(createOwner({ ...credentials, email: "invalid" }, database)).rejects.toMatchObject(
createOwner({ ...credentials, email: 'invalid' }, database), { status: 400 },
).rejects.toMatchObject({ status: 400 }) );
await expect( await expect(createOwner({ ...credentials, name: " " }, database)).rejects.toMatchObject({
createOwner({ ...credentials, name: ' ' }, database), status: 400,
).rejects.toMatchObject({ status: 400 }) });
}) });
it('creates a stable local setup token with private file permissions', () => { it("creates a stable local setup token with private file permissions", () => {
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-')) const directory = mkdtempSync(join(tmpdir(), "workspace-setup-"));
try { try {
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '') vi.stubEnv("WORKSPACE_SETUP_TOKEN", "");
const path = join(directory, 'workspace.sqlite') const path = join(directory, "workspace.sqlite");
vi.stubEnv('TWITTER_LITE_DB_PATH', path) vi.stubEnv("TWITTER_LITE_DB_PATH", path);
const token = getSetupToken() const token = getSetupToken();
expect(token.length).toBeGreaterThanOrEqual(32) expect(token.length).toBeGreaterThanOrEqual(32);
expect(getSetupToken()).toBe(token) expect(getSetupToken()).toBe(token);
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token) expect(readFileSync(`${path}.setup-token`, "utf8")).toBe(token);
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600) expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600);
} finally { } finally {
rmSync(directory, { recursive: true, force: true }) rmSync(directory, { recursive: true, force: true });
} }
}) });
}) });
+25 -24
View File
@@ -1,36 +1,37 @@
import { getSession, readAuthState } from './auth.server' import { getSession, readAuthState } from "./auth.server";
import { sessionToken } from './http.server' import { sessionToken } from "./http.server";
import { safeReturnTo } from "./return-to";
const publicPaths = new Set(['/login', '/setup', '/api/auth']) const publicPaths = new Set(["/login", "/setup", "/api/auth"]);
export function checkSessionAccess(request: Request): Response | null { export function checkSessionAccess(request: Request): Response | null {
const url = new URL(request.url) const url = new URL(request.url);
if (publicPaths.has(url.pathname)) return null if (publicPaths.has(url.pathname)) return null;
const owner = getSession(sessionToken(request)) const owner = getSession(sessionToken(request));
if (owner?.onboardingCompletedAt) return null if (owner?.onboardingCompletedAt) return null;
if (owner && url.pathname === '/onboarding') return null if (owner && url.pathname === "/onboarding") return null;
const document = const document =
request.method === 'GET' && request.method === "GET" &&
request.headers.get('accept')?.includes('text/html') && request.headers.get("accept")?.includes("text/html") &&
!url.pathname.startsWith('/api/') && !url.pathname.startsWith("/api/") &&
!url.pathname.startsWith('/_serverFn/') !url.pathname.startsWith("/_serverFn/");
if (document) { if (document) {
const location = owner const location = owner ? "/onboarding" : readAuthState().needsSetup ? "/setup" : "/login";
? '/onboarding'
: readAuthState().needsSetup
? '/setup'
: '/login'
return new Response(null, { return new Response(null, {
status: 303, status: 303,
headers: { location, 'cache-control': 'no-store' }, headers: {
}) location:
location === "/login" && (url.pathname !== "/" || !!url.search)
? `/login?${new URLSearchParams({ returnTo: safeReturnTo(url.pathname + url.search) })}`
: location,
"cache-control": "no-store",
},
});
} }
return Response.json( return Response.json(
{ {
error: owner error: owner ? "Complete onboarding to continue." : "Sign in to continue.",
? 'Complete onboarding to continue.'
: 'Sign in to continue.',
}, },
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } }, { status: owner ? 403 : 401, headers: { "cache-control": "no-store" } },
) );
} }
+48 -60
View File
@@ -7,97 +7,85 @@ import {
SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_SECONDS,
signIn, signIn,
signOut, signOut,
} from './auth.server' } from "./auth.server";
const SESSION_COOKIE = 'workspace_session' const SESSION_COOKIE = "workspace_session";
export function sessionToken(request: Request) { export function sessionToken(request: Request) {
return request.headers return request.headers
.get('cookie') .get("cookie")
?.split(';') ?.split(";")
.map((part) => part.trim()) .map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`)) .find((part) => part.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1) ?.slice(SESSION_COOKIE.length + 1);
} }
export function authState(request: Request) { export function authState(request: Request) {
return { ...readAuthState(), owner: getSession(sessionToken(request)) } return { ...readAuthState(), owner: getSession(sessionToken(request)) };
} }
function cookie(token: string, clear = false) { function cookie(token: string, clear = false) {
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:') const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith("https:");
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}` return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? "; Secure" : ""}`;
} }
export async function authEndpoint(request: Request) { export async function authEndpoint(request: Request) {
const headers = new Headers({ const headers = new Headers({
'cache-control': 'no-store', "cache-control": "no-store",
vary: 'Cookie', vary: "Cookie",
'content-type': 'application/json', "content-type": "application/json",
}) });
try { try {
if (request.method === 'GET') if (request.method === "GET") return Response.json(authState(request), { headers });
return Response.json(authState(request), { headers }) if (request.method !== "POST") return new Response(null, { status: 405, headers });
if (request.method !== 'POST') if (!request.headers.get("content-type")?.startsWith("application/json"))
return new Response(null, { status: 405, headers }) throw new AuthError(400, "Use a JSON request.");
if (!request.headers.get('content-type')?.startsWith('application/json')) const body = await request.text();
throw new AuthError(400, 'Use a JSON request.') if (body.length > 8192) throw new AuthError(413, "Request is too large.");
const body = await request.text() let data: Record<string, unknown>;
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
let data: Record<string, unknown>
try { try {
const parsed = JSON.parse(body) const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error('invalid body') throw new Error("invalid body");
data = parsed data = parsed;
} catch { } catch {
throw new AuthError(400, 'Check the form and try again.') throw new AuthError(400, "Check the form and try again.");
} }
const text = (key: string) => const text = (key: string) => (typeof data[key] === "string" ? data[key] : "");
typeof data[key] === 'string' ? data[key] : '' const oldToken = sessionToken(request);
const oldToken = sessionToken(request)
switch (data.action) { switch (data.action) {
case 'setup': case "setup":
case 'login': { case "login": {
const result = const result =
data.action === 'setup' data.action === "setup"
? await createOwner({ ? await createOwner({
email: text('email'), email: text("email"),
password: text('password'), password: text("password"),
name: text('name'), name: text("name"),
setupToken: text('setupToken'), setupToken: text("setupToken"),
}) })
: await signIn({ email: text('email'), password: text('password') }) : await signIn({ email: text("email"), password: text("password") });
signOut(oldToken) signOut(oldToken);
headers.set('set-cookie', cookie(result.sessionToken)) headers.set("set-cookie", cookie(result.sessionToken));
return Response.json( return Response.json({ needsSetup: false, owner: result.owner }, { headers });
{ needsSetup: false, owner: result.owner },
{ headers },
)
} }
case 'onboard': { case "onboard": {
const owner = completeOnboarding(oldToken ?? '', { name: text('name') }) const owner = completeOnboarding(oldToken ?? "", { name: text("name") });
return Response.json({ needsSetup: false, owner }, { headers }) return Response.json({ needsSetup: false, owner }, { headers });
} }
case 'logout': case "logout":
signOut(oldToken) signOut(oldToken);
headers.set('set-cookie', cookie('', true)) headers.set("set-cookie", cookie("", true));
return Response.json( return Response.json({ needsSetup: readAuthState().needsSetup, owner: null }, { headers });
{ needsSetup: readAuthState().needsSetup, owner: null },
{ headers },
)
default: default:
throw new AuthError(400, 'Unknown action.') throw new AuthError(400, "Unknown action.");
} }
} catch (error) { } catch (error) {
if (error instanceof AuthError) if (error instanceof AuthError)
return Response.json( return Response.json({ error: error.message }, { status: error.status, headers });
{ error: error.message },
{ status: error.status, headers },
)
return Response.json( return Response.json(
{ error: 'Unable to complete the request. Please try again.' }, { error: "Unable to complete the request. Please try again." },
{ status: 500, headers }, { status: 500, headers },
) );
} }
} }
+33 -46
View File
@@ -1,55 +1,47 @@
import { useRouteContext } from '@tanstack/react-router' import { useRouteContext } from "@tanstack/react-router";
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react' import { ArrowRight, BookOpen, House, MessagesSquare } from "lucide-react";
import { type FormEvent, useState } from 'react' import { type FormEvent, useState } from "react";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { authRequest } from './auth-client' import { authRequest } from "./auth-client";
import { AuthFrame } from './auth-page' import { AuthFrame } from "./auth-page";
export function OnboardingPage() { export function OnboardingPage() {
const { auth } = useRouteContext({ from: '__root__' }) const { auth } = useRouteContext({ from: "__root__" });
const [name, setName] = useState(auth.owner?.name ?? '') const [name, setName] = useState(auth.owner?.name ?? "");
const [step, setStep] = useState(1) const [step, setStep] = useState(1);
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
async function finish() { async function finish() {
setPending(true) setPending(true);
setError('') setError("");
try { try {
await authRequest({ action: 'onboard', name: name.trim() }) await authRequest({ action: "onboard", name: name.trim() });
window.location.assign('/') window.location.assign("/");
} catch (cause) { } catch (cause) {
setError( setError(cause instanceof Error ? cause.message : "Unable to save. Please try again.");
cause instanceof Error setPending(false);
? cause.message
: 'Unable to save. Please try again.',
)
setPending(false)
} }
} }
async function signOut() { async function signOut() {
setPending(true) setPending(true);
setError('') setError("");
try { try {
await authRequest({ action: 'logout' }) await authRequest({ action: "logout" });
window.location.assign('/login') window.location.assign("/login");
} catch (cause) { } catch (cause) {
setError( setError(cause instanceof Error ? cause.message : "Unable to sign out. Please try again.");
cause instanceof Error setPending(false);
? cause.message
: 'Unable to sign out. Please try again.',
)
setPending(false)
} }
} }
function next(event: FormEvent<HTMLFormElement>) { function next(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault();
if (!name.trim()) return if (!name.trim()) return;
setStep(2) setStep(2);
} }
return ( return (
@@ -116,20 +108,15 @@ export function OnboardingPage() {
</li> </li>
</ul> </ul>
<p className="auth-prototype"> <p className="auth-prototype">
Home, contacts, notes, reading, and vitals currently use sample Home, contacts, notes, reading, and vitals currently use sample data. You can add
data. You can add connections from your profile menu whenever you’re connections from your profile menu whenever you’re ready.
ready.
</p> </p>
<div className="auth-actions"> <div className="auth-actions">
<Button <Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
Back Back
</Button> </Button>
<Button size="lg" onClick={finish} disabled={pending}> <Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'} {pending ? "Saving…" : "Open workspace"}
<ArrowRight aria-hidden="true" /> <ArrowRight aria-hidden="true" />
</Button> </Button>
</div> </div>
@@ -147,5 +134,5 @@ export function OnboardingPage() {
</Button> </Button>
</footer> </footer>
</AuthFrame> </AuthFrame>
) );
} }
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { safeReturnTo } from "./return-to";
it("keeps the selected view through login", () => {
expect(safeReturnTo("/inbox?q=agents&article=browser#read")).toBe(
"/inbox?q=agents&article=browser#read",
);
});
it.each([
"https://outside.invalid",
"//outside.invalid",
"/\\outside.invalid",
"/api/auth",
"/login",
"/inbox\n",
undefined,
])("rejects an unsafe destination: %s", (value) => {
expect(safeReturnTo(value)).toBe("/");
});
+15
View File
@@ -0,0 +1,15 @@
const paths = new Set(["/", "/deck", "/support", "/journal", "/inbox", "/vitals"]);
export function safeReturnTo(value: unknown): string {
if (
typeof value !== "string" ||
!value.startsWith("/") ||
value.startsWith("//") ||
/[\\\r\n]/.test(value)
)
return "/";
const url = new URL(value, "https://workspace.invalid");
return url.origin === "https://workspace.invalid" && paths.has(url.pathname)
? `${url.pathname}${url.search}${url.hash}`
: "/";
}
+9 -9
View File
@@ -1,14 +1,14 @@
import { createIsomorphicFn } from '@tanstack/react-start' import { createIsomorphicFn } from "@tanstack/react-start";
import type { AuthState } from './auth-client' import type { AuthState } from "./auth-client";
export const loadAuthState = createIsomorphicFn() export const loadAuthState = createIsomorphicFn()
.server(async (): Promise<AuthState> => { .server(async (): Promise<AuthState> => {
const { getRequest } = await import('@tanstack/react-start/server') const { getRequest } = await import("@tanstack/react-start/server");
const { authState } = await import('./http.server') const { authState } = await import("./http.server");
return authState(getRequest()) return authState(getRequest());
}) })
.client(async (): Promise<AuthState> => { .client(async (): Promise<AuthState> => {
const response = await fetch('/api/auth', { cache: 'no-store' }) const response = await fetch("/api/auth", { cache: "no-store" });
if (!response.ok) throw new Error('Unable to check your session.') if (!response.ok) throw new Error("Unable to check your session.");
return response.json() return response.json();
}) });
@@ -1,60 +1,50 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from '@tanstack/react-router' import { useRouteContext } from "@tanstack/react-router";
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from "@tanstack/react-start";
import { useState } from 'react' import { useState } from "react";
import { Dialog } from '#/components/dialog' import { Dialog } from "#/components/dialog";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { authRequest } from '#/features/auth/auth-client' import { authRequest } from "#/features/auth/auth-client";
import { ConnectionManager } from './connection-manager' import { ConnectionManager } from "./connection-manager";
import { loadConnections } from './server-functions' import { loadConnections } from "./server-functions";
export function ConnectionManagerDialog({ export function ConnectionManagerDialog({
onClose, onClose,
hasTemporaryDecks, hasTemporaryDecks,
}: { }: {
onClose: () => void onClose: () => void;
hasTemporaryDecks?: boolean hasTemporaryDecks?: boolean;
}) { }) {
const { auth } = useRouteContext({ from: '__root__' }) const { auth } = useRouteContext({ from: "__root__" });
const [signingOut, setSigningOut] = useState(false) const [signingOut, setSigningOut] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
async function logout() { async function logout() {
setSigningOut(true) setSigningOut(true);
setError('') setError("");
try { try {
await authRequest({ action: 'logout' }) await authRequest({ action: "logout" });
window.location.assign('/login') window.location.assign("/login");
} catch { } catch {
setError('Unable to sign out. Please try again.') setError("Unable to sign out. Please try again.");
setSigningOut(false) setSigningOut(false);
} }
} }
const fetchConnections = useServerFn(loadConnections) const fetchConnections = useServerFn(loadConnections);
const connections = useQuery({ const connections = useQuery({
queryKey: ['connections'], queryKey: ["connections"],
queryFn: () => fetchConnections(), queryFn: () => fetchConnections(),
retry: false, retry: false,
}) });
return ( return (
<Dialog <Dialog title="Connected accounts" onClose={onClose} className="sm:max-w-2xl">
title="Connected accounts"
onClose={onClose}
className="sm:max-w-2xl"
>
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4"> <div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{auth.owner?.name}</p> <p className="truncate font-medium">{auth.owner?.name}</p>
<p className="truncate text-sm text-muted-foreground"> <p className="truncate text-sm text-muted-foreground">{auth.owner?.email}</p>
{auth.owner?.email}
</p>
</div> </div>
<Button <Button variant="outline" disabled={signingOut} onClick={() => void logout()}>
variant="outline" {signingOut ? "Signing out…" : "Sign out"}
disabled={signingOut}
onClick={() => void logout()}
>
{signingOut ? 'Signing out…' : 'Sign out'}
</Button> </Button>
</div> </div>
{error && <p role="alert">{error}</p>} {error && <p role="alert">{error}</p>}
@@ -69,9 +59,7 @@ export function ConnectionManagerDialog({
</div> </div>
) : ( ) : (
<> <>
{connections.data.relayError && ( {connections.data.relayError && <p role="alert">{connections.data.relayError}</p>}
<p role="alert">{connections.data.relayError}</p>
)}
<ConnectionManager <ConnectionManager
connections={connections.data.connections} connections={connections.data.connections}
onChanged={() => connections.refetch()} onChanged={() => connections.refetch()}
@@ -80,5 +68,5 @@ export function ConnectionManagerDialog({
</> </>
)} )}
</Dialog> </Dialog>
) );
} }
@@ -1,46 +1,44 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from "vitest";
import { ConnectionManager } from './connection-manager' import { ConnectionManager } from "./connection-manager";
import type { Connection } from './model' import type { Connection } from "./model";
const actions = vi.hoisted(() => ({ const actions = vi.hoisted(() => ({
load: vi.fn(), load: vi.fn<typeof import("../mastodon/server-functions").loadMastodonInstances>(),
authorize: vi.fn(), authorize: vi.fn<typeof import("../mastodon/server-functions").startMastodonOAuth>(),
disconnect: vi.fn(), disconnect: vi.fn<typeof import("../mastodon/server-functions").disconnectMastodonAccount>(),
})) }));
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn })) vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock('../mastodon/server-functions', () => ({ vi.mock("../mastodon/server-functions", () => ({
loadMastodonInstances: actions.load, loadMastodonInstances: actions.load,
startMastodonOAuth: actions.authorize, startMastodonOAuth: actions.authorize,
disconnectMastodonAccount: actions.disconnect, disconnectMastodonAccount: actions.disconnect,
})) }));
const account: Connection = { const account: Connection = {
id: 'mastodon-first', id: "mastodon-first",
platform: 'mastodon', platform: "mastodon",
origin: 'https://mastodon.invalid', origin: "https://mastodon.invalid",
accountId: '1', accountId: "1",
displayName: '@first', displayName: "@first",
status: 'connected', status: "connected",
} };
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks();
actions.load.mockResolvedValue({ actions.load.mockResolvedValue({
origins: ['https://mastodon.invalid', 'https://second.invalid'], origins: ["https://mastodon.invalid", "https://second.invalid"],
}) });
}) });
function show( function show(
connections: Connection[] = [account], connections: Connection[] = [account],
onChanged = vi.fn(), onChanged = vi.fn<() => Promise<void> | void>(),
hasTemporaryDecks = false, hasTemporaryDecks = false,
) { ) {
return render( return render(
<QueryClientProvider <QueryClientProvider
client={ client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
> >
<ConnectionManager <ConnectionManager
connections={connections} connections={connections}
@@ -48,92 +46,77 @@ function show(
hasTemporaryDecks={hasTemporaryDecks} hasTemporaryDecks={hasTemporaryDecks}
/> />
</QueryClientProvider>, </QueryClientProvider>,
) );
} }
it('shows per-account status and warns about temporary decks without blocking actions', async () => { it("shows per-account status and warns about temporary decks without blocking actions", async () => {
show( show(
[ [account, { ...account, id: "expired", displayName: "@expired", status: "expired" }],
account,
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
],
vi.fn(), vi.fn(),
true, true,
) );
expect(screen.getByText('Connected')).toBeVisible() expect(screen.getByText("Connected")).toBeVisible();
expect(screen.getByText('Reconnect required')).toBeVisible() expect(screen.getByText("Reconnect required")).toBeVisible();
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible() expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible();
expect( expect(await screen.findByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
await screen.findByRole('button', { name: 'Authorize with Mastodon' }), });
).toBeEnabled()
})
it('uses the selected instance and reports a safe error when authorization fails', async () => { it("uses the selected instance and reports a safe error when authorization fails", async () => {
actions.authorize.mockRejectedValue( actions.authorize.mockRejectedValue(new Error("private server credential detail"));
new Error('private server credential detail'), show();
) fireEvent.change(await screen.findByLabelText("Mastodon server"), {
show() target: { value: "https://second.invalid" },
fireEvent.change(await screen.findByLabelText('Mastodon server'), { });
target: { value: 'https://second.invalid' }, fireEvent.click(screen.getByRole("button", { name: "Authorize with Mastodon" }));
})
fireEvent.click(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
)
await waitFor(() => await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({ expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' }, data: { origin: "https://second.invalid" },
}), }),
) );
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
'Could not start Mastodon authorization', "Could not start Mastodon authorization",
) );
expect(screen.queryByText(/private server/)).toBeNull() expect(screen.queryByText(/private server/)).toBeNull();
expect( expect(screen.getByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
screen.getByRole('button', { name: 'Authorize with Mastodon' }), });
).toBeEnabled()
})
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => { it("binds reconnect to the selected account rather than whichever instance is selected for addition", async () => {
actions.authorize.mockRejectedValue(new Error('offline')) actions.authorize.mockRejectedValue(new Error("offline"));
show() show();
await screen.findByLabelText('Mastodon server') await screen.findByLabelText("Mastodon server");
fireEvent.click(screen.getByRole('button', { name: 'Reconnect @first' })) fireEvent.click(screen.getByRole("button", { name: "Reconnect @first" }));
await waitFor(() => await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({ expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id }, data: { origin: account.origin, connectionId: account.id },
}), }),
) );
expect(await screen.findByRole('alert')).toBeVisible() expect(await screen.findByRole("alert")).toBeVisible();
}) });
it('refreshes the account list after successful disconnect', async () => { it("refreshes the account list after successful disconnect", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined) const onChanged = vi.fn<() => Promise<void> | void>().mockResolvedValue(undefined);
actions.disconnect.mockResolvedValue({ disconnected: true }) actions.disconnect.mockResolvedValue({ disconnected: true });
show([account], onChanged) show([account], onChanged);
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' })) fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()) await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
expect(actions.disconnect).toHaveBeenCalledWith({ expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id }, data: { connectionId: account.id },
}) });
}) });
it('keeps account controls available after a failed disconnect', async () => { it("keeps account controls available after a failed disconnect", async () => {
actions.disconnect.mockRejectedValue(new Error('private token')) actions.disconnect.mockRejectedValue(new Error("private token"));
const onChanged = vi.fn() const onChanged = vi.fn<() => Promise<void> | void>();
show([account], onChanged) show([account], onChanged);
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' })) fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent("Could not disconnect the account");
'Could not disconnect the account', expect(onChanged).not.toHaveBeenCalled();
) expect(screen.getByRole("button", { name: "Disconnect @first" })).toBeEnabled();
expect(onChanged).not.toHaveBeenCalled() });
expect(
screen.getByRole('button', { name: 'Disconnect @first' }),
).toBeEnabled()
})
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => { it("does not offer Mastodon reconnect or revoke for Twitter relay accounts", async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }]) show([{ ...account, platform: "twitter", id: "twitter-first" }]);
await screen.findByLabelText('Mastodon server') await screen.findByLabelText("Mastodon server");
expect(screen.queryByRole('button', { name: 'Reconnect @first' })).toBeNull() expect(screen.queryByRole("button", { name: "Reconnect @first" })).toBeNull();
expect(screen.queryByRole('button', { name: 'Disconnect @first' })).toBeNull() expect(screen.queryByRole("button", { name: "Disconnect @first" })).toBeNull();
}) });
+51 -69
View File
@@ -1,88 +1,80 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from "@tanstack/react-start";
import { useId, useState } from 'react' import { useId, useState } from "react";
import { Badge } from '#/components/ui/badge' import { Badge } from "#/components/ui/badge";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select' import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { import {
disconnectMastodonAccount, disconnectMastodonAccount,
loadMastodonInstances, loadMastodonInstances,
startMastodonOAuth, startMastodonOAuth,
} from '../mastodon/server-functions' } from "../mastodon/server-functions";
import type { Connection } from './model' import type { Connection } from "./model";
import './connection-manager.css'
const statusNames = { const statusNames = {
connected: 'Connected', connected: "Connected",
disconnected: 'Disconnected', disconnected: "Disconnected",
expired: 'Reconnect required', expired: "Reconnect required",
} };
export function ConnectionManager({ export function ConnectionManager({
connections, connections,
onChanged, onChanged,
hasTemporaryDecks = false, hasTemporaryDecks = false,
}: { }: {
connections: Connection[] connections: Connection[];
onChanged: () => unknown onChanged: () => unknown;
hasTemporaryDecks?: boolean hasTemporaryDecks?: boolean;
}) { }) {
const fetchInstances = useServerFn(loadMastodonInstances) const fetchInstances = useServerFn(loadMastodonInstances);
const authorize = useServerFn(startMastodonOAuth) const authorize = useServerFn(startMastodonOAuth);
const disconnect = useServerFn(disconnectMastodonAccount) const disconnect = useServerFn(disconnectMastodonAccount);
const instances = useQuery({ const instances = useQuery({
queryKey: ['mastodon-instances'], queryKey: ["mastodon-instances"],
queryFn: () => fetchInstances(), queryFn: () => fetchInstances(),
retry: false, retry: false,
}) });
const [selected, setSelected] = useState('') const [selected, setSelected] = useState("");
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>() const [error, setError] = useState<string>();
const selectId = useId() const selectId = useId();
const origin = selected || instances.data?.origins[0] || '' const origin = selected || instances.data?.origins[0] || "";
async function connect(origin: string, connectionId?: string) { async function connect(origin: string, connectionId?: string) {
setBusy(true) setBusy(true);
setError(undefined) setError(undefined);
try { try {
const result = await authorize({ const result = await authorize({
data: { origin, ...(connectionId ? { connectionId } : {}) }, data: { origin, ...(connectionId ? { connectionId } : {}) },
}) });
window.location.assign(result.authorizationUrl) window.location.assign(result.authorizationUrl);
} catch { } catch {
setError( setError("Could not start Mastodon authorization. Check the server and try again.");
'Could not start Mastodon authorization. Check the server and try again.',
)
} finally { } finally {
setBusy(false) setBusy(false);
} }
} }
async function remove(connectionId: string) { async function remove(connectionId: string) {
setBusy(true) setBusy(true);
setError(undefined) setError(undefined);
try { try {
await disconnect({ data: { connectionId } }) await disconnect({ data: { connectionId } });
try { try {
await onChanged() await onChanged();
} catch { } catch {
setError( setError("Account disconnected, but the list could not be refreshed. Reload the page.");
'Account disconnected, but the list could not be refreshed. Reload the page.',
)
} }
} catch { } catch {
setError('Could not disconnect the account. Try again later.') setError("Could not disconnect the account. Try again later.");
} finally { } finally {
setBusy(false) setBusy(false);
} }
} }
return ( return (
<section <section className="connection-manager" aria-label="Connected account management">
className="connection-manager"
aria-label="Connected account management"
>
<p className="connection-manager-description"> <p className="connection-manager-description">
Connected accounts are available in columns on all your devices. Connected accounts are available in columns on all your devices.
</p> </p>
@@ -96,31 +88,25 @@ export function ConnectionManager({
<div className="connection-manager-account"> <div className="connection-manager-account">
<strong>{connection.displayName}</strong> <strong>{connection.displayName}</strong>
<span> <span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '} {connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
{new URL(connection.origin).hostname} {new URL(connection.origin).hostname}
</span> </span>
<Badge <Badge variant={connection.status === "connected" ? "secondary" : "outline"}>
variant={
connection.status === 'connected' ? 'secondary' : 'outline'
}
>
{statusNames[connection.status]} {statusNames[connection.status]}
</Badge> </Badge>
</div> </div>
{connection.platform === 'mastodon' && ( {connection.platform === "mastodon" && (
<div className="connection-manager-actions"> <div className="connection-manager-actions">
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
disabled={busy} disabled={busy}
aria-label={`Reconnect ${connection.displayName}`} aria-label={`Reconnect ${connection.displayName}`}
onClick={() => onClick={() => void connect(connection.origin, connection.id)}
void connect(connection.origin, connection.id)
}
> >
Reconnect Reconnect
</Button> </Button>
{connection.status !== 'disconnected' && ( {connection.status !== "disconnected" && (
<Button <Button
variant="destructive" variant="destructive"
type="button" type="button"
@@ -140,8 +126,8 @@ export function ConnectionManager({
<form <form
className="connection-manager-add" className="connection-manager-add"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault();
if (origin) void connect(origin) if (origin) void connect(origin);
}} }}
> >
<h3>Add a Mastodon account</h3> <h3>Add a Mastodon account</h3>
@@ -150,11 +136,7 @@ export function ConnectionManager({
) : instances.isError ? ( ) : instances.isError ? (
<p role="alert"> <p role="alert">
Could not load servers. Could not load servers.
<Button <Button variant="outline" type="button" onClick={() => void instances.refetch()}>
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
Retry Retry
</Button> </Button>
</p> </p>
@@ -183,12 +165,12 @@ export function ConnectionManager({
)} )}
{hasTemporaryDecks && ( {hasTemporaryDecks && (
<p className="connection-manager-note"> <p className="connection-manager-note">
Temporary decks will be lost when you leave to authorize. Save any Temporary decks will be lost when you leave to authorize. Save any decks you want to
decks you want to keep first. keep first.
</p> </p>
)} )}
{busy && <p role="status">Working…</p>} {busy && <p role="status">Working…</p>}
</form> </form>
</section> </section>
) );
} }
+42 -53
View File
@@ -1,11 +1,6 @@
import { import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
createCipheriv, import { readFileSync } from "node:fs";
createDecipheriv, import { z } from "zod";
createHash,
randomBytes,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
const envelopeSchema = z const envelopeSchema = z
.object({ .object({
@@ -15,83 +10,77 @@ const envelopeSchema = z
tag: z.string(), tag: z.string(),
ciphertext: z.string(), ciphertext: z.string(),
}) })
.strict() .strict();
function decodeBase64(value: string): Buffer { function decodeBase64(value: string): Buffer {
const result = Buffer.from(value, 'base64') const result = Buffer.from(value, "base64");
if (result.toString('base64') !== value) throw new Error('Invalid encoding') if (result.toString("base64") !== value) throw new Error("Invalid encoding");
return result return result;
} }
function loadKey() { function loadKey() {
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE;
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.') if (!path) throw new Error("TWITTER_LITE_CREDENTIAL_KEY_FILE is required.");
let encoded: string let encoded: string;
try { try {
encoded = readFileSync(path, 'utf8').trim() encoded = readFileSync(path, "utf8").trim();
} catch { } catch {
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.') throw new Error("Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.");
} }
let key: Buffer let key: Buffer;
try { try {
key = decodeBase64(encoded) key = decodeBase64(encoded);
if (key.length !== 32) throw new Error('Invalid key length') if (key.length !== 32) throw new Error("Invalid key length");
} catch { } catch {
throw new Error( throw new Error(
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.', "TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.",
) );
} }
return { return {
key, key,
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32), keyId: createHash("sha256").update(key).digest("hex").slice(0, 32),
} };
} }
function associatedData(context: string, keyId: string) { function associatedData(context: string, keyId: string) {
if (!context) throw new Error('A credential record and purpose are required.') if (!context) throw new Error("A credential record and purpose are required.");
return Buffer.from( return Buffer.from(JSON.stringify(["twitter-lite-credential", 1, keyId, context]));
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
)
} }
/** Context must identify both record and purpose; persist the returned opaque JSON. */ /** Context must identify both record and purpose; persist the returned opaque JSON. */
export function encryptCredential(plaintext: string, context: string): string { export function encryptCredential(plaintext: string, context: string): string {
const { key, keyId } = loadKey() const { key, keyId } = loadKey();
const iv = randomBytes(12) const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv) const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(associatedData(context, keyId)) cipher.setAAD(associatedData(context, keyId));
const ciphertext = Buffer.concat([ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
return JSON.stringify({ return JSON.stringify({
version: 1, version: 1,
keyId, keyId,
iv: iv.toString('base64'), iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString('base64'), tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString('base64'), ciphertext: ciphertext.toString("base64"),
}) });
} }
export function decryptCredential(serialized: string, context: string): string { export function decryptCredential(serialized: string, context: string): string {
const { key, keyId } = loadKey() const { key, keyId } = loadKey();
try { try {
const envelope = envelopeSchema.parse(JSON.parse(serialized)) const envelope = envelopeSchema.parse(JSON.parse(serialized));
if (envelope.keyId !== keyId) throw new Error('Different key') if (envelope.keyId !== keyId) throw new Error("Different key");
const iv = decodeBase64(envelope.iv) const iv = decodeBase64(envelope.iv);
const tag = decodeBase64(envelope.tag) const tag = decodeBase64(envelope.tag);
if (iv.length !== 12 || tag.length !== 16) if (iv.length !== 12 || tag.length !== 16) throw new Error("Invalid envelope");
throw new Error('Invalid envelope') const decipher = createDecipheriv("aes-256-gcm", key, iv);
const decipher = createDecipheriv('aes-256-gcm', key, iv) decipher.setAAD(associatedData(context, keyId));
decipher.setAAD(associatedData(context, keyId)) decipher.setAuthTag(tag);
decipher.setAuthTag(tag)
return Buffer.concat([ return Buffer.concat([
decipher.update(decodeBase64(envelope.ciphertext)), decipher.update(decodeBase64(envelope.ciphertext)),
decipher.final(), decipher.final(),
]).toString('utf8') ]).toString("utf8");
} catch { } catch {
throw new Error( throw new Error(
'Credential could not be decrypted. Check the stored credential and encryption key.', "Credential could not be decrypted. Check the stored credential and encryption key.",
) );
} }
} }
+97 -104
View File
@@ -1,126 +1,119 @@
// @vitest-environment node // @vitest-environment node
import { randomBytes } from 'node:crypto' import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from 'node:os' import { tmpdir } from "node:os";
import { join } from 'node:path' import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { decryptCredential, encryptCredential } from './credentials.server' import { decryptCredential, encryptCredential } from "./credentials.server";
let directory: string let directory: string;
let keyPath: string let keyPath: string;
const context = 'connection:first:access-token' const context = "connection:first:access-token";
const secret = 'sensitive-token-秘密' const secret = "sensitive-token-秘密";
beforeEach(() => { beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-')) directory = mkdtempSync(join(tmpdir(), "twitter-lite-credentials-"));
keyPath = join(directory, 'key') keyPath = join(directory, "key");
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, { writeFileSync(keyPath, `${randomBytes(32).toString("base64")}\n`, {
mode: 0o600, mode: 0o600,
}) });
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath) vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", keyPath);
}) });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs() vi.unstubAllEnvs();
rmSync(directory, { recursive: true, force: true }) rmSync(directory, { recursive: true, force: true });
}) });
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => { it("round-trips credentials with distinct randomized ciphertext and no plaintext", () => {
const first = encryptCredential(secret, context) const first = encryptCredential(secret, context);
const second = encryptCredential(secret, context) const second = encryptCredential(secret, context);
expect(first).not.toBe(second) expect(first).not.toBe(second);
expect(first).not.toContain(secret) expect(first).not.toContain(secret);
expect(first).not.toContain(Buffer.from(secret).toString('base64')) expect(first).not.toContain(Buffer.from(secret).toString("base64"));
expect(JSON.parse(first)).toMatchObject({ expect(JSON.parse(first)).toMatchObject({
version: 1, version: 1,
keyId: expect.stringMatching(/^[a-f0-9]{32}$/), keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
}) });
expect(decryptCredential(first, context)).toBe(secret) expect(decryptCredential(first, context)).toBe(secret);
expect(decryptCredential(second, context)).toBe(secret) expect(decryptCredential(second, context)).toBe(secret);
}) });
it.each([ it.each(["connection:second:access-token", "oauth-app:first:client-secret"])(
'connection:second:access-token', "rejects ciphertext moved to another record or purpose: %s",
'oauth-app:first:client-secret', (otherContext) => {
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => { const stored = encryptCredential(secret, context);
const stored = encryptCredential(secret, context) expect(() => decryptCredential(stored, otherContext)).toThrow(
expect(() => decryptCredential(stored, otherContext)).toThrow( "Credential could not be decrypted.",
'Credential could not be decrypted.', );
) },
}) );
it.each([ it.each(["iv", "tag", "ciphertext", "keyId", "version"])(
'iv', "rejects tampering with %s without disclosing the secret",
'tag', (field) => {
'ciphertext', const envelope = JSON.parse(encryptCredential(secret, context));
'keyId', envelope[field] = "tampered";
'version', expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
])('rejects tampering with %s without disclosing the secret', (field) => { "Credential could not be decrypted. Check the stored credential and encryption key.",
const envelope = JSON.parse(encryptCredential(secret, context)) );
envelope[field] = 'tampered' },
);
it("rejects a validly encoded altered authentication tag", () => {
const envelope = JSON.parse(encryptCredential(secret, context));
const tag = Buffer.from(envelope.tag, "base64");
tag[0] = (tag[0] ?? 0) ^ 1;
envelope.tag = tag.toString("base64");
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow( expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted. Check the stored credential and encryption key.', "Credential could not be decrypted.",
) );
}) });
it('rejects a validly encoded altered authentication tag', () => { it("requires the original key after restart or restore", () => {
const envelope = JSON.parse(encryptCredential(secret, context)) const originalKey = readFileSync(keyPath);
const tag = Buffer.from(envelope.tag, 'base64') const stored = encryptCredential(secret, context);
tag[0] = (tag[0] ?? 0) ^ 1 writeFileSync(keyPath, randomBytes(32).toString("base64"));
envelope.tag = tag.toString('base64') expect(() => decryptCredential(stored, context)).toThrow("Credential could not be decrypted.");
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow( writeFileSync(keyPath, originalKey);
'Credential could not be decrypted.', expect(decryptCredential(stored, context)).toBe(secret);
) });
})
it('requires the original key after restart or restore', () => { it.each(["not-json", "{}", '{"version":2}'])(
const originalKey = readFileSync(keyPath) "rejects malformed or unsupported stored credentials: %s",
const stored = encryptCredential(secret, context) (serialized) => {
writeFileSync(keyPath, randomBytes(32).toString('base64')) expect(() => decryptCredential(serialized, context)).toThrow(
expect(() => decryptCredential(stored, context)).toThrow( "Credential could not be decrypted.",
'Credential could not be decrypted.', );
) },
writeFileSync(keyPath, originalKey) );
expect(decryptCredential(stored, context)).toBe(secret)
})
it.each([ it("requires an explicitly configured credential key", () => {
'not-json', vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", "");
'{}',
'{"version":2}',
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires an explicitly configured credential key', () => {
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
expect(() => encryptCredential(secret, context)).toThrow( expect(() => encryptCredential(secret, context)).toThrow(
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.', "TWITTER_LITE_CREDENTIAL_KEY_FILE is required.",
) );
}) });
it('reports an unreadable key without including the path or plaintext', () => { it("reports an unreadable key without including the path or plaintext", () => {
rmSync(keyPath) rmSync(keyPath);
expect(() => encryptCredential(secret, context)).toThrow( expect(() => encryptCredential(secret, context)).toThrow(
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.', "Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.",
) );
}) });
it.each([ it.each(["", "this is not base64", Buffer.alloc(16).toString("base64")])(
'', "rejects invalid key material",
'this is not base64', (encoded) => {
Buffer.alloc(16).toString('base64'), writeFileSync(keyPath, encoded);
])('rejects invalid key material', (encoded) => { expect(() => encryptCredential(secret, context)).toThrow(
writeFileSync(keyPath, encoded) "must contain 32 random bytes encoded as base64.",
expect(() => encryptCredential(secret, context)).toThrow( );
'must contain 32 random bytes encoded as base64.', },
) );
})
it('refuses encryption without record binding', () => { it("refuses encryption without record binding", () => {
expect(() => encryptCredential(secret, '')).toThrow( expect(() => encryptCredential(secret, "")).toThrow(
'A credential record and purpose are required.', "A credential record and purpose are required.",
) );
}) });
+5 -5
View File
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from "zod";
const connectionSchema = z.object({ const connectionSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
platform: z.enum(['twitter', 'mastodon']), platform: z.enum(["twitter", "mastodon"]),
origin: z.string().url(), origin: z.string().url(),
accountId: z.string().nullable(), accountId: z.string().nullable(),
displayName: z.string(), displayName: z.string(),
status: z.enum(['connected', 'disconnected', 'expired']), status: z.enum(["connected", "disconnected", "expired"]),
}) });
export type Connection = z.infer<typeof connectionSchema> export type Connection = z.infer<typeof connectionSchema>;
+40 -49
View File
@@ -1,73 +1,68 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from "node:crypto";
import { asc, eq } from 'drizzle-orm' import { asc, eq } from "drizzle-orm";
import { ProfileUnavailableError } from '../profiles/errors' import { ProfileUnavailableError } from "../profiles/errors";
import { fetchProfileNames } from '../profiles/profile-service.server' import { fetchProfileNames } from "../profiles/profile-service.server";
import { type AppDatabase, getDatabase } from '../storage/database.server' import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections } from '../storage/schema' import { connections } from "../storage/schema";
import type { Connection } from './model' import type { Connection } from "./model";
function relayOrigin(): string { function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL const configured = process.env.TWITTER_RELAY_BASE_URL;
if (!configured) if (!configured) throw new ProfileUnavailableError("Set TWITTER_RELAY_BASE_URL.");
throw new ProfileUnavailableError('Set TWITTER_RELAY_BASE_URL.') return new URL(configured).origin;
return new URL(configured).origin
} }
async function syncTwitterConnections( async function syncTwitterConnections(
database: AppDatabase, database: AppDatabase,
fetchProfiles: typeof fetchProfileNames, fetchProfiles: typeof fetchProfileNames,
) { ) {
const origin = relayOrigin() const origin = relayOrigin();
const profiles = new Set( const profiles = new Set(
await fetchProfiles(origin).catch(() => { await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'Could not verify connection profiles. Check the relay connection and try again.', "Could not verify connection profiles. Check the relay connection and try again.",
) );
}), }),
) );
database.transaction((tx) => { database.transaction((tx) => {
const existing = tx const existing = tx.select().from(connections).where(eq(connections.platform, "twitter")).all();
.select() const now = Date.now();
.from(connections)
.where(eq(connections.platform, 'twitter'))
.all()
const now = Date.now()
for (const connection of existing) { for (const connection of existing) {
const status = const status =
connection.origin === origin && connection.origin === origin &&
connection.relayProfile !== null && connection.relayProfile !== null &&
profiles.has(connection.relayProfile) profiles.has(connection.relayProfile)
? 'connected' ? "connected"
: 'disconnected' : "disconnected";
if (connection.status !== status) { if (connection.status !== status) {
tx.update(connections) tx.update(connections)
.set({ status, updatedAt: now }) .set({ status, updatedAt: now })
.where(eq(connections.id, connection.id)) .where(eq(connections.id, connection.id))
.run() .run();
} }
} }
const known = new Set( const known = new Set(
existing existing
.filter((connection) => connection.origin === origin) .filter((connection) => connection.origin === origin)
.map((connection) => connection.relayProfile), .map((connection) => connection.relayProfile),
) );
for (const profile of profiles) { for (const profile of profiles) {
if (known.has(profile)) continue if (known.has(profile)) continue;
tx.insert(connections) tx.insert(connections)
.values({ .values({
id: randomUUID(), id: randomUUID(),
platform: 'twitter', platform: "twitter",
origin, origin,
relayProfile: profile, relayProfile: profile,
displayName: profile, displayName: profile,
status: 'connected', status: "connected",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.run() .run();
} }
}) });
return origin return origin;
} }
const publicFields = { const publicFields = {
@@ -77,25 +72,25 @@ const publicFields = {
accountId: connections.accountId, accountId: connections.accountId,
displayName: connections.displayName, displayName: connections.displayName,
status: connections.status, status: connections.status,
} };
export async function listConnections( export async function listConnections(
database = getDatabase(), database = getDatabase(),
fetchProfiles = fetchProfileNames, fetchProfiles = fetchProfileNames,
): Promise<{ connections: Connection[]; relayError?: string }> { ): Promise<{ connections: Connection[]; relayError?: string }> {
let relayError: string | undefined let relayError: string | undefined;
try { try {
await syncTwitterConnections(database, fetchProfiles) await syncTwitterConnections(database, fetchProfiles);
} catch (error) { } catch (error) {
if (!(error instanceof ProfileUnavailableError)) throw error if (!(error instanceof ProfileUnavailableError)) throw error;
relayError = error.message relayError = error.message;
} }
const saved = database const saved = database
.select(publicFields) .select(publicFields)
.from(connections) .from(connections)
.orderBy(asc(connections.createdAt), asc(connections.id)) .orderBy(asc(connections.createdAt), asc(connections.id))
.all() .all();
return { connections: saved, ...(relayError ? { relayError } : {}) } return { connections: saved, ...(relayError ? { relayError } : {}) };
} }
export async function requireTwitterConnection( export async function requireTwitterConnection(
@@ -103,21 +98,17 @@ export async function requireTwitterConnection(
database = getDatabase(), database = getDatabase(),
fetchProfiles = fetchProfileNames, fetchProfiles = fetchProfileNames,
): Promise<string> { ): Promise<string> {
const origin = await syncTwitterConnections(database, fetchProfiles) const origin = await syncTwitterConnections(database, fetchProfiles);
const connection = database const connection = database.select().from(connections).where(eq(connections.id, id)).get();
.select()
.from(connections)
.where(eq(connections.id, id))
.get()
if ( if (
connection?.platform !== 'twitter' || connection?.platform !== "twitter" ||
connection.origin !== origin || connection.origin !== origin ||
connection.status !== 'connected' || connection.status !== "connected" ||
!connection.relayProfile !connection.relayProfile
) { ) {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'This connection is unavailable. Check the connected account for this column.', "This connection is unavailable. Check the connected account for this column.",
) );
} }
return connection.relayProfile return connection.relayProfile;
} }
+100 -123
View File
@@ -1,157 +1,134 @@
// @vitest-environment node // @vitest-environment node
import { eq } from 'drizzle-orm' import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { type AppDatabase, openDatabase } from '../storage/database.server' import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections } from '../storage/schema' import { connectionCredentials, connections } from "../storage/schema";
import { listConnections, requireTwitterConnection } from './repository.server' import { listConnections, requireTwitterConnection } from "./repository.server";
let database: AppDatabase let database: AppDatabase;
beforeEach(() => { beforeEach(() => {
database = openDatabase(':memory:') database = openDatabase(":memory:");
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/') vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://relay.invalid/");
}) });
afterEach(() => { afterEach(() => {
database.$client.close() database.$client.close();
vi.unstubAllEnvs() vi.unstubAllEnvs();
}) });
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => { it("retains stable distinct IDs for multiple relay profiles across repeated discovery", async () => {
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first']) const fetchProfiles = vi
const { connections: first } = await listConnections(database, fetchProfiles) .fn<() => Promise<string[]>>()
const { connections: second } = await listConnections(database, fetchProfiles) .mockResolvedValue(["first", "second", "first"]);
expect(first).toHaveLength(2) const { connections: first } = await listConnections(database, fetchProfiles);
expect(new Set(first.map((connection) => connection.id)).size).toBe(2) const { connections: second } = await listConnections(database, fetchProfiles);
expect(second).toEqual(first) expect(first).toHaveLength(2);
expect( expect(new Set(first.map((connection) => connection.id)).size).toBe(2);
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles), expect(second).toEqual(first);
).toBe(first[0]?.displayName) expect(await requireTwitterConnection(first[0]?.id ?? "", database, fetchProfiles)).toBe(
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid') first[0]?.displayName,
}) );
expect(fetchProfiles).toHaveBeenCalledWith("https://relay.invalid");
});
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => { it("preserves unavailable bindings and reconnects the original ID when a profile returns", async () => {
const { connections: original } = await listConnections( const { connections: original } = await listConnections(database, async () => ["first"]);
database, const id = original[0]?.id ?? "";
async () => ['first'], expect((await listConnections(database, async () => [])).connections).toEqual([
) expect.objectContaining({ id, status: "disconnected" }),
const id = original[0]?.id ?? '' ]);
expect((await listConnections(database, async () => [])).connections).toEqual( await expect(requireTwitterConnection(id, database, async () => [])).rejects.toThrow(
[expect.objectContaining({ id, status: 'disconnected' })], "This connection is unavailable",
) );
await expect( expect((await listConnections(database, async () => ["first"])).connections).toEqual(original);
requireTwitterConnection(id, database, async () => []), });
).rejects.toThrow('This connection is unavailable')
expect(
(await listConnections(database, async () => ['first'])).connections,
).toEqual(original)
})
it('does not reuse an old relay binding for the same profile at another origin', async () => { it("does not reuse an old relay binding for the same profile at another origin", async () => {
const { connections: original } = await listConnections( const { connections: original } = await listConnections(database, async () => ["first"]);
database, vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://another-relay.invalid");
async () => ['first'], const { connections: current } = await listConnections(database, async () => ["first"]);
) expect(current).toHaveLength(2);
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
const { connections: current } = await listConnections(database, async () => [
'first',
])
expect(current).toHaveLength(2)
expect(current).toEqual( expect(current).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }), expect.objectContaining({ id: original[0]?.id, status: "disconnected" }),
expect.objectContaining({ expect.objectContaining({
origin: 'https://another-relay.invalid', origin: "https://another-relay.invalid",
status: 'connected', status: "connected",
}), }),
]), ]),
) );
await expect( await expect(
requireTwitterConnection(original[0]?.id ?? '', database, async () => [ requireTwitterConnection(original[0]?.id ?? "", database, async () => ["first"]),
'first', ).rejects.toThrow("This connection is unavailable");
]), });
).rejects.toThrow('This connection is unavailable')
})
it('does not convert a relay outage into persisted account removal', async () => { it("does not convert a relay outage into persisted account removal", async () => {
const original = await listConnections(database, async () => ['first']) const original = await listConnections(database, async () => ["first"]);
const unavailable = vi const unavailable = vi
.fn() .fn<() => Promise<string[]>>()
.mockRejectedValue(new Error('private network detail')) .mockRejectedValue(new Error("private network detail"));
const failed = await listConnections(database, unavailable) const failed = await listConnections(database, unavailable);
expect(failed.connections).toEqual(original.connections) expect(failed.connections).toEqual(original.connections);
expect(failed.relayError).toContain( expect(failed.relayError).toContain("Check the relay connection and try again.");
'Check the relay connection and try again.', expect(database.select().from(connections).get()?.status).toBe("connected");
) expect(await listConnections(database, async () => ["first"])).toEqual(original);
expect(database.select().from(connections).get()?.status).toBe('connected') });
expect(await listConnections(database, async () => ['first'])).toEqual(
original,
)
})
it('returns public metadata for all platforms without joining or exposing credentials', async () => { it("returns public metadata for all platforms without joining or exposing credentials", async () => {
database database
.insert(connections) .insert(connections)
.values({ .values({
id: 'mastodon-account', id: "mastodon-account",
platform: 'mastodon', platform: "mastodon",
origin: 'https://mastodon.invalid', origin: "https://mastodon.invalid",
accountId: '42', accountId: "42",
displayName: '@owner', displayName: "@owner",
status: 'connected', status: "connected",
createdAt: 1, createdAt: 1,
updatedAt: 1, updatedAt: 1,
}) })
.run() .run();
database database
.insert(connectionCredentials) .insert(connectionCredentials)
.values({ .values({
connectionId: 'mastodon-account', connectionId: "mastodon-account",
encryptedToken: 'stored-secret-envelope', encryptedToken: "stored-secret-envelope",
updatedAt: 1, updatedAt: 1,
}) })
.run() .run();
const { connections: discovered } = await listConnections( const { connections: discovered } = await listConnections(database, async () => ["first"]);
database,
async () => ['first'],
)
expect(discovered[0]).toEqual({ expect(discovered[0]).toEqual({
id: 'mastodon-account', id: "mastodon-account",
platform: 'mastodon', platform: "mastodon",
origin: 'https://mastodon.invalid', origin: "https://mastodon.invalid",
accountId: '42', accountId: "42",
displayName: '@owner', displayName: "@owner",
status: 'connected', status: "connected",
}) });
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([ expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
'accountId', "accountId",
'displayName', "displayName",
'id', "id",
'origin', "origin",
'platform', "platform",
'status', "status",
]) ]);
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope') expect(JSON.stringify(discovered)).not.toContain("stored-secret-envelope");
const outage = await listConnections(database, async () => { const outage = await listConnections(database, async () => {
throw new Error('offline') throw new Error("offline");
}) });
expect(outage.connections).toEqual(discovered) expect(outage.connections).toEqual(discovered);
expect(outage.relayError).toBeDefined() expect(outage.relayError).toBeDefined();
await expect( await expect(
requireTwitterConnection('mastodon-account', database, async () => [ requireTwitterConnection("mastodon-account", database, async () => ["first"]),
'first', ).rejects.toThrow("This connection is unavailable");
]),
).rejects.toThrow('This connection is unavailable')
expect( expect(
database database.select().from(connections).where(eq(connections.id, "mastodon-account")).get()?.status,
.select() ).toBe("connected");
.from(connections) });
.where(eq(connections.id, 'mastodon-account'))
.get()?.status,
).toBe('connected')
})
it('rejects an unknown connection without treating its ID as a relay profile', async () => { it("rejects an unknown connection without treating its ID as a relay profile", async () => {
await expect( await expect(requireTwitterConnection("first", database, async () => ["first"])).rejects.toThrow(
requireTwitterConnection('first', database, async () => ['first']), "This connection is unavailable",
).rejects.toThrow('This connection is unavailable') );
}) });
+5 -7
View File
@@ -1,8 +1,6 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from "@tanstack/react-start";
export const loadConnections = createServerFn({ method: 'GET' }).handler( export const loadConnections = createServerFn({ method: "GET" }).handler(async () => {
async () => { const { listConnections } = await import("./repository.server");
const { listConnections } = await import('./repository.server') return listConnections();
return listConnections() });
},
)
+128 -138
View File
@@ -1,177 +1,167 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from "vitest";
import type { Connection } from '../connections/model' import type { Connection } from "../connections/model";
import { ColumnEditor } from './column-editor' import { ColumnEditor } from "./column-editor";
const lists = vi.hoisted(() => vi.fn()) const lists = vi.hoisted(() =>
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn })) vi.fn<typeof import("../platforms/mastodon-server-functions").loadMastodonLists>(),
vi.mock('#/features/platforms/mastodon-server-functions', () => ({ );
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("#/features/platforms/mastodon-server-functions", () => ({
loadMastodonLists: lists, loadMastodonLists: lists,
})) }));
vi.mock('#/features/posts/use-list-choices', () => ({ vi.mock("#/features/posts/use-list-choices", () => ({
useListChoices: () => ({ useListChoices: () => ({
isPending: false, isPending: false,
isError: false, isError: false,
data: [], data: [],
refetch: vi.fn(), refetch: vi.fn<() => void>(),
}), }),
})) }));
const connections: Connection[] = [ const connections: Connection[] = [
{ {
id: 'twitter', id: "twitter",
platform: 'twitter', platform: "twitter",
origin: 'https://relay.invalid', origin: "https://relay.invalid",
accountId: null, accountId: null,
displayName: 'Twitter account', displayName: "Twitter account",
status: 'connected', status: "connected",
}, },
{ {
id: 'mastodon-first', id: "mastodon-first",
platform: 'mastodon', platform: "mastodon",
origin: 'https://mastodon.invalid', origin: "https://mastodon.invalid",
accountId: '1', accountId: "1",
displayName: '@first', displayName: "@first",
status: 'connected', status: "connected",
}, },
{ {
id: 'mastodon-second', id: "mastodon-second",
platform: 'mastodon', platform: "mastodon",
origin: 'https://mastodon.invalid', origin: "https://mastodon.invalid",
accountId: '2', accountId: "2",
displayName: '@second', displayName: "@second",
status: 'connected', status: "connected",
}, },
] ];
beforeEach(() => { beforeEach(() => {
lists lists.mockReset().mockImplementation(async ({ data }: { data: { connectionId: string } }) => ({
.mockReset() lists: [
.mockImplementation( {
async ({ data }: { data: { connectionId: string } }) => ({ id: data.connectionId === "mastodon-first" ? "10" : "20",
lists: [ name: data.connectionId,
{ isPrivate: true,
id: data.connectionId === 'mastodon-first' ? '10' : '20', },
name: data.connectionId, ],
isPrivate: true, }));
}, });
],
}),
)
})
function show() { function show() {
const onSave = vi.fn().mockResolvedValue(true) const onSave = vi
.fn<(column: import("./model").DeckColumn) => Promise<boolean>>()
.mockResolvedValue(true);
render( render(
<QueryClientProvider <QueryClientProvider
client={ client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
> >
<ColumnEditor <ColumnEditor connections={connections} onSave={onSave} onCancel={vi.fn<() => void>()} />
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
</QueryClientProvider>, </QueryClientProvider>,
) );
fireEvent.change(screen.getByLabelText('Column name'), { fireEvent.change(screen.getByLabelText("Column name"), {
target: { value: '調査' }, target: { value: "調査" },
}) });
return onSave return onSave;
} }
it('creates a Mastodon search without Twitter ordering or following fields', async () => { it("creates a Mastodon search without Twitter ordering or following fields", async () => {
const save = show() const save = show();
fireEvent.change(screen.getByLabelText('Connection profile'), { fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: 'mastodon-first' }, target: { value: "mastodon-first" },
}) });
expect(screen.queryByLabelText('Sort by')).toBeNull() expect(screen.queryByLabelText("Sort by")).toBeNull();
expect(screen.queryByLabelText('Following only')).toBeNull() expect(screen.queryByLabelText("Following only")).toBeNull();
expect(screen.getByText(/Zero results/)).toBeVisible() expect(screen.getByText(/Zero results/)).toBeVisible();
fireEvent.change(screen.getByLabelText('Mastodon search query'), { fireEvent.change(screen.getByLabelText("Mastodon search query"), {
target: { value: 'WebMCP' }, target: { value: "WebMCP" },
}) });
fireEvent.submit(screen.getByRole('form', { name: 'Add column' })) fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => await waitFor(() =>
expect(save).toHaveBeenCalledWith( expect(save).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
connectionId: 'mastodon-first', connectionId: "mastodon-first",
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' }, source: { platform: "mastodon", kind: "search", query: "WebMCP" },
}), }),
), ),
) );
}) });
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => { it("creates hashtags and resets unsupported source kind when changing to Twitter", async () => {
const save = show() const save = show();
fireEvent.change(screen.getByLabelText('Connection profile'), { fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: 'mastodon-first' }, target: { value: "mastodon-first" },
}) });
fireEvent.change(screen.getByLabelText('Column type'), { fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: 'hashtag' }, target: { value: "hashtag" },
}) });
fireEvent.change(screen.getByLabelText('Hashtag'), { fireEvent.change(screen.getByLabelText("Hashtag"), {
target: { value: 'WebMCP' }, target: { value: "WebMCP" },
}) });
fireEvent.submit(screen.getByRole('form', { name: 'Add column' })) fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => await waitFor(() =>
expect(save).toHaveBeenCalledWith( expect(save).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' }, source: { platform: "mastodon", kind: "hashtag", target: "WebMCP" },
}), }),
), ),
) );
fireEvent.change(screen.getByLabelText('Connection profile'), { fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: 'twitter' }, target: { value: "twitter" },
}) });
expect(screen.getByLabelText('Column type')).toHaveValue('search') expect(screen.getByLabelText("Column type")).toHaveValue("search");
expect(screen.getByLabelText('Twitter search query')).toHaveValue('') expect(screen.getByLabelText("Twitter search query")).toHaveValue("");
expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull() expect(screen.queryByRole("option", { name: "Hashtag" })).toBeNull();
}) });
it('loads lists for each account and clears the selected list when the binding changes', async () => { it("loads lists for each account and clears the selected list when the binding changes", async () => {
show() show();
fireEvent.change(screen.getByLabelText('Connection profile'), { fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: 'mastodon-first' }, target: { value: "mastodon-first" },
}) });
fireEvent.change(screen.getByLabelText('Column type'), { fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: 'list' }, target: { value: "list" },
}) });
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' })) fireEvent.click(await screen.findByRole("button", { name: "mastodon-first" }));
expect(screen.getByLabelText('List')).toHaveValue('10') expect(screen.getByLabelText("List")).toHaveValue("10");
fireEvent.change(screen.getByLabelText('Connection profile'), { fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: 'mastodon-second' }, target: { value: "mastodon-second" },
}) });
expect(screen.getByLabelText('List')).toHaveValue('') expect(screen.getByLabelText("List")).toHaveValue("");
fireEvent.click( fireEvent.click(await screen.findByRole("button", { name: "mastodon-second" }));
await screen.findByRole('button', { name: 'mastodon-second' }), expect(screen.getByLabelText("List")).toHaveValue("20");
)
expect(screen.getByLabelText('List')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({ expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' }, data: { connectionId: "mastodon-second" },
}) });
}) });
it('preserves title and Twitter search options after a rejected save', async () => { it("preserves title and Twitter search options after a rejected save", async () => {
const save = show() const save = show();
save.mockResolvedValue(false) save.mockResolvedValue(false);
fireEvent.change(screen.getByLabelText('Twitter search query'), { fireEvent.change(screen.getByLabelText("Twitter search query"), {
target: { value: 'from:owner WebMCP' }, target: { value: "from:owner WebMCP" },
}) });
fireEvent.change(screen.getByLabelText('Sort by'), { fireEvent.change(screen.getByLabelText("Sort by"), {
target: { value: 'Top' }, target: { value: "Top" },
}) });
fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' })) fireEvent.click(screen.getByRole("checkbox", { name: "Following only" }));
fireEvent.submit(screen.getByRole('form', { name: 'Add column' })) fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => expect(save).toHaveBeenCalledOnce()) await waitFor(() => expect(save).toHaveBeenCalledOnce());
expect(screen.getByLabelText('Column name')).toHaveValue('調査') expect(screen.getByLabelText("Column name")).toHaveValue("調査");
expect(screen.getByLabelText('Twitter search query')).toHaveValue( expect(screen.getByLabelText("Twitter search query")).toHaveValue("from:owner WebMCP");
'from:owner WebMCP', expect(screen.getByLabelText("Sort by")).toHaveValue("Top");
) expect(screen.getByRole("checkbox", { name: "Following only" })).toBeChecked();
expect(screen.getByLabelText('Sort by')).toHaveValue('Top') fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked() await waitFor(() => expect(save).toHaveBeenCalledTimes(2));
fireEvent.submit(screen.getByRole('form', { name: 'Add column' })) expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source);
await waitFor(() => expect(save).toHaveBeenCalledTimes(2)) });
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
+40 -53
View File
@@ -1,15 +1,15 @@
import { useId, useState } from 'react' import { useId, useState } from "react";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select' import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { Connection } from '#/features/connections/model' import type { Connection } from "#/features/connections/model";
import { import {
ColumnSourceEditor, ColumnSourceEditor,
defaultColumnSource, defaultColumnSource,
rebindColumnSource, rebindColumnSource,
} from './column-source-editor' } from "./column-source-editor";
import { columnSchema, type DeckColumn } from './model' import { columnSchema, type DeckColumn } from "./model";
export function ColumnEditor({ export function ColumnEditor({
column, column,
@@ -17,50 +17,41 @@ export function ColumnEditor({
onSave, onSave,
onCancel, onCancel,
}: { }: {
column?: DeckColumn column?: DeckColumn;
connections: Connection[] connections: Connection[];
onSave: (column: DeckColumn) => Promise<boolean> onSave: (column: DeckColumn) => Promise<boolean>;
onCancel: () => void onCancel: () => void;
}) { }) {
const available = connections.filter( const available = connections.filter((connection) => connection.status === "connected");
(connection) => connection.status === 'connected', const formId = useId();
) const [error, setError] = useState<string>();
const formId = useId() const [connectionId, setConnectionId] = useState(column?.connectionId ?? available[0]?.id ?? "");
const [error, setError] = useState<string>()
const [connectionId, setConnectionId] = useState(
column?.connectionId ?? available[0]?.id ?? '',
)
const [source, setSource] = useState( const [source, setSource] = useState(
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'), column?.source ?? defaultColumnSource(available[0]?.platform ?? "twitter"),
) );
const validConnection = available.some( const validConnection = available.some((connection) => connection.id === connectionId);
(connection) => connection.id === connectionId,
)
return ( return (
<form <form
className="deck-editor" className="deck-editor"
aria-label={column ? 'Edit column' : 'Add column'} aria-label={column ? "Edit column" : "Add column"}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault();
setError(undefined) setError(undefined);
const data = new FormData(event.currentTarget) const data = new FormData(event.currentTarget);
const parsed = columnSchema.safeParse({ const parsed = columnSchema.safeParse({
id: id: column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join("-"),
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'), title: data.get("title"),
title: data.get('title'),
connectionId, connectionId,
source, source,
}) });
if (!parsed.success || !validConnection) { if (!parsed.success || !validConnection) {
setError( setError(
parsed.success parsed.success ? "Select a connection profile." : parsed.error.issues[0]?.message,
? 'Select a connection profile.' );
: parsed.error.issues[0]?.message, return;
)
return
} }
await onSave(parsed.data) await onSave(parsed.data);
}} }}
> >
<div className="deck-editor-fields"> <div className="deck-editor-fields">
@@ -69,7 +60,7 @@ export function ColumnEditor({
<Input <Input
id={`${formId}-title`} id={`${formId}-title`}
name="title" name="title"
defaultValue={column?.title ?? ''} defaultValue={column?.title ?? ""}
maxLength={80} maxLength={80}
placeholder="e.g. Announcements" placeholder="e.g. Announcements"
required required
@@ -83,12 +74,10 @@ export function ColumnEditor({
value={connectionId} value={connectionId}
required required
onChange={(event) => { onChange={(event) => {
const connection = available.find( const connection = available.find((value) => value.id === event.target.value);
(value) => value.id === event.target.value, if (!connection) return;
) setConnectionId(connection.id);
if (!connection) return setSource(rebindColumnSource(source, connection.platform));
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
}} }}
> >
<NativeSelectOption value="" disabled> <NativeSelectOption value="" disabled>
@@ -102,7 +91,7 @@ export function ColumnEditor({
{available.map((connection) => ( {available.map((connection) => (
<NativeSelectOption key={connection.id} value={connection.id}> <NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName} {connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''} {connection.platform === "mastodon" ? " · Mastodon" : ""}
</NativeSelectOption> </NativeSelectOption>
))} ))}
</NativeSelect> </NativeSelect>
@@ -110,12 +99,10 @@ export function ColumnEditor({
</div> </div>
<ColumnSourceEditor <ColumnSourceEditor
source={source} source={source}
connectionId={validConnection ? connectionId : ''} connectionId={validConnection ? connectionId : ""}
onChange={setSource} onChange={setSource}
/> />
<p className="deck-help"> <p className="deck-help">This column uses the selected connection profile.</p>
This column uses the selected connection profile.
</p>
{error && ( {error && (
<p role="alert" className="deck-error"> <p role="alert" className="deck-error">
{error} {error}
@@ -123,12 +110,12 @@ export function ColumnEditor({
)} )}
<div className="deck-actions"> <div className="deck-actions">
<Button type="submit" disabled={!validConnection}> <Button type="submit" disabled={!validConnection}>
{column ? 'Save changes' : 'Add and search'} {column ? "Save changes" : "Add and search"}
</Button> </Button>
<Button variant="outline" type="button" onClick={onCancel}> <Button variant="outline" type="button" onClick={onCancel}>
Cancel Cancel
</Button> </Button>
</div> </div>
</form> </form>
) );
} }
+26 -45
View File
@@ -1,41 +1,30 @@
import type { ReactElement } from 'react' import type { ReactElement } from "react";
import type { Connection } from '../connections/model' import type { Connection } from "../connections/model";
import { import { defaultMastodonSource, MastodonSourceEditor } from "./mastodon-source-editor";
defaultMastodonSource, import type { DeckColumn } from "./model";
MastodonSourceEditor, import { defaultTwitterSource, TwitterSourceEditor } from "./twitter-source-editor";
} from './mastodon-source-editor'
import type { DeckColumn } from './model'
import {
defaultTwitterSource,
TwitterSourceEditor,
} from './twitter-source-editor'
type Source = DeckColumn['source'] type Source = DeckColumn["source"];
export function defaultColumnSource(platform: Connection['platform']): Source { export function defaultColumnSource(platform: Connection["platform"]): Source {
switch (platform) { switch (platform) {
case 'twitter': case "twitter":
return defaultTwitterSource() return defaultTwitterSource();
case 'mastodon': case "mastodon":
return defaultMastodonSource() return defaultMastodonSource();
} }
} }
/** Account-local targets never carry across bindings; compatible searches may. */ /** Account-local targets never carry across bindings; compatible searches may. */
export function rebindColumnSource( export function rebindColumnSource(source: Source, platform: Connection["platform"]): Source {
source: Source,
platform: Connection['platform'],
): Source {
if (source.platform === platform) { if (source.platform === platform) {
return source.kind === 'search' ? source : { ...source, target: '' } return source.kind === "search" ? source : { ...source, target: "" };
} }
switch (platform) { switch (platform) {
case 'twitter': case "twitter":
return defaultTwitterSource( return defaultTwitterSource(source.kind === "hashtag" ? "search" : source.kind);
source.kind === 'hashtag' ? 'search' : source.kind, case "mastodon":
) return defaultMastodonSource(source.kind);
case 'mastodon':
return defaultMastodonSource(source.kind)
} }
} }
@@ -44,26 +33,18 @@ export function ColumnSourceEditor({
connectionId, connectionId,
onChange, onChange,
}: { }: {
source: Source source: Source;
connectionId: string connectionId: string;
onChange: (source: Source) => void onChange: (source: Source) => void;
}): ReactElement { }): ReactElement {
switch (source.platform) { switch (source.platform) {
case 'twitter': case "twitter":
return ( return (
<TwitterSourceEditor <TwitterSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
source={source} );
connectionId={connectionId} case "mastodon":
onChange={onChange}
/>
)
case 'mastodon':
return ( return (
<MastodonSourceEditor <MastodonSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
source={source} );
connectionId={connectionId}
onChange={onChange}
/>
)
} }
} }
+55 -69
View File
@@ -1,117 +1,103 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from "react";
import { useWebMCP } from 'usewebmcp' import { useWebMCP } from "usewebmcp";
import { z } from 'zod' import { z } from "zod";
import { import { flattenResearchPages, type useResearchFeed } from "#/features/platforms/use-research-feed";
flattenResearchPages, import { PostLoadError } from "#/features/posts/use-post-feed";
type useResearchFeed, import type { DeckColumn } from "./model";
} from '#/features/platforms/use-research-feed' import { useWebMCPSupported } from "./use-webmcp-supported";
import { PostLoadError } from '#/features/posts/use-post-feed' import { toolResult } from "./webmcp-contracts";
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
type ColumnFeed = { type ColumnFeed = {
column: DeckColumn column: DeckColumn;
query: ReturnType<typeof useResearchFeed> query: ReturnType<typeof useResearchFeed>;
} };
export type ColumnRegistry = Map<string, { current: ColumnFeed }> export type ColumnRegistry = Map<string, { current: ColumnFeed }>;
export function useRegisterColumn( export function useRegisterColumn(
registry: ColumnRegistry, registry: ColumnRegistry,
column: DeckColumn, column: DeckColumn,
query: ReturnType<typeof useResearchFeed>, query: ReturnType<typeof useResearchFeed>,
) { ) {
const entry = useRef({ column, query }) const entry = useRef({ column, query });
entry.current = { column, query }
useEffect(() => { useEffect(() => {
registry.set(column.id, entry) entry.current = { column, query };
}, [column, query]);
useEffect(() => {
registry.set(column.id, entry);
return () => { return () => {
if (registry.get(column.id) === entry) registry.delete(column.id) if (registry.get(column.id) === entry) registry.delete(column.id);
} };
}, [registry, column.id]) }, [registry, column.id]);
} }
const columnInput = z.object({ columnId: z.string().min(1) }).strict() const columnInput = z.object({ columnId: z.string().min(1) }).strict();
const readInput = columnInput.extend({ const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0), offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20), limit: z.number().int().min(1).max(50).default(20),
}) });
function read(feed: ColumnFeed, offset = 0, limit = 20) { function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenResearchPages(feed.query.data?.pages ?? []) const posts = flattenResearchPages(feed.query.data?.pages ?? []);
const selected = posts.slice(offset, offset + limit) const selected = posts.slice(offset, offset + limit);
return { return {
column: feed.column, column: feed.column,
status: feed.query.isPending status: feed.query.isPending ? "loading" : feed.query.isError ? "error" : "ready",
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
loading: feed.query.isFetching, loading: feed.query.isFetching,
error: error: feed.query.error instanceof PostLoadError ? feed.query.error.detail : null,
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
posts: selected, posts: selected,
loadedCount: posts.length, loadedCount: posts.length,
offset, offset,
nextOffset: nextOffset: offset + selected.length < posts.length ? offset + selected.length : null,
offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage), hasMore: Boolean(feed.query.hasNextPage),
} };
} }
export function useColumnTools(registry: ColumnRegistry, ready: boolean) { export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported() const supported = useWebMCPSupported();
const enabled = supported && ready const enabled = supported && ready;
function requireColumn(id: string) { function requireColumn(id: string) {
const entry = registry.get(id) const entry = registry.get(id);
if (!entry) if (!entry)
throw new Error( throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.', "Column is not loaded in the active deck. Select its deck and wait for it to render.",
) );
return entry return entry;
} }
useWebMCP({ useWebMCP({
name: 'get_column_posts', name: "get_column_posts",
description: description:
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.', "Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.",
inputSchema: readInput, inputSchema: readInput,
enabled, enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true }, annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) => execute: (input) =>
toolResult(() => { toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input) const { columnId, offset, limit } = readInput.parse(input);
return read(requireColumn(columnId).current, offset, limit) return read(requireColumn(columnId).current, offset, limit);
}), }),
}) });
useWebMCP({ useWebMCP({
name: 'load_more_column', name: "load_more_column",
description: description:
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.', "Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.",
inputSchema: columnInput, inputSchema: columnInput,
enabled, enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true }, annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) => execute: (input) =>
toolResult(async () => { toolResult(async () => {
const { columnId } = columnInput.parse(input) const { columnId } = columnInput.parse(input);
const entry = requireColumn(columnId) const entry = requireColumn(columnId);
const { column, query } = entry.current const { column, query } = entry.current;
const identity = JSON.stringify(column) const identity = JSON.stringify(column);
if (query.isPending || (query.isFetching && !query.isFetchingNextPage)) if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.') throw new Error("Wait for the column to finish loading.");
if (query.isError && !query.isFetchNextPageError) throw query.error if (query.isError && !query.isFetchNextPageError) throw query.error;
const offset = flattenResearchPages(query.data?.pages ?? []).length const offset = flattenResearchPages(query.data?.pages ?? []).length;
if (!query.hasNextPage) return read(entry.current, offset) if (!query.hasNextPage) return read(entry.current, offset);
const result = await query.fetchNextPage({ cancelRefetch: false }) const result = await query.fetchNextPage({ cancelRefetch: false });
if ( if (registry.get(columnId) !== entry || JSON.stringify(entry.current.column) !== identity)
registry.get(columnId) !== entry || throw new Error("The deck or column changed while loading. Read the current deck first.");
JSON.stringify(entry.current.column) !== identity if (result.isError) throw result.error;
) return read({ column, query: result }, offset);
throw new Error(
'The deck or column changed while loading. Read the current deck first.',
)
if (result.isError) throw result.error
return read({ column, query: result }, offset)
}), }),
}) });
} }
+64 -59
View File
@@ -1,63 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from 'vitest' import { expect, it, vi } from "vitest";
import type { ResearchPost } from '../platforms/types' import type { ResearchPost } from "../platforms/types";
import type { CitationTarget } from '../research/use-citation-navigation' import type { CitationTarget } from "../research/use-citation-navigation";
import { ResearchColumn } from './deck-column' import { ResearchColumn } from "./deck-column";
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() })) const feed = vi.hoisted(() => ({
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({ use: vi.fn<
...(await importOriginal<typeof import('../platforms/use-research-feed')>()), () => {
data: { pages: readonly { posts: readonly ResearchPost[]; nextCursor?: string }[] };
hasNextPage?: boolean;
fetchNextPage?: () => void;
}
>(),
next: vi.fn<() => void>(),
}));
vi.mock("../platforms/use-research-feed", async (importOriginal) => ({
...(await importOriginal<typeof import("../platforms/use-research-feed")>()),
useResearchFeed: feed.use, useResearchFeed: feed.use,
})) }));
vi.mock('./research-post-card', () => ({ vi.mock("./research-post-card", () => ({
ResearchPostCard: ({ ResearchPostCard: ({
post, post,
citationSnapshot, citationSnapshot,
}: { }: {
post: ResearchPost post: ResearchPost;
citationSnapshot?: boolean citationSnapshot?: boolean;
}) => ( }) => (
<article> <article>
{post.text} {post.text}
{citationSnapshot ? 'Post saved during research' : ''} {citationSnapshot ? "Post saved during research" : ""}
</article> </article>
), ),
})) }));
const citation: CitationTarget = { const citation: CitationTarget = {
deckId: 'deck', deckId: "deck",
request: 1, request: 1,
column: { column: {
id: 'column', id: "column",
title: 'Research', title: "Research",
connectionId: 'account', connectionId: "account",
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'WebMCP', query: "WebMCP",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
}, },
post: { post: {
key: 'twitter:123', key: "twitter:123",
platform: 'twitter', platform: "twitter",
nativeId: '123', nativeId: "123",
url: 'https://x.com/alice/status/123', url: "https://x.com/alice/status/123",
text: 'Snapshot', text: "Snapshot",
author: { name: 'Alice', handle: 'alice' }, author: { name: "Alice", handle: "alice" },
}, },
} };
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => { it("temporarily renders missing evidence without inserting it into feed pages or changing pagination", () => {
const page = Object.freeze({ const page = Object.freeze({
posts: Object.freeze([]), posts: Object.freeze([]),
nextCursor: 'original-cursor', nextCursor: "original-cursor",
}) });
feed.use.mockReturnValue({ feed.use.mockReturnValue({
data: { pages: [page] }, data: { pages: [page] },
hasNextPage: true, hasNextPage: true,
fetchNextPage: feed.next, fetchNextPage: feed.next,
}) });
render( render(
<ResearchColumn <ResearchColumn
column={citation.column} column={citation.column}
@@ -65,21 +74,19 @@ it('temporarily renders missing evidence without inserting it into feed pages or
registry={new Map()} registry={new Map()}
first first
last last
onEdit={vi.fn()} onEdit={vi.fn<() => void>()}
onMove={vi.fn()} onMove={vi.fn<() => void>()}
onRemove={vi.fn()} onRemove={vi.fn<() => void>()}
/>, />,
) );
expect(screen.getByRole('article')).toHaveTextContent( expect(screen.getByRole("article")).toHaveTextContent("SnapshotPost saved during research");
'SnapshotPost saved during research', expect(page.posts).toHaveLength(0);
) expect(page.nextCursor).toBe("original-cursor");
expect(page.posts).toHaveLength(0) fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(page.nextCursor).toBe('original-cursor') expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false });
fireEvent.click(screen.getByRole('button', { name: 'Load more' })) });
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => { it("keeps the live post instead of a duplicate snapshot with a Twitter URL alias", () => {
feed.use.mockReturnValue({ feed.use.mockReturnValue({
data: { data: {
pages: [ pages: [
@@ -87,14 +94,14 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
posts: [ posts: [
{ {
...citation.post, ...citation.post,
url: 'https://mobile.twitter.com/alice/status/123', url: "https://mobile.twitter.com/alice/status/123",
text: 'Live', text: "Live",
}, },
], ],
}, },
], ],
}, },
}) });
render( render(
<ResearchColumn <ResearchColumn
column={citation.column} column={citation.column}
@@ -102,14 +109,12 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
registry={new Map()} registry={new Map()}
first first
last last
onEdit={vi.fn()} onEdit={vi.fn<() => void>()}
onMove={vi.fn()} onMove={vi.fn<() => void>()}
onRemove={vi.fn()} onRemove={vi.fn<() => void>()}
/>, />,
) );
expect(screen.getAllByRole('article')).toHaveLength(1) expect(screen.getAllByRole("article")).toHaveLength(1);
expect(screen.getByRole('article')).toHaveTextContent('Live') expect(screen.getByRole("article")).toHaveTextContent("Live");
expect( expect(screen.queryByText(/Snapshot|Post saved during research/)).not.toBeInTheDocument();
screen.queryByText(/Snapshot|Post saved during research/), });
).not.toBeInTheDocument()
})
+37 -47
View File
@@ -1,25 +1,22 @@
import { Icon } from '#/components/icon' import { Icon } from "#/components/icon";
import { Badge } from '#/components/ui/badge' import { Badge } from "#/components/ui/badge";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu' } from "#/components/ui/dropdown-menu";
import { import { flattenResearchPages, useResearchFeed } from "#/features/platforms/use-research-feed";
flattenResearchPages, import { PostLoadError } from "#/features/posts/use-post-feed";
useResearchFeed, import { citationUrlKey } from "#/features/research/citation-url";
} from '#/features/platforms/use-research-feed' import type { CitationTarget } from "#/features/research/use-citation-navigation";
import { PostLoadError } from '#/features/posts/use-post-feed' import { PlatformIcon } from "../platforms/platform-icon";
import { citationUrlKey } from '#/features/research/citation-url' import { type ColumnRegistry, useRegisterColumn } from "./column-tools";
import type { CitationTarget } from '#/features/research/use-citation-navigation' import type { DeckColumn } from "./model";
import { PlatformIcon } from '../platforms/platform-icon' import { ResearchPostCard } from "./research-post-card";
import { type ColumnRegistry, useRegisterColumn } from './column-tools' import { describeSource } from "./source-description";
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
import { describeSource } from './source-description'
export function ResearchColumn({ export function ResearchColumn({
column, column,
@@ -32,30 +29,30 @@ export function ResearchColumn({
registry, registry,
citation, citation,
}: { }: {
column: DeckColumn column: DeckColumn;
connectionLabel?: string connectionLabel?: string;
onEdit: () => void onEdit: () => void;
onRemove: () => void onRemove: () => void;
onMove: (direction: -1 | 1) => void onMove: (direction: -1 | 1) => void;
first: boolean first: boolean;
last: boolean last: boolean;
registry: ColumnRegistry registry: ColumnRegistry;
citation?: CitationTarget citation?: CitationTarget;
}) { }) {
const query = useResearchFeed(column) const query = useResearchFeed(column);
useRegisterColumn(registry, column, query) useRegisterColumn(registry, column, query);
const posts = flattenResearchPages(query.data?.pages ?? []) const posts = flattenResearchPages(query.data?.pages ?? []);
const citedKey = citation ? citationUrlKey(citation.post.url) : null const citedKey = citation ? citationUrlKey(citation.post.url) : null;
const visiblePosts = const visiblePosts =
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey) citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
? [citation.post, ...posts] ? [citation.post, ...posts]
: posts : posts;
const error = const error =
query.error instanceof PostLoadError query.error instanceof PostLoadError
? query.error.detail ? query.error.detail
: { message: 'Unable to load posts.', retryable: true } : { message: "Unable to load posts.", retryable: true };
const headingId = `column-${column.id}` const headingId = `column-${column.id}`;
const sourceDescription = describeSource(column.source) const sourceDescription = describeSource(column.source);
return ( return (
<section className="deck-column" aria-labelledby={headingId}> <section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header"> <header className="deck-column-header">
@@ -114,10 +111,8 @@ export function ResearchColumn({
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="deck-column-label"> <div className="deck-column-label">
<span <span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`} {connectionLabel ?? "Connection not found"}
>
{connectionLabel ?? 'Connection not found'}
</span> </span>
<Badge variant="outline" className="deck-source-label"> <Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} /> <Icon name={column.source.kind} />
@@ -126,12 +121,12 @@ export function ResearchColumn({
</div> </div>
<p className="deck-query">{sourceDescription.query}</p> <p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status"> <p className="deck-column-status" role="status">
{query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`} {query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
</p> </p>
</header> </header>
<section <section
className="deck-column-results" className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
tabIndex={0} tabIndex={0}
aria-label={`Posts in ${column.title}`} aria-label={`Posts in ${column.title}`}
> >
@@ -140,9 +135,7 @@ export function ResearchColumn({
key={post.key} key={post.key}
post={post} post={post}
citationRequest={ citationRequest={
citation && citationUrlKey(post.url) === citedKey citation && citationUrlKey(post.url) === citedKey ? citation.request : undefined
? citation.request
: undefined
} }
citationSnapshot={citation?.post === post && !posts.includes(post)} citationSnapshot={citation?.post === post && !posts.includes(post)}
/> />
@@ -181,13 +174,10 @@ export function ResearchColumn({
</Button> </Button>
</div> </div>
) : null} ) : null}
{!query.isPending && {!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
<p className="deck-message">No more posts.</p> <p className="deck-message">No more posts.</p>
) : null} ) : null}
</section> </section>
</section> </section>
) );
} }
+99 -127
View File
@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useLocation } from '@tanstack/react-router' import { useLocation } from "@tanstack/react-router";
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from "@tanstack/react-start";
import { useRef, useState } from 'react' import { useState } from "react";
import { AppShell } from '#/components/app-shell' import { AppShell } from "#/components/app-shell";
import { Dialog } from '#/components/dialog' import { Dialog } from "#/components/dialog";
import { Icon } from '#/components/icon' import { Icon } from "#/components/icon";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -13,36 +13,34 @@ import {
EmptyHeader, EmptyHeader,
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '#/components/ui/empty' } from "#/components/ui/empty";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { loadConnections } from '#/features/connections/server-functions' import { loadConnections } from "#/features/connections/server-functions";
import { syncResearchDeck } from '#/features/research/research-deck-sync' import { syncResearchDeck } from "#/features/research/research-deck-sync";
import { ResearchPanel } from '#/features/research/research-panel' import { ResearchPanel } from "#/features/research/research-panel";
import { import {
sameCitationSource, sameCitationSource,
useCitationNavigation, useCitationNavigation,
} from '#/features/research/use-citation-navigation' } from "#/features/research/use-citation-navigation";
import { ColumnEditor } from './column-editor' import { ColumnEditor } from "./column-editor";
import { type ColumnRegistry, useColumnTools } from './column-tools' import { type ColumnRegistry, useColumnTools } from "./column-tools";
import { ResearchColumn } from './deck-column' import { ResearchColumn } from "./deck-column";
import { DeckSwitcher } from './deck-switcher' import { DeckSwitcher } from "./deck-switcher";
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model' import { type DeckColumn, MAX_COLUMNS, moveColumn } from "./model";
import { useDeck } from './use-deck' import { useDeck } from "./use-deck";
import { useDeckTools } from './webmcp-tools' import { useDeckTools } from "./webmcp-tools";
import './decks.css'
export function DeckPage() { export function DeckPage() {
const oauthResult = useLocation({ const oauthResult = useLocation({
select: (location) => select: (location) => new URLSearchParams(location.searchStr).get("mastodon"),
new URLSearchParams(location.searchStr).get('mastodon'), });
}) const [registry] = useState<ColumnRegistry>(() => new Map());
const registry = useRef<ColumnRegistry>(new Map()).current const [researchDeckBindings] = useState(() => new Map<string, string>());
const researchDeckBindings = useRef(new Map<string, string>()).current const [editing, setEditing] = useState<{ id: string } | "new" | null>(null);
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null) const [renaming, setRenaming] = useState(false);
const [renaming, setRenaming] = useState(false) const [deckDialog, setDeckDialog] = useState<"create" | "delete" | null>(null);
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null) const [newDeckTitle, setNewDeckTitle] = useState("");
const [newDeckTitle, setNewDeckTitle] = useState('') const switcherEditing = deckDialog !== null;
const switcherEditing = deckDialog !== null
const { const {
deck, deck,
workspace, workspace,
@@ -59,24 +57,24 @@ export function DeckPage() {
select, select,
remove, remove,
getWorkspace, getWorkspace,
} = useDeck(editing !== null || renaming || switcherEditing) } = useDeck(editing !== null || renaming || switcherEditing);
const citationNavigation = useCitationNavigation({ const citationNavigation = useCitationNavigation({
registry, registry,
getWorkspace, getWorkspace,
select, select,
createTemporary, createTemporary,
}) });
const fetchProfiles = useServerFn(loadConnections) const fetchProfiles = useServerFn(loadConnections);
const profiles = useQuery({ const profiles = useQuery({
queryKey: ['connections'], queryKey: ["connections"],
queryFn: () => fetchProfiles(), queryFn: () => fetchProfiles(),
retry: false, retry: false,
}) });
useColumnTools(registry, ready) useColumnTools(registry, ready);
const [removed, setRemoved] = useState<{ const [removed, setRemoved] = useState<{
column: DeckColumn column: DeckColumn;
index: number index: number;
}>() }>();
useDeckTools({ useDeckTools({
ready, ready,
@@ -90,28 +88,26 @@ export function DeckPage() {
persist, persist,
connections: profiles.data?.connections, connections: profiles.data?.connections,
onApplied: clearEditors, onApplied: clearEditors,
}) });
function clearEditors() { function clearEditors() {
setDeckDialog(null) setDeckDialog(null);
setNewDeckTitle('') setNewDeckTitle("");
setEditing(null) setEditing(null);
setRenaming(false) setRenaming(false);
setRemoved(undefined) setRemoved(undefined);
} }
async function saveColumn(column: DeckColumn) { async function saveColumn(column: DeckColumn) {
const saved = await save({ const saved = await save({
...deck, ...deck,
columns: columns:
editing === 'new' editing === "new"
? [...deck.columns, column] ? [...deck.columns, column]
: deck.columns.map((current) => : deck.columns.map((current) => (current.id === column.id ? column : current)),
current.id === column.id ? column : current, });
), if (saved) setEditing(null);
}) return saved;
if (saved) setEditing(null)
return saved
} }
const storageErrorNotice = storageError ? ( const storageErrorNotice = storageError ? (
@@ -122,14 +118,14 @@ export function DeckPage() {
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => {
clearEditors() clearEditors();
void refresh(true) void refresh(true);
}} }}
> >
Reload latest deck Reload latest deck
</Button> </Button>
</p> </p>
) : null ) : null;
return ( return (
<ResearchPanel <ResearchPanel
@@ -147,8 +143,8 @@ export function DeckPage() {
createTemporary, createTemporary,
save, save,
select, select,
}) });
if (activate) clearEditors() if (activate) clearEditors();
}} }}
> >
{(renderResearchPanel) => ( {(renderResearchPanel) => (
@@ -167,22 +163,22 @@ export function DeckPage() {
newTitle={newDeckTitle} newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle} onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => { onDialogChange={(dialog) => {
setDeckDialog(dialog) setDeckDialog(dialog);
if (dialog !== 'create') setNewDeckTitle('') if (dialog !== "create") setNewDeckTitle("");
}} }}
onSelect={(id) => { onSelect={(id) => {
select(id) select(id);
clearEditors() clearEditors();
}} }}
onCreate={async (title) => { onCreate={async (title) => {
const saved = await create(title) const saved = await create(title);
if (saved) clearEditors() if (saved) clearEditors();
return saved return saved;
}} }}
onDelete={async () => { onDelete={async () => {
const saved = await remove(deck.id) const saved = await remove(deck.id);
if (saved) clearEditors() if (saved) clearEditors();
return saved return saved;
}} }}
/> />
<Button <Button
@@ -190,18 +186,16 @@ export function DeckPage() {
aria-label="Add column" aria-label="Add column"
title="Add column" title="Add column"
type="button" type="button"
disabled={ disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
!ready || saving || deck.columns.length >= MAX_COLUMNS onClick={() => setEditing("new")}
}
onClick={() => setEditing('new')}
> >
<Icon name="plus" /> <Icon name="plus" />
<span>Add column</span> <span>Add column</span>
</Button> </Button>
<p className="sidebar-note"> <p className="sidebar-note">
{deck.persisted {deck.persisted
? 'Saved to server · Shared across devices' ? "Saved to server · Shared across devices"
: 'Temporary view · This tab only'} : "Temporary view · This tab only"}
</p> </p>
</> </>
} }
@@ -220,8 +214,8 @@ export function DeckPage() {
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => {
createTemporary(`${deck.title} copy`, deck.columns) createTemporary(`${deck.title} copy`, deck.columns);
clearEditors() clearEditors();
}} }}
> >
Create temporary copy Create temporary copy
@@ -238,7 +232,6 @@ export function DeckPage() {
)} )}
<Button <Button
variant="outline" variant="outline"
className="icon-button"
aria-label="Rename" aria-label="Rename"
title="Rename" title="Rename"
type="button" type="button"
@@ -275,22 +268,17 @@ export function DeckPage() {
{profiles.isError ? ( {profiles.isError ? (
<p role="alert" className="deck-error"> <p role="alert" className="deck-error">
Unable to load connection profiles. Unable to load connection profiles.
<Button <Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
Retry Retry
</Button> </Button>
</p> </p>
) : null} ) : null}
{oauthResult === 'failed' ? ( {oauthResult === "failed" ? (
<p className="deck-error" role="alert"> <p className="deck-error" role="alert">
Could not complete Mastodon authorization. Try again from Could not complete Mastodon authorization. Try again from Connected accounts.
Connected accounts.
</p> </p>
) : null} ) : null}
{oauthResult === 'connected' ? ( {oauthResult === "connected" ? (
<p className="deck-save-status" role="status"> <p className="deck-save-status" role="status">
Mastodon account connected. You can now use it in a column. Mastodon account connected. You can now use it in a column.
</p> </p>
@@ -300,23 +288,18 @@ export function DeckPage() {
{profiles.data.relayError} {profiles.data.relayError}
</p> </p>
) : null} ) : null}
{profiles.isPending ? ( {profiles.isPending ? <p role="status">Loading connection profiles…</p> : null}
<p role="status">Loading connection profiles…</p> {!renaming && editing === null && !switcherEditing ? storageErrorNotice : null}
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{renaming ? ( {renaming ? (
<Dialog title="Rename deck" onClose={() => setRenaming(false)}> <Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form <form
className="deck-rename" className="deck-rename"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault();
const title = String( const rawTitle = new FormData(event.currentTarget).get("title");
new FormData(event.currentTarget).get('title') ?? '', const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
).trim() if (!title) return;
if (!title) return if (await save({ ...deck, title })) setRenaming(false);
if (await save({ ...deck, title })) setRenaming(false)
}} }}
> >
<label htmlFor="deck-title"> <label htmlFor="deck-title">
@@ -338,14 +321,14 @@ export function DeckPage() {
) : null} ) : null}
{editing !== null ? ( {editing !== null ? (
<Dialog <Dialog
title={editing === 'new' ? 'Add column' : 'Edit column'} title={editing === "new" ? "Add column" : "Edit column"}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
> >
<ColumnEditor <ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`} key={`${deck.id}-${editing === "new" ? "new" : editing.id}`}
connections={profiles.data?.connections ?? []} connections={profiles.data?.connections ?? []}
column={ column={
editing === 'new' editing === "new"
? undefined ? undefined
: deck.columns.find((column) => column.id === editing.id) : deck.columns.find((column) => column.id === editing.id)
} }
@@ -361,13 +344,11 @@ export function DeckPage() {
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
disabled={ disabled={deck.columns.length >= MAX_COLUMNS || editing === "new"}
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
onClick={async () => { onClick={async () => {
const columns = [...deck.columns] const columns = [...deck.columns];
columns.splice(removed.index, 0, removed.column) columns.splice(removed.index, 0, removed.column);
if (await save({ ...deck, columns })) setRemoved(undefined) if (await save({ ...deck, columns })) setRemoved(undefined);
}} }}
> >
Undo Undo
@@ -384,13 +365,12 @@ export function DeckPage() {
<h2>Start a deck with a question</h2> <h2>Start a deck with a question</h2>
</EmptyTitle> </EmptyTitle>
<EmptyDescription> <EmptyDescription>
Tell the assistant what you want to research to create Tell the assistant what you want to research to create relevant columns. You can
relevant columns. You can also add search, user posts, and also add search, user posts, and list columns yourself.
list columns yourself.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>
<Button type="button" onClick={() => setEditing('new')}> <Button type="button" onClick={() => setEditing("new")}>
Add column manually Add column manually
</Button> </Button>
</EmptyContent> </EmptyContent>
@@ -400,7 +380,7 @@ export function DeckPage() {
<section <section
className="deck-board" className="deck-board"
aria-label="Research columns" aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
tabIndex={0} tabIndex={0}
> >
{deck.columns.map((column, index) => ( {deck.columns.map((column, index) => (
@@ -423,22 +403,14 @@ export function DeckPage() {
first={index === 0} first={index === 0}
last={index === deck.columns.length - 1} last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })} onEdit={() => setEditing({ id: column.id })}
onMove={(direction) => onMove={(direction) => save(moveColumn(deck, column.id, direction))}
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => { onRemove={async () => {
const saved = await save({ const saved = await save({
...deck, ...deck,
columns: deck.columns.filter( columns: deck.columns.filter((current) => current.id !== column.id),
(current) => current.id !== column.id, });
), if (saved) setRemoved({ column, index });
}) if (typeof editing === "object" && editing?.id === column.id) setEditing(null);
if (saved) setRemoved({ column, index })
if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null)
}} }}
/> />
))} ))}
@@ -447,5 +419,5 @@ export function DeckPage() {
</AppShell> </AppShell>
)} )}
</ResearchPanel> </ResearchPanel>
) );
} }
+34 -44
View File
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import { Dialog } from '#/components/dialog' import { Dialog } from "#/components/dialog";
import { Icon } from '#/components/icon' import { Icon } from "#/components/icon";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select' import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { DeckView } from './model' import type { DeckView } from "./model";
export function DeckSwitcher({ export function DeckSwitcher({
decks, decks,
@@ -20,17 +20,17 @@ export function DeckSwitcher({
onNewTitleChange, onNewTitleChange,
errorNotice, errorNotice,
}: { }: {
decks: DeckView[] decks: DeckView[];
activeId: string activeId: string;
ready: boolean ready: boolean;
onSelect: (id: string) => void onSelect: (id: string) => void;
onCreate: (title: string) => Promise<boolean> onCreate: (title: string) => Promise<boolean>;
onDelete: () => Promise<boolean> onDelete: () => Promise<boolean>;
dialog: 'create' | 'delete' | null dialog: "create" | "delete" | null;
onDialogChange: (dialog: 'create' | 'delete' | null) => void onDialogChange: (dialog: "create" | "delete" | null) => void;
newTitle: string newTitle: string;
onNewTitleChange: (title: string) => void onNewTitleChange: (title: string) => void;
errorNotice?: ReactNode errorNotice?: ReactNode;
}) { }) {
return ( return (
<div className="deck-workspaces"> <div className="deck-workspaces">
@@ -42,26 +42,25 @@ export function DeckSwitcher({
value={activeId} value={activeId}
disabled={!ready} disabled={!ready}
onChange={(event) => { onChange={(event) => {
onSelect(event.target.value) onSelect(event.target.value);
onDialogChange(null) onDialogChange(null);
}} }}
> >
{decks.map((deck) => ( {decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}> <NativeSelectOption key={deck.id} value={deck.id}>
{deck.title} {deck.title}
{deck.persisted ? '' : ' (temporary)'} {deck.persisted ? "" : " (temporary)"}
</NativeSelectOption> </NativeSelectOption>
))} ))}
</NativeSelect> </NativeSelect>
<Button <Button
variant="outline" variant="outline"
className="workspace-create"
aria-label="Create deck" aria-label="Create deck"
title="Create deck" title="Create deck"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
onDialogChange('create') onDialogChange("create");
}} }}
> >
<Icon name="plus" /> <Icon name="plus" />
@@ -69,28 +68,27 @@ export function DeckSwitcher({
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="workspace-delete"
aria-label="Delete deck" aria-label="Delete deck"
title="Delete deck" title="Delete deck"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
onDialogChange('delete') onDialogChange("delete");
}} }}
> >
<Icon name="trash" /> <Icon name="trash" />
<span>Delete deck</span> <span>Delete deck</span>
</Button> </Button>
</div> </div>
{dialog === 'create' ? ( {dialog === "create" ? (
<Dialog title="Create deck" onClose={() => onDialogChange(null)}> <Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form <form
className="deck-rename" className="deck-rename"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault();
const title = newTitle.trim() const title = newTitle.trim();
if (!title) return if (!title) return;
if (await onCreate(title)) onDialogChange(null) if (await onCreate(title)) onDialogChange(null);
}} }}
> >
<Label htmlFor="new-deck-title"> <Label htmlFor="new-deck-title">
@@ -105,18 +103,14 @@ export function DeckSwitcher({
/> />
</Label> </Label>
<Button type="submit">Create</Button> <Button type="submit">Create</Button>
<Button <Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
Cancel Cancel
</Button> </Button>
</form> </form>
{errorNotice} {errorNotice}
</Dialog> </Dialog>
) : null} ) : null}
{dialog === 'delete' ? ( {dialog === "delete" ? (
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}> <Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo"> <div className="deck-undo">
<p>Delete this deck and its column settings.</p> <p>Delete this deck and its column settings.</p>
@@ -124,16 +118,12 @@ export function DeckSwitcher({
variant="destructive" variant="destructive"
type="button" type="button"
onClick={async () => { onClick={async () => {
if (await onDelete()) onDialogChange(null) if (await onDelete()) onDialogChange(null);
}} }}
> >
Delete Delete
</Button> </Button>
<Button <Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -141,5 +131,5 @@ export function DeckSwitcher({
</Dialog> </Dialog>
) : null} ) : null}
</div> </div>
) );
} }
+28 -34
View File
@@ -1,49 +1,43 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from "node:crypto";
import { and, eq } from 'drizzle-orm' import { and, eq } from "drizzle-orm";
import { getDatabase } from '../storage/database.server' import { getDatabase } from "../storage/database.server";
import { connections, legacyImports } from '../storage/schema' import { connections, legacyImports } from "../storage/schema";
import { legacyWorkspaceSchema } from './legacy-import' import { legacyWorkspaceSchema } from "./legacy-import";
import { import { createDeck, DeckPersistenceError, listDecks } from "./repository.server";
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
export function importLegacyWorkspace( export function importLegacyWorkspace(
input: unknown, input: unknown,
database = getDatabase(), database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin, relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? "").origin,
) { ) {
const legacy = legacyWorkspaceSchema.parse(input) const legacy = legacyWorkspaceSchema.parse(input);
const payloadHash = createHash('sha256') const payloadHash = createHash("sha256").update(JSON.stringify(legacy.decks)).digest("hex");
.update(JSON.stringify(legacy.decks))
.digest('hex')
return database.transaction( return database.transaction(
() => { () => {
const marker = database const marker = database
.select() .select()
.from(legacyImports) .from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2')) .where(eq(legacyImports.id, "local-storage-v2"))
.get() .get();
if (marker) { if (marker) {
if (marker.payloadHash !== payloadHash) if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError( throw new DeckPersistenceError(
'conflict', "conflict",
'Legacy decks have already been imported and cannot be overwritten.', "Legacy decks have already been imported and cannot be overwritten.",
) );
return listDecks(database) return listDecks(database);
} }
const available = database const available = database
.select() .select()
.from(connections) .from(connections)
.where( .where(
and( and(
eq(connections.platform, 'twitter'), eq(connections.platform, "twitter"),
eq(connections.origin, relayOrigin), eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'), eq(connections.status, "connected"),
), ),
) )
.all() .all();
const imported = legacy.decks.map((deck) => const imported = legacy.decks.map((deck) =>
createDeck( createDeck(
{ {
@@ -52,29 +46,29 @@ export function importLegacyWorkspace(
columns: deck.columns.map(({ profileName, ...column }) => { columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find( const connection = available.find(
(connection) => connection.relayProfile === profileName, (connection) => connection.relayProfile === profileName,
) );
if (!connection) if (!connection)
throw new DeckPersistenceError( throw new DeckPersistenceError(
'connection-unavailable', "connection-unavailable",
`No connection matches the legacy profile “${profileName}”. Refresh your connections.`, `No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
) );
return { ...column, connectionId: connection.id } return { ...column, connectionId: connection.id };
}), }),
}, },
database, database,
), ),
) );
database database
.insert(legacyImports) .insert(legacyImports)
.values({ .values({
id: 'local-storage-v2', id: "local-storage-v2",
payloadHash, payloadHash,
deckIds: imported.map((deck) => deck.id), deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run() .run();
return listDecks(database) return listDecks(database);
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
+55 -65
View File
@@ -1,98 +1,88 @@
// @vitest-environment node // @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest' import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from '../storage/database.server' import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connections, legacyImports } from '../storage/schema' import { connections, legacyImports } from "../storage/schema";
import { importLegacyWorkspace } from './import.server' import { importLegacyWorkspace } from "./import.server";
import { createDeck, listDecks } from './repository.server' import { createDeck, listDecks } from "./repository.server";
let database: AppDatabase let database: AppDatabase;
const legacy = { const legacy = {
version: 2, version: 2,
activeDeckId: 'same-id', activeDeckId: "same-id",
decks: [ decks: [
{ {
id: 'same-id', id: "same-id",
title: 'Legacy', title: "Legacy",
columns: [ columns: [
{ {
id: 'a', id: "a",
title: 'Account', title: "Account",
profileName: 'old-profile', profileName: "old-profile",
source: { platform: 'twitter', kind: 'user', target: 'alice' }, source: { platform: "twitter", kind: "user", target: "alice" },
}, },
], ],
}, },
], ],
} };
beforeEach(() => { beforeEach(() => {
database = openDatabase(':memory:') database = openDatabase(":memory:");
database database
.insert(connections) .insert(connections)
.values({ .values({
id: 'new-connection-id', id: "new-connection-id",
platform: 'twitter', platform: "twitter",
origin: 'https://relay.invalid', origin: "https://relay.invalid",
relayProfile: 'old-profile', relayProfile: "old-profile",
displayName: 'Old account', displayName: "Old account",
status: 'connected', status: "connected",
createdAt: 1, createdAt: 1,
updatedAt: 1, updatedAt: 1,
}) })
.run() .run();
}) });
afterEach(() => database.$client.close()) afterEach(() => database.$client.close());
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => { it("imports legacy profiles as connection bindings without overwriting existing same-ID decks", () => {
const existing = createDeck( const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] }, { id: "same-id", title: "Existing server deck", columns: [] },
database, database,
) );
const result = importLegacyWorkspace( const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
legacy, expect(result).toHaveLength(2);
database, expect(result.find((deck) => deck.id === existing.id)).toEqual(existing);
'https://relay.invalid', const imported = result.find((deck) => deck.title === "Legacy");
) expect(imported?.id).not.toBe("same-id");
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
expect(imported?.columns[0]).toEqual({ expect(imported?.columns[0]).toEqual({
id: 'a', id: "a",
title: 'Account', title: "Account",
connectionId: 'new-connection-id', connectionId: "new-connection-id",
source: { platform: 'twitter', kind: 'user', target: 'alice' }, source: { platform: "twitter", kind: "user", target: "alice" },
}) });
expect( expect(importLegacyWorkspace(legacy, database, "https://relay.invalid")).toEqual(result);
importLegacyWorkspace(legacy, database, 'https://relay.invalid'), });
).toEqual(result) it("blocks a different browser snapshot after the one-time import", () => {
}) const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(() => expect(() =>
importLegacyWorkspace( importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] }, { ...legacy, decks: [{ ...legacy.decks[0], title: "Different" }] },
database, database,
'https://relay.invalid', "https://relay.invalid",
), ),
).toThrow('Legacy decks have already been imported') ).toThrow("Legacy decks have already been imported");
expect(listDecks(database)).toEqual(result) expect(listDecks(database)).toEqual(result);
}) });
it('rolls back all imported decks and marker when any profile is unavailable', () => { it("rolls back all imported decks and marker when any profile is unavailable", () => {
const second = { const second = {
...legacy.decks[0], ...legacy.decks[0],
id: 'second', id: "second",
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }], columns: [{ ...legacy.decks[0]?.columns[0], profileName: "missing" }],
} };
expect(() => expect(() =>
importLegacyWorkspace( importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] }, { ...legacy, decks: [...legacy.decks, second] },
database, database,
'https://relay.invalid', "https://relay.invalid",
), ),
).toThrow('No connection matches') ).toThrow("No connection matches");
expect(listDecks(database)).toEqual([]) expect(listDecks(database)).toEqual([]);
expect(database.select().from(legacyImports).all()).toEqual([]) expect(database.select().from(legacyImports).all()).toEqual([]);
}) });
+10 -13
View File
@@ -1,10 +1,10 @@
import { z } from 'zod' import { z } from "zod";
import { columnSchema, deckSchema, MAX_COLUMNS } from './model' import { columnSchema, deckSchema, MAX_COLUMNS } from "./model";
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck' export const LEGACY_STORAGE_KEY = "twitter-lite-research-deck";
const legacyColumn = columnSchema const legacyColumn = columnSchema
.omit({ connectionId: true }) .omit({ connectionId: true })
.extend({ profileName: z.string().min(1) }) .extend({ profileName: z.string().min(1) });
const legacyDeck = z const legacyDeck = z
.object({ .object({
id: deckSchema.shape.id, id: deckSchema.shape.id,
@@ -13,11 +13,9 @@ const legacyDeck = z
}) })
.strict() .strict()
.refine( .refine(
(deck) => (deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
new Set(deck.columns.map((column) => column.id)).size === "Column IDs must be unique.",
deck.columns.length, );
'Column IDs must be unique.',
)
export const legacyWorkspaceSchema = z export const legacyWorkspaceSchema = z
.object({ .object({
version: z.literal(2), version: z.literal(2),
@@ -26,7 +24,6 @@ export const legacyWorkspaceSchema = z
}) })
.strict() .strict()
.refine( .refine(
(value) => (value) => new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length, "Deck IDs must be unique.",
'Deck IDs must be unique.', );
)
+62 -95
View File
@@ -1,30 +1,28 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from "@tanstack/react-start";
import { type ReactElement, useId } from 'react' import { type ReactElement, useId } from "react";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select' import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { loadMastodonLists } from '../platforms/mastodon-server-functions' import { loadMastodonLists } from "../platforms/mastodon-server-functions";
import type { MastodonSource } from '../platforms/mastodon-source' import type { MastodonSource } from "../platforms/mastodon-source";
type EditorProps<T extends MastodonSource = MastodonSource> = { type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T source: T;
onChange: (source: MastodonSource) => void onChange: (source: MastodonSource) => void;
} };
export function defaultMastodonSource( export function defaultMastodonSource(kind: MastodonSource["kind"] = "search"): MastodonSource {
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
switch (kind) { switch (kind) {
case 'search': case "search":
return { platform: 'mastodon', kind, query: '' } return { platform: "mastodon", kind, query: "" };
case 'user': case "user":
return { platform: 'mastodon', kind, target: '' } return { platform: "mastodon", kind, target: "" };
case 'list': case "list":
return { platform: 'mastodon', kind, target: '' } return { platform: "mastodon", kind, target: "" };
case 'hashtag': case "hashtag":
return { platform: 'mastodon', kind, target: '' } return { platform: "mastodon", kind, target: "" };
} }
} }
@@ -33,7 +31,7 @@ export function MastodonSourceEditor({
connectionId, connectionId,
onChange, onChange,
}: EditorProps & { connectionId: string }) { }: EditorProps & { connectionId: string }) {
const id = useId() const id = useId();
return ( return (
<> <>
<Label htmlFor={id}> <Label htmlFor={id}>
@@ -43,11 +41,7 @@ export function MastodonSourceEditor({
id={id} id={id}
value={source.kind} value={source.kind}
onChange={(event) => onChange={(event) =>
onChange( onChange(defaultMastodonSource(event.target.value as MastodonSource["kind"]))
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
} }
> >
<NativeSelectOption value="search">Search</NativeSelectOption> <NativeSelectOption value="search">Search</NativeSelectOption>
@@ -56,13 +50,9 @@ export function MastodonSourceEditor({
<NativeSelectOption value="hashtag">Hashtag</NativeSelectOption> <NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect> </NativeSelect>
</Label> </Label>
<MastodonSourceFields <MastodonSourceFields source={source} connectionId={connectionId} onChange={onChange} />
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</> </>
) );
} }
function MastodonSourceFields({ function MastodonSourceFields({
@@ -71,28 +61,22 @@ function MastodonSourceFields({
onChange, onChange,
}: EditorProps & { connectionId: string }): ReactElement { }: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) { switch (source.kind) {
case 'search': case "search":
return <MastodonSearchEditor source={source} onChange={onChange} /> return <MastodonSearchEditor source={source} onChange={onChange} />;
case 'user': case "user":
return <MastodonUserEditor source={source} onChange={onChange} /> return <MastodonUserEditor source={source} onChange={onChange} />;
case 'list': case "list":
return ( return <MastodonListEditor source={source} connectionId={connectionId} onChange={onChange} />;
<MastodonListEditor case "hashtag":
source={source} return <MastodonHashtagEditor source={source} onChange={onChange} />;
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
} }
} }
function MastodonSearchEditor({ function MastodonSearchEditor({
source, source,
onChange, onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) { }: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
const id = useId() const id = useId();
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
@@ -100,27 +84,25 @@ function MastodonSearchEditor({
<Input <Input
id={id} id={id}
value={source.query} value={source.query}
onChange={(event) => onChange={(event) => onChange({ ...source, query: event.target.value })}
onChange({ ...source, query: event.target.value })
}
maxLength={512} maxLength={512}
placeholder="e.g. WebMCP" placeholder="e.g. WebMCP"
required required
/> />
</Label> </Label>
<p className="deck-help"> <p className="deck-help">
Full-text search depends on server settings. Zero results do not Full-text search depends on server settings. Zero results do not necessarily mean search is
necessarily mean search is supported. supported.
</p> </p>
</div> </div>
) );
} }
function MastodonUserEditor({ function MastodonUserEditor({
source, source,
onChange, onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) { }: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
const id = useId() const id = useId();
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
@@ -128,23 +110,21 @@ function MastodonUserEditor({
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) => onChange({ ...source, target: event.target.value })}
onChange({ ...source, target: event.target.value })
}
placeholder="user@server or account ID" placeholder="user@server or account ID"
maxLength={256} maxLength={256}
required required
/> />
</Label> </Label>
</div> </div>
) );
} }
function MastodonHashtagEditor({ function MastodonHashtagEditor({
source, source,
onChange, onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) { }: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
const id = useId() const id = useId();
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
@@ -152,26 +132,24 @@ function MastodonHashtagEditor({
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) => onChange({ ...source, target: event.target.value })}
onChange({ ...source, target: event.target.value })
}
placeholder="Tag without #" placeholder="Tag without #"
maxLength={128} maxLength={128}
required required
/> />
</Label> </Label>
</div> </div>
) );
} }
function MastodonListEditor({ function MastodonListEditor({
source, source,
connectionId, connectionId,
onChange, onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & { }: EditorProps<Extract<MastodonSource, { kind: "list" }>> & {
connectionId: string connectionId: string;
}) { }) {
const id = useId() const id = useId();
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
@@ -179,9 +157,7 @@ function MastodonListEditor({
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) => onChange({ ...source, target: event.target.value })}
onChange({ ...source, target: event.target.value })
}
placeholder="List ID for this account" placeholder="List ID for this account"
maxLength={32} maxLength={32}
required required
@@ -194,52 +170,43 @@ function MastodonListEditor({
/> />
)} )}
</div> </div>
) );
} }
function MastodonListChoices({ function MastodonListChoices({
connectionId, connectionId,
onSelect, onSelect,
}: { }: {
connectionId: string connectionId: string;
onSelect: (target: string) => void onSelect: (target: string) => void;
}) { }) {
const fetchLists = useServerFn(loadMastodonLists) const fetchLists = useServerFn(loadMastodonLists);
const lists = useQuery({ const lists = useQuery({
queryKey: ['mastodon-lists', connectionId], queryKey: ["mastodon-lists", connectionId],
queryFn: () => fetchLists({ data: { connectionId } }), queryFn: () => fetchLists({ data: { connectionId } }),
retry: false, retry: false,
}) });
if (lists.isPending) return <p role="status">Loading lists…</p> if (lists.isPending) return <p role="status">Loading lists…</p>;
if (lists.isError) if (lists.isError)
return ( return (
<p role="alert"> <p role="alert">
Unable to load lists. Unable to load lists.
<Button <Button variant="outline" type="button" onClick={() => void lists.refetch()}>
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
Retry Retry
</Button> </Button>
</p> </p>
) );
return ( return (
<section className="deck-list-choices" aria-label="Lists for this account"> <section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? ( {lists.data.lists.length === 0 ? (
<p>This account has no lists.</p> <p>This account has no lists.</p>
) : ( ) : (
lists.data.lists.map((list) => ( lists.data.lists.map((list) => (
<Button <Button variant="outline" type="button" key={list.id} onClick={() => onSelect(list.id)}>
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
{list.name} {list.name}
</Button> </Button>
)) ))
)} )}
</section> </section>
) );
} }
+44 -47
View File
@@ -1,39 +1,39 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from "vitest";
import { type Deck, deckSchema, moveColumn } from './model' import { type Deck, deckSchema, moveColumn } from "./model";
const deck: Deck = { const deck: Deck = {
id: 'research', id: "research",
title: '調査', title: "調査",
columns: ['a', 'b'].map((id) => ({ columns: ["a", "b"].map((id) => ({
id, id,
title: id, title: id,
connectionId: 'main', connectionId: "main",
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'AI lang:ja', query: "AI lang:ja",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
})), })),
} };
describe('deck definitions', () => { describe("deck definitions", () => {
it('preserves native search and the bound relay profile when serialized', () => { it("preserves native search and the bound relay profile when serialized", () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck) expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck);
}) });
it('normalizes user and list targets and rejects invalid targets', () => { it("normalizes user and list targets and rejects invalid targets", () => {
const column = deck.columns[0] const column = deck.columns[0];
expect( expect(
deckSchema.parse({ deckSchema.parse({
...deck, ...deck,
columns: [ columns: [
{ {
...column, ...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' }, source: { platform: "twitter", kind: "user", target: "@alice" },
}, },
], ],
}).columns[0]?.source, }).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' }) ).toEqual({ platform: "twitter", kind: "user", target: "alice" });
expect( expect(
deckSchema.parse({ deckSchema.parse({
...deck, ...deck,
@@ -41,41 +41,41 @@ describe('deck definitions', () => {
{ {
...column, ...column,
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'list', kind: "list",
target: 'https://x.com/i/lists/123', target: "https://x.com/i/lists/123",
}, },
}, },
], ],
}).columns[0]?.source, }).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' }) ).toEqual({ platform: "twitter", kind: "list", target: "123" });
expect( expect(
deckSchema.safeParse({ deckSchema.safeParse({
...deck, ...deck,
columns: [ columns: [
{ {
...column, ...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' }, source: { platform: "twitter", kind: "list", target: "invalid" },
}, },
], ],
}).success, }).success,
).toBe(false) ).toBe(false);
}) });
it('rejects duplicate identities and missing profiles', () => { it("rejects duplicate identities and missing profiles", () => {
expect( expect(
deckSchema.safeParse({ deckSchema.safeParse({
...deck, ...deck,
columns: [deck.columns[0], deck.columns[0]], columns: [deck.columns[0], deck.columns[0]],
}).success, }).success,
).toBe(false) ).toBe(false);
expect( expect(
deckSchema.safeParse({ deckSchema.safeParse({
...deck, ...deck,
columns: [{ ...deck.columns[0], connectionId: '' }], columns: [{ ...deck.columns[0], connectionId: "" }],
}).success, }).success,
).toBe(false) ).toBe(false);
}) });
it('rejects queries exceeding the upstream limit after adding follows', () => { it("rejects queries exceeding the upstream limit after adding follows", () => {
expect( expect(
deckSchema.safeParse({ deckSchema.safeParse({
...deck, ...deck,
@@ -83,23 +83,20 @@ describe('deck definitions', () => {
{ {
...deck.columns[0], ...deck.columns[0],
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'a'.repeat(512), query: "a".repeat(512),
product: 'Latest', product: "Latest",
following: true, following: true,
}, },
}, },
], ],
}).success, }).success,
).toBe(false) ).toBe(false);
}) });
it('moves a column without changing its conditions or the previous deck', () => { it("moves a column without changing its conditions or the previous deck", () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([ expect(moveColumn(deck, "b", -1).columns).toEqual([deck.columns[1], deck.columns[0]]);
deck.columns[1], expect(deck.columns.map((column) => column.id)).toEqual(["a", "b"]);
deck.columns[0], expect(moveColumn(deck, "a", -1)).toBe(deck);
]) });
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b']) });
expect(moveColumn(deck, 'a', -1)).toBe(deck)
})
})
+45 -52
View File
@@ -1,33 +1,32 @@
import { z } from 'zod' import { z } from "zod";
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source' import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import { import {
buildSearchQuery, buildSearchQuery,
normalizeListTarget, normalizeListTarget,
normalizeUserTarget, normalizeUserTarget,
} from '#/features/posts/inputs' } from "#/features/posts/inputs";
export const MAX_COLUMNS = 6 export const MAX_COLUMNS = 6;
const searchSourceSchema = z const searchSourceSchema = z
.object({ .object({
platform: z.literal('twitter'), platform: z.literal("twitter"),
kind: z.literal('search'), kind: z.literal("search"),
query: z.string().trim().min(1).max(512), query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']), product: z.enum(["Latest", "Top"]),
following: z.boolean(), following: z.boolean(),
}) })
.strict() .strict()
.superRefine((source, ctx) => { .superRefine((source, ctx) => {
try { try {
buildSearchQuery(source.query, source.following) buildSearchQuery(source.query, source.following);
} catch (error) { } catch (error) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: "custom",
message: message: error instanceof Error ? error.message : "Check the search query.",
error instanceof Error ? error.message : 'Check the search query.', });
})
} }
}) });
function targetSchema(normalize: (target: string) => string) { function targetSchema(normalize: (target: string) => string) {
return z return z
@@ -37,31 +36,31 @@ function targetSchema(normalize: (target: string) => string) {
.max(256) .max(256)
.transform((target, ctx) => { .transform((target, ctx) => {
try { try {
return normalize(target) return normalize(target);
} catch (error) { } catch (error) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: "custom",
message: error instanceof Error ? error.message : 'Check the target.', message: error instanceof Error ? error.message : "Check the target.",
}) });
return z.NEVER return z.NEVER;
} }
}) });
} }
const userSourceSchema = z const userSourceSchema = z
.object({ .object({
platform: z.literal('twitter'), platform: z.literal("twitter"),
kind: z.literal('user'), kind: z.literal("user"),
target: targetSchema(normalizeUserTarget), target: targetSchema(normalizeUserTarget),
}) })
.strict() .strict();
const listSourceSchema = z const listSourceSchema = z
.object({ .object({
platform: z.literal('twitter'), platform: z.literal("twitter"),
kind: z.literal('list'), kind: z.literal("list"),
target: targetSchema(normalizeListTarget), target: targetSchema(normalizeListTarget),
}) })
.strict() .strict();
export const columnSchema = z export const columnSchema = z
.object({ .object({
@@ -69,15 +68,11 @@ export const columnSchema = z
title: z.string().trim().min(1).max(80), title: z.string().trim().min(1).max(80),
connectionId: z.string().min(1), connectionId: z.string().min(1),
source: z.union([ source: z.union([
z.discriminatedUnion('kind', [ z.discriminatedUnion("kind", [searchSourceSchema, userSourceSchema, listSourceSchema]),
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
mastodonSourceSchema, mastodonSourceSchema,
]), ]),
}) })
.strict() .strict();
export const deckSchema = z export const deckSchema = z
.object({ .object({
@@ -87,35 +82,33 @@ export const deckSchema = z
}) })
.strict() .strict()
.refine( .refine(
(deck) => (deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
new Set(deck.columns.map((column) => column.id)).size === "Column IDs must be unique.",
deck.columns.length, );
'Column IDs must be unique.',
)
export type Deck = z.infer<typeof deckSchema> export type Deck = z.infer<typeof deckSchema>;
export type DeckColumn = z.infer<typeof columnSchema> export type DeckColumn = z.infer<typeof columnSchema>;
export type DeckView = Deck & { persisted: boolean; revision?: number } export type DeckView = Deck & { persisted: boolean; revision?: number };
export type Workspace = { activeDeckId: string; decks: DeckView[] } export type Workspace = { activeDeckId: string; decks: DeckView[] };
export const EMPTY_DECK: Deck = { export const EMPTY_DECK: Deck = {
id: 'initial', id: "initial",
title: 'New research', title: "New research",
columns: [], columns: [],
} };
export const EMPTY_WORKSPACE: Workspace = { export const EMPTY_WORKSPACE: Workspace = {
activeDeckId: EMPTY_DECK.id, activeDeckId: EMPTY_DECK.id,
decks: [{ ...EMPTY_DECK, persisted: false }], decks: [{ ...EMPTY_DECK, persisted: false }],
} };
export function newId() { export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-') return crypto.getRandomValues(new Uint32Array(4)).join("-");
} }
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck { export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id) const index = deck.columns.findIndex((column) => column.id === id);
const target = index + direction const target = index + direction;
if (index < 0 || target < 0 || target >= deck.columns.length) return deck if (index < 0 || target < 0 || target >= deck.columns.length) return deck;
const columns = [...deck.columns] const columns = [...deck.columns];
const [column] = columns.splice(index, 1) const [column] = columns.splice(index, 1);
if (column) columns.splice(target, 0, column) if (column) columns.splice(target, 0, column);
return { ...deck, columns } return { ...deck, columns };
} }
+17 -17
View File
@@ -1,36 +1,36 @@
import { z } from 'zod' import { z } from "zod";
import { deckSchema } from './model' import { deckSchema } from "./model";
export const savedDeckDefinitionSchema = deckSchema export const savedDeckDefinitionSchema = deckSchema;
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({ export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(), revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(), createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(), updatedAt: z.number().int().nonnegative(),
}) });
export const replaceDeckInputSchema = z export const replaceDeckInputSchema = z
.object({ .object({
deck: savedDeckDefinitionSchema, deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(), expectedRevision: z.number().int().positive(),
}) })
.strict() .strict();
export const deleteDeckInputSchema = z export const deleteDeckInputSchema = z
.object({ .object({
id: deckSchema.shape.id, id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(), expectedRevision: z.number().int().positive(),
}) })
.strict() .strict();
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema> export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>;
export type SavedDeck = z.infer<typeof savedDeckSchema> export type SavedDeck = z.infer<typeof savedDeckSchema>;
export type DeckPersistenceFailure = { export type DeckPersistenceFailure = {
ok: false ok: false;
error: { error: {
code: code:
| 'conflict' | "conflict"
| 'not-found' | "not-found"
| 'connection-unavailable' | "connection-unavailable"
| 'invalid-input' | "invalid-input"
| 'storage-unavailable' | "storage-unavailable";
message: string message: string;
} };
} };
+60 -72
View File
@@ -1,6 +1,6 @@
import { and, asc, eq } from 'drizzle-orm' import { and, asc, eq } from "drizzle-orm";
import { type AppDatabase, getDatabase } from '../storage/database.server' import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections, deckColumns, decks } from '../storage/schema' import { connections, deckColumns, decks } from "../storage/schema";
import { import {
type DeckPersistenceFailure, type DeckPersistenceFailure,
deleteDeckInputSchema, deleteDeckInputSchema,
@@ -9,33 +9,27 @@ import {
type SavedDeckDefinition, type SavedDeckDefinition,
savedDeckDefinitionSchema, savedDeckDefinitionSchema,
savedDeckSchema, savedDeckSchema,
} from './persistence-model' } from "./persistence-model";
export class DeckPersistenceError extends Error { export class DeckPersistenceError extends Error {
constructor( constructor(
readonly code: DeckPersistenceFailure['error']['code'], readonly code: DeckPersistenceFailure["error"]["code"],
message: string, message: string,
) { ) {
super(message) super(message);
} }
} }
const conflict = () => const conflict = () =>
new DeckPersistenceError( new DeckPersistenceError(
'conflict', "conflict",
'This deck changed on another device. Reload the latest deck.', "This deck changed on another device. Reload the latest deck.",
) );
const missing = () => const missing = () =>
new DeckPersistenceError( new DeckPersistenceError("not-found", "This deck was deleted or could not be found.");
'not-found',
'This deck was deleted or could not be found.',
)
export function loadDeck( export function loadDeck(id: string, database = getDatabase()): SavedDeck | null {
id: string, const record = database.select().from(decks).where(eq(decks.id, id)).get();
database = getDatabase(), if (!record) return null;
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
const columns = database const columns = database
.select({ .select({
id: deckColumns.id, id: deckColumns.id,
@@ -46,8 +40,8 @@ export function loadDeck(
.from(deckColumns) .from(deckColumns)
.where(eq(deckColumns.deckId, id)) .where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position)) .orderBy(asc(deckColumns.position))
.all() .all();
return savedDeckSchema.parse({ ...record, columns }) return savedDeckSchema.parse({ ...record, columns });
} }
export function listDecks(database = getDatabase()): SavedDeck[] { export function listDecks(database = getDatabase()): SavedDeck[] {
@@ -58,11 +52,11 @@ export function listDecks(database = getDatabase()): SavedDeck[] {
.orderBy(asc(decks.createdAt), asc(decks.id)) .orderBy(asc(decks.createdAt), asc(decks.id))
.all() .all()
.map(({ id }) => { .map(({ id }) => {
const deck = loadDeck(id, database) const deck = loadDeck(id, database);
if (!deck) throw missing() if (!deck) throw missing();
return deck return deck;
}), }),
) );
} }
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) { function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
@@ -71,22 +65,22 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
.select({ platform: connections.platform, status: connections.status }) .select({ platform: connections.platform, status: connections.status })
.from(connections) .from(connections)
.where(eq(connections.id, column.connectionId)) .where(eq(connections.id, column.connectionId))
.get() .get();
if ( if (
!connection || !connection ||
connection.platform !== column.source.platform || connection.platform !== column.source.platform ||
connection.status !== 'connected' connection.status !== "connected"
) { ) {
throw new DeckPersistenceError( throw new DeckPersistenceError(
'connection-unavailable', "connection-unavailable",
'The account for this column is unavailable. Check your connections.', "The account for this column is unavailable. Check your connections.",
) );
} }
} }
} }
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) { function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run() database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run();
if (deck.columns.length) if (deck.columns.length)
database database
.insert(deckColumns) .insert(deckColumns)
@@ -97,29 +91,26 @@ function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
position, position,
})), })),
) )
.run() .run();
} }
export function createDeck( export function createDeck(input: unknown, database = getDatabase()): SavedDeck {
input: unknown, const deck = savedDeckDefinitionSchema.parse(input);
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
return database.transaction( return database.transaction(
() => { () => {
const existing = loadDeck(deck.id, database) const existing = loadDeck(deck.id, database);
if (existing) { if (existing) {
const original = savedDeckDefinitionSchema.parse({ const original = savedDeckDefinitionSchema.parse({
id: existing.id, id: existing.id,
title: existing.title, title: existing.title,
columns: existing.columns, columns: existing.columns,
}) });
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict() if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict();
return existing return existing;
} }
validateConnections(deck, database) validateConnections(deck, database);
const now = Date.now() const now = Date.now();
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now } const result = { ...deck, revision: 1, createdAt: now, updatedAt: now };
database database
.insert(decks) .insert(decks)
.values({ .values({
@@ -129,56 +120,53 @@ export function createDeck(
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.run() .run();
writeColumns(deck, database) writeColumns(deck, database);
return result return result;
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
export function replaceDeck( export function replaceDeck(input: unknown, database = getDatabase()): SavedDeck {
input: unknown, const { deck, expectedRevision } = replaceDeckInputSchema.parse(input);
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
return database.transaction( return database.transaction(
() => { () => {
const existing = loadDeck(deck.id, database) const existing = loadDeck(deck.id, database);
if (!existing) throw missing() if (!existing) throw missing();
if (existing.revision !== expectedRevision) throw conflict() if (existing.revision !== expectedRevision) throw conflict();
validateConnections(deck, database) validateConnections(deck, database);
const updatedAt = Date.now() const updatedAt = Date.now();
const result = database const result = database
.update(decks) .update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt }) .set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision))) .where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run() .run();
if (result.changes !== 1) throw conflict() if (result.changes !== 1) throw conflict();
writeColumns(deck, database) writeColumns(deck, database);
return { return {
...deck, ...deck,
revision: expectedRevision + 1, revision: expectedRevision + 1,
createdAt: existing.createdAt, createdAt: existing.createdAt,
updatedAt, updatedAt,
} };
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
export function deleteDeck(input: unknown, database = getDatabase()): string { export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input) const { id, expectedRevision } = deleteDeckInputSchema.parse(input);
return database.transaction( return database.transaction(
() => { () => {
if (!loadDeck(id, database)) throw missing() if (!loadDeck(id, database)) throw missing();
const result = database const result = database
.delete(decks) .delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision))) .where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run() .run();
if (result.changes !== 1) throw conflict() if (result.changes !== 1) throw conflict();
return id return id;
}, },
{ behavior: 'immediate' }, { behavior: "immediate" },
) );
} }
+128 -157
View File
@@ -1,183 +1,154 @@
// @vitest-environment node // @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs' import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from 'node:os' import { tmpdir } from "node:os";
import { join } from 'node:path' import { join } from "node:path";
import { eq } from 'drizzle-orm' import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it } from 'vitest' import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from '../storage/database.server' import { type AppDatabase, openDatabase } from "../storage/database.server";
import { import { connectionCredentials, connections, deckColumns } from "../storage/schema";
connectionCredentials, import type { SavedDeckDefinition } from "./persistence-model";
connections, import { createDeck, deleteDeck, listDecks, loadDeck, replaceDeck } from "./repository.server";
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
let directory: string let directory: string;
let database: AppDatabase let database: AppDatabase;
let second: AppDatabase let second: AppDatabase;
const deck: SavedDeckDefinition = { const deck: SavedDeckDefinition = {
id: 'request-123', id: "request-123",
title: 'Research', title: "Research",
columns: [ columns: [
{ {
id: 'first', id: "first",
title: 'First', title: "First",
connectionId: 'twitter-one', connectionId: "twitter-one",
source: { source: {
platform: 'twitter', platform: "twitter",
kind: 'search', kind: "search",
query: 'WebMCP', query: "WebMCP",
product: 'Latest', product: "Latest",
following: false, following: false,
}, },
}, },
{ {
id: 'second', id: "second",
title: 'Second', title: "Second",
connectionId: 'twitter-two', connectionId: "twitter-two",
source: { platform: 'twitter', kind: 'user', target: 'alice' }, source: { platform: "twitter", kind: "user", target: "alice" },
}, },
], ],
} };
beforeEach(() => { beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-')) directory = mkdtempSync(join(tmpdir(), "twitter-lite-deck-repository-"));
database = openDatabase(join(directory, 'app.db')) database = openDatabase(join(directory, "app.db"));
second = openDatabase(join(directory, 'app.db')) second = openDatabase(join(directory, "app.db"));
database database
.insert(connections) .insert(connections)
.values( .values(
['twitter-one', 'twitter-two'].map((id) => ({ ["twitter-one", "twitter-two"].map((id) => ({
id, id,
platform: 'twitter' as const, platform: "twitter" as const,
origin: 'https://relay.invalid', origin: "https://relay.invalid",
relayProfile: id, relayProfile: id,
displayName: id, displayName: id,
status: 'connected' as const, status: "connected" as const,
createdAt: 1, createdAt: 1,
updatedAt: 1, updatedAt: 1,
})), })),
) )
.run() .run();
}) });
afterEach(() => { afterEach(() => {
second.$client.close() second.$client.close();
database.$client.close() database.$client.close();
rmSync(directory, { recursive: true }) rmSync(directory, { recursive: true });
}) });
it('shares deck order and per-column account bindings with a separate database client', () => { it("shares deck order and per-column account bindings with a separate database client", () => {
const saved = createDeck(deck, database) const saved = createDeck(deck, database);
expect(saved).toMatchObject({ ...deck, revision: 1 }) expect(saved).toMatchObject({ ...deck, revision: 1 });
expect(loadDeck(deck.id, second)).toEqual(saved) expect(loadDeck(deck.id, second)).toEqual(saved);
expect(listDecks(second)).toEqual([saved]) expect(listDecks(second)).toEqual([saved]);
}) });
it('makes retried creation idempotent but rejects different content using the same ID', () => { it("makes retried creation idempotent but rejects different content using the same ID", () => {
const saved = createDeck(deck, database) const saved = createDeck(deck, database);
expect(createDeck(deck, second)).toEqual(saved) expect(createDeck(deck, second)).toEqual(saved);
expect(() => expect(() => createDeck({ ...deck, title: "Different request" }, second)).toThrow(
createDeck({ ...deck, title: 'Different request' }, second), "This deck changed on another device",
).toThrow('This deck changed on another device') );
expect(listDecks(database)).toEqual([saved]) expect(listDecks(database)).toEqual([saved]);
}) });
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => { it("replaces title, ordered columns and bindings atomically, then rejects stale edits", () => {
const initial = createDeck(deck, database) const initial = createDeck(deck, database);
const reversed = { const reversed = {
...deck, ...deck,
title: 'Changed', title: "Changed",
columns: [...deck.columns].reverse(), columns: [...deck.columns].reverse(),
} };
const saved = replaceDeck( const saved = replaceDeck({ deck: reversed, expectedRevision: initial.revision }, database);
{ deck: reversed, expectedRevision: initial.revision },
database,
)
expect(saved).toMatchObject({ expect(saved).toMatchObject({
...reversed, ...reversed,
revision: 2, revision: 2,
createdAt: initial.createdAt, createdAt: initial.createdAt,
}) });
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow( expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'This deck changed on another device', "This deck changed on another device",
) );
expect(loadDeck(deck.id, second)).toEqual(saved) expect(loadDeck(deck.id, second)).toEqual(saved);
}) });
it('clears all columns and prevents stale deletion from another device', () => { it("clears all columns and prevents stale deletion from another device", () => {
createDeck(deck, database) createDeck(deck, database);
const saved = replaceDeck( const saved = replaceDeck({ deck: { ...deck, columns: [] }, expectedRevision: 1 }, database);
{ deck: { ...deck, columns: [] }, expectedRevision: 1 }, expect(loadDeck(deck.id, second)?.columns).toEqual([]);
database, expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
) "This deck changed on another device",
expect(loadDeck(deck.id, second)?.columns).toEqual([]) );
expect(() => expect(loadDeck(deck.id, second)).toEqual(saved);
deleteDeck({ id: deck.id, expectedRevision: 1 }, second), expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id);
).toThrow('This deck changed on another device') expect(loadDeck(deck.id, database)).toBeNull();
expect(loadDeck(deck.id, second)).toEqual(saved) });
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it('cascades deleted deck columns and reports already-deleted decks', () => { it("cascades deleted deck columns and reports already-deleted decks", () => {
createDeck(deck, database) createDeck(deck, database);
deleteDeck({ id: deck.id, expectedRevision: 1 }, database) deleteDeck({ id: deck.id, expectedRevision: 1 }, database);
expect(database.select().from(deckColumns).all()).toEqual([]) expect(database.select().from(deckColumns).all()).toEqual([]);
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow( expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow("This deck was deleted");
'This deck was deleted', expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
) "This deck was deleted",
expect(() => );
deleteDeck({ id: deck.id, expectedRevision: 1 }, second), });
).toThrow('This deck was deleted')
})
it.each([ it.each(["disconnected", "expired"] as const)(
'disconnected', "rejects %s connections without partially saving a deck",
'expired', (status) => {
] as const)('rejects %s connections without partially saving a deck', (status) => { const initial = createDeck(deck, database);
const initial = createDeck(deck, database) database.update(connections).set({ status }).where(eq(connections.id, "twitter-two")).run();
expect(() =>
replaceDeck({ deck: { ...deck, title: "Should not save" }, expectedRevision: 1 }, database),
).toThrow("The account for this column is unavailable");
expect(loadDeck(deck.id, second)).toEqual(initial);
},
);
it("rejects a connection from another platform", () => {
database database
.update(connections) .update(connections)
.set({ status }) .set({ platform: "mastodon" })
.where(eq(connections.id, 'twitter-two')) .where(eq(connections.id, "twitter-one"))
.run() .run();
expect(() => expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
replaceDeck( expect(listDecks(database)).toEqual([]);
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 }, });
database,
),
).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
it('rejects a connection from another platform', () => { it("rejects unknown connections without creating rows", () => {
database database.delete(connections).where(eq(connections.id, "twitter-two")).run();
.update(connections) expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
.set({ platform: 'mastodon' }) expect(listDecks(database)).toEqual([]);
.where(eq(connections.id, 'twitter-one')) });
.run()
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it('rejects unknown connections without creating rows', () => { it("rejects duplicate column IDs without changing the saved version", () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run() const saved = createDeck(deck, database);
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
expect(() => expect(() =>
replaceDeck( replaceDeck(
{ {
@@ -186,29 +157,29 @@ it('rejects duplicate column IDs without changing the saved version', () => {
}, },
database, database,
), ),
).toThrow('Column IDs must be unique') ).toThrow("Column IDs must be unique");
expect(loadDeck(deck.id, database)).toEqual(saved) expect(loadDeck(deck.id, database)).toEqual(saved);
}) });
it('returns only deck definitions and revision metadata, never credential data', () => { it("returns only deck definitions and revision metadata, never credential data", () => {
database database
.insert(connectionCredentials) .insert(connectionCredentials)
.values({ .values({
connectionId: 'twitter-one', connectionId: "twitter-one",
encryptedToken: 'secret-envelope', encryptedToken: "secret-envelope",
updatedAt: 1, updatedAt: 1,
}) })
.run() .run();
createDeck(deck, database) createDeck(deck, database);
const loaded = listDecks(database) const loaded = listDecks(database);
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([ expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns', "columns",
'createdAt', "createdAt",
'id', "id",
'revision', "revision",
'title', "title",
'updatedAt', "updatedAt",
]) ]);
expect(JSON.stringify(loaded)).not.toContain('secret-envelope') expect(JSON.stringify(loaded)).not.toContain("secret-envelope");
expect(JSON.stringify(loaded)).not.toContain('relayProfile') expect(JSON.stringify(loaded)).not.toContain("relayProfile");
}) });
+71 -75
View File
@@ -1,89 +1,85 @@
import { act, fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from 'vitest' import { afterEach, expect, it, vi } from "vitest";
import type { ResearchPost } from '../platforms/types' import type { ResearchPost } from "../platforms/types";
import { ResearchPostCard } from './research-post-card' import { ResearchPostCard } from "./research-post-card";
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
vi.unstubAllGlobals() vi.unstubAllGlobals();
}) });
it('focuses and briefly highlights a cited card without revealing sensitive content', () => { it("focuses and briefly highlights a cited card without revealing sensitive content", () => {
vi.useFakeTimers() vi.useFakeTimers();
vi.stubGlobal('matchMedia', () => ({ matches: true })) vi.stubGlobal("matchMedia", () => ({ matches: true }));
const post: ResearchPost = { const post: ResearchPost = {
key: 'mastodon:1', key: "mastodon:1",
platform: 'mastodon', platform: "mastodon",
nativeId: '1', nativeId: "1",
url: 'https://social.invalid/@a/1', url: "https://social.invalid/@a/1",
text: 'Hidden content', text: "Hidden content",
author: { name: 'Alice', handle: '[email protected]' }, author: { name: "Alice", handle: "[email protected]" },
contentWarning: 'Spoiler', contentWarning: "Spoiler",
sensitive: true, sensitive: true,
} };
const { rerender } = render(<ResearchPostCard post={post} />) const { rerender } = render(<ResearchPostCard post={post} />);
const article = screen.getByRole('article') const article = screen.getByRole("article");
const scroll = vi.fn() const scroll = vi.fn<() => void>();
article.scrollIntoView = scroll article.scrollIntoView = scroll;
rerender( rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />, expect(article).toHaveFocus();
) expect(article).toHaveClass("citation-highlight");
expect(article).toHaveFocus()
expect(article).toHaveClass('citation-highlight')
expect(scroll).toHaveBeenCalledWith({ expect(scroll).toHaveBeenCalledWith({
block: 'center', block: "center",
inline: 'center', inline: "center",
behavior: 'instant', behavior: "instant",
}) });
expect(screen.getByText('Post saved during research')).toBeInTheDocument() expect(screen.getByText("Post saved during research")).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute( expect(screen.getByRole("button", { name: "Show content" })).toHaveAttribute(
'aria-expanded', "aria-expanded",
'false', "false",
) );
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument() expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
rerender(<ResearchPostCard post={post} citationRequest={1} />) rerender(<ResearchPostCard post={post} citationRequest={1} />);
expect(scroll).toHaveBeenCalledTimes(2) expect(scroll).toHaveBeenCalledTimes(2);
expect(article).toHaveFocus() expect(article).toHaveFocus();
expect( expect(screen.queryByText("Post saved during research")).not.toBeInTheDocument();
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
act(() => { act(() => {
vi.advanceTimersByTime(2500) vi.advanceTimersByTime(2500);
}) });
expect(article).not.toHaveClass('citation-highlight') expect(article).not.toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} citationRequest={2} />) rerender(<ResearchPostCard post={post} citationRequest={2} />);
expect(article).toHaveClass('citation-highlight') expect(article).toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} />) rerender(<ResearchPostCard post={post} />);
expect(article).not.toHaveClass('citation-highlight') expect(article).not.toHaveClass("citation-highlight");
}) });
it('waits for reveal before mounting CW content or sensitive media', () => { it("waits for reveal before mounting CW content or sensitive media", () => {
const post: ResearchPost = { const post: ResearchPost = {
key: 'mastodon:1', key: "mastodon:1",
platform: 'mastodon', platform: "mastodon",
nativeId: '1', nativeId: "1",
url: 'https://social.invalid/@a/1', url: "https://social.invalid/@a/1",
text: 'Hidden content', text: "Hidden content",
html: '<p>Hidden <strong>content</strong></p>', html: "<p>Hidden <strong>content</strong></p>",
author: { name: 'Alice', handle: '[email protected]' }, author: { name: "Alice", handle: "[email protected]" },
contentWarning: 'Spoiler', contentWarning: "Spoiler",
sensitive: true, sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' }, boostedBy: { name: "Bob", handle: "[email protected]" },
media: [ media: [
{ {
type: 'photo', type: "photo",
url: 'https://social.invalid/image.png', url: "https://social.invalid/image.png",
alt: 'Hidden image', alt: "Hidden image",
}, },
], ],
} };
render(<ResearchPostCard post={post} />) render(<ResearchPostCard post={post} />);
expect(screen.getByText('Boosted by Bob')).toBeInTheDocument() expect(screen.getByText("Boosted by Bob")).toBeInTheDocument();
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument() expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
expect(screen.queryByText('content')).not.toBeInTheDocument() expect(screen.queryByText("content")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show content' })) fireEvent.click(screen.getByRole("button", { name: "Show content" }));
expect(screen.getByAltText('Hidden image')).toBeInTheDocument() expect(screen.getByAltText("Hidden image")).toBeInTheDocument();
expect(screen.getByText('content').tagName).toBe('STRONG') expect(screen.getByText("content").tagName).toBe("STRONG");
fireEvent.click(screen.getByRole('button', { name: 'Hide content' })) fireEvent.click(screen.getByRole("button", { name: "Hide content" }));
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument() expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
}) });
+46 -64
View File
@@ -1,63 +1,56 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { Icon } from '#/components/icon' import { Icon } from "#/components/icon";
import { PostText } from '#/components/post-text' import { PostText } from "#/components/post-text";
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from "#/components/ui/avatar";
import { Badge } from '#/components/ui/badge' import { Badge } from "#/components/ui/badge";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import type { ResearchPost } from '#/features/platforms/types' import type { ResearchPost } from "#/features/platforms/types";
const dateFormatter = new Intl.DateTimeFormat('en-US', { const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: 'numeric', month: "numeric",
day: 'numeric', day: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
timeZone: 'Asia/Tokyo', timeZone: "Asia/Tokyo",
}) });
export function ResearchPostCard({ export function ResearchPostCard({
post, post,
citationRequest, citationRequest,
citationSnapshot, citationSnapshot,
}: { }: {
post: ResearchPost post: ResearchPost;
citationRequest?: number citationRequest?: number;
citationSnapshot?: boolean citationSnapshot?: boolean;
}) { }) {
const [revealed, setRevealed] = useState(false) const [revealed, setRevealed] = useState(false);
const [highlighted, setHighlighted] = useState(false) const [expiredHighlight, setExpiredHighlight] = useState<string>();
const article = useRef<HTMLElement>(null) const highlightKey = `${citationRequest}:${citationSnapshot}`;
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position. const highlighted = citationRequest !== undefined && expiredHighlight !== highlightKey;
const article = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
if (citationRequest === undefined) { if (citationRequest === undefined) return;
setHighlighted(false) article.current?.focus({ preventScroll: true });
return
}
setHighlighted(true)
article.current?.focus({ preventScroll: true })
article.current?.scrollIntoView({ article.current?.scrollIntoView({
block: 'center', block: "center",
inline: 'center', inline: "center",
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? 'instant' ? "instant"
: 'smooth', : "smooth",
}) });
const timer = setTimeout(() => setHighlighted(false), 2500) const timer = setTimeout(() => setExpiredHighlight(highlightKey), 2500);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, [citationRequest, citationSnapshot]) }, [citationRequest, highlightKey]);
const concealed = Boolean(post.contentWarning || post.sensitive) const concealed = Boolean(post.contentWarning || post.sensitive);
return ( return (
<article <article
ref={article} ref={article}
tabIndex={-1} tabIndex={-1}
className={`research-post${highlighted ? ' citation-highlight' : ''}`} className={`research-post${highlighted ? " citation-highlight" : ""}`}
data-post-url={post.url} data-post-url={post.url}
> >
{citationSnapshot ? ( {citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
<Badge variant="secondary">Post saved during research</Badge> {post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null}
) : null}
{post.boostedBy ? (
<p className="handle">Boosted by {post.boostedBy.name}</p>
) : null}
<header> <header>
<Avatar className="size-9 shrink-0"> <Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" /> <AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
@@ -65,9 +58,7 @@ export function ResearchPostCard({
</Avatar> </Avatar>
<div> <div>
<strong>{post.author.name}</strong> <strong>{post.author.name}</strong>
<span className="handle"> <span className="handle">@{post.author.handle.replace(/^@/, "")}</span>
@{post.author.handle.replace(/^@/, '')}
</span>
</div> </div>
{post.createdAt ? ( {post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}> <time dateTime={post.createdAt} title={post.createdAt}>
@@ -77,7 +68,7 @@ export function ResearchPostCard({
</header> </header>
{concealed ? ( {concealed ? (
<div> <div>
<p>{post.contentWarning || 'This post contains sensitive media'}</p> <p>{post.contentWarning || "This post contains sensitive media"}</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -85,7 +76,7 @@ export function ResearchPostCard({
aria-expanded={revealed} aria-expanded={revealed}
onClick={() => setRevealed(!revealed)} onClick={() => setRevealed(!revealed)}
> >
{revealed ? 'Hide content' : 'Show content'} {revealed ? "Hide content" : "Show content"}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -94,7 +85,7 @@ export function ResearchPostCard({
{post.html !== undefined ? ( {post.html !== undefined ? (
<div <div
className="research-post-text" className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization. // Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }} dangerouslySetInnerHTML={{ __html: post.html }}
/> />
) : ( ) : (
@@ -103,21 +94,16 @@ export function ResearchPostCard({
</p> </p>
)} )}
{post.media?.map((media) => {post.media?.map((media) =>
media.type === 'photo' ? ( media.type === "photo" ? (
<img <img
className="research-media" className="research-media"
key={media.url} key={media.url}
src={media.url} src={media.url}
alt={media.alt ?? ''} alt={media.alt ?? ""}
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<a <a key={media.url} href={media.url} target="_blank" rel="noreferrer noopener">
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
Open video Open video
</a> </a>
), ),
@@ -128,11 +114,7 @@ export function ResearchPostCard({
<p> <p>
<PostText text={post.quotedPost.text} /> <PostText text={post.quotedPost.text} />
</p> </p>
<a <a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
Open quoted post Open quoted post
</a> </a>
</blockquote> </blockquote>
@@ -145,5 +127,5 @@ export function ResearchPostCard({
</a> </a>
</footer> </footer>
</article> </article>
) );
} }
+26 -28
View File
@@ -1,60 +1,58 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from "@tanstack/react-start";
import { z } from 'zod' import { z } from "zod";
import { legacyWorkspaceSchema } from './legacy-import' import { legacyWorkspaceSchema } from "./legacy-import";
import { import {
type DeckPersistenceFailure, type DeckPersistenceFailure,
deleteDeckInputSchema, deleteDeckInputSchema,
replaceDeckInputSchema, replaceDeckInputSchema,
savedDeckDefinitionSchema, savedDeckDefinitionSchema,
} from './persistence-model' } from "./persistence-model";
async function run<T>( async function run<T>(
operation: ( operation: (repository: typeof import("./repository.server")) => T | Promise<T>,
repository: typeof import('./repository.server'),
) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> { ): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server') const repository = await import("./repository.server");
try { try {
return { ok: true, data: await operation(repository) } return { ok: true, data: await operation(repository) };
} catch (error) { } catch (error) {
if (error instanceof repository.DeckPersistenceError) if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } } return { ok: false, error: { code: error.code, message: error.message } };
if (error instanceof z.ZodError) if (error instanceof z.ZodError)
return { return {
ok: false, ok: false,
error: { error: {
code: 'invalid-input', code: "invalid-input",
message: 'Check the deck details.', message: "Check the deck details.",
}, },
} };
return { return {
ok: false, ok: false,
error: { error: {
code: 'storage-unavailable', code: "storage-unavailable",
message: 'Unable to save or load the deck. Please try again.', message: "Unable to save or load the deck. Please try again.",
}, },
} };
} }
} }
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() => export const loadSavedDecks = createServerFn({ method: "GET" }).handler(() =>
run((repository) => repository.listDecks()), run((repository) => repository.listDecks()),
) );
export const createSavedDeck = createServerFn({ method: 'POST' }) export const createSavedDeck = createServerFn({ method: "POST" })
.validator(savedDeckDefinitionSchema) .validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data))) .handler(({ data }) => run((repository) => repository.createDeck(data)));
export const replaceSavedDeck = createServerFn({ method: 'POST' }) export const replaceSavedDeck = createServerFn({ method: "POST" })
.validator(replaceDeckInputSchema) .validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data))) .handler(({ data }) => run((repository) => repository.replaceDeck(data)));
export const deleteSavedDeck = createServerFn({ method: 'POST' }) export const deleteSavedDeck = createServerFn({ method: "POST" })
.validator(deleteDeckInputSchema) .validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data))) .handler(({ data }) => run((repository) => repository.deleteDeck(data)));
export const importBrowserDecks = createServerFn({ method: 'POST' }) export const importBrowserDecks = createServerFn({ method: "POST" })
.validator(legacyWorkspaceSchema) .validator(legacyWorkspaceSchema)
.handler(({ data }) => .handler(({ data }) =>
run(async () => { run(async () => {
const { importLegacyWorkspace } = await import('./import.server') const { importLegacyWorkspace } = await import("./import.server");
return importLegacyWorkspace(data) return importLegacyWorkspace(data);
}), }),
) );
+25 -25
View File
@@ -1,37 +1,37 @@
import type { DeckColumn } from './model' import type { DeckColumn } from "./model";
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */ /** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): { export function describeSource(source: DeckColumn["source"]): {
label: string label: string;
query: string query: string;
} { } {
switch (source.platform) { switch (source.platform) {
case 'twitter': case "twitter":
switch (source.kind) { switch (source.kind) {
case 'search': case "search":
return { return {
label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`, label: `${source.product === "Latest" ? "Latest" : "Top"}${source.following ? " · Following" : ""}`,
query: source.query, query: source.query,
} };
case 'user': case "user":
return { label: 'User posts', query: source.target } return { label: "User posts", query: source.target };
case 'list': case "list":
return { label: 'List', query: source.target } return { label: "List", query: source.target };
} }
break break;
case 'mastodon': case "mastodon":
switch (source.kind) { switch (source.kind) {
case 'search': case "search":
return { label: 'Full-text search', query: source.query } return { label: "Full-text search", query: source.query };
case 'user': case "user":
return { label: 'User posts', query: source.target } return { label: "User posts", query: source.target };
case 'list': case "list":
return { label: 'List', query: source.target } return { label: "List", query: source.target };
case 'hashtag': case "hashtag":
return { label: 'Hashtag', query: `#${source.target}` } return { label: "Hashtag", query: `#${source.target}` };
} }
break break;
} }
const exhaustive: never = source const exhaustive: never = source;
return exhaustive return exhaustive;
} }

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