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
+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))