feat: unify research in profile-bound decks with TweetDeck-style UI

This commit is contained in:
2026-09-24 15:59:54 +09:00
parent da8f52e605
commit d2cbf4dbd3
90 changed files with 2617 additions and 6934 deletions
-1
View File
@@ -1,2 +1 @@
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
BIRD_PROFILE_NAME=
+8
View File
@@ -1,4 +1,12 @@
[
{
"date": "2026-09-24",
"macrostructure": "Workbench",
"theme": "custom",
"theme_axes": "dark navy / system sans / blue",
"enrichment": "none",
"brief": "Full-height TweetDeck-style research columns with compact sidebar and modal editors"
},
{
"date": "2026-07-26",
"macrostructure": "Workbench",
+7 -6
View File
@@ -1,9 +1,10 @@
{
"date": "2026-07-26",
"font_stack": "Inter / Noto Sans JP / system sans declared in src/styles.css",
"palette": "CSS custom properties with muted teal hex values",
"motion": "motion-cut; no motion library",
"spacing": "no named scale",
"date": "2026-09-24",
"font_stack": "System sans; tokens.css",
"palette": "Dark navy and blue OKLCH tokens",
"motion": "motion-cut",
"spacing": "named token scale",
"framework": "React 19 + TanStack Start + Vite 8",
"preserve": ["routes", "information architecture", "read-only behavior"]
"preserve": ["deck data model", "profile binding", "WebMCP contracts"],
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
}
+73 -101
View File
@@ -1,50 +1,32 @@
# Twitter Lite
Twitter Lite is an intentional, read-only X reader. It shows content only after
you enter a user handle, profile URL, search query, or list URL, follow a
deliberate post detail link, or manually open a valid
`/:handle/status/:tweetId` URL.
Twitter Lite is a read-only research deck for X. Create multiple named decks,
and arrange up to six columns per deck for searches, user timelines, and lists.
Each column is bound to an explicit relay profile, so different accounts can
be used side by side.
## Scope
- User timelines and raw X search syntax
- Button-driven search filters for author, period, language, content type, replies, and reposts
- Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID
- Relay profile switching from the profiles exposed by the relay
- Infinite cursor pagination with explicit retry
- Deliberate post detail pages with the visible conversation
- Infinite conversation loading with explicit continuation retry
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
- Experimental WebMCP tools for search, loaded-post reading, and continuation
- Deck WebMCP tools to inspect or create/replace up to six columns at once
- Research deck with named Twitter search columns, independent pagination,
editing, ordering, and browser-local condition persistence
- Multiple deck profiles with creation, selection, renaming, and deletion
- Search, user timeline, and list columns with independent relay profiles
- Native X search syntax, Top/Latest ranking, and optional `filter:follows`
- List discovery for the profile selected in the column editor
- Column editing, ordering, deletion/undo, manual refresh, and cursor pagination
- Browser-local persistence of deck definitions and the active deck
- Read-only cards with original-post links, text, media, and quotes
- Experimental WebMCP tools to manage decks and read or paginate their columns
It intentionally has no home feed, recommendations, trends, notifications,
history, account directory, or write actions.
Both `/` and `/deck` open the deck workspace. The separate reader, search,
list, user-profile, and conversation routes have been removed. Original-post
links open X; conversations are not rendered inside the app.
Post detail pages show only the selected post and its visible conversation;
they do not add related-post recommendations or an account-discovery surface.
## Routes
- `/` shows the empty user input.
- `/deck` opens the research deck (up to six search columns).
- `/:handle` shows a user timeline.
- `/search` shows search results. Its typed query parameters preserve the query,
author, period, language, content type, exclusions, ranking, and follows filter
so filtered searches can be reloaded or shared.
- `/i/lists` shows the authenticated account's lists.
- `/i/lists/:listId` shows a list timeline.
- `/:handle/status/:tweetId` shows one selected post and its visible conversation.
Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr
connectors are not implemented yet. Twitter is the only supported platform.
## Requirements and setup
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
`0.10.0` provides the required Top and Latest search interface.
routes the `@yuta` scope to the public Gitea Packages registry.
```bash
nix develop -c pnpm install --frozen-lockfile
@@ -52,21 +34,19 @@ cp .env.example .env.local
```
The Nix development shell installs Hallmark's agent skill for the supported
local agent targets.
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development.
`BIRD_PROFILE_NAME` optionally selects the initial relay profile; the header
selector can switch between the profiles returned by the relay's `/profiles`
endpoint. Export the same values in the process environment before `start` or
`test:live`:
local agent targets. Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite
development, or export it before running the production server:
```bash
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
export BIRD_PROFILE_NAME=
```
Both values are read by server-only code at runtime; neither value nor Bird is
sent to the browser.
The app discovers available account profiles through the relay's `/profiles`
endpoint. Select a profile for each column. There is no global account selector,
profile cookie, or application-level `BIRD_PROFILE_NAME` default. Requests
validate that the bound profile still exists before fetching posts or lists.
Bird and relay credentials stay on the server; saved deck definitions include
the selected profile names.
## Commands
@@ -82,68 +62,64 @@ nix develop -c pnpm build
nix develop -c pnpm start
```
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
the dev shell. Browser tests are organized under `e2e/`: page object models
and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1
A and AA. The tests keep a standalone HTTP relay so TanStack Start server
functions and SSR requests exercise the same network boundary as production.
The API client and fixture types are generated with Orval from a pinned
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba);
run `generate:e2e-openapi` only when updating that pin or the selected
operations. Generated files are committed, so normal E2E runs do not require
network access. E2E fixtures use the generated response types but stay
deterministic. Runtime requests continue through Bird because its transport
differs from the upstream OpenAPI client. Relay-only endpoints, request
transport differences, cursors, and stateful failure scenarios remain in the
handwritten mock server.
`test:live` is an explicit, opt-in smoke test that performs exactly one
read-only Top search using the configured relay.
`test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright
integration and Axe accessibility tests live under `e2e/`. A standalone mock
HTTP relay exercises the production server-function boundary without contacting
X or a personal relay. Orval generates API fixture types from a pinned
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba).
Generated files are committed; run `generate:e2e-openapi` only when changing
the pin or selected operations. Runtime requests use Bird.
## Research deck
`test:live` is an explicit, opt-in smoke test that performs one read-only Top
search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account
and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
Open **デッキ** in the navigation, name the investigation, and add a column
for each perspective. Each column has a title, native Twitter search query,
Top/Latest ordering, and an optional follows filter. Update or paginate a
column independently, edit its conditions, move it left/right, or undo a
removal. All columns use the currently selected relay profile.
## Deck workspace
One deck's conditions are stored in this browser's localStorage. Reloading
fetches the first page again; collected posts are not archived or synced
between devices. Other open tabs are not synchronized. A WebMCP-capable agent
can use `get_deck` and `set_deck` on `/deck` to create or edit the whole deck.
Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr connectors
are not implemented yet. Only Twitter can currently be selected. See
[the platform boundary and next steps](docs/research-decks.md).
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar,
compact column headers, and independently scrolling post columns. On narrow
screens, the sidebar becomes a compact top bar. Add/edit forms open in native
dialogs; Escape closes them and returns focus. Column menus contain editing,
ordering, and deletion; refresh stays available directly in each header.
## WebMCP prototype
Create a deck for an investigation, then add columns for each perspective.
Choose a relay profile and a source: search, user timeline, or list. Profile
changes affect only the edited column. Matching source conditions and profiles
share the query cache; different profiles never share posts or cursors.
In a WebMCP-enabled browser, `search_posts` searches and updates the page,
`get_loaded_posts` reads a bounded slice of the active feed, and
`load_more_posts` appends a continuation and returns the new posts. The tools
share the reader's query cache and active relay profile. They do not publish
posts or change profiles.
The workspace saves multiple decks and the active selection in localStorage.
Only the active deck is rendered and fetched. Posts and cursors are not saved;
reloading fetches first pages. Devices and tabs do not synchronize edits.
Old single-deck data is not migrated automatically because it has no explicit
column profile binding. Invalid or older saved data remains untouched while
the app shows an empty workspace and an explanation. Saving a new edit replaces
that saved data. See [the deck model and persistence contract](docs/research-decks.md).
## WebMCP
A WebMCP-enabled browser exposes `list_decks`, `get_deck`, `set_deck`,
`select_deck`, `delete_deck`, `get_column_posts`, and `load_more_column` on
both deck routes. Start with `list_decks` to discover deck IDs and available
relay profiles. `set_deck` creates a new deck when `deckId` is omitted; supply
an existing ID to replace that deck's complete ordered columns and activate it.
Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the
app on `http://localhost:3000` or `http://127.0.0.1:3000`. Use the
local app. Use the
[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension)
to list and invoke tools. Try `search_posts` with
`{"q":"TypeScript","lang":"ja","product":"Latest"}`.
This prototype uses native `document.modelContext` through `usewebmcp`; it
does not initialize a polyfill or provide an external MCP transport. Browsers
without the API keep the regular reader UI. Public deployment requires the
appropriate browser support/origin trial and a secure context. See
[tool contracts and verification](docs/webmcp-prototype.md) for details.
to invoke tools. Registration uses native `document.modelContext` through
`usewebmcp`; there is no polyfill or external MCP transport. Unsupported
browsers retain the manual deck UI. See [tool contracts and verification](docs/webmcp-prototype.md).
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it
only on a trusted network and obtain the Tailscale address with
`tailscale ip -4`.
`0.0.0.0`, including LAN interfaces. Native WebMCP needs a secure context;
use a Tailscale Serve HTTPS origin for remote access, and allow its exact
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process
environment. HTTP and HTTPS origins have separate localStorage.
## NixOS service
The flake provides both a production package and a NixOS module. Import the
module and configure the relay:
The flake provides a production package and a NixOS module:
```nix
{
@@ -159,7 +135,6 @@ module and configure the relay:
services.twitter-lite = {
enable = true;
relayBaseUrl = "http://127.0.0.1:6900";
# profileName = "default";
};
}
];
@@ -170,13 +145,10 @@ module and configure the relay:
The service listens on `127.0.0.1:3000` by default. Set
`services.twitter-lite.host` or `services.twitter-lite.port` to change the
listener. The package can also be built directly with `nix build`.
listener. Profiles are selected in column definitions, not service options.
The package can also be built directly with `nix build`.
## Reliability
Bird uses X's internal GraphQL operations through the configured relay. Query
IDs and response shapes can change without notice.
Conversation pages use Bird's current per-page chronological ordering. Keeping
X's original ranked branch order is deferred until Bird exposes that order
without expanding the relay surface.
+114 -108
View File
@@ -1,134 +1,140 @@
# Research decks
## Workspace interface
The deck occupies the viewport with a dark sidebar and horizontally arranged,
independently scrolling columns. A compact toolbar names the active deck.
The sidebar switches decks, adds columns, and jumps to a column; on mobile,
it becomes a compact top bar. Column header menus expose editing, ordering,
and deletion. Creation and editing use native modal dialogs with Escape and
focus restoration. Tokens use a navy/blue palette and a system sans font.
This replaces the original spacious Garden reader layout. Layout inspiration:
[Twitter's TweetDeck design notes](https://blog.x.com/en_us/a/2012/designing-the-new-tweetdeck).
## Product direction
A research topic should become a TweetDeck-style workspace: columns represent
questions or perspectives, collect posts across social platforms, and support
AI summaries with references back to the evidence. The intended platforms are
Twitter, Mastodon, Bluesky, Threads, and Nostr.
A research topic becomes a TweetDeck-style workspace: columns represent
questions or perspectives and will eventually collect posts across Twitter,
Mastodon, Bluesky, Threads, and Nostr for AI summaries with source references.
The current implementation supports Twitter only, with manually or
WebMCP-authored decks. Built-in planning, summaries, and other connectors
remain future work.
The first increment is a manually composed deck backed by Twitter. It makes
the deck definition, column lifecycle, and post normalization concrete before
adding AI or more connectors.
## Workspace and column model
## Implemented boundary
Both `/` and `/deck` render the same deck-only application. Separate reader,
search, list, user-profile, and conversation pages have been removed.
Original-post links open X.
- `src/features/decks/model.ts` validates a versioned deck definition: name,
ordered columns with stable IDs, and provider-specific search conditions.
At most six columns are loaded, bounding concurrent first-page requests.
- `src/features/platforms/types.ts` defines the display/evidence record without
importing Bird: stable key, platform, native identity, original URL, text,
author, optional publication time, media, and quoted post.
- `src/features/platforms/twitter.ts` maps existing reader posts into that
record. Keys use `twitter:<id>` rather than the author's mutable handle.
Raw provider responses and credentials do not enter the common record.
- `ResearchColumn` uses the existing Twitter search hook and server functions.
Equal search conditions share the reader's query cache. Distinct conditions
have independent loading/error/pagination state. Profile changes invalidate
queries through the existing profile switcher.
- `ResearchPostCard` only consumes the common record. Provider-specific detail
routes remain in the existing Twitter reader. Original-post links, media,
and one level of quotes are shown in the deck; engagement metrics and
Twitter article previews are not normalized yet.
`src/features/decks/model.ts` validates a version-2 workspace containing
`activeDeckId` and one or more named decks. Each deck has a stable ID and up to
six ordered columns. Only the active deck mounts its columns. The UI supports
creating, selecting, renaming, and deleting deck profiles; the last deck cannot
be deleted. Deleting the active deck selects the first remaining deck.
The current source is a single Twitter search per column. Its `platform`
discriminator is the extension point for another real connector, not a claim
that five adapters already work. Twitter's raw syntax, Top/Latest, and follows
filter are not requirements imposed on the other platforms. Unsupported
source definitions fail validation instead of silently dropping conditions.
Each column has a stable ID, title, required `profileName`, and one source:
The existing reader and its active-feed WebMCP tools remain separate from the
deck. Mounting multiple reader `PostFeed` components would register conflicting
active-feed tools; deck columns therefore use the search hook directly.
On `/deck`, `get_deck` and `set_deck` expose the definition once local storage
has loaded. They unregister when leaving the route.
| Source kind | Conditions |
| --- | --- |
| `search` | Native Twitter `query`, `product` (`Top`/`Latest`), and `following` |
| `user` | `target`: handle or X/Twitter profile URL, normalized to a handle |
| `list` | `target`: numeric ID or X/Twitter list URL, normalized to an ID |
All sources currently require `platform: "twitter"`. Unsupported definitions
fail validation. Column IDs must be unique within a deck, and deck IDs within
the workspace. Manual edits and agent tools use the same final schema.
`profileName` is the relay account binding, distinct from a named deck profile.
The editor discovers names through `/profiles` and lists through the selected
profile. A column's profile can be changed independently. Every feed and list
request carries an explicit profile name; the server confirms it still exists.
Deleted profiles and unavailable discovery produce errors instead of falling
back to another account. There is no browser-wide profile selection.
The complete source and profile participate in query-cache identity. Equal
conditions on the same profile share loaded pages; distinct profiles retain
separate results and cursors. Columns have independent refresh, pagination,
and error state. Pagination and refresh are manual, with no polling.
## Platform boundary
`src/features/platforms/types.ts` defines the display/evidence record without
Bird imports: stable key, platform, native identity, original URL, text,
author, optional publication time, media, and quoted post. The Twitter mapper
uses `twitter:<id>` keys rather than mutable author handles. Raw responses
and credentials do not enter this record. Cards consume the normalized record;
engagement metrics and Twitter article previews are not normalized yet.
The source union is the extension point for future connectors. Twitter search
syntax and ranking controls are provider-specific. When implementing another
connector, add its real schema and server operation, normalize stable identity,
and bind connection details into cache identity. Multiple sources in one
column should wait until a second connector exercises that need; each source
must retain its own opaque continuation and error state.
## Persistence
The workspace is stored under `twitter-lite-research-deck` in localStorage,
including all deck definitions and the active selection. It stores conditions
and relay profile names, not credentials, posts, summaries, or cursors.
Reloading fetches first pages of the selected deck. SSR and the first browser
render show a loading state until storage has been read.
There is no automatic migration of the former single-deck format, which did
not pin profiles to columns. Invalid or older saved data remains untouched
while the UI presents an empty workspace and an error. An explicit saved edit
replaces it. Storage failures are visible: changes still apply in the current
tab, but persistence failures mean they will be lost on reload. Tabs and
devices do not synchronize; the last write to an origin's localStorage wins.
HTTP and HTTPS origins maintain separate workspaces.
## Deck WebMCP tools
`get_deck({})` returns the current definition (including column IDs) and any
storage error. `set_deck` replaces the complete ordered definition and closes
unsaved editor forms. Read before editing, keep IDs of retained columns, and
include every column you want to keep. Omit IDs for new columns. An empty array
clears the deck. The same deck schema validates the entire input before changes.
Both deck routes expose workspace management and active-column reading.
`list_decks` discovers definitions and available relay profiles; `get_deck`
reads a specific or active deck. `set_deck` creates or replaces and activates a
deck; `select_deck` and `delete_deck` operate by ID. `get_column_posts` and
`load_more_column` read or paginate columns in the active deck. See the
[full tool contracts](webmcp-prototype.md).
For example, after discovering a relay profile named `main`, create a deck:
```json
{
"title": "WebMCPの反応",
"columns": [
{ "title": "日本語", "source": { "query": "WebMCP lang:ja" } },
{ "title": "海外の話題", "source": { "query": "WebMCP lang:en", "product": "Top" } }
{
"title": "日本語",
"profileName": "main",
"source": { "kind": "search", "query": "WebMCP lang:ja" }
},
{
"title": "開発者",
"profileName": "main",
"source": { "kind": "user", "target": "@example" }
}
]
}
```
`source.platform` defaults to `twitter`, `product` to `Latest`, and `following`
to false. `set_deck` returns the applied definition and `persisted: true` before
post loading completes. It does not claim that searches succeeded. A storage
failure returns `isError: true` and explains that the in-memory change was
applied but will be lost on reload. Invalid input changes neither UI nor storage.
Omitting `deckId` creates a deck. To edit, read first and include its `deckId`
and every column to retain; keep existing column IDs. Omitted columns are
removed and omitted column IDs are generated. Post loading is asynchronous,
so a successful save does not mean the upstream requests succeeded.
Native WebMCP needs a supported browser and a secure context. Use the HTTPS
Tailscale Serve origin rather than an HTTP tailnet IP. For Vite, allow that exact
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the dev process's
environment. HTTP and HTTPS origins have separate browser-local deck storage.
## Future AI work
## Persistence and fetching
One deck is saved under `twitter-lite-research-deck` in localStorage. It contains
only conditions and names, not posts, summaries, credentials, or cursors.
SSR and the initial browser render show a loading state before reading it.
Invalid saved data is retained until the user explicitly saves an edit. Storage
read/write failures are surfaced in the UI.
The selected relay profile is browser-wide, not pinned to a column. Reopening
a deck uses that current profile and fetches first pages. Pagination is manual;
there is no polling or background collection. Tabs do not synchronize deck
edits; the last edit written to localStorage wins. LocalStorage is an initial
single-browser workspace, not the eventual research archive.
## Adding the second platform
1. Add a real provider-specific source schema and a validated server-side
search operation. Keep authentication on the server. Extend the source
union and put dispatch at the feed boundary, outside the post card.
2. Implement normalization with stable provider identity. For Mastodon, do not
treat an instance-local numeric ID as globally unique; for Bluesky prefer
the canonical record identity; for Nostr use event identity. The human
original-post URL and the deduplication identity are separate fields.
3. Add a connection reference when per-column accounts, Mastodon instances,
or Nostr relay sets are introduced. Include it in query-cache identity.
4. Support multiple sources within a column only when a second connector can
exercise it. Each source needs its own continuation and error state. Do not
merge opaque upstream cursors into one cross-platform cursor or claim a
globally complete chronological feed from separately ranked searches.
Search capability is connection-dependent. Mastodon documents that status
search depends on the instance's search backend and authentication:
[Mastodon search API](https://docs.joinmastodon.org/methods/search/).
Nostr's full-text search is an optional relay capability:
[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md).
Validate actual connection capabilities when those connectors are added.
Threads authorization and Bluesky endpoint behavior must likewise be verified
when implementing those adapters, not inferred from Twitter's contract.
## AI increment
The next product slice should turn a topic into validated column definitions,
then let the user refine them. AI-generated conditions use the same schema as
manual edits. Summaries should reference a persisted collection snapshot
(post keys, original URLs, retrieval time, source/query and profile context),
so a later refresh does not change the evidence behind an earlier claim.
LocalStorage of conditions alone does not provide this evidence store.
ACP or Codex app-server can connect the planner to the application, but they
are not part of the platform data model or this implementation.
A planner can generate definitions through the existing schema and tools.
Summaries will need persisted collection snapshots: post identity and URL,
retrieval time, source conditions, and profile context. Saved conditions alone
do not preserve the evidence behind a summary. ACP or Codex app-server may
connect a future planner, but neither is part of this implementation.
## Verification
Unit tests cover normalization, source/definition validation, ordering and
storage errors. Playwright tests use the existing standalone mock relay through
the real server functions to verify independent pagination, retry, editing,
ordering, reload, deletion/undo, and invalid saved data. They also check viewport
widths 320/375/414/768 and run Axe on the populated deck.
No live SNS search is required by these tests.
Unit tests cover normalization, source/workspace validation, ordering,
profile-specific caching and pagination, profile discovery failures, and
storage behavior. Playwright uses a standalone mock relay through real server
functions to exercise deck switching, column/profile editing, list selection,
pagination, persistence, and native WebMCP. Accessibility checks run on the
workspace. Automated tests do not need a live SNS search.
+81 -96
View File
@@ -1,98 +1,88 @@
# WebMCP prototype
## Scope and design
## Scope and registration
Expose the existing intentional reader to agents through three React-owned
reader tools. The deck additionally exposes `get_deck` and `set_deck` on `/deck`;
see [deck tool contracts](research-decks.md#deck-webmcp-tools).
Search remains available across routes. Feed tools are registered only
while a valid timeline, search, or conversation is open. Returning home or to
an empty search/list page removes the feed tools. Unsupported browsers do not
register tools or fetch posts automatically.
The deck workspace exposes seven React-owned tools on `/` and `/deck`.
`usewebmcp` owns native browser registration and cleanup. There is no polyfill
or external MCP transport. Unsupported browsers retain the manual UI. Tools
are enabled after local storage loads; relay-profile discovery may still be
pending, which `list_decks` reports as `profiles: null`.
`usewebmcp` 5.1.0 owns browser registration and cleanup. No polyfill is
initialized. The root search tool navigates through TanStack Router and awaits
the same query options as the visible feed. Completed data is reused through
`ensureInfiniteQueryData`; an invalidated or in-progress query is awaited
through `fetchInfiniteQuery`, including refreshes after a profile change.
Reading the feed uses its current React Query result. Continuation and the
scroll observer use `fetchNextPage({ cancelRefetch: false })` to join an
existing request. A feed change during continuation returns an error instead
of reporting old results as belonging to the new feed.
The former `search_posts`, `get_loaded_posts`, and `load_more_posts` tools and
standalone reader routes have been removed. Agents manage named decks and
address columns explicitly, including their bound relay profiles.
## Tool contracts
## Workspace tools
### `search_posts`
Search X, show the criteria and results in the UI, and return the first slice
after retrieval. Searches use the currently selected relay profile.
Inputs match the existing search controls:
| Field | Default | Meaning |
| Tool | Input | Behavior |
| --- | --- | --- |
| `q` | empty | Search text, including raw X search syntax |
| `from` | empty | Author handle, optionally prefixed with `@` |
| `since` | empty | Inclusive `YYYY-MM-DD` date using X search semantics |
| `until` | empty | Exclusive `YYYY-MM-DD` date, later than `since` |
| `lang` | `all` | `all`, `ja`, or `en` |
| `content` | `all` | `all`, `images`, `videos`, or `links` |
| `excludeReplies`, `excludeReposts` | `false` | Exclusions |
| `product` | `Latest` | `Latest` or `Top` |
| `following` | `false` | Restrict to followed accounts |
| `list_decks` | `{}` | Return all deck definitions, `activeDeckId`, available `profiles`, and `storageError` |
| `get_deck` | Optional `deckId` | Read a saved deck; omitted ID selects the active deck |
| `set_deck` | Optional `deckId`, required `title` and `columns` | Create when ID is omitted; otherwise replace an existing deck, then activate it |
| `select_deck` | `deckId` | Activate a saved deck and persist the selection |
| `delete_deck` | `deckId` | Permanently remove the definition; cannot delete the last deck |
Supply `q` or `from`. Unknown properties and invalid inputs fail before
navigation. Existing query construction validates dates, handles, and the
512-character compiled-query limit. A concurrent search fails with an
actionable message. Navigating elsewhere during a search prevents a stale
success response.
`set_deck` accepts at most six columns. Each requires `title`, `profileName`
from `list_decks`, and a discriminated `source`. Its `kind` is `search`, `user`,
or `list`; `platform` defaults to `twitter`. Searches require `query`, with
`product` defaulting to `Latest` and `following` to false. User and list sources
require `target` (handle/profile URL or list ID/URL). Unknown source fields,
invalid targets, duplicate column IDs, and unknown profiles fail before saving.
### `get_loaded_posts`
Read before editing. Include every column to keep; omitted columns are removed.
Preserve IDs for retained columns and omit IDs for new ones. An empty columns
array clears a deck. A supplied deck ID must already exist. Successful mutation
closes unsaved editor forms. `set_deck` returns the applied definition,
`persisted: true`, and `posts: "loading-asynchronously"`; searches can fail
independently after the save succeeds.
Return a slice of the current feed without a network request. Accepts
`offset` (default 0, nonnegative integer) and `limit` (default 20, integer
1–50). Conversation results place the selected post first, once.
Deleting the active deck selects the first remaining one. Deletion has no
workspace-tool undo. Storage failures return `isError: true` explaining that
the mutation applied in memory but could not be persisted. Saving replaces
invalid or legacy saved data; there is no automatic legacy migration.
The response includes `status` (`loading`, `ready`, or `error`), `loading`,
and `error` information alongside the common result. This is a snapshot of
all loaded posts, not just posts inside the viewport.
## Column tools
### `load_more_posts`
`get_column_posts` accepts `columnId`, `offset` (default 0, nonnegative integer),
and `limit` (default 20, integer 1–50). It reads already loaded posts without a
network request. Only columns mounted in the active deck are available; select
the deck and allow it to render first.
Accepts `{}`. Wait for one continuation, append it to the UI, and return up to
20 newly appended posts. An in-progress scroll request is shared. Initial
loading or a feed refresh must finish first. A failed continuation can be retried explicitly.
At the end of the feed the result is an empty `posts` array and `hasMore: false`.
`load_more_column` accepts `columnId`. It loads or retries one continuation
using that column's bound profile. Wait for its initial load or refresh before
calling. Concurrent pagination joins the existing request. If the deck or
column changes during the request, the tool reports an error instead of
returning results under the new identity. At the end, it returns no appended
posts and `hasMore: false`.
Both return:
- `column`: ID, title, bound profile, and source definition
- `status`: `loading`, `ready`, or `error`, plus `loading` and `error` details
- `posts`: normalized records with original URLs, identity, text, author,
and available media/quotes
- `loadedCount`, `offset`, and `nextOffset` for slicing deduplicated cached posts
- `hasMore`: whether the current feed has an upstream continuation
Continuation returns up to 20 newly appended posts. Use `nextOffset` with
`get_column_posts` to read additional already loaded records, and
`load_more_column` for an upstream page. These are cache snapshots; manual UI
refreshes and pagination can change the available records.
## Results and errors
Successful tool results contain JSON in an MCP text content block:
Tools return JSON in an MCP text content block. Execution failures set
`isError: true` with `code`, `message`, and `retryable`. Schema failures use
`invalid-input`; other tool failures use `tool-error`. Column snapshots report
underlying relay failures through their `error` field. An empty successful
query is not an error.
- `request`: active feed kind and normalized criteria.
- `posts`: `id`, `author` (username/name), `text`, `textTruncated`, optional
`createdAt`, and the original X `url`.
- `loadedCount`: number of deduplicated, loaded posts.
- `offset`: start of this slice within the loaded feed.
- `nextOffset`: next unread offset within the already loaded feed, or `null`.
- `hasMore`: whether the last loaded page has an upstream continuation.
`list_decks`, `get_deck`, and `get_column_posts` carry `readOnlyHint: true`.
The other tools change local UI or storage. `delete_deck` carries
`destructiveHint: true`; tools returning external posts mark them untrusted.
Annotations are metadata, not authorization controls. No tool writes to X.
Use `get_loaded_posts` with `nextOffset` for loaded posts outside a returned
slice; use `load_more_posts` for an upstream continuation. Scrolling can load
more posts independently, so these fields describe a snapshot. Post text is
capped at 2,000 characters per post and explicitly marked when truncated;
the source URL remains available. Media, quote bodies, and article previews
are not included in this initial text-oriented tool response.
Failures use `isError: true` and JSON containing `code`, `message`, and
`retryable`. Existing relay error details are preserved. A successful empty
search is not an error.
All tools mark returned external content with `untrustedContentHint: true`.
Only `get_loaded_posts` has `readOnlyHint: true`: search and continuation
change local UI state. No tool performs X write actions. Annotations are
metadata, not authorization controls.
## Verification and limits
## Verification and browser setup
```sh
nix develop -c pnpm test
@@ -100,24 +90,19 @@ nix develop -c pnpm typecheck
nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts
```
The E2E file enables native Chromium WebMCP/testing flags and uses
`navigator.modelContextTesting` to invoke actual registered tools. The
standalone mock relay supplies deterministic data through the production
server-function boundary. Tests do not contact X or a personal relay.
The E2E tests enable native Chromium WebMCP/testing flags and use
`navigator.modelContextTesting` to invoke actual registered tools. A mock
relay supplies deterministic responses through real server functions.
For interactive testing, enable Chrome's WebMCP testing flag, restart, run
`nix develop -c pnpm dev`, and open the local site with Model Context Tool
Inspector. Registration uses `document.modelContext`; availability is checked
at mount, so reload after changing browser support. No production origin-trial
token or external MCP-client bridge is configured by this prototype.
For interactive testing, enable `chrome://flags/#enable-webmcp-testing`,
restart Chrome, and use Model Context Tool Inspector on the app. Registration
uses `document.modelContext`; reload after changing browser support. Native
WebMCP requires a secure context: local loopback works for development; remote
Tailscale access should use an HTTPS Serve origin. Allow the exact hostname
through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment.
HTTP and HTTPS have separate browser-local workspaces.
The hook does not forward the browser's execution AbortSignal to application
callbacks. Browser cancellation therefore does not guarantee cancellation of
the shared read request. Tools do detect navigation changes before returning
their asynchronous results. Agent task-selection quality still needs manual
evaluation with the consuming agent; deterministic browser tests verify the
tool contracts and UI behavior.
Design references: [Chrome best practices](https://developer.chrome.com/docs/ai/webmcp/best-practices),
[workflow design](https://developer.chrome.com/docs/ai/webmcp/build-tools),
and [usewebmcp](https://github.com/WebMCP-org/npm-packages/tree/main/packages/usewebmcp).
No production origin-trial token or external MCP-client bridge is configured.
Browser cancellation does not guarantee cancellation of a shared feed request.
Agent task-selection quality still needs evaluation with the consuming agent;
automated browser tests verify contracts and UI behavior.
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('home page has no accessibility violations', async ({
a11y,
homePage,
}) => {
await homePage.goTo()
await expect(homePage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from '../fixtures'
test('list picker has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('list timeline has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(listsPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-43
View File
@@ -1,43 +0,0 @@
import { expect, test } from '../fixtures'
test('empty search page has no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('search results have no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await searchPage.search('AI lang:ja')
await expect(searchPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('open filters and active condition chips have no accessibility violations', async ({
a11y,
page,
searchPage,
}) => {
await searchPage.goTo()
await searchPage.queryInputLocator.fill('TypeScript')
await page.getByRole('button', { name: '投稿者' }).click()
await page.getByLabel('投稿者').fill('@tan_stack')
await page.getByRole('button', { name: '言語' }).click()
await page.getByLabel('日本語').check()
await expect(
page.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('conversation has no accessibility violations', async ({
a11y,
threadPage,
}) => {
await threadPage.goTo('yuta', '1001')
await expect(threadPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('user timeline has no accessibility violations', async ({
a11y,
userPage,
}) => {
await userPage.goTo('yuta')
await expect(userPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
+4 -24
View File
@@ -1,34 +1,14 @@
import AxeBuilder from '@axe-core/playwright'
import { test as base } from '@playwright/test'
import { HomePage } from './models/HomePage'
import { ListsPage } from './models/ListsPage'
import { SearchPage } from './models/SearchPage'
import { ThreadPage } from './models/ThreadPage'
import { UserPage } from './models/UserPage'
type Fixtures = {
a11y: () => AxeBuilder
homePage: HomePage
listsPage: ListsPage
searchPage: SearchPage
threadPage: ThreadPage
userPage: UserPage
}
export const test = base.extend<Fixtures>({
export const test = base.extend<{ a11y: () => AxeBuilder }>({
a11y: async ({ page }, use) => {
const makeAxeBuilder = () =>
await use(() =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['meta-viewport'])
await use(makeAxeBuilder)
.disableRules(['meta-viewport']),
)
},
homePage: ({ page }, use) => use(new HomePage(page)),
listsPage: ({ page }, use) => use(new ListsPage(page)),
searchPage: ({ page }, use) => use(new SearchPage(page)),
threadPage: ({ page }, use) => use(new ThreadPage(page)),
userPage: ({ page }, use) => use(new UserPage(page)),
})
export { expect } from '@playwright/test'
+174 -100
View File
@@ -5,13 +5,28 @@ async function addColumn(
page: Page,
title: string,
query: string,
top = false,
profile = 'e2e',
kind = 'search',
) {
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title)
await editor.getByLabel('Twitterの検索条件').fill(query)
if (top) await editor.getByLabel('表示順').selectOption('Top')
await editor
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption(profile)
await editor
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption(kind)
await editor
.getByLabel(
kind === 'search'
? 'Twitterの検索条件'
: kind === 'user'
? 'ユーザー'
: 'リスト',
{ exact: true },
)
.fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click()
await expect(
page
@@ -22,63 +37,114 @@ async function addColumn(
}
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('creates independent columns, edits, reorders, and restores saved conditions', async ({
test('binds identical searches to independent profiles through paging, editing and reload', async ({
page,
}) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('WebMCPの反応')
await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '話題の投稿', 'WebMCP', true)
const first = page.getByRole('region', { name: '日本語の反応', exact: true })
const second = page.getByRole('region', { name: '話題の投稿', exact: true })
await addColumn(page, '通常の接続', 'same-query')
await addColumn(page, '別の接続', 'same-query', 'alternate')
const first = page.getByRole('region', { name: '通常の接続', exact: true })
const second = page.getByRole('region', { name: '別の接続', exact: true })
await expect(
first.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(
second.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await first.getByRole('button', { name: '続きを読み込む' }).click()
await expect(first.locator('article')).toHaveCount(2)
await expect(second.locator('article')).toHaveCount(1)
await second.getByRole('button', { name: '話題の投稿を左へ' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'話題の投稿',
'日本語の反応',
])
await second.getByRole('button', { name: '続きを読み込む' }).click()
await expect(
second.getByText('alternate · Latest · all page 2'),
).toBeVisible()
await second.locator('summary[aria-label="別の接続の操作"]').click()
await second.getByRole('button', { name: '編集', exact: true }).click()
await page
.getByRole('form', { name: 'カラムを編集' })
.getByLabel('カラム名', { exact: true })
.fill('公式の投稿')
await page.getByLabel('Twitterの検索条件').fill('from:OpenAI')
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('e2e')
await page.getByRole('button', { name: '変更を保存' }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'WebMCPの反応',
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
0,
)
await expect(page.locator('.deck-column h2')).toHaveText([
'公式の投稿',
'日本語の反応',
])
const official = page.getByRole('region', { name: '公式の投稿', exact: true })
await expect(official.locator('.deck-query')).toHaveText('from:OpenAI')
await expect(official.locator('article')).toHaveCount(1)
await official.getByRole('button', { name: '公式の投稿を削除' }).click()
await expect(page.locator('.deck-column')).toHaveCount(1)
await page.getByRole('button', { name: '元に戻す' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'公式の投稿',
'日本語の反応',
])
expect(errors).toEqual([])
await page.reload()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(first.locator('article')).toHaveCount(1)
})
test('keeps other columns usable when pagination fails and retries only that column', async ({
test('creates, switches, renames and deletes persisted deck profiles', async ({
page,
}) => {
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '最初の観点', 'first-deck')
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate')
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '最初の調査' })
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '別の調査' })
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
await page.getByRole('button', { name: '削除する', exact: true }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
).toHaveCount(1)
})
test('reads user and list timelines within columns and reorders, deletes and restores them', async ({
page,
}) => {
await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user')
await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list')
const user = page.getByRole('region', { name: 'アカウント', exact: true })
const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
await list.getByRole('button', { name: '続きを読み込む' }).click()
await expect(list.getByText('list page 2')).toBeVisible()
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
await list.getByRole('button', { name: 'リストの投稿を削除' }).click()
await page.getByRole('button', { name: '元に戻す' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await page.reload()
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
})
test('keeps other columns usable when pagination fails and retries that column', async ({
page,
}, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
await addColumn(page, '別の検索', 'independent-deck', true)
await addColumn(page, '別の検索', 'independent-deck')
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click()
@@ -88,15 +154,15 @@ test('keeps other columns usable when pagination fails and retries only that col
await expect(other.locator('article')).toHaveCount(2)
await failed.getByRole('button', { name: '再試行' }).click()
await expect(failed.locator('article')).toHaveCount(2)
await expect(failed.getByRole('alert')).toHaveCount(0)
})
test('keeps columns within narrow viewports and accessible', async ({
test('keeps the deck and editor accessible within narrow viewports', async ({
page,
a11y,
}, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '海外の反応', 'WebMCP lang:en', true)
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate')
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
@@ -104,16 +170,36 @@ test('keeps columns within narrow viewports and accessible', async ({
() => document.documentElement.scrollWidth > innerWidth,
),
).toBe(false)
expect(
await page.evaluate(
() => document.documentElement.scrollHeight <= innerHeight,
),
).toBe(true)
const board = await page.locator('.deck-board').boundingBox()
expect(board).not.toBeNull()
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901)
}
await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393,
height: 900,
})
await expect(
page
.getByRole('region', { name: '日本語の反応', exact: true })
.locator('article'),
).toHaveCount(1)
if (testInfo.project.name === 'desktop') {
await page
.getByRole('navigation', { name: 'カラム一覧' })
.getByRole('button', { name: '公式アカウント' })
.click()
await expect(
page
.getByRole('region', { name: '公式アカウント', exact: true })
.locator('.deck-column-results'),
).toBeFocused()
}
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck-populated.png'),
fullPage: true,
})
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck.png'),
@@ -121,9 +207,22 @@ test('keeps columns within narrow viewports and accessible', async ({
})
})
test('does not overwrite an unreadable saved deck on reload', async ({
test('closes the column editor with Escape and restores focus without applying edits', async ({
page,
}) => {
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
await opener.click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0)
await opener.click()
await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('')
})
test('does not overwrite unreadable storage on reload', async ({ page }) => {
await page.evaluate(() =>
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
@@ -136,58 +235,33 @@ test('does not overwrite an unreadable saved deck on reload', async ({
).toBe('{broken')
})
test('keeps the six-column limit when undo and add overlap', async ({
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
page,
}) => {
await page.evaluate(() =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify({
version: 1,
title: '上限の確認',
columns: Array.from({ length: 6 }, (_, index) => ({
id: `column-${index}`,
title: `観点${index}`,
source: {
platform: 'twitter',
query: `limit-${index}`,
product: 'Latest',
following: false,
},
})),
}),
),
)
await page.reload()
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: 'カラムを追加', exact: true }),
).toBeDisabled()
await page.getByRole('button', { name: '観点0を削除' }).click()
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
await page.getByLabel('カラム名', { exact: true }).fill('追加の観点')
await page.getByLabel('Twitterの検索条件').fill('new-limit')
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(page.locator('.deck-column')).toHaveCount(6)
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
await page.reload()
await expect(page.locator('.deck-column')).toHaveCount(6)
})
test('creates columns without the secure-context-only randomUUID API', async ({
page,
}) => {
await page.addInitScript(() =>
Object.defineProperty(crypto, 'randomUUID', { value: undefined }),
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption('list')
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toBeVisible()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('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('リスト', { exact: true })).toHaveValue(
'1234567890',
)
await page.reload()
await page.waitForLoadState('networkidle')
await addColumn(page, 'HTTP接続からの調査', 'http-deck')
await page.reload()
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(
page
.getByRole('region', { name: 'HTTP接続からの調査', exact: true })
.locator('article'),
).toHaveCount(1)
.getByRole('region', { name: '購読リスト', exact: true })
.getByText('list page 1', { exact: true }),
).toBeVisible()
})
-241
View File
@@ -1,241 +0,0 @@
import { expect, test } from '../fixtures'
let consoleErrors: string[]
let pageErrors: string[]
test.beforeEach(async ({ page }) => {
consoleErrors = []
pageErrors = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
})
test.afterEach(() => {
expect(consoleErrors, 'browser console errors').toEqual([])
expect(pageErrors, 'uncaught browser errors').toEqual([])
})
test('requires intent and infinitely loads a user timeline', async ({
homePage,
page,
userPage,
}) => {
await homePage.goTo()
await expect(page).toHaveURL(/\/$/)
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
'content',
'no-referrer',
)
await expect(homePage.articlesLocator).toHaveCount(0)
await expect(homePage.ambientLinksLocator).toHaveCount(0)
await homePage.displayUser('@yuta')
await expect(page).toHaveURL(/\/yuta$/)
await expect(page.getByText('user page 1')).toBeVisible()
await expect(page.getByText('user page 2')).toBeVisible()
await expect(userPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-user.png', {
animations: 'disabled',
fullPage: true,
})
})
test('switches the relay profile and reloads visible content', async ({
homePage,
page,
}) => {
await homePage.goTo()
const profile = page.getByLabel('プロファイル')
await expect(profile).toHaveValue('e2e')
await homePage.displayUser('@yuta')
await expect(page.getByText('user page 1')).toBeVisible()
await profile.selectOption('alternate')
await expect(page.getByRole('status')).toHaveText('切り替えました。')
await expect(page.getByText('alternate profile page 1')).toBeVisible()
})
test('keeps the profile selector within narrow viewports', async ({
homePage,
page,
}) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
await homePage.goTo()
await expect(page.getByLabel('プロファイル')).toBeVisible()
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
),
).toBe(false)
}
})
test('selects an authenticated list and infinitely loads its timeline', async ({
listsPage,
page,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible()
await expect(listsPage.endOfFeedLocator).toBeVisible()
})
test('opens a card detail and infinitely loads its conversation', async ({
page,
threadPage,
userPage,
}) => {
await userPage.goTo('yuta')
await userPage.openPostDetails('user page 1')
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
await expect(threadPage.headingLocator).toBeVisible()
await expect(threadPage.selectedPostLabelLocator).toBeVisible()
await expect(page.getByText('user page 1')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-thread.png', {
animations: 'disabled',
fullPage: true,
})
})
test('resolves a reply root and retries only the failed continuation', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '2001')
await expect(page.getByText('retry focal')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('retry focal')).toHaveCount(1)
await threadPage.retryContinuation()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
})
test('ends a thread without loading a continuation when there are no replies', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '3001')
await expect(page.getByText('no replies')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
})
test('searches Top posts from followed accounts', async ({
page,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.searchTopFromFollowing('AI lang:ja')
await expect(searchPage.followingOnlyLocator).toBeChecked()
await expect(page).toHaveURL(/following=true/)
await expect(page.getByText('Top · follows page 1')).toBeVisible()
await expect(page.getByText('Top · follows page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('retains the first page through a pagination error and retries explicitly', async ({
page,
searchPage,
}, testInfo) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`retry-${testInfo.project.name}`)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
await searchPage.retryContinuation()
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
searchPage,
}) => {
await searchPage.goTo()
await searchPage.tabTo(
searchPage.page.getByRole('combobox', { name: 'プロファイル' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'デッキ' }))
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
await searchPage.tabTo(
searchPage.page.getByRole('link', { name: 'ユーザー' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator)
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿者' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '期間' }))
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '言語' }))
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿の種類' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '除外' }))
await searchPage.tabTo(
searchPage.page.getByText('検索式を確認', { exact: true }),
)
await expect(searchPage.latestRankingLocator).toBeChecked()
await searchPage.tabTo(searchPage.latestRankingLocator)
await searchPage.tabTo(searchPage.followingOnlyLocator)
await searchPage.tabTo(searchPage.searchButtonLocator)
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
})
test('removes loading rail animation for reduced motion', async ({
page,
searchPage,
}, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`slow-${testInfo.project.name}`)
const rail = searchPage.loadingRailLocator
await expect(rail).toBeVisible()
await expect
.poll(() =>
rail.evaluate((element) =>
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
),
)
.toBe('none')
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

+178 -259
View File
@@ -1,26 +1,15 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
import { SearchPage } from '../models/SearchPage'
type NativeTesting = {
listTools(): { name: string }[]
executeTool(name: string, input: string): Promise<string | null>
}
type ToolResult = {
content: { type: string; text: string }[]
isError?: boolean
}
type FeedResult = {
request: { kind: string; query: string; product: string; following: boolean }
posts: { id: string; text: string; url: string }[]
loadedCount: number
offset: number
nextOffset: number | null
hasMore: boolean
}
test.use({
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
@@ -38,7 +27,6 @@ async function toolNames(page: Page) {
.sort(),
)
}
async function executeTool(
page: Page,
name: string,
@@ -55,290 +43,139 @@ async function executeTool(
if (result === null) throw new Error(`${name} returned no result`)
return JSON.parse(result)
}
async function executeFeedTool(
page: Page,
name: string,
input: Record<string, unknown> = {},
): Promise<FeedResult> {
const result = await executeTool(page, name, input)
expect(result.isError, JSON.stringify(result.content)).not.toBe(true)
expect(result.content[0]?.type).toBe('text')
return JSON.parse(result.content[0]?.text ?? '')
}
const column = (title: string, profileName = 'e2e') => ({
title,
profileName,
source: { kind: 'search', query: 'WebMCP' },
})
let pageErrors: string[]
test.beforeEach(async ({ page }, testInfo) => {
test.beforeEach(async ({ page }) => {
pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message))
if (testInfo.title === 'joins pagination already started by scrolling') return
// Keep pagination under the tool's control without replacing WebMCP itself.
await page.addInitScript(() => {
window.IntersectionObserver = class {
readonly root = null
readonly rootMargin = '0px'
readonly scrollMargin = '0px'
readonly thresholds = [0]
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
})
})
test.afterEach(() => {
expect(pageErrors, 'uncaught browser errors').toEqual([])
})
test('searches through native WebMCP and keeps results synchronized with the UI', async ({
homePage,
page,
searchPage,
}) => {
await homePage.goTo()
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
const first = await executeFeedTool(page, 'search_posts', {
q: 'AI',
product: 'Top',
following: true,
})
expect(first.request).toMatchObject({
kind: 'search',
query: 'AI filter:follows',
product: 'Top',
following: true,
})
expect(first.posts.map((post) => post.text)).toEqual(['Top · follows page 1'])
expect(first).toMatchObject({ loadedCount: 1, offset: 0, hasMore: true })
await expect(page).toHaveURL(/\/search\?/)
await expect(searchPage.queryInputLocator).toHaveValue('AI')
await expect(searchPage.topRankingLocator).toBeChecked()
await expect(searchPage.followingOnlyLocator).toBeChecked()
await expect(
page.getByText(first.posts[0]?.text ?? '', { exact: true }),
).toBeVisible()
await expect
.poll(() => toolNames(page))
.toEqual(['get_loaded_posts', 'load_more_posts', 'search_posts'])
const more = await executeFeedTool(page, 'load_more_posts')
expect(more.posts.map((post) => post.text)).toEqual(['Top · follows page 2'])
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
await expect(searchPage.articlesLocator).toHaveCount(2)
await expect(searchPage.endOfFeedLocator).toBeVisible()
const slice = await executeFeedTool(page, 'get_loaded_posts', {
offset: 1,
limit: 1,
})
expect(slice.posts).toEqual(more.posts)
expect(slice).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
const end = await executeFeedTool(page, 'load_more_posts')
expect(end).toMatchObject({ posts: [], loadedCount: 2, hasMore: false })
await expect(searchPage.articlesLocator).toHaveCount(2)
// SPA navigation must unregister the old feed's tools in the same document.
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
})
test('rejects invalid search conditions without navigating or dropping the active feed', async ({
homePage,
page,
searchPage,
}) => {
await homePage.goTo()
await expect.poll(() => toolNames(page)).toContain('search_posts')
const first = await executeFeedTool(page, 'search_posts', { q: 'existing' })
const previousURL = page.url()
const result = await executeTool(page, 'search_posts', {
q: 'invalid dates',
since: '2026-09-24',
until: '2026-09-23',
})
expect(result.isError).toBe(true)
expect(result.content[0]?.type).toBe('text')
expect(() => JSON.parse(result.content[0]?.text ?? '')).not.toThrow()
await expect(page).toHaveURL(previousURL)
await expect(searchPage.queryInputLocator).toHaveValue('existing')
const current = await executeFeedTool(page, 'get_loaded_posts')
expect(current.posts).toEqual(first.posts)
})
test('retries a failed continuation without losing the first page', async ({
homePage,
page,
searchPage,
}, testInfo) => {
await homePage.goTo()
await expect.poll(() => toolNames(page)).toContain('search_posts')
const first = await executeFeedTool(page, 'search_posts', {
q: `retry-webmcp-${testInfo.project.name}`,
})
const failed = await executeTool(page, 'load_more_posts')
expect(failed.isError, JSON.stringify(failed.content)).toBe(true)
await expect(searchPage.continuationErrorLocator).toBeVisible()
const retained = await executeFeedTool(page, 'get_loaded_posts')
expect(retained.posts).toEqual(first.posts)
const recovered = await executeFeedTool(page, 'load_more_posts')
expect(recovered.posts.map((post) => post.text)).toEqual([
'Latest · all page 2',
])
expect(recovered).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
await expect(searchPage.articlesLocator).toHaveCount(2)
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('joins pagination already started by scrolling', async ({
homePage,
page,
searchPage,
}, testInfo) => {
await homePage.goTo()
await expect.poll(() => toolNames(page)).toContain('search_posts')
await executeFeedTool(page, 'search_posts', {
q: `slow-webmcp-${testInfo.project.name}`,
})
await expect(searchPage.loadingRailLocator).toBeVisible()
const more = await executeFeedTool(page, 'load_more_posts')
expect(more.posts.map((post) => post.text)).toEqual(['Latest · all page 2'])
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
await expect(searchPage.articlesLocator).toHaveCount(2)
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('keeps the normal search form functional without native WebMCP', async ({
playwright,
baseURL,
}, testInfo) => {
const browser = await playwright.chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
})
const page = await browser.newPage({ baseURL })
const searchPage = new SearchPage(page)
try {
page.on('pageerror', (error) => pageErrors.push(error.message))
await searchPage.goTo()
expect(await page.evaluate(() => Boolean(document.modelContext))).toBe(
false,
)
await searchPage.search('ordinary search')
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toBeVisible()
} catch (error) {
await testInfo.attach('unsupported-browser-state', {
body: JSON.stringify({ url: page.url(), html: await page.content() }),
contentType: 'application/json',
})
await testInfo.attach('unsupported-browser-screen', {
body: await page.screenshot(),
contentType: 'image/png',
})
throw error
} finally {
await browser.close()
}
})
test('creates a deck through native WebMCP, edits it, and restores its saved definition', async ({
page,
}) => {
await page.goto('/deck')
await page.waitForLoadState('networkidle')
await expect
.poll(() => toolNames(page))
.toEqual(['get_deck', 'search_posts', 'set_deck'])
const result = await executeTool(page, 'set_deck', {
.toEqual([
'delete_deck',
'get_column_posts',
'get_deck',
'list_decks',
'load_more_column',
'select_deck',
'set_deck',
])
})
test.afterEach(() => expect(pageErrors, 'uncaught browser errors').toEqual([]))
test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({
page,
}) => {
const inventory = await executeTool(page, 'list_decks')
expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([
'e2e',
'alternate',
])
const created = await executeTool(page, 'set_deck', {
title: 'WebMCPの反応',
columns: [
{ id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } },
{ title: '話題', source: { query: 'WebMCP', product: 'Top' } },
{ ...column('日本語'), id: 'new' },
column('別の接続', 'alternate'),
],
})
expect(result.isError).not.toBe(true)
await expect(page.locator('.deck-column h2')).toHaveText(['日本語', '話題'])
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Top · all page 1')).toBeVisible()
const before = await executeTool(page, 'get_deck')
const { deck } = JSON.parse(before.content[0]?.text ?? '')
expect(deck.columns[0].id).toBe('new')
// Tool-provided IDs must not collide with the UI's new-column editing state.
expect(created.isError).not.toBe(true)
const { deck } = JSON.parse(created.content[0]?.text ?? '')
await expect(page.locator('.deck-column h2')).toHaveText([
'日本語',
'別の接続',
])
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await page
.getByRole('region', { name: '日本語', exact: true })
.locator('summary[aria-label="日本語の操作"]')
.click()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '編集', exact: true })
.click()
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue(
'WebMCP lang:ja',
)
const edited = await executeTool(page, 'set_deck', {
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP')
const replaced = await executeTool(page, 'set_deck', {
deckId: deck.id,
title: '更新した調査',
columns: [deck.columns[1]],
})
expect(edited.isError).not.toBe(true)
expect(replaced.isError).not.toBe(true)
await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0)
await expect(page.locator('.deck-column h2')).toHaveText(['話題'])
await expect(page.locator('.deck-column h2')).toHaveText(['別の接続'])
const another = await executeTool(page, 'set_deck', {
title: '別の調査',
columns: [],
})
const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id
expect(
(await executeTool(page, 'select_deck', { deckId: deck.id })).isError,
).not.toBe(true)
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
await expect.poll(() => toolNames(page)).toContain('get_deck')
const restored = await executeTool(page, 'get_deck')
expect(JSON.parse(restored.content[0]?.text ?? '').deck.columns[0].id).toBe(
deck.columns[1].id,
const restored = JSON.parse(
(await executeTool(page, 'get_deck')).content[0]?.text ?? '',
).deck
expect(restored).toEqual({
...deck,
title: '更新した調査',
columns: [deck.columns[1]],
})
expect(
(await executeTool(page, 'delete_deck', { deckId: anotherId })).isError,
).not.toBe(true)
const after = JSON.parse(
(await executeTool(page, 'list_decks')).content[0]?.text ?? '',
)
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
expect(
after.decks.some((item: { id: string }) => item.id === anotherId),
).toBe(false)
expect(after.activeDeckId).toBe(deck.id)
})
test('rejects an invalid deck without changing the visible or saved columns', async ({
test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({
page,
}) => {
await page.goto('/deck')
await page.waitForLoadState('networkidle')
await expect.poll(() => toolNames(page)).toContain('set_deck')
await executeTool(page, 'set_deck', {
title: '残す調査',
columns: [{ title: '残す観点', source: { query: 'AI' } }],
columns: [column('残す観点')],
})
const saved = await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
)
const column = { id: 'duplicate', title: '不正', source: { query: 'AI' } }
const result = await executeTool(page, 'set_deck', {
title: '変更しない',
columns: [column, column],
})
expect(result.isError).toBe(true)
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe(saved)
const duplicate = { ...column('不正'), id: 'duplicate' }
for (const columns of [
[duplicate, duplicate],
[column('不明', 'missing')],
[{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }],
]) {
expect(
(await executeTool(page, 'set_deck', { title: '変更しない', columns }))
.isError,
).toBe(true)
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe(saved)
}
})
test('reports a deck persistence failure instead of claiming it was saved', async ({
test('reports persistence failure while keeping the applied deck visible', async ({
page,
}) => {
await page.goto('/deck')
await page.waitForLoadState('networkidle')
await expect.poll(() => toolNames(page)).toContain('set_deck')
await page.evaluate(() => {
Storage.prototype.setItem = () => {
throw new Error('QuotaExceededError')
const setItem = Storage.prototype.setItem
Storage.prototype.setItem = function (key, value) {
if (this === localStorage) throw new Error('QuotaExceededError')
setItem.call(this, key, value)
}
})
const result = await executeTool(page, 'set_deck', {
@@ -354,3 +191,85 @@ test('reports a deck persistence failure instead of claiming it was saved', asyn
)
await expect(page.getByRole('alert')).toContainText('保存できません')
})
test('keeps manual deck creation functional when WebMCP is disabled', async ({
playwright,
baseURL,
}) => {
const browser = await playwright.chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
})
try {
const page = await browser.newPage({ baseURL })
await page.goto('/')
await page
.getByRole('button', { name: 'カラムを追加', exact: true })
.click()
await page.getByLabel('カラム名', { exact: true }).fill('手動で調査')
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('e2e')
await page.getByLabel('Twitterの検索条件').fill('ordinary-search')
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
} finally {
await browser.close()
}
})
test('reads and pages the bound column profile without mixing sibling feeds', async ({
page,
}) => {
const result = await executeTool(page, 'set_deck', {
title: '接続を比較',
columns: [column('通常'), column('別の接続', 'alternate')],
})
const { deck } = JSON.parse(result.content[0]?.text ?? '')
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
const boundId = deck.columns[1].id
const loaded = JSON.parse(
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
.content[0]?.text ?? '',
)
expect(loaded.column.profileName).toBe('alternate')
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 1',
])
const moreResult = await executeTool(page, 'load_more_column', {
columnId: boundId,
})
expect(moreResult.isError).not.toBe(true)
const more = JSON.parse(moreResult.content[0]?.text ?? '')
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 2',
])
await expect(
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
).toHaveCount(1)
await expect(
page
.getByRole('region', { name: '別の接続', exact: true })
.locator('article'),
).toHaveCount(2)
const slice = JSON.parse(
(
await executeTool(page, 'get_column_posts', {
columnId: boundId,
offset: 1,
limit: 1,
})
).content[0]?.text ?? '',
)
expect(slice.posts).toEqual(more.posts)
await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] })
expect(
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
.isError,
).toBe(true)
})
+5 -2
View File
@@ -218,7 +218,10 @@ const server = createServer(async (request, response) => {
itemContent: {
list: {
id_str: '1234567890',
name: 'My E2E List',
name:
profileName === 'alternate'
? 'Alternate List'
: 'My E2E List',
description: 'Accounts selected for testing',
member_count: 10,
mode: 'Public',
@@ -456,7 +459,7 @@ const server = createServer(async (request, response) => {
}
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
const label = `${variables.product} · ${follows}`
const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}`
const id = encodeURIComponent(rawQuery)
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
-49
View File
@@ -1,49 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test'
export class Base {
readonly ambientLinksLocator: Locator
readonly articlesLocator: Locator
readonly continuationErrorLocator: Locator
readonly endOfFeedLocator: Locator
readonly loadingRailLocator: Locator
readonly mutationButtonsLocator: Locator
readonly mutationLinksLocator: Locator
constructor(readonly page: Page) {
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
const mutationControlNames =
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
this.ambientLinksLocator = page.getByRole('link', {
name: ambientLinkNames,
})
this.articlesLocator = page.locator('article')
this.continuationErrorLocator = page
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
this.loadingRailLocator = page.locator('.loading-rail')
this.mutationButtonsLocator = page.getByRole('button', {
name: mutationControlNames,
})
this.mutationLinksLocator = page.getByRole('link', {
name: mutationControlNames,
})
}
protected async navigateTo(path: string) {
await this.page.goto(path)
await this.page.waitForLoadState('networkidle')
}
async tabTo(target: Locator) {
await this.page.keyboard.press('Tab')
await expect(target).toBeFocused()
}
async retryContinuation() {
await this.continuationErrorLocator
.getByRole('button', { name: '再試行' })
.click()
}
}
-22
View File
@@ -1,22 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class HomePage extends Base {
readonly handleInputLocator: Locator
readonly submitButtonLocator: Locator
constructor(page: Page) {
super(page)
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
}
async goTo() {
await this.navigateTo('/')
}
async displayUser(handle: string) {
await this.handleInputLocator.fill(handle)
await this.submitButtonLocator.click()
}
}
-11
View File
@@ -1,11 +0,0 @@
import { Base } from './Base'
export class ListsPage extends Base {
async goTo() {
await this.navigateTo('/i/lists')
}
async openList(name: RegExp | string) {
await this.page.getByRole('link', { name }).click()
}
}
-35
View File
@@ -1,35 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class SearchPage extends Base {
readonly followingOnlyLocator: Locator
readonly latestRankingLocator: Locator
readonly queryInputLocator: Locator
readonly searchButtonLocator: Locator
readonly topRankingLocator: Locator
constructor(page: Page) {
super(page)
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
this.latestRankingLocator = page.getByLabel('最新順')
this.queryInputLocator = page.getByLabel('検索語')
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
this.topRankingLocator = page.getByLabel('人気順')
}
async goTo() {
await this.navigateTo('/search?q=&product=Latest&following=false')
}
async search(query: string) {
await this.queryInputLocator.fill(query)
await this.searchButtonLocator.click()
}
async searchTopFromFollowing(query: string) {
await this.queryInputLocator.fill(query)
await this.topRankingLocator.check()
await this.followingOnlyLocator.check()
await this.searchButtonLocator.click()
}
}
-17
View File
@@ -1,17 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class ThreadPage extends Base {
readonly headingLocator: Locator
readonly selectedPostLabelLocator: Locator
constructor(page: Page) {
super(page)
this.headingLocator = page.getByRole('heading', { name: '会話' })
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
}
async goTo(handle: string, tweetId: string) {
await this.navigateTo(`/${handle}/status/${tweetId}`)
}
}
-15
View File
@@ -1,15 +0,0 @@
import { Base } from './Base'
export class UserPage extends Base {
async goTo(handle: string) {
await this.navigateTo(`/${handle}`)
}
async openPostDetails(postText: string) {
await this.page
.getByRole('article')
.filter({ hasText: postText })
.getByRole('link', { name: '詳細・スレッド' })
.click()
}
}
-9
View File
@@ -113,13 +113,6 @@
description = "Base URL of the Twitter relay.";
};
profileName = lib.mkOption {
type = lib.types.nullOr lib.types.nonEmptyStr;
default = null;
example = "default";
description = "Relay profile to use when the relay has multiple profiles.";
};
host = lib.mkOption {
type = lib.types.nonEmptyStr;
default = "127.0.0.1";
@@ -142,8 +135,6 @@
HOST = cfg.host;
PORT = toString cfg.port;
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
} // lib.optionalAttrs (cfg.profileName != null) {
BIRD_PROFILE_NAME = cfg.profileName;
};
serviceConfig = {
DynamicUser = true;
+11 -33
View File
@@ -1,43 +1,21 @@
import { Link } from '@tanstack/react-router'
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
import { Icon } from './icon'
export function AppShell({
active,
sidebar,
children,
}: {
active?: 'user' | 'search' | 'list' | 'deck'
sidebar: React.ReactNode
children: React.ReactNode
}) {
return (
<main className={active === 'deck' ? 'app app-deck' : 'app'}>
<header className="app-header">
<p className="brand">Twitter Lite</p>
<ProfileSwitcher />
<nav aria-label="閲覧方法" className="tabs">
<Link
aria-current={active === 'deck' ? 'page' : undefined}
to="/deck"
>
デッキ
</Link>
<Link
aria-current={active === 'search' ? 'page' : undefined}
search={{ q: '', product: 'Latest', following: false }}
to="/search"
>
検索
</Link>
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
ユーザー
</Link>
<Link
aria-current={active === 'list' ? 'page' : undefined}
to="/i/lists"
>
リスト
</Link>
</nav>
</header>
<main className="app app-deck">
<aside className="app-sidebar" aria-label="デッキ管理">
<p className="brand">
<Icon name="columns" />
<span>Twitter Lite</span>
</p>
{sidebar}
</aside>
<section className="reader">{children}</section>
</main>
)
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useId, useRef } from 'react'
import { Icon } from './icon'
export function Dialog({
title,
onClose,
children,
}: {
title: string
onClose: () => void
children: React.ReactNode
}) {
const dialog = useRef<HTMLDialogElement>(null)
const headingId = useId()
useEffect(() => {
const element = dialog.current
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
element?.showModal()
return () => {
element?.close()
if (opener?.isConnected) opener.focus({ preventScroll: true })
}
}, [])
return (
<dialog
ref={dialog}
className="deck-dialog"
aria-labelledby={headingId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
>
<div className="dialog-heading">
<h2 id={headingId}>{title}</h2>
<button
type="button"
className="icon-button"
aria-label="閉じる"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
{children}
</dialog>
)
}
+31
View File
@@ -0,0 +1,31 @@
const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01',
plus: 'M12 5v14 M5 12h14',
close: 'M6 6l12 12 M18 6 6 18',
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
arrow: 'M7 17 17 7 M7 7h10v10',
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
} as const
export function Icon({ name }: { name: keyof typeof paths }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={paths[name]} />
</svg>
)
}
+177 -41
View File
@@ -1,17 +1,66 @@
import { useId, useState } from 'react'
import { useListChoices } from '#/features/posts/use-list-choices'
import { columnSchema, type DeckColumn } from './model'
function ListChoices({
profileName,
onSelect,
}: {
profileName: string
onSelect: (id: string) => void
}) {
const lists = useListChoices(profileName)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
export function ColumnEditor({
column,
profiles,
onSave,
onCancel,
}: {
column?: DeckColumn
profiles: string[]
onSave: (column: DeckColumn) => void
onCancel: () => void
}) {
const formId = useId()
const [error, setError] = useState<string>()
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
column?.source.kind ?? 'search',
)
const [profileName, setProfileName] = useState(
column?.profileName ?? profiles[0] ?? '',
)
const [target, setTarget] = useState(
column && column.source.kind !== 'search' ? column.source.target : '',
)
return (
<form
className="deck-editor"
@@ -19,28 +68,34 @@ export function ColumnEditor({
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const source =
kind === 'search'
? {
platform: 'twitter',
kind,
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
}
: { platform: 'twitter', kind, target }
const parsed = columnSchema.safeParse({
// getRandomValues also works on HTTP tailnet origins.
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
source: {
platform: 'twitter',
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
},
profileName,
source,
})
if (!parsed.success) {
if (!parsed.success || !profiles.includes(profileName)) {
setError(
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
parsed.success
? '接続プロファイルを選択してください。'
: parsed.error.issues[0]?.message,
)
return
}
onSave(parsed.data)
}}
>
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
カラム名
@@ -49,52 +104,133 @@ export function ColumnEditor({
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:日本語での反応"
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={column?.source.query ?? ''}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
aria-describedby={`${formId}-help`}
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
id={`${formId}-product`}
name="product"
defaultValue={column?.source.product ?? 'Latest'}
id={`${formId}-profile`}
value={profileName}
onChange={(event) => setProfileName(event.target.value)}
required
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
<option value="" disabled>
選択してください
</option>
{profileName && !profiles.includes(profileName) ? (
<option value={profileName} disabled>
{profileName}(利用不可)
</option>
) : null}
{profiles.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label htmlFor={`${formId}-kind`}>
カラムの種類
<select
id={`${formId}-kind`}
value={kind}
onChange={(event) => {
setKind(event.target.value as typeof kind)
setTarget('')
}}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
</div>
<p className="deck-help" id={`${formId}-help`}>
from:、lang:、since: など、Twitterの検索構文を使えます。
{kind === 'search' ? (
<div className="deck-source-fields" key="search">
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={
column?.source.kind === 'search' ? column.source.query : ''
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<select
id={`${formId}-product`}
name="product"
defaultValue={
column?.source.kind === 'search'
? column.source.product
: 'Latest'
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={
column?.source.kind === 'search' && column.source.following
}
/>
フォロー中のみ
</label>
</div>
) : (
<div className="deck-source-fields" key={kind}>
<label htmlFor={`${formId}-target`}>
{kind === 'user' ? 'ユーザー' : 'リスト'}
<input
id={`${formId}-target`}
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={
kind === 'user'
? '@handle またはプロフィールURL'
: 'リストのURLまたはID'
}
maxLength={256}
required
/>
</label>
{kind === 'list' && profiles.includes(profileName) ? (
<ListChoices
key={profileName}
profileName={profileName}
onSelect={setTarget}
/>
) : null}
</div>
)}
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={column?.source.following ?? false}
/>
フォロー中のみ
</label>
{error ? (
<p role="alert" className="deck-error">
{error}
</p>
) : null}
<div className="deck-actions">
<button type="submit" className="deck-primary">
<button
type="submit"
className="deck-primary"
disabled={!profiles.includes(profileName)}
>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
+7
View File
@@ -0,0 +1,7 @@
import type { FeedRequest } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
export function columnRequest(column: DeckColumn): FeedRequest {
const { platform: _, ...source } = column.source
return { ...source, profileName: column.profileName }
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
type ColumnFeed = { column: DeckColumn; query: ReturnType<typeof usePostFeed> }
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof usePostFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
useEffect(() => {
registry.set(column.id, entry)
return () => {
if (registry.get(column.id) === entry) registry.delete(column.id)
}
}, [registry, column.id])
}
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20),
})
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenPostPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
return {
column: feed.column,
status: feed.query.isPending
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
loading: feed.query.isFetching,
error:
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
posts: selected.map(mapTwitterPost),
loadedCount: posts.length,
offset,
nextOffset:
offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage),
}
}
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported()
const enabled = supported && ready
function requireColumn(id: string) {
const entry = registry.get(id)
if (!entry)
throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
)
return entry
}
useWebMCP({
name: 'get_column_posts',
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.',
inputSchema: readInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input)
return read(requireColumn(columnId).current, offset, limit)
}),
})
useWebMCP({
name: 'load_more_column',
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.',
inputSchema: columnInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
const { columnId } = columnInput.parse(input)
const entry = requireColumn(columnId)
const { column, query } = entry.current
const identity = JSON.stringify(column)
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.')
if (query.isError && !query.isFetchNextPageError) throw query.error
const offset = flattenPostPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
registry.get(columnId) !== entry ||
JSON.stringify(entry.current.column) !== identity
)
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)
}),
})
}
+79 -43
View File
@@ -1,6 +1,9 @@
import { Icon } from '#/components/icon'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
import { columnRequest } from './column-request'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
@@ -11,6 +14,7 @@ export function ResearchColumn({
onMove,
first,
last,
registry,
}: {
column: DeckColumn
onEdit: () => void
@@ -18,13 +22,10 @@ export function ResearchColumn({
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
}) {
const query = usePostFeed({
kind: 'search',
query: column.source.query,
product: column.source.product,
following: column.source.following,
})
const query = usePostFeed(columnRequest(column))
useRegisterColumn(registry, column, query)
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
const error =
query.error instanceof PostLoadError
@@ -34,50 +35,85 @@ export function ResearchColumn({
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
<div className="deck-column-label">
<span>Twitter</span>
<span>
{column.source.product === 'Latest' ? '最新' : '話題'}
{column.source.following ? ' · フォロー中' : ''}
</span>
</div>
<h2 id={headingId}>{column.title}</h2>
<p className="deck-query">{column.source.query}</p>
<div className="deck-actions">
<button type="button" onClick={onEdit}>
編集
</button>
<div className="deck-column-title">
<Icon name={column.source.kind} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
aria-label="更新"
title="更新"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
更新
</button>
<button
type="button"
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
>
←
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
>
→
</button>
<button
type="button"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
削除
<Icon name="refresh" />
</button>
<details className="column-menu">
<summary
className="icon-button"
aria-label={`${column.title}の操作`}
title="カラムの操作"
>
<Icon name="more" />
</summary>
<div className="column-menu-panel">
<button type="button" onClick={onEdit}>
編集
</button>
<button
type="button"
onClick={(event) => {
onMove(-1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={first}
aria-label={`${column.title}を左へ`}
>
← 左へ移動
</button>
<button
type="button"
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={last}
aria-label={`${column.title}を右へ`}
>
右へ移動 →
</button>
<button
type="button"
className="danger"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
カラムを削除
</button>
</div>
</details>
</div>
<div className="deck-column-label">
<span title={`接続プロファイル: ${column.profileName}`}>
{column.profileName}
</span>
<span>
{column.source.kind === 'search'
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
: column.source.kind === 'user'
? 'ユーザー投稿'
: 'リスト'}
</span>
</div>
<p className="deck-query">
{column.source.kind === 'search'
? column.source.query
: column.source.target}
</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
+164 -67
View File
@@ -1,33 +1,63 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { loadProfiles } from '#/features/profiles/server-functions'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
import { DeckSwitcher } from './deck-switcher'
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
import { useDeck } from './use-deck'
import { useDeckTools } from './webmcp-tools'
import './decks.css'
export function DeckPage() {
const { deck, ready, storageError, save } = useDeck()
const registry = useRef<ColumnRegistry>(new Map()).current
const {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace,
} = useDeck()
const fetchProfiles = useServerFn(loadProfiles)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => fetchProfiles(),
retry: false,
})
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
useColumnTools(registry, ready)
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
useDeckTools({
deck,
ready,
storageError,
save,
onApplied: () => {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
},
select,
remove,
getWorkspace,
profiles: profiles.data?.profiles,
onApplied: clearEditors,
})
function clearEditors() {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
}
function saveColumn(column: DeckColumn) {
save({
...deck,
@@ -42,83 +72,148 @@ export function DeckPage() {
}
return (
<AppShell active="deck">
<AppShell
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={(title) => {
create(title)
clearEditors()
}}
onDelete={() => {
remove(deck.id)
clearEditors()
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<nav className="sidebar-columns" aria-label="カラム一覧">
{deck.columns.map((column) => (
<button
key={column.id}
type="button"
title={column.title}
onClick={() => {
const element = document.getElementById(`column-${column.id}`)
element?.scrollIntoView({ block: 'nearest', inline: 'start' })
element
?.closest('.deck-column')
?.querySelector<HTMLElement>('.deck-column-results')
?.focus({ preventScroll: true })
}}
>
<Icon name={column.source.kind} />
<span>{column.title}</span>
</button>
))}
</nav>
<p className="sidebar-note">このブラウザに保存</p>
</>
}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<p className="deck-help">
観点ごとに検索を並べて、投稿を読み比べる。
</p>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready}
onClick={() => setRenaming(!renaming)}
>
名前を変更
</button>
<button
className="deck-primary"
type="button"
disabled={
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
}
onClick={() => setEditing('new')}
>
カラムを追加
<Icon name="edit" />
</button>
</div>
</div>
<p className="deck-save-status">
{!ready
? 'デッキを読み込んでいます…'
: storageError
? 'デッキの保存状態を確認してください。'
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
</p>
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}>
再試行
</button>
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
</p>
) : null}
{renaming ? (
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
</Dialog>
) : null}
{editing !== null ? (
<ColumnEditor
key={editing === 'new' ? 'new' : editing.id}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
profiles={profiles.data?.profiles ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
@@ -139,9 +234,10 @@ export function DeckPage() {
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<h2>最初の観点を追加しましょう。</h2>
<Icon name="columns" />
<h2>調査のカラムを並べましょう</h2>
<p>
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。
</p>
<button
className="deck-primary"
@@ -157,7 +253,8 @@ export function DeckPage() {
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={column.id}
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
first={index === 0}
last={index === deck.columns.length - 1}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { Deck } from './model'
export function DeckSwitcher({
decks,
activeId,
ready,
onSelect,
onCreate,
onDelete,
}: {
decks: Deck[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => void
onDelete: () => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
return (
<div className="deck-workspaces">
<div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label>
<select
id="deck-selection"
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
setCreating(false)
setDeleting(false)
}}
>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.title}
</option>
))}
</select>
<button
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
type="button"
disabled={!ready}
onClick={() => {
setCreating(!creating)
setDeleting(false)
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
</button>
<button
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready || decks.length <= 1}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
onCreate(title)
setCreating(false)
}}
>
<label htmlFor="new-deck-title">
新しいデッキ名
<input
id="new-deck-title"
name="title"
maxLength={120}
required
/>
</label>
<button type="submit">作成</button>
<button type="button" onClick={() => setCreating(false)}>
キャンセル
</button>
</form>
</Dialog>
) : null}
{deleting ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<button
type="button"
onClick={() => {
onDelete()
setDeleting(false)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
キャンセル
</button>
</div>
</Dialog>
) : null}
</div>
)
}
+381 -142
View File
@@ -1,61 +1,68 @@
/* Hallmark · existing Garden tokens · research deck: horizontal columns
* User-requested application layout; inherit the reader's typography and color.
* pre-emit critique: P4 H4 E4 S5 R5 V4
*/
.app-deck .reader {
min-width: 0;
}
.app-deck button,
.app-deck input,
.app-deck select {
font: inherit;
}
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--ink);
background: var(--paper);
background: var(--surface);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--dur-short) var(--ease-out);
}
.app-deck button:hover:not(:disabled) {
background: var(--surface);
}
.app-deck button:active:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.5;
opacity: 0.45;
cursor: default;
}
.app-deck :focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 3px;
.app-deck .icon-button {
width: 36px;
height: 36px;
padding: var(--space-xs);
border-color: transparent;
background: transparent;
}
.app-deck .deck-primary {
color: var(--color-accent-ink);
background: var(--accent);
border-color: var(--accent);
font-weight: 650;
}
.app-deck .deck-primary:hover:not(:disabled),
.app-deck .deck-primary:active:not(:disabled) {
background: var(--accent-hover);
color: var(--color-accent-ink);
}
.deck-heading {
display: flex;
align-items: start;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
flex-shrink: 0;
gap: var(--space-sm);
min-height: 58px;
padding: var(--space-xs) var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-actions {
display: flex;
@@ -67,131 +74,82 @@
.deck-save-status,
.deck-column-status {
color: var(--secondary);
font-size: var(--text-sm);
line-height: 1.6;
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: var(--space-md) 0;
}
.deck-editor {
margin-block: var(--space-lg);
padding-block: var(--space-lg);
border-block: var(--rule-hairline) solid var(--border);
}
.deck-editor h2,
.deck-empty h2 {
font-size: var(--text-md);
margin: 0 0 var(--space-md);
overflow-wrap: anywhere;
}
.deck-editor-fields {
display: grid;
gap: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input {
min-width: 0;
width: 100%;
min-height: 44px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 44px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin-block: var(--space-md);
}
.deck-rename label {
flex: 1 1 15rem;
}
.deck-error {
color: var(--error);
line-height: 1.6;
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-block: var(--space-md);
overflow-wrap: anywhere;
}
.deck-empty {
max-width: 40rem;
padding-block: var(--space-2xl);
}
.deck-empty p {
line-height: 1.8;
margin-bottom: var(--space-lg);
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(100%, 23rem);
gap: var(--space-md);
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
overflow-y: hidden;
padding: 0 var(--space-xs) var(--space-xs);
scroll-snap-type: x proximity;
scrollbar-color: var(--control-border) var(--canvas);
}
.deck-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: max(30rem, calc(100svh - 20rem));
border: var(--rule-hairline) solid var(--border);
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
padding: var(--space-md);
position: relative;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--surface);
}
.deck-column-title {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.deck-column-title > svg {
color: var(--accent);
}
.deck-column h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-base);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-label {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-xs);
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column h2 {
margin: var(--space-sm) 0;
font-size: var(--text-md);
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-query {
margin: 0 0 var(--space-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
max-height: 4.5em;
overflow-y: auto;
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-status {
margin-bottom: 0;
margin: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
@@ -199,27 +157,70 @@
overflow-y: auto;
flex: 1;
overscroll-behavior-y: contain;
scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper);
}
.column-menu {
position: relative;
}
.column-menu summary {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
list-style: none;
cursor: pointer;
}
.column-menu summary::-webkit-details-marker {
display: none;
}
.column-menu summary:hover,
.column-menu[open] summary {
background: var(--color-paper-3);
}
.column-menu-panel {
position: absolute;
top: 100%;
right: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 158px;
padding: var(--space-2xs);
border: var(--rule-hairline) solid var(--control-border);
background: var(--surface);
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
}
.column-menu-panel button {
justify-content: flex-start;
border: 0;
}
.deck-message {
padding: var(--space-md);
color: var(--secondary);
line-height: 1.7;
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
padding: var(--space-md);
padding: var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-sm);
gap: var(--space-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post header img {
border-radius: 50%;
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
@@ -227,20 +228,22 @@
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.8;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
margin: var(--space-md) 0;
padding-left: var(--space-md);
border-left: var(--rule-hairline) solid var(--border);
margin: var(--space-sm) 0;
padding: var(--space-sm);
border: var(--rule-hairline) solid var(--border);
border-radius: var(--radius-sm);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-md);
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--secondary);
font-size: var(--text-xs);
}
@@ -253,22 +256,258 @@
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-sm);
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
@media (min-width: 52rem) {
.app.app-deck {
width: 100%;
grid-template-columns: 11rem minmax(0, 1fr);
.deck-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: var(--space-lg);
overflow-y: auto;
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-md);
background: var(--paper);
color: var(--ink);
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
}
.deck-dialog::backdrop {
background: var(--color-overlay);
}
.dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.dialog-heading h2,
.deck-editor h2,
.deck-empty h2 {
margin: 0;
font-size: var(--text-md);
overflow-wrap: anywhere;
}
.deck-editor {
margin: 0;
padding: 0;
}
.deck-editor-fields,
.deck-source-fields {
display: grid;
gap: var(--space-md);
}
.deck-source-fields {
margin-block: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input,
.deck-workspaces select {
min-width: 0;
width: 100%;
min-height: 40px;
padding: var(--space-xs);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--canvas);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin: 0;
}
.deck-rename label {
flex: 1 1 100%;
}
.deck-error {
color: var(--error);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.reader > .deck-error {
margin: 0;
padding: var(--space-xs) var(--space-md);
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.deck-undo p {
margin: 0;
}
.deck-empty {
margin: auto;
max-width: 32rem;
padding: var(--space-lg);
}
.deck-empty p {
color: var(--secondary);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
.deck-workspaces {
min-width: 0;
}
.deck-workspaces > .deck-actions {
align-items: stretch;
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > select {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
flex: 1;
}
.deck-list-choices {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.app-deck .sidebar-add {
width: 100%;
}
@media (max-width: 767px) {
.deck-workspaces {
flex: 1;
}
.app-deck .reader {
padding: var(--space-xl) var(--space-lg);
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.deck-editor-fields {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
.deck-workspaces > .deck-actions > label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.deck-workspaces > .deck-actions > select {
flex: 1;
width: 0;
}
.deck-workspaces > .deck-actions > button {
flex: none;
}
.app-deck .sidebar-add {
width: 36px;
flex-shrink: 0;
padding: var(--space-xs);
}
.sidebar-add span {
display: none;
}
.deck-heading {
min-height: 52px;
padding-inline: var(--space-sm);
}
.deck-heading .deck-actions {
flex-wrap: nowrap;
}
.deck-board {
grid-auto-columns: min(328px, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.app-deck button {
transition: none;
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count {
color: var(--secondary);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
@media (max-width: 767px) {
.workspace-create span,
.workspace-delete span {
display: none;
}
.app-deck .workspace-create,
.app-deck .workspace-delete {
width: 36px;
padding: var(--space-xs);
}
}
.sidebar-columns {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
min-height: 0;
overflow-y: auto;
}
.app-deck .sidebar-columns button {
flex-shrink: 0;
justify-content: flex-start;
width: 100%;
background: transparent;
border-color: transparent;
}
.sidebar-columns span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.research-post header > div {
flex: 1;
}
.research-post header time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.research-post footer a {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.research-post footer svg {
width: 14px;
height: 14px;
}
.column-menu-panel .danger {
color: var(--error);
}
@media (max-width: 767px) {
.sidebar-columns {
display: none;
}
}
+64 -10
View File
@@ -1,26 +1,67 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
const deck: Deck = {
version: 1,
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id,
title: id,
profileName: 'main',
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native Twitter search conditions when serialized', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('rejects duplicate column identities and unsupported platforms', () => {
it('normalizes user and list targets and rejects invalid targets', () => {
const column = deck.columns[0]
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' },
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: {
platform: 'twitter',
kind: 'list',
target: 'https://x.com/i/lists/123',
},
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
},
],
}).success,
).toBe(false)
})
it('rejects duplicate identities and missing profiles', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -30,13 +71,25 @@ describe('deck definitions', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
],
columns: [{ ...deck.columns[0], profileName: '' }],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: 'missing',
decks: [deck],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: deck.id,
decks: [deck, deck],
}).success,
).toBe(false)
})
it('rejects queries that exceed the upstream limit after adding follows', () => {
it('rejects queries exceeding the upstream limit after adding follows', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -45,6 +98,7 @@ describe('deck definitions', () => {
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
following: true,
@@ -54,7 +108,7 @@ describe('deck definitions', () => {
}).success,
).toBe(false)
})
it('moves a column without changing its query or the previous deck', () => {
it('moves a column without changing its conditions or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
+82 -8
View File
@@ -1,13 +1,16 @@
import { z } from 'zod'
import { buildSearchQuery } from '#/features/posts/inputs'
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const MAX_COLUMNS = 6
// Queries stay native to the provider. Add a discriminated-union member when
// the next connector is implemented instead of inventing a shared search DSL.
const columnSourceSchema = z
const searchSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
following: z.boolean(),
@@ -27,17 +30,57 @@ const columnSourceSchema = z
}
})
function targetSchema(normalize: (target: string) => string) {
return z
.string()
.trim()
.min(1)
.max(256)
.transform((target, ctx) => {
try {
return normalize(target)
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : '対象を確認してください。',
})
return z.NEVER
}
})
}
const userSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('user'),
target: targetSchema(normalizeUserTarget),
})
.strict()
const listSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('list'),
target: targetSchema(normalizeListTarget),
})
.strict()
export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
source: columnSourceSchema,
profileName: z.string().min(1),
source: z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
})
.strict()
export const deckSchema = z
.object({
version: z.literal(1),
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(120),
columns: z.array(columnSchema).max(MAX_COLUMNS),
})
@@ -49,10 +92,41 @@ export const deckSchema = z
'カラム ID が重複しています。',
)
export const workspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: deckSchema.shape.id,
decks: z.array(deckSchema).min(1),
})
.strict()
.refine(
(workspace) =>
new Set(workspace.decks.map((deck) => deck.id)).size ===
workspace.decks.length,
'デッキ ID が重複しています。',
)
.refine(
(workspace) =>
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
'選択したデッキが見つかりません。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
export type Workspace = z.infer<typeof workspaceSchema>
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
version: 2,
activeDeckId: EMPTY_DECK.id,
decks: [EMPTY_DECK],
}
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
}
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
+16 -11
View File
@@ -1,9 +1,12 @@
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'short',
timeStyle: 'short',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
@@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
<img
src={post.author.avatarUrl}
alt=""
width={32}
height={32}
width={36}
height={36}
loading="lazy"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">{post.author.handle}</span>
<span className="handle">
@{post.author.handle.replace(/^@/, '')}
</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
</header>
<p className="research-post-text">
<PostText text={post.text} />
@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</blockquote>
) : null}
<footer>
{post.createdAt ? (
<time dateTime={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く
元の投稿を開く <Icon name="arrow" />
</a>
</footer>
</article>
+36 -15
View File
@@ -1,20 +1,20 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK } from './model'
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('saved deck', () => {
describe('saved decks', () => {
it('restores saved conditions without overwriting them during mount', () => {
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
const { result } = renderHook(useDeck)
expect(result.current.ready).toBe(true)
expect(result.current.deck).toEqual(saved)
expect(result.current.deck).toEqual(deck)
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
)
@@ -25,21 +25,42 @@ describe('saved deck', () => {
expect(result.current.storageError).toContain('読み込めません')
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
})
it('persists edits and restores them on remount', () => {
const first = renderHook(useDeck)
act(() =>
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
)
first.unmount()
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
})
it('reports failed writes instead of claiming that changes were saved', () => {
it('reports failed writes while keeping changes usable in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
let persisted = true
act(() => {
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
})
expect(persisted).toBe(false)
expect(result.current.deck.title).toBe('未保存')
expect(result.current.storageError).toContain('保存できません')
})
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
const { result, unmount } = renderHook(useDeck)
const firstId = result.current.deck.id
let secondId = ''
act(() => {
result.current.create('第二の調査')
secondId = result.current.getWorkspace().activeDeckId
result.current.save({
id: secondId,
title: '変更した第二の調査',
columns: [],
})
result.current.select(firstId)
})
expect(result.current.deck.title).toBe('新しい調査')
expect(result.current.workspace.decks).toHaveLength(2)
unmount()
const restored = renderHook(useDeck)
act(() => restored.result.current.select(secondId))
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
act(() => restored.result.current.remove(secondId))
expect(restored.result.current.deck.id).toBe(firstId)
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
expect(() => restored.result.current.select('missing')).toThrow()
})
})
+73 -8
View File
@@ -1,17 +1,30 @@
import { useEffect, useState } from 'react'
import { type Deck, deckSchema, EMPTY_DECK } from './model'
import { useEffect, useRef, useState } from 'react'
import {
type Deck,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
workspaceSchema,
} from './model'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export function useDeck() {
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const [storageError, setStorageError] = useState<string>()
useEffect(() => {
try {
const saved = localStorage.getItem(DECK_STORAGE_KEY)
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
if (saved !== null) {
const restored = workspaceSchema.parse(JSON.parse(saved))
current.current = restored
setWorkspace(restored)
}
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
@@ -20,9 +33,10 @@ export function useDeck() {
setReady(true)
}, [])
function save(next: Deck) {
const validated = deckSchema.parse(next)
setDeck(validated)
function persist(next: Workspace) {
const validated = workspaceSchema.parse(next)
current.current = validated
setWorkspace(validated)
try {
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
setStorageError(undefined)
@@ -35,5 +49,56 @@ export function useDeck() {
}
}
return { deck, ready, storageError, save }
function save(next: Deck) {
const validated = deckSchema.parse(next)
const previous = current.current
const exists = previous.decks.some((deck) => deck.id === validated.id)
return persist({
...previous,
activeDeckId: validated.id,
decks: exists
? previous.decks.map((deck) =>
deck.id === validated.id ? validated : deck,
)
: [...previous.decks, validated],
})
}
function create(title: string) {
return save({ id: newId(), title, columns: [] })
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
return persist({ ...current.current, activeDeckId: id })
}
function remove(id: string) {
const previous = current.current
if (!previous.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
const decks = previous.decks.filter((deck) => deck.id !== id)
const first = decks[0]
if (!first) throw new Error('最後のデッキは削除できません。')
return persist({
...previous,
decks,
activeDeckId:
previous.activeDeckId === id ? first.id : previous.activeDeckId,
})
}
const deck =
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
EMPTY_DECK
return {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace: () => current.current,
}
}
@@ -0,0 +1,8 @@
import { useEffect, useState } from 'react'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
+86 -38
View File
@@ -1,60 +1,108 @@
import { describe, expect, it } from 'vitest'
import { prepareDeck } from './webmcp-contracts'
const column = {
title: '観点',
profileName: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
const deck = prepareDeck({
title: '調査',
columns: [
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
{ title: '海外', source: { query: 'WebMCP lang:en' } },
],
})
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
'main',
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
query: 'WebMCP lang:ja',
kind: 'search',
query: 'AI',
product: 'Latest',
following: false,
})
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
})
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
expect(
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
).toBe('existing')
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
).toBe(3)
})
it('keeps supplied identities and rejects duplicates', () => {
const identified = { ...column, id: 'existing' }
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
['main'],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [column, column] }),
prepareDeck({ title: '調査', columns: [identified, identified] }, [
'main',
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, ['work']),
).toThrow('Unknown relay profile')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
{
title: '調査',
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
profileName: 'work',
source: { kind: 'list', target: '123' },
},
],
},
['main', 'work'],
)
expect(deck.columns.map((column) => column.profileName)).toEqual([
'main',
'work',
])
expect(deck.columns.map((column) => column.source.kind)).toEqual([
'user',
'list',
])
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
expect(() =>
prepareDeck({
title: '調査',
columns: [
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: [
{
title: '観点',
source: { query: 'a'.repeat(512), following: true },
},
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
following: true,
},
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: Array.from({ length: 7 }, () => ({
title: '観点',
source: { query: 'AI' },
})),
}),
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
['main'],
),
).toThrow()
})
})
+78 -19
View File
@@ -1,43 +1,102 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
const source = columnSchema.shape.source
const toolColumn = columnSchema.omit({ id: true }).extend({
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing ID from get_deck when editing; omit for a new column.',
'Keep an existing column ID when editing; omit for a new column.',
),
source: z
.object({
...source.shape,
platform: source.shape.platform.default('twitter'),
product: source.shape.product.default('Latest'),
following: source.shape.following.default(false),
})
.strict(),
source: z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
target: z.string().min(1).max(256),
})
.strict(),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
export const setDeckInput = z
.object({
title: deckSchema.shape.title.describe('The research topic or deck title.'),
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(input: unknown) {
export function prepareDeck(input: unknown, profiles: readonly string[]) {
const parsed = setDeckInput.parse(input)
return deckSchema.parse({
version: 1,
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
id: column.id ?? newId(),
})),
})
for (const column of deck.columns) {
if (!profiles.includes(column.profileName))
throw new InputError(`Unknown relay profile: ${column.profileName}`)
}
return deck
}
export async function toolResult(run: () => unknown | Promise<unknown>) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
} catch (error) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
retryable: false,
}),
},
] as const,
}
}
}
+101 -27
View File
@@ -1,63 +1,137 @@
import { useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
import type { Deck } from './model'
import { prepareDeck, setDeckInput } from './webmcp-contracts'
import type { Deck, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
emptyToolInput,
getDeckInput,
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
export function useDeckTools({
deck,
ready,
storageError,
save,
select,
remove,
getWorkspace,
profiles,
onApplied,
}: {
deck: Deck
ready: boolean
storageError?: string
save: (deck: Deck) => boolean
select: (id: string) => boolean
remove: (id: string) => boolean
getWorkspace: () => Workspace
profiles: string[] | undefined
onApplied: () => void
}) {
const supported = useWebMCPSupported()
const current = useRef({ deck, storageError })
current.current = { deck, storageError }
const enabled = supported && ready
function applied(persisted: boolean) {
onApplied()
if (!persisted)
throw new Error(
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
)
}
useWebMCP({
name: 'get_deck',
name: 'list_decks',
description:
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
inputSchema: emptyToolInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
const workspace = getWorkspace()
return {
deck: current.current.deck,
storageError: current.current.storageError ?? null,
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
profiles: profiles ?? null,
storageError: storageError ?? null,
}
}),
})
useWebMCP({
name: 'get_deck',
description:
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = getDeckInput.parse(input)
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return { deck, storageError: storageError ?? null }
}),
})
useWebMCP({
name: 'set_deck',
description:
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
inputSchema: setDeckInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
// Validate the whole definition before modifying either storage or UI.
const next = prepareDeck(input)
const persisted = save(next)
onApplied()
const error = persisted
? undefined
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
current.current = { deck: next, storageError: error }
if (error) throw new Error(error)
const parsed = setDeckInput.parse(input)
if (
parsed.deckId &&
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
)
throw new Error('Deck not found.')
if (!profiles) throw new Error('Relay profiles are not available yet.')
const next = prepareDeck(parsed, profiles)
applied(save(next))
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
}),
})
useWebMCP({
name: 'select_deck',
description:
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
useWebMCP({
name: 'delete_deck',
description:
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(remove(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { TwitterClient } from '@yuta/bird'
import type { BirdReader } from './post-service'
const clients = new Map<string | undefined, TwitterClient>()
const clients = new Map<string, TwitterClient>()
export function getBirdReader(profileName?: string): BirdReader {
export function getBirdReader(profileName: string): BirdReader {
const existing = clients.get(profileName)
if (existing) return existing
@@ -1,152 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ListForm } from './list-form'
import { SearchForm } from './search-form'
import { UserForm } from './user-form'
const searchValues = {
q: '',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
product: 'Latest' as const,
following: false,
}
describe('UserForm', () => {
it('submits only after the user enters a target', () => {
const onSubmit = vi.fn()
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), {
target: { value: '@yuta' },
})
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(onSubmit).toHaveBeenCalledWith('yuta')
})
it('explains invalid input beside the field', () => {
const onSubmit = vi.fn()
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'ハンドルまたはプロフィール URL を入力してください。',
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('ListForm', () => {
it('submits the list ID from a pasted list URL', () => {
const onSubmit = vi.fn()
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('リスト URL または ID'), {
target: { value: 'https://x.com/i/lists/1234567890' },
})
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(onSubmit).toHaveBeenCalledWith('1234567890')
})
it('explains invalid input beside the field', () => {
const onSubmit = vi.fn()
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'リストの URL または ID を入力してください。',
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('SearchForm', () => {
it('uses a labelled grouping for ranking controls', () => {
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
})
it('submits the selected ranking and follows filter', () => {
const onSubmit = vi.fn()
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: ' AI lang:ja ' },
})
fireEvent.click(screen.getByLabelText('人気順'))
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
q: 'AI lang:ja',
from: '',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
product: 'Top',
following: true,
})
})
it('submits conditions selected with the filter buttons', () => {
const onSubmit = vi.fn()
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: 'TypeScript' },
})
fireEvent.click(screen.getByRole('button', { name: '投稿者' }))
fireEvent.change(screen.getByLabelText('投稿者'), {
target: { value: '@tan_stack' },
})
fireEvent.click(screen.getByRole('button', { name: '言語' }))
fireEvent.click(screen.getByLabelText('日本語'))
fireEvent.click(screen.getByRole('button', { name: '投稿の種類' }))
fireEvent.click(screen.getByLabelText('画像'))
fireEvent.click(screen.getByRole('button', { name: '除外' }))
fireEvent.click(screen.getByLabelText('返信'))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'TypeScript',
from: '@tan_stack',
lang: 'ja',
content: 'images',
excludeReplies: true,
})
})
it('removes only the selected condition from its active chip', () => {
const onSubmit = vi.fn()
render(
<SearchForm
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
onSubmit={onSubmit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'AI',
lang: 'ja',
})
})
})
@@ -1,47 +0,0 @@
import { type FormEvent, useState } from 'react'
import { normalizeListTarget } from '../inputs'
type Props = {
initialTarget: string
onSubmit: (target: string) => void
}
export function ListForm({ initialTarget, onSubmit }: Props) {
const [target, setTarget] = useState(initialTarget)
const [error, setError] = useState('')
const submit = (event: FormEvent) => {
event.preventDefault()
try {
const listId = normalizeListTarget(target)
setError('')
onSubmit(listId)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
)
}
}
return (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>リスト URL または ID</span>
<input
autoComplete="off"
aria-describedby={error ? 'list-error' : undefined}
aria-invalid={Boolean(error)}
name="target"
value={target}
onChange={(event) => setTarget(event.currentTarget.value)}
placeholder="https://x.com/i/lists/1234567890 または ID"
/>
{error ? (
<span className="field-error" id="list-error" role="alert">
{error}
</span>
) : null}
</label>
<button type="submit">表示</button>
</form>
)
}
@@ -1,65 +0,0 @@
import { Link } from '@tanstack/react-router'
import { ListChoicesLoadError, useListChoices } from '../use-list-choices'
export function ListPicker({ selectedId }: { selectedId?: string }) {
const query = useListChoices()
if (query.isPending) {
return (
<section aria-labelledby="list-picker-title" className="list-picker">
<h2 id="list-picker-title">自分のリスト</h2>
<p className="list-picker-state" role="status">
リストを取得しています…
</p>
</section>
)
}
if (query.isError) {
const error =
query.error instanceof ListChoicesLoadError
? query.error.detail
: { message: 'リストを取得できませんでした。', retryable: true }
return (
<section aria-labelledby="list-picker-title" className="list-picker">
<h2 id="list-picker-title">自分のリスト</h2>
<div className="list-picker-state" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button onClick={() => void query.refetch()} type="button">
再試行
</button>
) : null}
</div>
</section>
)
}
return (
<section aria-labelledby="list-picker-title" className="list-picker">
<h2 id="list-picker-title">自分のリスト</h2>
{query.data.length === 0 ? (
<p className="list-picker-state">リストはありません。</p>
) : (
<ul className="list-choices">
{query.data.map((list) => (
<li key={list.id}>
<Link
aria-current={selectedId === list.id ? 'page' : undefined}
params={{ listId: list.id }}
to="/i/lists/$listId"
>
<span>{list.name}</span>
<small>
{list.isPrivate ? '非公開 · ' : ''}
{list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー
</small>
{list.description ? <p>{list.description}</p> : null}
</Link>
</li>
))}
</ul>
)}
</section>
)
}
@@ -1,159 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Post } from '../types'
import { PostCard } from './post-card'
const richPost: Post = {
id: '123',
text: '詳細 https://content.invalid/article',
author: {
username: 'yuta',
name: 'Yuta',
profileImageUrl: 'https://media.invalid/avatar.jpg',
},
createdAt: '2026-07-13T00:00:00.000Z',
replyCount: 3,
retweetCount: 4,
likeCount: 5,
media: [
{
type: 'photo',
url: 'https://media.invalid/photo.jpg',
width: 1200,
height: 800,
},
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
videoUrl: 'https://media.invalid/video.mp4',
},
{
type: 'animated_gif',
url: 'https://media.invalid/gif-poster.jpg',
videoUrl: 'https://media.invalid/animation.mp4',
},
],
article: { title: 'Article title', previewText: 'Preview' },
quotedTweet: {
id: '122',
text: 'quoted',
author: { username: 'other', name: 'Other' },
},
}
describe('PostCard', () => {
it('renders rich read-only content without mutation controls', () => {
const { container } = render(<PostCard post={richPost} />)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(screen.getByText('@yuta')).toBeInTheDocument()
expect(container.querySelector('time')).toHaveAttribute(
'datetime',
richPost.createdAt,
)
expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument()
expect(container.querySelectorAll('video')).toHaveLength(2)
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
'src',
'https://media.invalid/video.mp4',
)
expect(screen.getByText('Article title')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
expect(screen.getByText('quoted')).toBeInTheDocument()
expect(screen.getByText(/返信 3.*再投稿 4.*いいね 5/)).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('uses deliberate safe external links without linking the author', () => {
render(<PostCard post={richPost} />)
const original = screen.getByRole('link', { name: '元の投稿を開く' })
expect(original).toHaveAttribute('href', 'https://x.com/yuta/status/123')
expect(original).toHaveAttribute('target', '_blank')
expect(original).toHaveAttribute('rel', 'noreferrer noopener')
const textLink = screen.getByRole('link', {
name: 'https://content.invalid/article',
})
expect(textLink).toHaveAttribute('target', '_blank')
expect(textLink).toHaveAttribute('rel', 'noreferrer noopener')
expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument()
expect(screen.getByText('Yuta').closest('a')).toBeNull()
expect(screen.getByText('@yuta').closest('a')).toBeNull()
})
it('decodes HTML entities in the post body before linking URLs', () => {
render(
<PostCard
post={{
...richPost,
text: 'A &gt; B &amp; C &#39;D&#39; https://example.com/?a=1&amp;b=2',
}}
/>,
)
expect(
screen.getByText((_, element) =>
Boolean(
element?.classList.contains('post-text') &&
element.textContent ===
"A > B & C 'D' https://example.com/?a=1&b=2",
),
),
).toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
})
it('links deliberately to the internal detail page', () => {
render(<PostCard post={richPost} />)
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
expect(detail).toHaveAttribute('href', '/yuta/status/123')
expect(detail.parentElement).toHaveClass('post-actions')
expect(
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
).toBe(detail.parentElement)
})
it('marks the focal post without linking to its current page', () => {
render(<PostCard current post={{ ...richPost, quotedTweet: undefined }} />)
expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull()
expect(screen.getByRole('article')).toHaveClass('current-post')
})
it('collapses a malformed optional timestamp without losing the post', () => {
const { container } = render(
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(container.querySelector('time')).not.toBeInTheDocument()
})
it('does not try to play a poster image when Bird has no video URL', () => {
const { container } = render(
<PostCard
post={{
...richPost,
media: [
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
previewUrl: 'https://media.invalid/video-preview.jpg',
},
],
}}
/>,
)
expect(container.querySelector('video')).not.toBeInTheDocument()
expect(
screen.getByRole('img', { name: '動画のプレビュー' }),
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
})
})
@@ -1,87 +0,0 @@
import { PostText } from '#/components/post-text'
import type { Post } from '../types'
import { PostMedia } from './post-media'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
function formattedTime(createdAt?: string): string | undefined {
if (!createdAt) return undefined
const date = new Date(createdAt)
return Number.isNaN(date.getTime()) ? undefined : dateFormatter.format(date)
}
export function PostCard({
post,
quoted = false,
current = false,
}: {
post: Post
quoted?: boolean
current?: boolean
}) {
const time = formattedTime(post.createdAt)
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
return (
<article
className={quoted ? 'post quote' : current ? 'post current-post' : 'post'}
>
<header className="post-header">
{post.author.profileImageUrl ? (
<img
alt=""
className="avatar"
height="40"
loading="lazy"
src={post.author.profileImageUrl}
width="40"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">@{post.author.username}</span>
</div>
{time && post.createdAt ? (
<time dateTime={post.createdAt}>{time}</time>
) : null}
</header>
<p className="post-text">
<PostText text={post.text} />
</p>
{post.media ? <PostMedia media={post.media} /> : null}
{post.article ? (
<section className="article-card">
<strong>{post.article.title}</strong>
{post.article.previewText ? <p>{post.article.previewText}</p> : null}
</section>
) : null}
{post.quotedTweet && !quoted ? (
<PostCard post={post.quotedTweet} quoted />
) : null}
{!quoted ? (
<footer className="post-footer">
<span className="counts">
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0}
</span>
<span className="post-actions">
{!current ? (
<a
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
>
詳細・スレッド
</a>
) : null}
<a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く
</a>
</span>
</footer>
) : null}
</article>
)
}
@@ -1,414 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Post } from '../types'
import { PostLoadError } from '../use-post-feed'
import { PostFeed } from './post-feed'
const usePostFeed = vi.hoisted(() => vi.fn())
vi.mock('../use-post-feed', async (importOriginal) => {
const original = await importOriginal<typeof import('../use-post-feed')>()
return { ...original, usePostFeed }
})
const observe = vi.fn()
const unobserve = vi.fn()
const disconnect = vi.fn()
let observerCallback: IntersectionObserverCallback = () => {}
let observerOptions: IntersectionObserverInit | undefined
class FakeIntersectionObserver {
readonly root = null
readonly rootMargin: string
readonly thresholds: readonly number[] = []
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
observerCallback = callback
observerOptions = options
this.rootMargin = options?.rootMargin ?? '0px'
}
disconnect = disconnect
observe = observe
takeRecords = () => []
unobserve = unobserve
}
const post = (id: string): Post => ({
id,
text: `post-${id}`,
author: { username: `user-${id}`, name: `User ${id}` },
})
const queryResult = () => ({
data: undefined,
error: null,
fetchNextPage: vi.fn().mockResolvedValue(undefined),
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn().mockResolvedValue(undefined),
})
describe('PostFeed', () => {
beforeEach(() => {
usePostFeed.mockReset()
usePostFeed.mockReturnValue(queryResult() as never)
observe.mockReset()
unobserve.mockReset()
disconnect.mockReset()
observerCallback = () => {}
observerOptions = undefined
vi.stubGlobal(
'IntersectionObserver',
FakeIntersectionObserver as unknown as typeof IntersectionObserver,
)
})
afterEach(() => vi.unstubAllGlobals())
it('renders no feed state before a deliberate request', () => {
render(<PostFeed request={undefined} />)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
expect(screen.queryByRole('article')).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('announces the initial loading state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
isPending: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByRole('status')).toHaveTextContent(
'投稿を取得しています…',
)
})
it('renders a quiet empty state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: { pages: [{ tweets: [] }], pageParams: [undefined] },
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('continues from an empty page when a next cursor exists', () => {
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
act(() => {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(
screen.queryByText('条件に一致する投稿はありません。'),
).not.toBeInTheDocument()
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it('deduplicates every page while preserving a quiet end state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
{ tweets: [post('2'), post('3')] },
],
pageParams: [undefined, 'next'],
},
} as never)
const { container } = render(
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
)
expect(screen.getAllByRole('article')).toHaveLength(3)
expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument()
expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('renders the focal post once above its deduplicated conversation', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), post('2')],
},
{
conversationId: '1',
tweets: [post('2'), post('3')],
},
],
pageParams: [undefined, { cursor: 'next', conversationId: '1' }],
},
} as never)
const { container } = render(
<PostFeed request={{ kind: 'thread', tweetId: '2' }} />,
)
expect(screen.getByText('表示中の投稿')).toBeVisible()
expect(screen.getAllByText('post-2')).toHaveLength(1)
expect(screen.getAllByRole('article')).toHaveLength(3)
expect(container.querySelector('.current-post')).toBeInTheDocument()
})
it('keeps the focal post visible when there are no other posts', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
tweets: [post('2')],
},
],
pageParams: [undefined],
},
} as never)
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
expect(screen.getByText('post-2')).toBeVisible()
expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible()
})
it('keeps the focal post and conversation across a later-page error', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), post('2')],
},
],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
retryable: true,
}),
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
} as never)
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
expect(screen.getByText('post-2')).toBeVisible()
expect(screen.getByText('post-1')).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
})
it('retries an initial retryable error with refetch', () => {
const refetch = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
error: new PostLoadError({
code: 'upstream',
message: '最初の取得に失敗しました。',
retryable: true,
}),
isError: true,
refetch,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'最初の取得に失敗しました。',
)
expect(refetch).toHaveBeenCalledOnce()
})
it('does not offer retry for a non-retryable initial error', () => {
usePostFeed.mockReturnValue({
...queryResult(),
error: new PostLoadError({
code: 'invalid-input',
message: '入力を確認してください。',
retryable: false,
}),
isError: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'bad' }} />)
expect(screen.getByRole('alert')).toHaveTextContent(
'入力を確認してください。',
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('keeps loaded posts while retrying a later page', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('kept')], nextCursor: 'next' }],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'timeout',
message: '取得がタイムアウトしました。',
retryable: true,
}),
fetchNextPage,
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByText('post-kept')).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
expect(observe).not.toHaveBeenCalled()
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it('retries a later page after an empty page with fetchNextPage', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
const refetch = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [], nextCursor: 'next' }],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'timeout',
message: '取得がタイムアウトしました。',
retryable: true,
}),
fetchNextPage,
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
refetch,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
expect(fetchNextPage).toHaveBeenCalledOnce()
expect(refetch).not.toHaveBeenCalled()
})
it('announces next-page loading', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
hasNextPage: true,
isFetchingNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByRole('status')).toHaveTextContent(
'次の投稿を取得しています…',
)
})
it('does not auto-fetch while a next page is already loading', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
isFetchingNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
act(() => {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(observe).not.toHaveBeenCalled()
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('auto-fetches once near the sentinel and disconnects on cleanup', () => {
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
} as never)
const { unmount } = render(
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
)
act(() => {
const entries = [{ isIntersecting: true } as IntersectionObserverEntry]
observerCallback(entries, {} as IntersectionObserver)
observerCallback(entries, {} as IntersectionObserver)
})
expect(observerOptions).toEqual({ rootMargin: '600px 0px' })
expect(observe).toHaveBeenCalledOnce()
expect(fetchNextPage).toHaveBeenCalledOnce()
unmount()
expect(disconnect).toHaveBeenCalledOnce()
})
})
-127
View File
@@ -1,127 +0,0 @@
import { useEffect, useRef } from 'react'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from '../page'
import type { Post } from '../types'
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
import { useFeedTools } from '../webmcp-tools'
import { PostCard } from './post-card'
function FocalPost({ post }: { post: Post }) {
return (
<section aria-labelledby="focal-post-label" className="focal-post">
<p className="focal-label" id="focal-post-label">
表示中の投稿
</p>
<PostCard current post={post} />
</section>
)
}
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
const query = usePostFeed(request)
useFeedTools(request, query)
const sentinel = useRef<HTMLDivElement>(null)
const pages = query.data?.pages ?? []
const focalPost =
request?.kind === 'thread' ? focalPostFromPages(pages) : undefined
const posts = focalPost
? flattenConversationPages(pages, focalPost.id)
: flattenPostPages(pages)
useEffect(() => {
if (
!sentinel.current ||
!query.hasNextPage ||
query.isFetchNextPageError ||
query.isFetchingNextPage
) {
return
}
let requested = false
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !requested) {
requested = true
void query.fetchNextPage({ cancelRefetch: false })
}
},
{ rootMargin: '600px 0px' },
)
observer.observe(sentinel.current)
return () => observer.disconnect()
}, [
query.fetchNextPage,
query.hasNextPage,
query.isFetchNextPageError,
query.isFetchingNextPage,
])
if (!request) return null
if (query.isPending) {
return (
<p className="state" role="status">
投稿を取得しています…
</p>
)
}
if (query.isError && !query.isFetchNextPageError && posts.length === 0) {
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
return (
<div className="state" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button onClick={() => void query.refetch()} type="button">
再試行
</button>
) : null}
</div>
)
}
if (posts.length === 0 && !query.hasNextPage) {
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<p className="state">
{focalPost
? '会話にほかの投稿はありません。'
: '条件に一致する投稿はありません。'}
</p>
</>
)
}
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<section aria-live="polite" className="feed">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div aria-hidden="true" ref={sentinel} />
{query.isFetchingNextPage ? (
<div className="loading-rail" role="status">
次の投稿を取得しています…
</div>
) : null}
{query.isFetchNextPageError ? (
<div className="state" role="alert">
<p>続きの投稿を取得できませんでした。</p>
<button onClick={() => void query.fetchNextPage()} type="button">
再試行
</button>
</div>
) : null}
{!query.hasNextPage ? (
<p className="state">これ以上の投稿はありません。</p>
) : null}
</section>
</>
)
}
@@ -1,40 +0,0 @@
import type { Post } from '../types'
type Media = NonNullable<Post['media']>[number]
export function PostMedia({ media }: { media: Media[] }) {
if (media.length === 0) return null
return (
<div className="media-grid">
{media.map((item) => {
if (item.type === 'photo' || !item.videoUrl) {
return (
<img
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
className="media"
height={item.height}
key={item.url}
loading="lazy"
src={item.previewUrl ?? item.url}
width={item.width}
/>
)
}
return (
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
<video
className="media"
controls
key={item.videoUrl}
loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url}
preload="metadata"
src={item.videoUrl}
/>
)
})}
</div>
)
}
@@ -1,413 +0,0 @@
import type { SearchProduct } from '@yuta/bird'
import { type FormEvent, useState } from 'react'
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
export type SearchValues = AdvancedSearchQuery & {
product: SearchProduct
following: boolean
}
type Props = {
initialValues: SearchValues
onSubmit: (values: SearchValues) => void
}
type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
const contentLabels = {
all: 'すべて',
images: '画像',
videos: '動画',
links: 'リンク',
} as const
function localDate(daysAgo: number): string {
const date = new Date()
date.setDate(date.getDate() - daysAgo)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function SearchForm({ initialValues, onSubmit }: Props) {
const [values, setValues] = useState(initialValues)
const [openFilter, setOpenFilter] = useState<FilterName>()
const [error, setError] = useState('')
const update = <Key extends keyof SearchValues>(
key: Key,
value: SearchValues[Key],
) => setValues((current) => ({ ...current, [key]: value }))
const submit = (event: FormEvent) => {
event.preventDefault()
const normalized = {
...values,
q: values.q.trim(),
from: values.from.trim(),
}
try {
buildFilteredSearchQuery(normalized, normalized.following)
setError('')
onSubmit(normalized)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
)
}
}
const toggleFilter = (filter: FilterName) => {
setOpenFilter((current) => (current === filter ? undefined : filter))
}
const setRecentPeriod = (days: number) => {
setValues((current) => ({
...current,
since: localDate(days - 1),
until: '',
}))
}
let preview = '検索語または投稿者を入力すると表示されます。'
try {
preview = buildFilteredSearchQuery(values, values.following)
} catch {
// The submit error gives the actionable message; the preview stays quiet.
}
const filterButtons: Array<{
name: FilterName
label: string
active: boolean
}> = [
{ name: 'author', label: '投稿者', active: Boolean(values.from) },
{
name: 'period',
label: '期間',
active: Boolean(values.since || values.until),
},
{ name: 'language', label: '言語', active: values.lang !== 'all' },
{
name: 'content',
label: '投稿の種類',
active: values.content !== 'all',
},
{
name: 'exclude',
label: '除外',
active: values.excludeReplies || values.excludeReposts,
},
]
return (
<form className="intent-form search-form" onSubmit={submit}>
<div className="field search-query">
<label htmlFor="search-query">検索語</label>
<input
aria-describedby="search-query-help"
autoComplete="off"
id="search-query"
name="q"
value={values.q}
onChange={(event) => update('q', event.currentTarget.value)}
placeholder="キーワードや高度な検索式"
/>
<span className="field-help" id="search-query-help">
演算子を直接入力することもできます。
</span>
</div>
<fieldset className="search-filter-buttons">
<legend>条件を追加</legend>
{filterButtons.map((filter) => (
<button
aria-controls={`search-filter-${filter.name}`}
aria-expanded={openFilter === filter.name}
className="filter-button"
data-active={filter.active || undefined}
key={filter.name}
onClick={() => toggleFilter(filter.name)}
type="button"
>
{filter.label}
{filter.active ? <span aria-hidden="true">●</span> : null}
</button>
))}
</fieldset>
{openFilter ? (
<fieldset
className="search-filter-panel"
id={`search-filter-${openFilter}`}
>
<legend>
{filterButtons.find((item) => item.name === openFilter)?.label}
の条件
</legend>
{openFilter === 'author' ? (
<div className="field compact-field">
<label htmlFor="search-author">投稿者</label>
<input
aria-describedby="search-author-help"
autoComplete="off"
id="search-author"
name="from"
onChange={(event) => update('from', event.currentTarget.value)}
placeholder="@handle"
value={values.from}
/>
<span className="field-help" id="search-author-help">
@は省略できます。
</span>
</div>
) : null}
{openFilter === 'period' ? (
<>
<fieldset className="segmented search-filter-options">
<legend>すぐ選ぶ</legend>
<button onClick={() => setRecentPeriod(1)} type="button">
今日
</button>
<button onClick={() => setRecentPeriod(7)} type="button">
過去7日
</button>
<button onClick={() => setRecentPeriod(30)} type="button">
過去30日
</button>
<button
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
すべて
</button>
</fieldset>
<div className="date-fields">
<div className="field compact-field">
<label htmlFor="search-since">開始日</label>
<input
id="search-since"
name="since"
onChange={(event) =>
update('since', event.currentTarget.value)
}
type="date"
value={values.since}
/>
<span className="field-help" />
</div>
<div className="field compact-field">
<label htmlFor="search-until">
終了日(この日は含まない)
</label>
<input
id="search-until"
name="until"
onChange={(event) =>
update('until', event.currentTarget.value)
}
type="date"
value={values.until}
/>
<span className="field-help" />
</div>
</div>
</>
) : null}
{openFilter === 'language' ? (
<fieldset className="segmented search-filter-options">
<legend>言語</legend>
{(
Object.keys(languageLabels) as Array<
keyof typeof languageLabels
>
).map((value) => (
<label key={value}>
<input
checked={values.lang === value}
name="lang"
onChange={() => update('lang', value)}
type="radio"
value={value}
/>
{languageLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'content' ? (
<fieldset className="segmented search-filter-options">
<legend>投稿の種類</legend>
{(
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
).map((value) => (
<label key={value}>
<input
checked={values.content === value}
name="content"
onChange={() => update('content', value)}
type="radio"
value={value}
/>
{contentLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'exclude' ? (
<fieldset className="search-exclusions">
<legend>検索結果から除外</legend>
<label className="check">
<input
checked={values.excludeReplies}
onChange={(event) =>
update('excludeReplies', event.currentTarget.checked)
}
type="checkbox"
/>
返信
</label>
<label className="check">
<input
checked={values.excludeReposts}
onChange={(event) =>
update('excludeReposts', event.currentTarget.checked)
}
type="checkbox"
/>
リポスト
</label>
</fieldset>
) : null}
</fieldset>
) : null}
<ul aria-label="選択中の条件" className="active-filters">
{values.from ? (
<li>
<button
aria-label={`投稿者 ${values.from} を解除`}
onClick={() => update('from', '')}
type="button"
>
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.since || values.until ? (
<li>
<button
aria-label="期間を解除"
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
{values.since || '指定なし'} → {values.until || '現在'}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.lang !== 'all' ? (
<li>
<button
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
onClick={() => update('lang', 'all')}
type="button"
>
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.content !== 'all' ? (
<li>
<button
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
onClick={() => update('content', 'all')}
type="button"
>
{contentLabels[values.content]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReplies ? (
<li>
<button
aria-label="返信の除外を解除"
onClick={() => update('excludeReplies', false)}
type="button"
>
返信を除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReposts ? (
<li>
<button
aria-label="リポストの除外を解除"
onClick={() => update('excludeReposts', false)}
type="button"
>
リポストを除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
</ul>
<details className="search-query-preview">
<summary>検索式を確認</summary>
<code>{preview}</code>
</details>
{error ? (
<p className="field-error search-error" id="query-error" role="alert">
{error}
</p>
) : null}
<fieldset className="segmented search-ranking">
<legend>並び順</legend>
{(['Top', 'Latest'] as const).map((value) => (
<label key={value}>
<input
checked={values.product === value}
name="product"
onChange={() => update('product', value)}
type="radio"
value={value}
/>
{value === 'Top' ? '人気順' : '最新順'}
</label>
))}
</fieldset>
<label className="check search-following">
<input
checked={values.following}
onChange={(event) => update('following', event.currentTarget.checked)}
type="checkbox"
/>
フォロー中のみ
</label>
<button className="search-submit" type="submit">
検索
</button>
</form>
)
}
@@ -1,47 +0,0 @@
import { type FormEvent, useState } from 'react'
import { normalizeUserTarget } from '../inputs'
type Props = {
initialTarget: string
onSubmit: (target: string) => void
}
export function UserForm({ initialTarget, onSubmit }: Props) {
const [target, setTarget] = useState(initialTarget)
const [error, setError] = useState('')
const submit = (event: FormEvent) => {
event.preventDefault()
try {
const handle = normalizeUserTarget(target)
setError('')
onSubmit(handle)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
)
}
}
return (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>ハンドルまたはプロフィール URL</span>
<input
autoComplete="off"
aria-describedby={error ? 'target-error' : undefined}
aria-invalid={Boolean(error)}
name="target"
value={target}
onChange={(event) => setTarget(event.currentTarget.value)}
placeholder="@handle または https://x.com/handle"
/>
{error ? (
<span className="field-error" id="target-error" role="alert">
{error}
</span>
) : null}
</label>
<button type="submit">表示</button>
</form>
)
}
+17 -172
View File
@@ -1,52 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
buildAdvancedSearchQuery,
buildFilteredSearchQuery,
buildSearchQuery,
listChoicesInputSchema,
listPageInputSchema,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
postSearchRouteSchema,
threadPageInputSchema,
searchPageInputSchema,
userPageInputSchema,
} from './inputs'
describe('threadPageInputSchema', () => {
it('accepts initial and complete continuation requests', () => {
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
tweetId: '123',
})
expect(
threadPageInputSchema.parse({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
}),
).toEqual({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
})
describe('explicit relay profile binding', () => {
it.each([
{ tweetId: '' },
{ tweetId: 'abc' },
{ tweetId: '123', cursor: 'thread-next' },
{ tweetId: '123', conversationId: '100' },
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
])('rejects an invalid thread request %#', (input) => {
expect(() => threadPageInputSchema.parse(input)).toThrow()
})
})
describe('normalizeTweetId', () => {
it('keeps a decimal post ID', () => {
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
})
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
expect(() => normalizeTweetId(input)).toThrow(
'投稿 ID を確認してください。',
[
searchPageInputSchema,
{ query: 'AI', product: 'Latest', following: false },
],
[listPageInputSchema, { target: '123' }],
[userPageInputSchema, { target: 'yuta' }],
[listChoicesInputSchema, {}],
])('rejects missing or empty bindings for loader %#', (schema, input) => {
expect(schema.safeParse(input).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: ' ' }).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: 'work' }).success).toBe(
true,
)
})
})
@@ -111,134 +87,3 @@ describe('buildSearchQuery', () => {
expect(() => buildSearchQuery(' ', false)).toThrow()
})
})
describe('buildAdvancedSearchQuery', () => {
it('builds a query from button-friendly filters', () => {
expect(
buildAdvancedSearchQuery({
q: 'TypeScript',
from: '@tan_stack',
since: '2026-08-04',
until: '2026-08-11',
lang: 'ja',
content: 'images',
excludeReplies: true,
excludeReposts: true,
}),
).toBe(
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
)
})
it('allows an author search without keywords', () => {
expect(
buildAdvancedSearchQuery({
q: '',
from: 'tan_stack',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toBe('from:tan_stack')
})
it('rejects filters that have no keyword or author', () => {
expect(() =>
buildAdvancedSearchQuery({
q: '',
from: '',
since: '2026-08-04',
until: '',
lang: 'ja',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toThrow('検索語または投稿者を入力してください。')
})
it('rejects an invalid author and reversed dates', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
).toThrow('投稿者のハンドルを確認してください。')
expect(() =>
buildAdvancedSearchQuery({
...base,
since: '2026-08-11',
until: '2026-08-10',
}),
).toThrow('終了日は開始日より後の日付を選んでください。')
})
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
).toThrow('開始日を確認してください。')
expect(() =>
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('buildFilteredSearchQuery', () => {
it('includes the follows filter in the final query and enforces its length', () => {
const input = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'ja' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(buildFilteredSearchQuery(input, true)).toBe(
'TypeScript lang:ja filter:follows',
)
expect(() =>
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('postSearchRouteSchema', () => {
it('keeps omitted filter parameters optional', () => {
expect(
postSearchRouteSchema.parse({
q: 'AI',
product: 'Latest',
following: false,
}),
).toEqual({
q: 'AI',
product: 'Latest',
following: false,
})
})
})
+7 -128
View File
@@ -1,157 +1,36 @@
import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/
const tweetIdSchema = z
.string()
.trim()
.regex(TWEET_ID, '投稿 ID を確認してください。')
const profileNameSchema = z.string().trim().min(1).max(256)
export class InputError extends Error {}
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
type SearchLanguage = z.infer<typeof searchLanguageSchema>
type SearchContent = z.infer<typeof searchContentSchema>
export type AdvancedSearchQuery = {
q: string
from: string
since: string
until: string
lang: SearchLanguage
content: SearchContent
excludeReplies: boolean
excludeReposts: boolean
}
export const threadPageInputSchema = z.union([
z.object({
tweetId: tweetIdSchema,
conversationId: z.undefined().optional(),
cursor: z.undefined().optional(),
}),
z.object({
tweetId: tweetIdSchema,
conversationId: tweetIdSchema,
cursor: z.string().min(1),
}),
])
export const userPageInputSchema = z.object({
profileName: profileNameSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listPageInputSchema = z.object({
profileName: profileNameSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listChoicesInputSchema = z.object({})
export const listChoicesInputSchema = z.object({
profileName: profileNameSchema,
})
export const searchPageInputSchema = z.object({
profileName: profileNameSchema,
query: z.string().trim().min(1).max(512),
product: z.enum(['Top', 'Latest']),
following: z.boolean(),
cursor: z.string().min(1).optional(),
})
export const postSearchRouteSchema = z.object({
q: z.string().catch(''),
from: z
.string()
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
.optional()
.catch(undefined),
since: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
until: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
lang: searchLanguageSchema.optional().catch(undefined),
content: searchContentSchema.optional().catch(undefined),
excludeReplies: z.boolean().optional().catch(undefined),
excludeReposts: z.boolean().optional().catch(undefined),
product: z.enum(['Top', 'Latest']).catch('Latest'),
following: z.boolean().catch(false),
})
function isValidDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() + 1 === month &&
date.getUTCDate() === day
)
}
function requireDate(value: string, label: string): string {
if (!isValidDate(value)) {
throw new InputError(`${label}を確認してください。`)
}
return value
}
function normalizeSearchAuthor(raw: string): string {
const value = raw.trim().replace(/^@/, '')
if (!HANDLE.test(value)) {
throw new InputError('投稿者のハンドルを確認してください。')
}
return value
}
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
const query = input.q.trim()
const author = input.from.trim()
if (!query && !author) {
throw new InputError('検索語または投稿者を入力してください。')
}
const parts = [query]
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
const since = input.since ? requireDate(input.since, '開始日') : ''
const until = input.until ? requireDate(input.until, '終了日') : ''
if (since && until && since >= until) {
throw new InputError('終了日は開始日より後の日付を選んでください。')
}
if (since) parts.push(`since:${since}`)
if (until) parts.push(`until:${until}`)
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
if (input.content !== 'all') parts.push(`filter:${input.content}`)
if (input.excludeReplies) parts.push('-filter:replies')
if (input.excludeReposts) parts.push('-filter:retweets')
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
}
export function buildFilteredSearchQuery(
input: AdvancedSearchQuery,
following: boolean,
): string {
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
}
export function normalizeTweetId(raw: string): string {
const value = raw.trim()
if (!TWEET_ID.test(value)) {
throw new InputError('投稿 ID を確認してください。')
}
return value
}
function requireHandle(value: string): string {
if (!HANDLE.test(value)) {
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
+2 -32
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from './page'
import type { Post, PostPage, ThreadPage } from './types'
import { flattenPostPages } from './page'
import type { Post, PostPage } from './types'
const post = (id: string): Post => ({
id,
@@ -22,29 +18,3 @@ describe('flattenPostPages', () => {
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
})
})
describe('thread page helpers', () => {
const focal = post('2')
const pages: ThreadPage[] = [
{
focalPost: focal,
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), focal],
},
{
conversationId: '1',
tweets: [focal, post('3')],
},
]
it('takes the focal post from the first page', () => {
expect(focalPostFromPages(pages)).toEqual(focal)
})
it('deduplicates the conversation and removes the focal post', () => {
expect(
flattenConversationPages(pages, focal.id).map(({ id }) => id),
).toEqual(['1', '3'])
})
})
+1 -15
View File
@@ -1,4 +1,4 @@
import type { Post, PostPage, ThreadPage } from './types'
import type { Post, PostPage } from './types'
export function flattenPostPages(pages: PostPage[]): Post[] {
const seen = new Set<string>()
@@ -10,17 +10,3 @@ export function flattenPostPages(pages: PostPage[]): Post[] {
}),
)
}
export function focalPostFromPages(
pages: Array<PostPage | ThreadPage>,
): Post | undefined {
const first = pages[0]
return first && 'focalPost' in first ? first.focalPost : undefined
}
export function flattenConversationPages(
pages: Array<PostPage | ThreadPage>,
focalPostId: string,
): Post[] {
return flattenPostPages(pages).filter(({ id }) => id !== focalPostId)
}
-247
View File
@@ -3,7 +3,6 @@ import {
type BirdReader,
loadListChoices,
loadListPage,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
@@ -24,27 +23,6 @@ const reader = (): BirdReader => ({
},
],
}),
getTweet: vi.fn().mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
}),
getThreadPaged: vi.fn().mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
nextCursor: 'thread-next',
}),
getUserIdByUsername: vi
.fn()
.mockResolvedValue({ success: true, userId: '42' }),
@@ -114,231 +92,6 @@ describe('loadListChoices', () => {
})
})
describe('loadThreadPage', () => {
it('resolves the focal post and fetches one root conversation page', async () => {
const client = reader()
const result = await loadThreadPage(client, { tweetId: '123' })
expect(client.getTweet).toHaveBeenCalledWith('123')
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toEqual({
ok: true,
page: {
focalPost: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
conversationId: '100',
nextCursor: 'thread-next',
},
})
})
it('uses the carried root for exactly one continuation read', async () => {
const client = reader()
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).toHaveBeenCalledOnce()
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
cursor: 'thread-next',
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toMatchObject({
ok: true,
page: { conversationId: '100', focalPost: undefined },
})
})
it('does not expose a continuation when the focal post has no replies', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
replyCount: 0,
author: { username: 'focus', name: 'Focus' },
},
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it.each([
{
name: 'an empty final page',
tweets: [],
returnedCursor: 'thread-final',
},
{
name: 'the same cursor again',
tweets: [
{
id: '101',
text: 'last reply',
author: { username: 'reply', name: 'Reply' },
},
],
returnedCursor: 'thread-next',
},
])('stops after $name', async ({ tweets, returnedCursor }) => {
const client = reader()
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets,
nextCursor: returnedCursor,
})
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it('removes raw data from focal, conversation, and quotes', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
quotedTweet: {
id: '90',
text: 'quote',
author: { username: 'quote', name: 'Quote' },
_raw: { rest_id: 'private-quote' },
},
_raw: { rest_id: 'private-focal' },
},
})
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
_raw: { rest_id: 'private-thread' },
},
],
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(JSON.stringify(result)).not.toContain('private-')
})
it('returns a safe non-retryable missing-post error', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: false,
error: 'Tweet not found: private relay detail',
})
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
ok: false,
error: {
code: 'post-not-found',
message: '投稿が見つかりませんでした。',
retryable: false,
},
})
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
it('classifies malformed Bird conversation metadata as an upstream failure', async () => {
const client = reader()
const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9'
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: privateMetadata,
author: { username: 'focus', name: 'Focus' },
},
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const result = await loadThreadPage(client, { tweetId: '123' })
expect(result).toEqual({
ok: false,
error: {
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
retryable: true,
},
})
expect(client.getTweet).toHaveBeenCalledWith('123')
expect(client.getThreadPaged).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(errorSpy).toHaveBeenCalledWith(
'Unexpected Bird failure:',
expect.objectContaining({
message: '投稿 ID を確認してください。',
}),
)
expect(JSON.stringify(result)).not.toContain(privateMetadata)
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(
privateMetadata,
)
} finally {
errorSpy.mockRestore()
}
})
it('rejects an invalid ID without a Bird call', async () => {
const client = reader()
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
ok: false,
error: {
code: 'invalid-input',
message: '投稿 ID を確認してください。',
retryable: false,
},
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
})
describe('loadUserPage', () => {
it('resolves a handle and fetches exactly one page', async () => {
const client = reader()
+5 -84
View File
@@ -1,14 +1,8 @@
import type {
GetTweetResult,
SearchProduct,
SearchResult,
TweetData,
} from '@yuta/bird'
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
import {
buildSearchQuery,
InputError,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
} from './inputs'
import type {
@@ -19,8 +13,6 @@ import type {
LoadResult,
Post,
SearchPageInput,
ThreadLoadResult,
ThreadPageInput,
UserPageInput,
} from './types'
@@ -45,15 +37,6 @@ type BirdListsResult =
export interface BirdReader {
getLists(count?: number): Promise<BirdListsResult>
getTweet(tweetId: string): Promise<GetTweetResult>
getThreadPaged(
tweetId: string,
options: {
cursor?: string
maxPages: number
pageDelayMs: number
},
): Promise<SearchResult>
getUserIdByUsername(username: string): Promise<UserLookupResult>
getUserTweetsPaged(
userId: string,
@@ -87,12 +70,11 @@ const failure = (
retryable: boolean,
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
type FailureSubject = 'user' | 'post' | 'list'
type FailureSubject = 'user' | 'list'
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
post: () => failure('post-not-found', '投稿が見つかりませんでした。', false),
list: () =>
failure('list-not-found', 'リストが見つかりませんでした。', false),
}
@@ -100,7 +82,6 @@ const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
post: () => failure('post-unavailable', 'この投稿は取得できません。', false),
list: () =>
failure('list-unavailable', 'このリストは取得できません。', false),
}
@@ -181,69 +162,9 @@ function resultPage(
: upstreamFailure(result.error, subject)
}
export async function loadThreadPage(
reader: BirdReader,
input: ThreadPageInput,
): Promise<ThreadLoadResult> {
try {
const tweetId = normalizeTweetId(input.tweetId)
let focalPost: Post | undefined
let conversationId: string
if (input.cursor && input.conversationId) {
conversationId = normalizeTweetId(input.conversationId)
} else {
const focal = await reader.getTweet(tweetId)
if (!focal.success || !focal.tweet) {
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
}
focalPost = publicPost(focal.tweet)
try {
conversationId = normalizeTweetId(
focal.tweet.conversationId ?? focal.tweet.id,
)
} catch (error) {
return upstreamFailure(error, 'post')
}
}
const result = await reader.getThreadPaged(conversationId, {
...(input.cursor ? { cursor: input.cursor } : {}),
maxPages: 1,
pageDelayMs: 0,
})
if (!result.success) {
return upstreamFailure(result.error, 'post')
}
const tweets = result.tweets ?? []
const nextCursor =
result.nextCursor &&
tweets.length > 0 &&
result.nextCursor !== input.cursor &&
focalPost?.replyCount !== 0
? result.nextCursor
: undefined
return {
ok: true,
page: {
tweets: tweets.map(publicPost),
focalPost,
conversationId,
nextCursor,
},
}
} catch (error) {
if (error instanceof InputError) {
return failure('invalid-input', error.message, false)
}
return upstreamFailure(error, 'post')
}
}
export async function loadUserPage(
reader: BirdReader,
input: UserPageInput,
input: Omit<UserPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
const handle = normalizeUserTarget(input.target)
@@ -271,7 +192,7 @@ export async function loadUserPage(
export async function loadListPage(
reader: BirdReader,
input: ListPageInput,
input: Omit<ListPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
const listId = normalizeListTarget(input.target)
@@ -295,7 +216,7 @@ export async function loadListPage(
export async function searchPage(
reader: BirdReader,
input: SearchPageInput,
input: Omit<SearchPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
return resultPage(
+20 -20
View File
@@ -1,21 +1,30 @@
import { createServerFn } from '@tanstack/react-start'
import { ProfileUnavailableError } from '../profiles/errors'
import {
listChoicesInputSchema,
listPageInputSchema,
searchPageInputSchema,
threadPageInputSchema,
userPageInputSchema,
} from './inputs'
import {
loadListChoices,
loadListPage,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
import type { LoadFailure } from './types'
const configFailure = (diagnostic: unknown): LoadFailure => {
if (diagnostic instanceof ProfileUnavailableError) {
return {
ok: false,
error: {
code: 'profile-unavailable',
message: diagnostic.message,
retryable: true,
},
}
}
console.error('Bird client configuration failed:', diagnostic)
return {
ok: false,
@@ -27,19 +36,20 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
}
}
async function reader() {
const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
async function reader(profileName: string) {
const [{ getBirdReader }, { requireProfileName }] = await Promise.all([
import('./bird-client.server'),
import('../profiles/profile-service.server'),
])
return getBirdReader(getSelectedProfileName())
await requireProfileName(profileName)
return getBirdReader(profileName)
}
export const loadUserPosts = createServerFn({ method: 'GET' })
.validator(userPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadUserPage(await reader(), data)
return await loadUserPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
@@ -49,7 +59,7 @@ export const loadListPosts = createServerFn({ method: 'GET' })
.validator(listPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadListPage(await reader(), data)
return await loadListPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
@@ -57,9 +67,9 @@ export const loadListPosts = createServerFn({ method: 'GET' })
export const loadLists = createServerFn({ method: 'GET' })
.validator(listChoicesInputSchema)
.handler(async () => {
.handler(async ({ data }) => {
try {
return await loadListChoices(await reader())
return await loadListChoices(await reader(data.profileName))
} catch (error) {
return configFailure(error)
}
@@ -69,17 +79,7 @@ export const searchPosts = createServerFn({ method: 'GET' })
.validator(searchPageInputSchema)
.handler(async ({ data }) => {
try {
return await searchPage(await reader(), data)
} catch (error) {
return configFailure(error)
}
})
export const loadThreadPosts = createServerFn({ method: 'GET' })
.validator(threadPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadThreadPage(await reader(), data)
return await searchPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
+4 -21
View File
@@ -9,19 +9,13 @@ export type PostPage = {
nextCursor?: string
}
export type ThreadPage = PostPage & {
focalPost?: Post
conversationId: string
}
type LoadErrorCode =
| 'invalid-input'
| 'list-not-found'
| 'list-unavailable'
| 'post-not-found'
| 'post-unavailable'
| 'user-not-found'
| 'user-unavailable'
| 'profile-unavailable'
| 'relay-config'
| 'timeout'
| 'upstream'
@@ -38,8 +32,6 @@ export type LoadResult<TPage extends PostPage = PostPage> =
| { ok: true; page: TPage }
| LoadFailure
export type ThreadLoadResult = LoadResult<ThreadPage>
export type ListChoice = {
id: string
name: string
@@ -50,29 +42,20 @@ export type ListChoice = {
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
export type ThreadPageInput =
| {
tweetId: string
conversationId?: never
cursor?: never
}
| {
tweetId: string
conversationId: string
cursor: string
}
export type UserPageInput = {
profileName: string
target: string
cursor?: string
}
export type ListPageInput = {
profileName: string
target: string
cursor?: string
}
export type SearchPageInput = {
profileName: string
query: string
product: SearchProduct
following: boolean
+4 -3
View File
@@ -10,13 +10,13 @@ describe('createListChoicesOptions', () => {
ok: true,
lists: [{ id: '123', name: 'My List' }],
})
const options = createListChoicesOptions(loader)
const options = createListChoicesOptions('main', loader)
await expect(options.queryFn()).resolves.toEqual([
{ id: '123', name: 'My List' },
])
expect(loader).toHaveBeenCalledWith({ data: {} })
expect(options.queryKey).toEqual(['list-choices'])
expect(loader).toHaveBeenCalledWith({ data: { profileName: 'main' } })
expect(options.queryKey).toEqual(['list-choices', 'main'])
expect(options.retry).toBe(false)
expect(options.refetchOnWindowFocus).toBe(false)
expect(options.refetchOnReconnect).toBe(false)
@@ -29,6 +29,7 @@ describe('createListChoicesOptions', () => {
retryable: true,
}
const options = createListChoicesOptions(
'main',
vi.fn().mockResolvedValue({ ok: false, error }),
)
+12 -6
View File
@@ -4,7 +4,7 @@ import { loadLists } from './server-functions'
import type { ListChoice, ListChoicesResult, LoadError } from './types'
type ListChoicesLoader = (options: {
data: Record<string, never>
data: { profileName: string }
}) => Promise<ListChoicesResult>
export class ListChoicesLoadError extends Error {
@@ -13,11 +13,14 @@ export class ListChoicesLoadError extends Error {
}
}
export function createListChoicesOptions(loader: ListChoicesLoader) {
export function createListChoicesOptions(
profileName: string,
loader: ListChoicesLoader,
) {
return {
queryKey: ['list-choices'] as const,
queryKey: ['list-choices', profileName] as const,
queryFn: async (): Promise<ListChoice[]> => {
const result = await loader({ data: {} })
const result = await loader({ data: { profileName } })
if (!result.ok) throw new ListChoicesLoadError(result.error)
return result.lists
},
@@ -27,7 +30,10 @@ export function createListChoicesOptions(loader: ListChoicesLoader) {
}
}
export function useListChoices() {
export function useListChoices(profileName: string) {
const loader = useServerFn(loadLists)
return useQuery(createListChoicesOptions(loader))
return useQuery({
...createListChoicesOptions(profileName, loader),
enabled: Boolean(profileName),
})
}
+43 -151
View File
@@ -4,7 +4,6 @@ import { createElement, type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPostFeedOptions,
getPostFeedData,
PostLoadError,
usePostFeed,
} from './use-post-feed'
@@ -23,7 +22,6 @@ const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
loadUser: vi.fn(),
loadList: vi.fn(),
search: vi.fn(),
thread: vi.fn(),
...overrides,
})
@@ -34,7 +32,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ kind: 'user', target: '@yuta' },
{ profileName: 'main', kind: 'user', target: '@yuta' },
loaders({ loadUser }),
)
@@ -42,7 +40,7 @@ describe('createPostFeedOptions', () => {
expect(loadUser).toHaveBeenCalledOnce()
expect(loadUser).toHaveBeenCalledWith({
data: { target: '@yuta', cursor: 'cursor' },
data: { profileName: 'main', target: '@yuta', cursor: 'cursor' },
})
})
@@ -52,7 +50,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ kind: 'list', target: '1234567890' },
{ profileName: 'main', kind: 'list', target: '1234567890' },
loaders({ loadList }),
)
@@ -60,17 +58,17 @@ describe('createPostFeedOptions', () => {
expect(loadList).toHaveBeenCalledOnce()
expect(loadList).toHaveBeenCalledWith({
data: { target: '1234567890', cursor: 'cursor' },
data: { profileName: 'main', target: '1234567890', cursor: 'cursor' },
})
expect(options.queryKey).toEqual([
'posts',
{ kind: 'list', target: '1234567890' },
{ profileName: 'main', kind: 'list', target: '1234567890' },
])
})
it('does not retry requests automatically', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -79,7 +77,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the window regains focus', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -88,7 +86,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the connection returns', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -97,7 +95,7 @@ describe('createPostFeedOptions', () => {
it('takes the next cursor only from the resolved page', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -110,6 +108,7 @@ describe('createPostFeedOptions', () => {
it('includes every search control in the query key', () => {
const options = createPostFeedOptions(
{
profileName: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
@@ -120,7 +119,13 @@ describe('createPostFeedOptions', () => {
expect(options.queryKey).toEqual([
'posts',
{ kind: 'search', query: 'AI', product: 'Top', following: true },
{
profileName: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
following: true,
},
])
})
@@ -131,6 +136,7 @@ describe('createPostFeedOptions', () => {
})
const options = createPostFeedOptions(
{
profileName: 'main',
kind: 'search',
query: 'AI lang:ja',
product: 'Top',
@@ -144,6 +150,7 @@ describe('createPostFeedOptions', () => {
expect(search).toHaveBeenCalledOnce()
expect(search).toHaveBeenCalledWith({
data: {
profileName: 'main',
query: 'AI lang:ja',
product: 'Top',
following: true,
@@ -152,61 +159,6 @@ describe('createPostFeedOptions', () => {
})
})
it('loads the initial thread and carries its root with the next cursor', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: {
tweets: [],
focalPost: {
id: '123',
text: 'focal',
author: { username: 'focus', name: 'Focus' },
},
conversationId: '100',
nextCursor: 'thread-next',
},
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
const page = await options.queryFn({ pageParam: undefined } as never)
expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } })
expect(options.queryKey).toEqual([
'posts',
{ kind: 'thread', tweetId: '123' },
])
expect(options.getNextPageParam(page)).toEqual({
cursor: 'thread-next',
conversationId: '100',
})
})
it('forwards a thread continuation page parameter intact', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: { tweets: [], conversationId: '100' },
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
await options.queryFn({
pageParam: { cursor: 'thread-next', conversationId: '100' },
} as never)
expect(thread).toHaveBeenCalledWith({
data: {
tweetId: '123',
cursor: 'thread-next',
conversationId: '100',
},
})
})
it('preserves a typed load failure for the feed', async () => {
const error = {
code: 'upstream' as const,
@@ -214,7 +166,7 @@ describe('createPostFeedOptions', () => {
retryable: true,
}
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders({
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
}),
@@ -230,93 +182,33 @@ describe('createPostFeedOptions', () => {
})
})
describe('getPostFeedData', () => {
it('reuses the visible feed without fetching a second copy', async () => {
const loadUser = vi
.fn()
.mockResolvedValue({ ok: true, page: { tweets: [] } })
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
describe('profile-bound feed cache', () => {
it('keeps cached posts and pagination isolated between bound profiles', async () => {
const loadUser = vi.fn(async ({ data }) => ({
ok: true as const,
page: { tweets: [], nextCursor: `${data.profileName}-next` },
}))
const main = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'main' },
loaders({ loadUser }),
)
const work = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'work' },
loaders({ loadUser }),
)
const client = new QueryClient()
const visible = await client.fetchInfiniteQuery(options)
expect(await getPostFeedData(client, options)).toEqual(visible)
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
it('waits for the active profile refresh instead of returning old cached posts', async () => {
let release = () => {}
const gate = new Promise<void>((resolve) => {
release = resolve
await client.ensureInfiniteQueryData(main)
expect(
(await client.ensureInfiniteQueryData(work)).pages[0]?.nextCursor,
).toBe('work-next')
expect(
(await client.ensureInfiniteQueryData(main)).pages[0]?.nextCursor,
).toBe('main-next')
expect(loadUser).toHaveBeenCalledTimes(2)
await work.queryFn({ pageParam: 'work-next' })
expect(loadUser).toHaveBeenLastCalledWith({
data: { target: 'yuta', profileName: 'work', cursor: 'work-next' },
})
const freshPage = {
tweets: [
{
id: 'new-profile',
text: 'fresh',
author: { username: 'new', name: 'New' },
},
],
}
const loadUser = vi.fn(async () => {
await gate
return { ok: true as const, page: freshPage }
})
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
loaders({ loadUser }),
)
const client = new QueryClient()
client.setQueryData(options.queryKey, {
pages: [{ tweets: [] }],
pageParams: [undefined],
})
await client.invalidateQueries({
queryKey: options.queryKey,
refetchType: 'none',
})
const refresh = client.fetchInfiniteQuery(options)
const resolved = vi.fn()
const tool = getPostFeedData(client, options).then(resolved)
await Promise.resolve()
expect(resolved).not.toHaveBeenCalled()
release()
await Promise.all([refresh, tool])
expect(resolved).toHaveBeenCalledWith({
pages: [freshPage],
pageParams: [undefined],
})
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
it('refreshes invalidated cached data before returning it', async () => {
const loadUser = vi.fn().mockResolvedValue({
ok: true,
page: { tweets: [], nextCursor: 'fresh' },
})
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
loaders({ loadUser }),
)
const client = new QueryClient()
client.setQueryData(options.queryKey, {
pages: [{ tweets: [] }],
pageParams: [undefined],
})
await client.invalidateQueries({
queryKey: options.queryKey,
refetchType: 'none',
})
expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe(
'fresh',
)
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
})
+21 -69
View File
@@ -1,25 +1,17 @@
import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import type { SearchProduct } from '@yuta/bird'
import {
loadListPosts,
loadThreadPosts,
loadUserPosts,
searchPosts,
} from './server-functions'
import { loadListPosts, loadUserPosts, searchPosts } from './server-functions'
import type {
ListPageInput,
LoadError,
LoadResult,
PostPage,
SearchPageInput,
ThreadLoadResult,
ThreadPage,
ThreadPageInput,
UserPageInput,
} from './types'
export type FeedRequest =
export type FeedRequest = { profileName: string } & (
| { kind: 'user'; target: string }
| { kind: 'list'; target: string }
| {
@@ -28,20 +20,12 @@ export type FeedRequest =
product: SearchProduct
following: boolean
}
| { kind: 'thread'; tweetId: string }
type ThreadPageParam = {
cursor: string
conversationId: string
}
type FeedPageParam = string | ThreadPageParam | undefined
)
type Loaders = {
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
}
export class PostLoadError extends Error {
@@ -58,17 +42,18 @@ function unwrap<TPage extends PostPage>(result: LoadResult<TPage>): TPage {
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return {
queryKey: ['posts', request] as const,
initialPageParam: undefined as FeedPageParam,
initialPageParam: undefined as string | undefined,
retry: false as const,
refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const,
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
if (request.kind === 'user') {
return unwrap(
await loaders.loadUser({
data: {
profileName: request.profileName,
target: request.target,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
cursor: pageParam,
},
}),
)
@@ -77,80 +62,47 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return unwrap(
await loaders.loadList({
data: {
profileName: request.profileName,
target: request.target,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
},
}),
)
}
if (request.kind === 'search') {
return unwrap(
await loaders.search({
data: {
query: request.query,
product: request.product,
following: request.following,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
cursor: pageParam,
},
}),
)
}
return unwrap(
await loaders.thread({
data:
typeof pageParam === 'object'
? {
tweetId: request.tweetId,
cursor: pageParam.cursor,
conversationId: pageParam.conversationId,
}
: { tweetId: request.tweetId },
await loaders.search({
data: {
profileName: request.profileName,
query: request.query,
product: request.product,
following: request.following,
cursor: pageParam,
},
}),
)
},
getNextPageParam: (page: PostPage | ThreadPage) => {
if (!page.nextCursor) return undefined
return request.kind === 'thread' && 'conversationId' in page
? {
cursor: page.nextCursor,
conversationId: page.conversationId,
}
: page.nextCursor
},
getNextPageParam: (page: PostPage) => page.nextCursor,
}
}
export function usePostFeedOptions() {
function usePostFeedOptions() {
const loadUser = useServerFn(loadUserPosts)
const loadList = useServerFn(loadListPosts)
const search = useServerFn(searchPosts)
const thread = useServerFn(loadThreadPosts)
return (request: FeedRequest) =>
createPostFeedOptions(request, {
loadUser,
loadList,
search,
thread,
})
}
export function getPostFeedData(
client: QueryClient,
options: ReturnType<typeof createPostFeedOptions>,
) {
const state = client.getQueryState(options.queryKey)
// Profile changes invalidate and refresh an existing feed. Wait for that
// refresh instead of returning the previous profile's cached posts.
return state?.fetchStatus === 'fetching' || state?.isInvalidated
? client.fetchInfiniteQuery(options)
: client.ensureInfiniteQueryData(options)
}
export function usePostFeed(request: FeedRequest | undefined) {
const options = usePostFeedOptions()
const disabled = {
kind: 'user',
target: '',
profileName: '',
} satisfies FeedRequest
return useInfiniteQuery({
-241
View File
@@ -1,241 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Post, ThreadPage } from './types'
import { type FeedRequest, PostLoadError } from './use-post-feed'
import {
feedResult,
prepareSearch,
readToolInput,
toolResult,
} from './webmcp-contracts'
const post = (id: string): Post => ({
id,
text: `Post ${id}`,
author: { username: 'reader', name: 'Reader' },
})
const request = { kind: 'user', target: 'reader' } satisfies FeedRequest
describe('WebMCP search input', () => {
it('defaults to chronological search without adding filters', () => {
expect(prepareSearch({ q: ' hello ' })).toMatchObject({
search: { q: 'hello', product: 'Latest', following: false },
request: {
kind: 'search',
query: 'hello',
product: 'Latest',
following: false,
},
})
})
it('compiles the same author, date, content, and exclusion filters as the UI', () => {
expect(
prepareSearch({
q: 'release',
from: '@reader',
since: '2026-09-01',
until: '2026-09-24',
lang: 'ja',
content: 'links',
excludeReplies: true,
excludeReposts: true,
product: 'Top',
following: true,
}).request,
).toEqual({
kind: 'search',
query:
'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows',
product: 'Top',
following: true,
})
})
it('accepts an author as the deliberate search intent', () => {
expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader')
})
it.each([
['an impossible date', { q: 'hello', since: '2026-02-30' }],
['an invalid date format', { q: 'hello', until: 'yesterday' }],
[
'an inverted period',
{ q: 'hello', since: '2026-09-24', until: '2026-09-01' },
],
[
'an empty period',
{ q: 'hello', since: '2026-09-24', until: '2026-09-24' },
],
['missing search intent', {}],
['whitespace-only intent', { q: ' ', from: ' ' }],
['an unrecognized property', { q: 'hello', profile: 'other' }],
])('rejects %s', (_description, input) => {
expect(() => prepareSearch(input)).toThrow()
})
})
describe('WebMCP read input', () => {
it('defaults to the first 20 loaded posts', () => {
expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 })
})
it('accepts the maximum batch size', () => {
expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({
offset: 10,
limit: 50,
})
})
it.each([
{ limit: 0 },
{ limit: 51 },
{ limit: 1.5 },
{ limit: '20' },
{ offset: -1 },
{ offset: 0.5 },
{ cursor: 'upstream-cursor' },
])('rejects invalid pagination %j', (input) => {
expect(readToolInput.safeParse(input).success).toBe(false)
})
})
describe('WebMCP feed output', () => {
it('bounds post text and indicates when text was truncated', () => {
const result = feedResult(request, [
{
tweets: [
{ ...post('1'), text: 'a'.repeat(2001) },
{ ...post('2'), text: 'b'.repeat(2000) },
],
},
])
expect(result.posts).toEqual([
{
id: '1',
author: { username: 'reader', name: 'Reader' },
text: 'a'.repeat(2000),
textTruncated: true,
createdAt: undefined,
url: 'https://x.com/reader/status/1',
},
{
id: '2',
author: { username: 'reader', name: 'Reader' },
text: 'b'.repeat(2000),
textTruncated: false,
createdAt: undefined,
url: 'https://x.com/reader/status/2',
},
])
})
it('deduplicates loaded pages before applying the requested offset and limit', () => {
const result = feedResult(
request,
[
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
{ tweets: [post('2'), post('3'), post('4')] },
],
1,
2,
)
expect(result.posts.map(({ id }) => id)).toEqual(['2', '3'])
expect(result).toMatchObject({
loadedCount: 4,
offset: 1,
nextOffset: 3,
hasMore: false,
})
})
it('includes the focal post once before the deduplicated conversation', () => {
const focal = post('2')
const pages: ThreadPage[] = [
{
focalPost: focal,
conversationId: '1',
tweets: [post('1'), focal],
nextCursor: 'next',
},
{ conversationId: '1', tweets: [focal, post('3'), post('1')] },
]
const result = feedResult({ kind: 'thread', tweetId: '2' }, pages)
expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3'])
expect(result.loadedCount).toBe(3)
})
it('reports upstream continuation separately from remaining loaded posts', () => {
const result = feedResult(request, [
{ tweets: [post('1')], nextCursor: 'more-on-server' },
])
expect(result).toMatchObject({
loadedCount: 1,
nextOffset: null,
hasMore: true,
})
})
it('returns an empty batch when the offset is beyond the loaded posts', () => {
expect(
feedResult(request, [{ tweets: [post('1')] }], 10, 20),
).toMatchObject({
posts: [],
loadedCount: 1,
offset: 10,
nextOffset: null,
hasMore: false,
})
})
})
describe('WebMCP execution result', () => {
it.each([
true,
false,
])('preserves a load failure with retryable=%s', async (retryable) => {
const detail = {
code: 'upstream' as const,
message: 'Relay unavailable',
retryable,
}
const result = await toolResult(() =>
Promise.reject(new PostLoadError(detail)),
)
expect(result).toEqual({
isError: true,
content: [{ type: 'text', text: JSON.stringify(detail) }],
})
})
it('returns invalid-input for schema validation failures', async () => {
const result = await toolResult(() => readToolInput.parse({ limit: 100 }))
expect(result).toMatchObject({ isError: true })
expect(JSON.parse(result.content[0].text)).toMatchObject({
code: 'invalid-input',
retryable: false,
})
})
it('keeps successful empty results distinguishable from failures', async () => {
const result = await toolResult(() => feedResult(request, [{ tweets: [] }]))
expect(result).not.toHaveProperty('isError')
expect(JSON.parse(result.content[0].text)).toEqual({
request,
posts: [],
loadedCount: 0,
offset: 0,
nextOffset: null,
hasMore: false,
})
})
})
-146
View File
@@ -1,146 +0,0 @@
import { z } from 'zod'
import { buildFilteredSearchQuery, InputError } from './inputs'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from './page'
import type { Post, PostPage, ThreadPage } from './types'
import { type FeedRequest, PostLoadError } from './use-post-feed'
export const searchToolInput = z
.object({
q: z
.string()
.trim()
.max(512)
.default('')
.describe(
'Search text; raw X search operators are supported. Provide q or from.',
),
from: z
.string()
.trim()
.max(16)
.default('')
.describe('Author handle, with or without @.'),
since: z
.string()
.default('')
.describe(
'Inclusive start date, YYYY-MM-DD, using X search date semantics.',
),
until: z
.string()
.default('')
.describe('Exclusive end date, YYYY-MM-DD; must be after since.'),
lang: z.enum(['all', 'ja', 'en']).default('all'),
content: z.enum(['all', 'images', 'videos', 'links']).default('all'),
excludeReplies: z.boolean().default(false),
excludeReposts: z.boolean().default(false),
product: z
.enum(['Latest', 'Top'])
.default('Latest')
.describe('Latest is chronological; Top uses X ranking.'),
following: z
.boolean()
.default(false)
.describe('Restrict to accounts followed by the active relay profile.'),
})
.strict()
export const readToolInput = z
.object({
offset: z
.number()
.int()
.min(0)
.default(0)
.describe('Offset into the currently loaded posts, starting at zero.'),
limit: z.number().int().min(1).max(50).default(20),
})
.strict()
export const emptyToolInput = z.object({}).strict()
export function prepareSearch(input: unknown) {
const search = searchToolInput.parse(input)
const request = {
kind: 'search',
query: buildFilteredSearchQuery(search, search.following),
product: search.product,
following: search.following,
} satisfies FeedRequest
return { search, request }
}
function summarizePost(post: Post) {
return {
id: post.id,
author: { username: post.author.username, name: post.author.name },
text: post.text.slice(0, 2000),
textTruncated: post.text.length > 2000,
createdAt: post.createdAt,
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
}
}
export function loadedPosts(
request: FeedRequest,
pages: Array<PostPage | ThreadPage>,
) {
const focal =
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
return focal
? [focal, ...flattenConversationPages(pages, focal.id)]
: flattenPostPages(pages)
}
export function feedResult(
request: FeedRequest,
pages: Array<PostPage | ThreadPage>,
offset = 0,
limit = 20,
) {
const posts = loadedPosts(request, pages)
const selected = posts.slice(offset, offset + limit)
const nextOffset = offset + selected.length
return {
request,
posts: selected.map(summarizePost),
loadedCount: posts.length,
offset,
nextOffset: nextOffset < posts.length ? nextOffset : null,
hasMore: Boolean(pages.at(-1)?.nextCursor),
}
}
// Keep the tool's failure distinguishable from a successful empty result.
export async function toolResult(run: () => unknown | Promise<unknown>) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
} catch (error) {
const detail =
error instanceof PostLoadError
? error.detail
: {
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
retryable: false,
}
return {
isError: true,
content: [
{ type: 'text' as const, text: JSON.stringify(detail) },
] as const,
}
}
}
-155
View File
@@ -1,155 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { useWebMCP } from 'usewebmcp'
import {
type FeedRequest,
getPostFeedData,
PostLoadError,
type usePostFeed,
usePostFeedOptions,
} from './use-post-feed'
import {
emptyToolInput,
feedResult,
loadedPosts,
prepareSearch,
readToolInput,
searchToolInput,
toolResult,
} from './webmcp-contracts'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
export function SearchTool() {
const supported = useWebMCPSupported()
const router = useRouter()
const queryClient = useQueryClient()
const options = usePostFeedOptions()
const searching = useRef(false)
useWebMCP({
name: 'search_posts',
description:
'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.',
inputSchema: searchToolInput,
enabled: supported,
annotations: {
readOnlyHint: false,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(async () => {
const { search, request } = prepareSearch(input)
if (searching.current)
throw new Error('A search is already running. Wait for it to finish.')
searching.current = true
try {
await router.navigate({ to: '/search', search })
const href = router.buildLocation({ to: '/search', search }).href
if (router.state.location.href !== href)
throw new Error(
'The page changed. Search again from the current page.',
)
// Join the UI request (or use its completed data), rather than fetch twice.
const result = await getPostFeedData(queryClient, options(request))
if (router.state.location.href !== href)
throw new Error(
'The page changed while searching. Read the current feed or search again.',
)
return feedResult(request, result.pages)
} finally {
searching.current = false
}
}),
})
return null
}
export function useFeedTools(
request: FeedRequest | undefined,
query: ReturnType<typeof usePostFeed>,
) {
const supported = useWebMCPSupported()
const generation = useRef(0)
const requestKey = JSON.stringify(request)
useEffect(() => {
// Executions belong to the feed that was visible when they started.
void requestKey
generation.current += 1
return () => {
generation.current += 1
}
}, [requestKey])
useWebMCP({
name: 'get_loaded_posts',
description:
'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.',
inputSchema: readToolInput,
enabled: supported && Boolean(request),
annotations: {
readOnlyHint: true,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(() => {
const { offset, limit } = readToolInput.parse(input)
if (!request)
throw new Error('Open a timeline, search or conversation first.')
return {
...feedResult(request, query.data?.pages ?? [], offset, limit),
status: query.isPending
? 'loading'
: query.isError
? 'error'
: 'ready',
loading: query.isFetching,
error:
query.error instanceof PostLoadError ? query.error.detail : null,
}
}),
})
useWebMCP({
name: 'load_more_posts',
description:
'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.',
inputSchema: emptyToolInput,
enabled: supported && Boolean(request),
annotations: {
readOnlyHint: false,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(async () => {
emptyToolInput.parse(input)
if (!request || query.isPending)
throw new Error(
'Wait for the initial feed to load before requesting more posts.',
)
if (query.isError && !query.isFetchNextPageError) throw query.error
if (query.isFetching && !query.isFetchingNextPage)
throw new Error(
'The feed is refreshing. Wait before requesting more posts.',
)
const pages = query.data?.pages ?? []
const offset = loadedPosts(request, pages).length
if (!query.hasNextPage) return feedResult(request, pages, offset)
const started = generation.current
const result = await query.fetchNextPage({ cancelRefetch: false })
if (generation.current !== started)
throw new Error(
'The feed changed while loading. Read the current feed before requesting more posts.',
)
if (result.isError) throw result.error
return feedResult(request, result.data?.pages ?? [], offset)
}),
})
}
+1
View File
@@ -0,0 +1 @@
export class ProfileUnavailableError extends Error {}
+11 -40
View File
@@ -1,12 +1,6 @@
import {
deleteCookie,
getCookie,
setCookie,
} from '@tanstack/react-start/server'
import { z } from 'zod'
import type { ProfileSettings } from './types'
import { ProfileUnavailableError } from './errors'
const PROFILE_COOKIE = 'twitter-lite-profile'
const profilesSchema = z.object({
profiles: z.array(z.string().min(1)),
})
@@ -32,38 +26,15 @@ export async function fetchProfileNames(
return profilesSchema.parse(await response.json()).profiles
}
export function getSelectedProfileName(): string | undefined {
return getCookie(PROFILE_COOKIE) || process.env.BIRD_PROFILE_NAME
}
export async function loadProfileSettings(): Promise<ProfileSettings> {
const profiles = await fetchProfileNames()
const selectedProfile = getSelectedProfileName()
return {
profiles,
...(selectedProfile && profiles.includes(selectedProfile)
? { selectedProfile }
: {}),
}
}
export async function saveProfileSelection(
profileName: string | null,
): Promise<void> {
if (profileName === null) {
deleteCookie(PROFILE_COOKIE, { path: '/' })
return
}
const profiles = await fetchProfileNames()
if (!profiles.includes(profileName)) {
throw new Error(`Unknown relay profile: ${profileName}`)
}
setCookie(PROFILE_COOKIE, profileName, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
export async function requireProfileName(profileName: string): Promise<void> {
const profiles = await fetchProfileNames().catch(() => {
throw new ProfileUnavailableError(
'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
)
})
if (!profiles.includes(profileName)) {
throw new ProfileUnavailableError(
'このプロファイルは利用できません。カラムのプロファイルを選び直してください。',
)
}
}
+35 -2
View File
@@ -1,7 +1,40 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { fetchProfileNames } from './profile-service.server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProfileUnavailableError } from './errors'
import { fetchProfileNames, requireProfileName } from './profile-service.server'
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
describe('requireProfileName', () => {
it('accepts a discovered profile and rejects a stale column binding', async () => {
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ profiles: ['main', 'work'] })),
)
await expect(requireProfileName('work')).resolves.toBeUndefined()
await expect(requireProfileName('removed')).rejects.toBeInstanceOf(
ProfileUnavailableError,
)
})
it('reports discovery failures without silently selecting another profile', async () => {
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 503 })),
)
await expect(requireProfileName('work')).rejects.toThrow(
'プロファイルを確認できませんでした。',
)
})
})
describe('fetchProfileNames', () => {
it('loads profile names from the relay endpoint', async () => {
@@ -1,60 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ProfileSwitcher } from './profile-switcher'
const serverFns = vi.hoisted(() => ({
loadProfiles: vi.fn(),
selectProfile: vi.fn(),
}))
vi.mock('./server-functions', () => serverFns)
vi.mock('@tanstack/react-start', () => ({
useServerFn: (serverFn: unknown) => serverFn,
}))
function renderSwitcher() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={queryClient}>
<ProfileSwitcher />
</QueryClientProvider>,
)
}
describe('ProfileSwitcher', () => {
it('shows relay profiles and switches the active profile', async () => {
serverFns.loadProfiles.mockResolvedValue({
profiles: ['main', 'work'],
selectedProfile: 'main',
})
serverFns.selectProfile.mockResolvedValue({ profileName: 'work' })
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toHaveValue('main'))
fireEvent.change(select, { target: { value: 'work' } })
await waitFor(() =>
expect(serverFns.selectProfile).toHaveBeenCalledWith({
data: { profileName: 'work' },
}),
)
expect(await screen.findByRole('status')).toHaveTextContent(
'切り替えました。',
)
})
it('reports a relay profiles failure without offering stale choices', async () => {
serverFns.loadProfiles.mockRejectedValue(new Error('relay unavailable'))
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toBeDisabled())
expect(select).toHaveTextContent('取得失敗')
expect(select).toHaveAttribute('aria-invalid', 'true')
})
})
@@ -1,72 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { loadProfiles, selectProfile } from './server-functions'
export function ProfileSwitcher() {
const queryClient = useQueryClient()
const load = useServerFn(loadProfiles)
const select = useServerFn(selectProfile)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => load(),
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const selection = useMutation({
mutationFn: (profileName: string | null) =>
select({ data: { profileName } }),
onSuccess: () => queryClient.invalidateQueries(),
})
const state = profiles.isError
? 'error'
: selection.isError
? 'error'
: selection.isSuccess
? 'success'
: profiles.isPending || selection.isPending
? 'loading'
: 'default'
return (
<div className="profile-switcher" data-state={state}>
<label htmlFor="relay-profile">プロファイル</label>
<select
aria-invalid={state === 'error' ? 'true' : undefined}
disabled={profiles.isPending || profiles.isError || selection.isPending}
id="relay-profile"
onChange={(event) =>
selection.mutate(
event.target.value.length ? event.target.value : null,
)
}
value={profiles.data?.selectedProfile ?? ''}
>
<option value="">
{profiles.isPending
? '読み込み中'
: profiles.isError
? '取得失敗'
: '自動'}
</option>
{profiles.data?.profiles.map((profile) => (
<option key={profile} value={profile}>
{profile}
</option>
))}
</select>
<span
aria-live="polite"
className="profile-switcher-status"
role={selection.isError ? 'alert' : 'status'}
>
{selection.isError
? '切り替えに失敗しました。'
: selection.isSuccess
? '切り替えました。'
: ''}
</span>
</div>
)
}
+2 -16
View File
@@ -1,22 +1,8 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import type { ProfileSelection } from './types'
const profileSelectionSchema = z.object({
profileName: z.string().min(1).nullable(),
})
export const loadProfiles = createServerFn({ method: 'GET' }).handler(
async () => {
const { loadProfileSettings } = await import('./profile-service.server')
return loadProfileSettings()
const { fetchProfileNames } = await import('./profile-service.server')
return { profiles: await fetchProfileNames() }
},
)
export const selectProfile = createServerFn({ method: 'POST' })
.validator(profileSelectionSchema)
.handler(async ({ data }): Promise<ProfileSelection> => {
const { saveProfileSelection } = await import('./profile-service.server')
await saveProfileSelection(data.profileName)
return data
})
-8
View File
@@ -1,8 +0,0 @@
export type ProfileSettings = {
profiles: string[]
selectedProfile?: string
}
export type ProfileSelection = {
profileName: string | null
}
+3 -115
View File
@@ -9,127 +9,48 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SearchRouteImport } from './routes/search'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as HandleRouteImport } from './routes/$handle'
import { Route as IndexRouteImport } from './routes/index'
import { Route as IListsRouteImport } from './routes/i.lists'
import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId'
import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId'
const SearchRoute = SearchRouteImport.update({
id: '/search',
path: '/search',
getParentRoute: () => rootRouteImport,
} as any)
const DeckRoute = DeckRouteImport.update({
id: '/deck',
path: '/deck',
getParentRoute: () => rootRouteImport,
} as any)
const HandleRoute = HandleRouteImport.update({
id: '/$handle',
path: '/$handle',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const IListsRoute = IListsRouteImport.update({
id: '/i/lists',
path: '/i/lists',
getParentRoute: () => rootRouteImport,
} as any)
const IListsListIdRoute = IListsListIdRouteImport.update({
id: '/i/lists_/$listId',
path: '/i/lists/$listId',
getParentRoute: () => rootRouteImport,
} as any)
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
id: '/$handle_/status/$tweetId',
path: '/$handle/status/$tweetId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists/$listId': typeof IListsListIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists/$listId': typeof IListsListIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists_/$listId': typeof IListsListIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
| '/i/lists/$listId'
fullPaths: '/' | '/deck'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
| '/i/lists/$listId'
id:
| '__root__'
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle_/status/$tweetId'
| '/i/lists_/$listId'
to: '/' | '/deck'
id: '__root__' | '/' | '/deck'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HandleRoute: typeof HandleRoute
DeckRoute: typeof DeckRoute
SearchRoute: typeof SearchRoute
IListsRoute: typeof IListsRoute
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
IListsListIdRoute: typeof IListsListIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/search': {
id: '/search'
path: '/search'
fullPath: '/search'
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/deck': {
id: '/deck'
path: '/deck'
@@ -137,13 +58,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DeckRouteImport
parentRoute: typeof rootRouteImport
}
'/$handle': {
id: '/$handle'
path: '/$handle'
fullPath: '/$handle'
preLoaderRoute: typeof HandleRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -151,38 +65,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/i/lists': {
id: '/i/lists'
path: '/i/lists'
fullPath: '/i/lists'
preLoaderRoute: typeof IListsRouteImport
parentRoute: typeof rootRouteImport
}
'/i/lists_/$listId': {
id: '/i/lists_/$listId'
path: '/i/lists/$listId'
fullPath: '/i/lists/$listId'
preLoaderRoute: typeof IListsListIdRouteImport
parentRoute: typeof rootRouteImport
}
'/$handle_/status/$tweetId': {
id: '/$handle_/status/$tweetId'
path: '/$handle/status/$tweetId'
fullPath: '/$handle/status/$tweetId'
preLoaderRoute: typeof HandleStatusTweetIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HandleRoute: HandleRoute,
DeckRoute: DeckRoute,
SearchRoute: SearchRoute,
IListsRoute: IListsRoute,
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
IListsListIdRoute: IListsListIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
-11
View File
@@ -1,11 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { UserPage } from './index'
export const Route = createFileRoute('/$handle')({
component: UserRoute,
})
function UserRoute() {
const { handle } = Route.useParams()
return <UserPage handle={handle} />
}
-39
View File
@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import {
InputError,
normalizeTweetId,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/$handle_/status/$tweetId')({
component: StatusRoute,
})
function StatusRoute() {
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
let tweetId: string | undefined
let error: string | undefined
try {
normalizeUserTarget(rawHandle)
tweetId = normalizeTweetId(rawTweetId)
} catch (cause) {
error =
cause instanceof InputError ? cause.message : 'URL を確認してください。'
}
return (
<AppShell>
<h1>会話</h1>
<p className="intro">選んだ投稿と、その会話だけを表示します。</p>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed request={tweetId ? { kind: 'thread', tweetId } : undefined} />
</AppShell>
)
}
-231
View File
@@ -1,231 +0,0 @@
import { QueryClient } from '@tanstack/react-query'
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { routeTree } from '../routeTree.gen'
const usePostFeed = vi.hoisted(() => vi.fn())
const useListChoices = vi.hoisted(() => vi.fn())
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
const original =
await importOriginal<typeof import('../features/posts/use-post-feed')>()
return { ...original, usePostFeed }
})
vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
const original =
await importOriginal<typeof import('../features/posts/use-list-choices')>()
return { ...original, useListChoices }
})
vi.mock('../features/profiles/profile-switcher', () => ({
ProfileSwitcher: () => null,
}))
vi.mock('./__root', async () => {
const { createRootRouteWithContext, Outlet } = await import(
'@tanstack/react-router'
)
return {
Route: createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: Outlet,
}),
}
})
async function renderRoute(path: string) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [path] }),
context: { queryClient: new QueryClient() },
})
await router.load()
render(<RouterProvider router={router} />)
}
describe('post feed route wiring', () => {
beforeEach(() => {
vi.stubGlobal('scrollTo', vi.fn())
usePostFeed.mockReset()
useListChoices.mockReset()
usePostFeed.mockReturnValue({
data: undefined,
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn(),
} as never)
useListChoices.mockReturnValue({
data: [{ id: '1234567890', name: 'My List', memberCount: 10 }],
error: null,
isError: false,
isPending: false,
refetch: vi.fn(),
} as never)
})
afterEach(() => vi.unstubAllGlobals())
it('keeps an empty user URL disabled', async () => {
await renderRoute('/')
expect(
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
).toBeVisible()
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a manually entered user URL into a deliberate request', async () => {
await renderRoute('/yuta')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'user',
target: 'yuta',
}),
)
})
it('keeps an invalid user path local and idle', async () => {
await renderRoute('/not-a-handle')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an empty list URL disabled', async () => {
await renderRoute('/i/lists')
expect(
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
).toBeVisible()
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a manually entered list URL into a deliberate request', async () => {
await renderRoute('/i/lists/1234567890')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'list',
target: '1234567890',
}),
)
expect(
await screen.findByRole('link', { name: 'リスト', current: 'page' }),
).toBeVisible()
})
it('keeps an invalid list path local and idle', async () => {
await renderRoute('/i/lists/not-a-list')
expect(await screen.findByRole('alert')).toHaveTextContent(
'リスト ID は数字で入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a selected authenticated list into a deliberate request', async () => {
await renderRoute('/i/lists')
fireEvent.click(
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
)
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'list',
target: '1234567890',
}),
)
})
it('forwards every manually entered search control', async () => {
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query: 'AI lang:ja filter:follows',
product: 'Top',
following: true,
}),
)
})
it('compiles button-selected search conditions from the URL', async () => {
await renderRoute(
'/search?q=TypeScript&from=%40tan_stack&since=2026-08-04&until=2026-08-11&lang=ja&content=images&excludeReplies=true&excludeReposts=true&product=Latest&following=false',
)
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query:
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
product: 'Latest',
following: false,
}),
)
expect(
screen.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
).toBeVisible()
})
it('keeps contradictory date parameters local and idle', async () => {
await renderRoute(
'/search?q=TypeScript&since=2026-08-11&until=2026-08-10&product=Latest&following=false',
)
fireEvent.click(screen.getByRole('button', { name: '期間' }))
expect(await screen.findByLabelText('開始日')).toHaveValue('2026-08-11')
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a decimal status path into a deliberate thread request', async () => {
await renderRoute('/yuta/status/123')
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'thread',
tweetId: '123',
}),
)
expect(
screen.queryByRole('link', { name: 'ユーザー', current: 'page' }),
).toBeNull()
expect(
screen.queryByRole('link', { name: '検索', current: 'page' }),
).toBeNull()
})
it('keeps an invalid tweet path local and idle', async () => {
await renderRoute('/yuta/status/not-a-tweet')
expect(await screen.findByRole('alert')).toHaveTextContent(
'投稿 ID を確認してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an invalid status handle local and idle', async () => {
await renderRoute('/not-a-handle/status/123')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
})
+1 -7
View File
@@ -5,7 +5,6 @@ import {
Outlet,
Scripts,
} from '@tanstack/react-router'
import { SearchTool } from '#/features/posts/webmcp-tools'
import appCss from '../styles.css?url'
type RouterContext = { queryClient: QueryClient }
@@ -34,12 +33,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
})
function RootContent() {
return (
<>
<SearchTool />
<Outlet />
</>
)
return <Outlet />
}
function RootDocument({ children }: { children: React.ReactNode }) {
-63
View File
@@ -1,63 +0,0 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { ListForm } from '#/features/posts/components/list-form'
import { ListPicker } from '#/features/posts/components/list-picker'
import { PostFeed } from '#/features/posts/components/post-feed'
import { InputError, normalizeListTarget } from '#/features/posts/inputs'
export const Route = createFileRoute('/i/lists')({
component: ListIndexRoute,
})
function ListIndexRoute() {
return <ListPage />
}
export function ListPage({ listId = '' }: { listId?: string }) {
const navigate = useNavigate()
let normalizedListId: string | undefined
let error: string | undefined
if (listId) {
try {
normalizedListId = normalizeListTarget(listId)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'リスト ID を確認してください。'
}
}
return (
<AppShell active="list">
<h1 className="reader-title-long">どのリストを読みますか?</h1>
<p className="intro">
自分のリストを選ぶか、リストURLを入力してください。
</p>
<ListPicker selectedId={listId || undefined} />
<ListForm
initialTarget={listId}
key={listId}
onSubmit={(nextListId) =>
navigate({
to: '/i/lists/$listId',
params: { listId: nextListId },
})
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedListId
? { kind: 'list', target: normalizedListId }
: undefined
}
/>
</AppShell>
)
}
-11
View File
@@ -1,11 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { ListPage } from './i.lists'
export const Route = createFileRoute('/i/lists_/$listId')({
component: ListRoute,
})
function ListRoute() {
const { listId } = Route.useParams()
return <ListPage listId={listId} />
}
+3 -55
View File
@@ -1,56 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import { UserForm } from '#/features/posts/components/user-form'
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
import { createFileRoute } from '@tanstack/react-router'
import { DeckPage } from '#/features/decks/deck-page'
export const Route = createFileRoute('/')({
component: UserIndexRoute,
})
function UserIndexRoute() {
return <UserPage />
}
export function UserPage({ handle = '' }: { handle?: string }) {
const navigate = useNavigate()
let normalizedHandle: string | undefined
let error: string | undefined
if (handle) {
try {
normalizedHandle = normalizeUserTarget(handle)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'ハンドルを確認してください。'
}
}
return (
<AppShell active="user">
<h1>誰の投稿を見ますか?</h1>
<p className="intro">履歴やおすすめは表示しません。</p>
<UserForm
initialTarget={handle}
key={handle}
onSubmit={(nextHandle) =>
navigate({ to: '/$handle', params: { handle: nextHandle } })
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedHandle
? { kind: 'user', target: normalizedHandle }
: undefined
}
/>
</AppShell>
)
}
export const Route = createFileRoute('/')({ component: DeckPage })
-76
View File
@@ -1,76 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import {
SearchForm,
type SearchValues,
} from '#/features/posts/components/search-form'
import {
buildFilteredSearchQuery,
postSearchRouteSchema,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/search')({
validateSearch: (search) => postSearchRouteSchema.parse(search),
component: SearchRoute,
})
function compileQuery(values: SearchValues): string | undefined {
if (!values.q && !values.from) return undefined
try {
return buildFilteredSearchQuery(values, values.following)
} catch {
return undefined
}
}
function SearchRoute() {
const values = Route.useSearch()
const navigate = Route.useNavigate()
const formValues = {
...values,
from: values.from ?? '',
since: values.since ?? '',
until: values.until ?? '',
lang: values.lang ?? 'all',
content: values.content ?? 'all',
excludeReplies: values.excludeReplies ?? false,
excludeReposts: values.excludeReposts ?? false,
} satisfies SearchValues
const query = compileQuery(formValues)
const formKey = [
formValues.q,
formValues.from,
formValues.since,
formValues.until,
formValues.lang,
formValues.content,
formValues.excludeReplies,
formValues.excludeReposts,
formValues.product,
formValues.following,
].join(':')
return (
<AppShell active="search">
<h1>何を確認しますか?</h1>
<p className="intro">入力した条件だけを検索します。</p>
<SearchForm
initialValues={formValues}
key={formKey}
onSubmit={(search) => navigate({ search })}
/>
<PostFeed
request={
query
? {
kind: 'search',
query,
product: values.product,
following: values.following,
}
: undefined
}
/>
</AppShell>
)
}
+65 -1386
View File
File diff suppressed because it is too large Load Diff
+34 -38
View File
@@ -1,29 +1,28 @@
/* Hallmark · macrostructure: Workbench · tone: soft · anchor hue: leaf-green */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
:root {
--color-canvas: oklch(93% 0.018 125);
--color-paper: oklch(98% 0.01 100);
--color-paper-2: oklch(95% 0.018 115);
--color-paper-3: oklch(90% 0.026 125);
--color-ink: oklch(23% 0.025 145);
--color-ink-2: oklch(32% 0.03 145);
--color-muted: oklch(42% 0.026 145);
--color-accent: oklch(38% 0.09 145);
--color-accent-hover: oklch(31% 0.085 145);
--color-accent-ink: oklch(98% 0.01 100);
--color-rule: oklch(80% 0.025 125);
--color-control-rule: oklch(55% 0.035 135);
--color-focus: oklch(45% 0.15 65);
--color-error: oklch(42% 0.13 25);
--color-success: oklch(38% 0.1 145);
--color-shadow: oklch(23% 0.025 145 / 10%);
--font-display:
"YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif;
--color-canvas: oklch(19% 0.021 250);
--color-paper: oklch(24% 0.025 250);
--color-paper-2: oklch(28% 0.027 250);
--color-paper-3: oklch(33% 0.031 250);
--color-ink: oklch(95% 0.005 250);
--color-ink-2: oklch(85% 0.015 250);
--color-muted: oklch(72% 0.025 250);
--color-accent: oklch(72% 0.15 240);
--color-accent-hover: oklch(79% 0.13 240);
--color-accent-ink: oklch(18% 0.03 250);
--color-rule: oklch(34% 0.023 250);
--color-control-rule: oklch(47% 0.029 250);
--color-focus: oklch(79% 0.13 240);
--color-error: oklch(78% 0.13 20);
--color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%);
--color-overlay: oklch(10% 0.015 250 / 70%);
--font-body:
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif;
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans",
"Noto Sans JP", sans-serif;
--font-display: var(--font-body);
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
--space-3xs: 0.125rem;
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
@@ -33,26 +32,23 @@
--space-xl: 2.5rem;
--space-2xl: 4rem;
--space-3xl: 6rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-xs: 0.8571rem;
--text-sm: 1rem;
--text-base: 1rem;
--text-md: 1.25rem;
--text-lg: 1.5625rem;
--text-xl: 1.9531rem;
--text-display-s: clamp(1.75rem, 7.4vw + 0.25rem, 2.75rem);
--text-display-long: clamp(1.5rem, 5.5vw + 0.4rem, 2.75rem);
--text-md: 1.125rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-display-s: 1.125rem;
--text-display-long: 1.125rem;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-instant: 100ms;
--dur-short: 200ms;
--dur-medium: 420ms;
--dur-short: 150ms;
--dur-medium: 300ms;
--rule-hairline: 1px;
--rule-active: 2px;
--radius-sm: 0.375rem;
--radius-md: 0.625rem;
--radius-lg: 1rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}