feat: add research decks with WebMCP controls

This commit is contained in:
2026-09-24 15:38:06 +09:00
parent 2bc052c354
commit da8f52e605
32 changed files with 1876 additions and 51 deletions
+20
View File
@@ -18,6 +18,9 @@ deliberate post detail link, or manually open a valid
- 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
It intentionally has no home feed, recommendations, trends, notifications,
history, account directory, or write actions.
@@ -28,6 +31,7 @@ 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
@@ -95,6 +99,22 @@ handwritten mock server.
`test:live` is an explicit, opt-in smoke test that performs exactly one
read-only Top search using the configured relay.
## Research deck
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.
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).
## WebMCP prototype
In a WebMCP-enabled browser, `search_posts` searches and updates the page,
+134
View File
@@ -0,0 +1,134 @@
# Research decks
## 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.
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.
## Implemented boundary
- `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.
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.
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.
## 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.
```json
{
"title": "WebMCPの反応",
"columns": [
{ "title": "日本語", "source": { "query": "WebMCP lang:ja" } },
{ "title": "海外の話題", "source": { "query": "WebMCP lang:en", "product": "Top" } }
]
}
```
`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.
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.
## 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.
## 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.
+3 -1
View File
@@ -3,7 +3,9 @@
## Scope and design
Expose the existing intentional reader to agents through three React-owned
tools. Search remains available across routes. Feed tools are registered only
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.
+193
View File
@@ -0,0 +1,193 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
async function addColumn(
page: Page,
title: string,
query: string,
top = false,
) {
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('button', { name: '追加して検索' }).click()
await expect(
page
.getByRole('region', { name: title, exact: true })
.locator('article')
.first(),
).toBeVisible()
}
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await page.waitForLoadState('networkidle')
})
test('creates independent columns, edits, reorders, and restores saved conditions', 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 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: '編集', exact: true }).click()
await page
.getByRole('form', { name: 'カラムを編集' })
.getByLabel('カラム名', { exact: true })
.fill('公式の投稿')
await page.getByLabel('Twitterの検索条件').fill('from:OpenAI')
await page.getByRole('button', { name: '変更を保存' }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'WebMCPの反応',
)
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([])
})
test('keeps other columns usable when pagination fails and retries only that column', async ({
page,
}, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
await addColumn(page, '別の検索', 'independent-deck', true)
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click()
await expect(failed.getByRole('alert')).toBeVisible()
await expect(failed.locator('article')).toHaveCount(1)
await other.getByRole('button', { name: '続きを読み込む' }).click()
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 ({
page,
a11y,
}, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '海外の反応', 'WebMCP lang:en', true)
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
),
).toBe(false)
}
await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393,
height: 900,
})
await expect(
page
.getByRole('region', { name: '日本語の反応', exact: true })
.locator('article'),
).toHaveCount(1)
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck.png'),
fullPage: true,
})
})
test('does not overwrite an unreadable saved deck on reload', async ({
page,
}) => {
await page.evaluate(() =>
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
await page.reload()
await expect(page.getByRole('alert')).toContainText('読み込めませんでした')
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe('{broken')
})
test('keeps the six-column limit when undo and add overlap', 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.reload()
await page.waitForLoadState('networkidle')
await addColumn(page, 'HTTP接続からの調査', 'http-deck')
await page.reload()
await expect(
page
.getByRole('region', { name: 'HTTP接続からの調査', exact: true })
.locator('article'),
).toHaveCount(1)
})
+1
View File
@@ -188,6 +188,7 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent
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: 'ユーザー' }),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+102
View File
@@ -252,3 +252,105 @@ test('keeps the normal search form functional without native WebMCP', async ({
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', {
title: 'WebMCPの反応',
columns: [
{ id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } },
{ title: '話題', source: { query: 'WebMCP', product: 'Top' } },
],
})
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.
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', {
title: '更新した調査',
columns: [deck.columns[1]],
})
expect(edited.isError).not.toBe(true)
await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0)
await expect(page.locator('.deck-column h2')).toHaveText(['話題'])
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,
)
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
})
test('rejects an invalid deck without changing the visible or saved columns', 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' } }],
})
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)
})
test('reports a deck persistence failure instead of claiming it was saved', 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 result = await executeTool(page, 'set_deck', {
title: '未保存の調査',
columns: [],
})
expect(result.isError).toBe(true)
expect(result.content[0]?.text).toContain(
'applied in this tab but could not be saved',
)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'未保存の調査',
)
await expect(page.getByRole('alert')).toContainText('保存できません')
})
+8 -2
View File
@@ -5,15 +5,21 @@ export function AppShell({
active,
children,
}: {
active?: 'user' | 'search' | 'list'
active?: 'user' | 'search' | 'list' | 'deck'
children: React.ReactNode
}) {
return (
<main className="app">
<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 }}
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react'
const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = []
let cursor = 0
for (const match of decoded.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
{href}
</a>,
)
cursor = start + href.length
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts
}
export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</>
}
+106
View File
@@ -0,0 +1,106 @@
import { useId, useState } from 'react'
import { columnSchema, type DeckColumn } from './model'
export function ColumnEditor({
column,
onSave,
onCancel,
}: {
column?: DeckColumn
onSave: (column: DeckColumn) => void
onCancel: () => void
}) {
const formId = useId()
const [error, setError] = useState<string>()
return (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
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',
},
})
if (!parsed.success) {
setError(
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
)
return
}
onSave(parsed.data)
}}
>
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
カラム名
<input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
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`}>
表示順
<select
id={`${formId}-product`}
name="product"
defaultValue={column?.source.product ?? 'Latest'}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
</div>
<p className="deck-help" id={`${formId}-help`}>
from:、lang:、since: など、Twitterの検索構文を使えます。
</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">
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
キャンセル
</button>
</div>
</form>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
export function ResearchColumn({
column,
onEdit,
onRemove,
onMove,
first,
last,
}: {
column: DeckColumn
onEdit: () => void
onRemove: () => void
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
}) {
const query = usePostFeed({
kind: 'search',
query: column.source.query,
product: column.source.product,
following: column.source.following,
})
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
const headingId = `column-${column.id}`
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>
<button
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}を削除`}
>
削除
</button>
</div>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
: `${posts.length}件を読み込み済み`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`${column.title}の投稿`}
>
{posts.map((post) => (
<ResearchPostCard key={post.key} post={post} />
))}
{query.isError ? (
<div className="deck-message" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button
type="button"
disabled={query.isFetching}
onClick={() =>
void (query.isFetchNextPageError
? query.fetchNextPage({ cancelRefetch: false })
: query.refetch())
}
>
再試行
</button>
) : null}
</div>
) : null}
{!query.isPending && !query.isError && posts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p>
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
<button
type="button"
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
</button>
</div>
) : null}
{!query.isPending &&
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
<p className="deck-message">これ以上の投稿はありません。</p>
) : null}
</section>
</section>
)
}
+185
View File
@@ -0,0 +1,185 @@
import { useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { ColumnEditor } from './column-editor'
import { ResearchColumn } from './deck-column'
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 [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
useDeckTools({
deck,
ready,
storageError,
save,
onApplied: () => {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
},
})
function saveColumn(column: DeckColumn) {
save({
...deck,
columns:
editing === 'new'
? [...deck.columns, column]
: deck.columns.map((current) =>
current.id === column.id ? column : current,
),
})
setEditing(null)
}
return (
<AppShell active="deck">
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<p className="deck-help">
観点ごとに検索を並べて、投稿を読み比べる。
</p>
</div>
<div className="deck-actions">
<button
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')}
>
カラムを追加
</button>
</div>
</div>
<p className="deck-save-status">
{!ready
? 'デッキを読み込んでいます…'
: storageError
? 'デッキの保存状態を確認してください。'
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
</p>
{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>
) : 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)}
/>
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={() => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
save({ ...deck, columns })
setRemoved(undefined)
}}
>
元に戻す
</button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<h2>最初の観点を追加しましょう。</h2>
<p>
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
</p>
<button
className="deck-primary"
type="button"
onClick={() => setEditing('new')}
>
最初のカラムを作る
</button>
</div>
) : null}
{ready && deck.columns.length > 0 ? (
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={column.id}
column={column}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={() => {
setRemoved({ column, index })
save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
/>
))}
</section>
) : null}
</AppShell>
)
}
+274
View File
@@ -0,0 +1,274 @@
/* 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;
}
.app-deck button {
min-height: 44px;
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);
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:disabled {
opacity: 0.5;
cursor: default;
}
.app-deck :focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 3px;
}
.app-deck .deck-primary {
color: var(--color-accent-ink);
background: var(--accent);
border-color: var(--accent);
}
.app-deck .deck-primary:hover:not(:disabled),
.app-deck .deck-primary:active:not(:disabled) {
background: var(--accent-hover);
}
.deck-heading {
display: flex;
align-items: start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow-wrap: anywhere;
}
.deck-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.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);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(100%, 23rem);
gap: var(--space-md);
overflow-x: auto;
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
scroll-snap-type: x proximity;
}
.deck-column {
min-width: 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);
border-bottom: var(--rule-hairline) solid var(--border);
}
.deck-column-label {
display: flex;
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);
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;
}
.deck-column-status {
margin-bottom: 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
min-height: 0;
overflow-y: auto;
flex: 1;
overscroll-behavior-y: contain;
}
.deck-message {
padding: var(--space-md);
color: var(--secondary);
line-height: 1.7;
}
.research-post {
padding: var(--space-md);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.research-post header > div {
min-width: 0;
}
.research-post header img {
border-radius: 50%;
}
.research-post strong {
font-size: var(--text-sm);
}
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.8;
font-size: var(--text-sm);
}
.research-post blockquote {
margin: var(--space-md) 0;
padding-left: var(--space-md);
border-left: var(--rule-hairline) solid var(--border);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-md);
color: var(--secondary);
font-size: var(--text-xs);
}
.research-post a {
color: var(--accent);
text-underline-offset: 0.2em;
}
.research-media {
display: block;
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-sm);
}
@media (min-width: 52rem) {
.app.app-deck {
width: 100%;
grid-template-columns: 11rem minmax(0, 1fr);
}
.app-deck .reader {
padding: var(--space-xl) var(--space-lg);
}
.deck-editor-fields {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
}
}
@media (prefers-reduced-motion: reduce) {
.app-deck button {
transition: none;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
const deck: Deck = {
version: 1,
title: '調査',
columns: ['a', 'b'].map((id) => ({
id,
title: id,
source: {
platform: 'twitter',
query: 'AI lang:ja',
product: 'Latest',
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native Twitter search conditions when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('rejects duplicate column identities and unsupported platforms', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [deck.columns[0], deck.columns[0]],
}).success,
).toBe(false)
expect(
deckSchema.safeParse({
...deck,
columns: [
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
],
}).success,
).toBe(false)
})
it('rejects queries that exceed the upstream limit after adding follows', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...deck.columns[0],
source: {
platform: 'twitter',
query: 'a'.repeat(512),
product: 'Latest',
following: true,
},
},
],
}).success,
).toBe(false)
})
it('moves a column without changing its query or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
])
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
expect(moveColumn(deck, 'a', -1)).toBe(deck)
})
})
+65
View File
@@ -0,0 +1,65 @@
import { z } from 'zod'
import { buildSearchQuery } 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
.object({
platform: z.literal('twitter'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
following: z.boolean(),
})
.strict()
.superRefine((source, ctx) => {
try {
buildSearchQuery(source.query, source.following)
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error
? error.message
: '検索条件を確認してください。',
})
}
})
export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
source: columnSourceSchema,
})
.strict()
export const deckSchema = z
.object({
version: z.literal(1),
title: z.string().trim().min(1).max(120),
columns: z.array(columnSchema).max(MAX_COLUMNS),
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
const columns = [...deck.columns]
const [column] = columns.splice(index, 1)
if (column) columns.splice(target, 0, column)
return { ...deck, columns }
}
+78
View File
@@ -0,0 +1,78 @@
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
export function ResearchPostCard({ post }: { post: ResearchPost }) {
return (
<article className="research-post">
<header>
{post.author.avatarUrl ? (
<img
src={post.author.avatarUrl}
alt=""
width={32}
height={32}
loading="lazy"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">{post.author.handle}</span>
</div>
</header>
<p className="research-post-text">
<PostText text={post.text} />
</p>
{post.media?.map((media) =>
media.type === 'photo' ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
動画を開く
</a>
),
)}
{post.quotedPost ? (
<blockquote>
<strong>{post.quotedPost.author.name}</strong>
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
</a>
</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">
元の投稿を開く
</a>
</footer>
</article>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('saved deck', () => {
it('restores saved conditions without overwriting them during mount', () => {
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
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(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
)
})
it('keeps invalid saved data untouched and explains the failure', () => {
localStorage.setItem(DECK_STORAGE_KEY, '{broken')
const { result } = renderHook(useDeck)
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', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
expect(result.current.deck.title).toBe('未保存')
expect(result.current.storageError).toContain('保存できません')
})
})
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react'
import { type Deck, deckSchema, EMPTY_DECK } from './model'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export function useDeck() {
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
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)))
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
)
}
setReady(true)
}, [])
function save(next: Deck) {
const validated = deckSchema.parse(next)
setDeck(validated)
try {
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
setStorageError(undefined)
return true
} catch {
setStorageError(
'このブラウザに保存できません。現在の変更は、ページを閉じると失われます。',
)
return false
}
}
return { deck, ready, storageError, save }
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { prepareDeck } from './webmcp-contracts'
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' } },
],
})
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
query: 'WebMCP lang:ja',
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')
expect(() =>
prepareDeck({ title: '調査', columns: [column, column] }),
).toThrow()
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
expect(() =>
prepareDeck({
title: '調査',
columns: [
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
],
}),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: [
{
title: '観点',
source: { query: 'a'.repeat(512), following: true },
},
],
}),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: Array.from({ length: 7 }, () => ({
title: '観点',
source: { query: 'AI' },
})),
}),
).toThrow()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
const source = columnSchema.shape.source
const toolColumn = columnSchema.omit({ id: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing ID from get_deck 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(),
})
export const setDeckInput = z
.object({
title: deckSchema.shape.title.describe('The research topic or deck title.'),
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
),
})
.strict()
export function prepareDeck(input: unknown) {
const parsed = setDeckInput.parse(input)
return deckSchema.parse({
version: 1,
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
})),
})
}
+63
View File
@@ -0,0 +1,63 @@
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'
export function useDeckTools({
deck,
ready,
storageError,
save,
onApplied,
}: {
deck: Deck
ready: boolean
storageError?: string
save: (deck: Deck) => boolean
onApplied: () => void
}) {
const supported = useWebMCPSupported()
const current = useRef({ deck, storageError })
current.current = { deck, storageError }
useWebMCP({
name: 'get_deck',
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.',
inputSchema: emptyToolInput,
enabled: supported && ready,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return {
deck: current.current.deck,
storageError: current.current.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.',
inputSchema: setDeckInput,
enabled: supported && ready,
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)
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
}),
})
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import type { Post } from '../posts/types'
import { mapTwitterPost } from './twitter'
const post: Post = {
id: '123',
text: 'A &amp; B https://content.invalid/story',
author: { name: 'Researcher', username: 'researcher' },
}
describe('mapTwitterPost', () => {
it('preserves the native identity and original text with explicit provenance', () => {
expect(mapTwitterPost(post)).toEqual({
key: 'twitter:123',
platform: 'twitter',
nativeId: '123',
url: 'https://x.com/researcher/status/123',
text: post.text,
author: { name: 'Researcher', handle: 'researcher' },
})
})
it('keeps identity stable when the author handle changes', () => {
const renamed = mapTwitterPost({
...post,
author: { ...post.author, username: 'new_name' },
})
expect(renamed.key).toBe(mapTwitterPost(post).key)
expect(renamed.url).toBe('https://x.com/new_name/status/123')
})
it('normalizes a valid timestamp and preserves the avatar', () => {
expect(
mapTwitterPost({
...post,
createdAt: '2026-09-24T09:00:00+09:00',
author: {
...post.author,
profileImageUrl: 'https://media.invalid/avatar.jpg',
},
}),
).toMatchObject({
createdAt: '2026-09-24T00:00:00.000Z',
author: { avatarUrl: 'https://media.invalid/avatar.jpg' },
})
})
it('omits invalid dates without dropping the post', () => {
const result = mapTwitterPost({ ...post, createdAt: 'not-a-date' })
expect(result).not.toHaveProperty('createdAt')
expect(result.nativeId).toBe('123')
})
it('maps playable media and uses an image when the video URL is missing', () => {
const result = mapTwitterPost({
...post,
media: [
{ type: 'photo', url: 'https://media.invalid/photo.jpg' },
{
type: 'video',
url: 'https://media.invalid/poster.jpg',
videoUrl: 'https://media.invalid/video.mp4',
},
{
type: 'animated_gif',
url: 'https://media.invalid/gif.jpg',
videoUrl: 'https://media.invalid/gif.mp4',
},
{
type: 'video',
url: 'https://media.invalid/missing.jpg',
previewUrl: 'https://media.invalid/preview.jpg',
},
],
})
expect(result.media).toEqual([
{ type: 'photo', url: 'https://media.invalid/photo.jpg' },
{
type: 'video',
url: 'https://media.invalid/video.mp4',
previewUrl: 'https://media.invalid/poster.jpg',
},
{
type: 'gif',
url: 'https://media.invalid/gif.mp4',
previewUrl: 'https://media.invalid/gif.jpg',
},
{ type: 'photo', url: 'https://media.invalid/preview.jpg' },
])
})
it('includes one quote with its own provenance and bounds nested quotes', () => {
const result = mapTwitterPost({
...post,
quotedTweet: { ...post, id: '122', quotedTweet: { ...post, id: '121' } },
})
expect(result.quotedPost).toMatchObject({
key: 'twitter:122',
nativeId: '122',
url: 'https://x.com/researcher/status/122',
})
expect(result.quotedPost).not.toHaveProperty('quotedPost')
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { Post } from '../posts/types'
import type { ResearchPost } from './types'
function normalizePost(post: Post, includeQuote: boolean): ResearchPost {
const timestamp = post.createdAt ? Date.parse(post.createdAt) : Number.NaN
return {
key: `twitter:${post.id}`,
platform: 'twitter',
nativeId: post.id,
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
text: post.text,
author: {
name: post.author.name,
handle: post.author.username,
...(post.author.profileImageUrl
? { avatarUrl: post.author.profileImageUrl }
: {}),
},
...(Number.isFinite(timestamp)
? { createdAt: new Date(timestamp).toISOString() }
: {}),
...(post.media?.length
? {
media: post.media.map((item) =>
item.type === 'photo' || !item.videoUrl
? {
type: 'photo' as const,
url: item.previewUrl ?? item.url,
}
: {
type:
item.type === 'animated_gif'
? ('gif' as const)
: ('video' as const),
url: item.videoUrl,
previewUrl: item.previewUrl ?? item.url,
},
),
}
: {}),
...(includeQuote && post.quotedTweet
? { quotedPost: normalizePost(post.quotedTweet, false) }
: {}),
}
}
export function mapTwitterPost(post: Post): ResearchPost {
return normalizePost(post, true)
}
+22
View File
@@ -0,0 +1,22 @@
type Platform = 'twitter' | 'mastodon' | 'bluesky' | 'threads' | 'nostr'
export type ResearchPost = {
key: string
platform: Platform
nativeId: string
url: string
text: string
author: {
name: string
handle: string
avatarUrl?: string
}
createdAt?: string
media?: {
type: 'photo' | 'video' | 'gif'
url: string
previewUrl?: string
alt?: string
}[]
quotedPost?: ResearchPost
}
+4 -46
View File
@@ -1,57 +1,13 @@
import type { ReactNode } from 'react'
import { PostText } from '#/components/post-text'
import type { Post } from '../types'
import { PostMedia } from './post-media'
const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = []
let cursor = 0
for (const match of decoded.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
{href}
</a>,
)
cursor = start + href.length
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts
}
function formattedTime(createdAt?: string): string | undefined {
if (!createdAt) return undefined
const date = new Date(createdAt)
@@ -93,7 +49,9 @@ export function PostCard({
<time dateTime={post.createdAt}>{time}</time>
) : null}
</header>
<p className="post-text">{linkedText(post.text)}</p>
<p className="post-text">
<PostText text={post.text} />
</p>
{post.media ? <PostMedia media={post.media} /> : null}
{post.article ? (
<section className="article-card">
+1 -1
View File
@@ -19,7 +19,7 @@ import {
toolResult,
} from './webmcp-contracts'
function useWebMCPSupported() {
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
+21
View File
@@ -10,6 +10,7 @@
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'
@@ -21,6 +22,11 @@ const SearchRoute = SearchRouteImport.update({
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',
@@ -50,6 +56,7 @@ const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
@@ -58,6 +65,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
@@ -67,6 +75,7 @@ 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
@@ -77,6 +86,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
@@ -85,6 +95,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
@@ -93,6 +104,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle_/status/$tweetId'
@@ -102,6 +114,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HandleRoute: typeof HandleRoute
DeckRoute: typeof DeckRoute
SearchRoute: typeof SearchRoute
IListsRoute: typeof IListsRoute
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
@@ -117,6 +130,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/deck': {
id: '/deck'
path: '/deck'
fullPath: '/deck'
preLoaderRoute: typeof DeckRouteImport
parentRoute: typeof rootRouteImport
}
'/$handle': {
id: '/$handle'
path: '/$handle'
@@ -158,6 +178,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HandleRoute: HandleRoute,
DeckRoute: DeckRoute,
SearchRoute: SearchRoute,
IListsRoute: IListsRoute,
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { DeckPage } from '#/features/decks/deck-page'
export const Route = createFileRoute('/deck')({ component: DeckPage })
+1 -1
View File
@@ -667,7 +667,7 @@ video.media {
.tabs {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
}