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

This commit is contained in:
2026-09-24 15:59:54 +09:00
parent da8f52e605
commit d2cbf4dbd3
90 changed files with 2617 additions and 6934 deletions
+177 -41
View File
@@ -1,17 +1,66 @@
import { useId, useState } from 'react'
import { useListChoices } from '#/features/posts/use-list-choices'
import { columnSchema, type DeckColumn } from './model'
function ListChoices({
profileName,
onSelect,
}: {
profileName: string
onSelect: (id: string) => void
}) {
const lists = useListChoices(profileName)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
export function ColumnEditor({
column,
profiles,
onSave,
onCancel,
}: {
column?: DeckColumn
profiles: string[]
onSave: (column: DeckColumn) => void
onCancel: () => void
}) {
const formId = useId()
const [error, setError] = useState<string>()
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
column?.source.kind ?? 'search',
)
const [profileName, setProfileName] = useState(
column?.profileName ?? profiles[0] ?? '',
)
const [target, setTarget] = useState(
column && column.source.kind !== 'search' ? column.source.target : '',
)
return (
<form
className="deck-editor"
@@ -19,28 +68,34 @@ export function ColumnEditor({
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const source =
kind === 'search'
? {
platform: 'twitter',
kind,
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
}
: { platform: 'twitter', kind, target }
const parsed = columnSchema.safeParse({
// getRandomValues also works on HTTP tailnet origins.
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
source: {
platform: 'twitter',
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
},
profileName,
source,
})
if (!parsed.success) {
if (!parsed.success || !profiles.includes(profileName)) {
setError(
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
parsed.success
? '接続プロファイルを選択してください。'
: parsed.error.issues[0]?.message,
)
return
}
onSave(parsed.data)
}}
>
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
カラム名
@@ -49,52 +104,133 @@ export function ColumnEditor({
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:日本語での反応"
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={column?.source.query ?? ''}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
aria-describedby={`${formId}-help`}
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
id={`${formId}-product`}
name="product"
defaultValue={column?.source.product ?? 'Latest'}
id={`${formId}-profile`}
value={profileName}
onChange={(event) => setProfileName(event.target.value)}
required
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
<option value="" disabled>
選択してください
</option>
{profileName && !profiles.includes(profileName) ? (
<option value={profileName} disabled>
{profileName}(利用不可)
</option>
) : null}
{profiles.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label htmlFor={`${formId}-kind`}>
カラムの種類
<select
id={`${formId}-kind`}
value={kind}
onChange={(event) => {
setKind(event.target.value as typeof kind)
setTarget('')
}}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
</div>
<p className="deck-help" id={`${formId}-help`}>
from:、lang:、since: など、Twitterの検索構文を使えます。
{kind === 'search' ? (
<div className="deck-source-fields" key="search">
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={
column?.source.kind === 'search' ? column.source.query : ''
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<select
id={`${formId}-product`}
name="product"
defaultValue={
column?.source.kind === 'search'
? column.source.product
: 'Latest'
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={
column?.source.kind === 'search' && column.source.following
}
/>
フォロー中のみ
</label>
</div>
) : (
<div className="deck-source-fields" key={kind}>
<label htmlFor={`${formId}-target`}>
{kind === 'user' ? 'ユーザー' : 'リスト'}
<input
id={`${formId}-target`}
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={
kind === 'user'
? '@handle またはプロフィールURL'
: 'リストのURLまたはID'
}
maxLength={256}
required
/>
</label>
{kind === 'list' && profiles.includes(profileName) ? (
<ListChoices
key={profileName}
profileName={profileName}
onSelect={setTarget}
/>
) : null}
</div>
)}
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={column?.source.following ?? false}
/>
フォロー中のみ
</label>
{error ? (
<p role="alert" className="deck-error">
{error}
</p>
) : null}
<div className="deck-actions">
<button type="submit" className="deck-primary">
<button
type="submit"
className="deck-primary"
disabled={!profiles.includes(profileName)}
>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
+7
View File
@@ -0,0 +1,7 @@
import type { FeedRequest } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
export function columnRequest(column: DeckColumn): FeedRequest {
const { platform: _, ...source } = column.source
return { ...source, profileName: column.profileName }
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
type ColumnFeed = { column: DeckColumn; query: ReturnType<typeof usePostFeed> }
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof usePostFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
useEffect(() => {
registry.set(column.id, entry)
return () => {
if (registry.get(column.id) === entry) registry.delete(column.id)
}
}, [registry, column.id])
}
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20),
})
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenPostPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
return {
column: feed.column,
status: feed.query.isPending
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
loading: feed.query.isFetching,
error:
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
posts: selected.map(mapTwitterPost),
loadedCount: posts.length,
offset,
nextOffset:
offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage),
}
}
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported()
const enabled = supported && ready
function requireColumn(id: string) {
const entry = registry.get(id)
if (!entry)
throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
)
return entry
}
useWebMCP({
name: 'get_column_posts',
description:
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
inputSchema: readInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input)
return read(requireColumn(columnId).current, offset, limit)
}),
})
useWebMCP({
name: 'load_more_column',
description:
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
inputSchema: columnInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
const { columnId } = columnInput.parse(input)
const entry = requireColumn(columnId)
const { column, query } = entry.current
const identity = JSON.stringify(column)
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.')
if (query.isError && !query.isFetchNextPageError) throw query.error
const offset = flattenPostPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
registry.get(columnId) !== entry ||
JSON.stringify(entry.current.column) !== identity
)
throw new Error(
'The deck or column changed while loading. Read the current deck first.',
)
if (result.isError) throw result.error
return read({ column, query: result }, offset)
}),
})
}
+79 -43
View File
@@ -1,6 +1,9 @@
import { Icon } from '#/components/icon'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
import { columnRequest } from './column-request'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
@@ -11,6 +14,7 @@ export function ResearchColumn({
onMove,
first,
last,
registry,
}: {
column: DeckColumn
onEdit: () => void
@@ -18,13 +22,10 @@ export function ResearchColumn({
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
}) {
const query = usePostFeed({
kind: 'search',
query: column.source.query,
product: column.source.product,
following: column.source.following,
})
const query = usePostFeed(columnRequest(column))
useRegisterColumn(registry, column, query)
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
const error =
query.error instanceof PostLoadError
@@ -34,50 +35,85 @@ export function ResearchColumn({
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
<div className="deck-column-label">
<span>Twitter</span>
<span>
{column.source.product === 'Latest' ? '最新' : '話題'}
{column.source.following ? ' · フォロー中' : ''}
</span>
</div>
<h2 id={headingId}>{column.title}</h2>
<p className="deck-query">{column.source.query}</p>
<div className="deck-actions">
<button type="button" onClick={onEdit}>
編集
</button>
<div className="deck-column-title">
<Icon name={column.source.kind} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
aria-label="更新"
title="更新"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
更新
</button>
<button
type="button"
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
>
←
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
>
→
</button>
<button
type="button"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
削除
<Icon name="refresh" />
</button>
<details className="column-menu">
<summary
className="icon-button"
aria-label={`${column.title}の操作`}
title="カラムの操作"
>
<Icon name="more" />
</summary>
<div className="column-menu-panel">
<button type="button" onClick={onEdit}>
編集
</button>
<button
type="button"
onClick={(event) => {
onMove(-1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={first}
aria-label={`${column.title}を左へ`}
>
← 左へ移動
</button>
<button
type="button"
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={last}
aria-label={`${column.title}を右へ`}
>
右へ移動 →
</button>
<button
type="button"
className="danger"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
カラムを削除
</button>
</div>
</details>
</div>
<div className="deck-column-label">
<span title={`接続プロファイル: ${column.profileName}`}>
{column.profileName}
</span>
<span>
{column.source.kind === 'search'
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
: column.source.kind === 'user'
? 'ユーザー投稿'
: 'リスト'}
</span>
</div>
<p className="deck-query">
{column.source.kind === 'search'
? column.source.query
: column.source.target}
</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
+164 -67
View File
@@ -1,33 +1,63 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { loadProfiles } from '#/features/profiles/server-functions'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
import { DeckSwitcher } from './deck-switcher'
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
import { useDeck } from './use-deck'
import { useDeckTools } from './webmcp-tools'
import './decks.css'
export function DeckPage() {
const { deck, ready, storageError, save } = useDeck()
const registry = useRef<ColumnRegistry>(new Map()).current
const {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace,
} = useDeck()
const fetchProfiles = useServerFn(loadProfiles)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => fetchProfiles(),
retry: false,
})
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
useColumnTools(registry, ready)
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
useDeckTools({
deck,
ready,
storageError,
save,
onApplied: () => {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
},
select,
remove,
getWorkspace,
profiles: profiles.data?.profiles,
onApplied: clearEditors,
})
function clearEditors() {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
}
function saveColumn(column: DeckColumn) {
save({
...deck,
@@ -42,83 +72,148 @@ export function DeckPage() {
}
return (
<AppShell active="deck">
<AppShell
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={(title) => {
create(title)
clearEditors()
}}
onDelete={() => {
remove(deck.id)
clearEditors()
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<nav className="sidebar-columns" aria-label="カラム一覧">
{deck.columns.map((column) => (
<button
key={column.id}
type="button"
title={column.title}
onClick={() => {
const element = document.getElementById(`column-${column.id}`)
element?.scrollIntoView({ block: 'nearest', inline: 'start' })
element
?.closest('.deck-column')
?.querySelector<HTMLElement>('.deck-column-results')
?.focus({ preventScroll: true })
}}
>
<Icon name={column.source.kind} />
<span>{column.title}</span>
</button>
))}
</nav>
<p className="sidebar-note">このブラウザに保存</p>
</>
}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<p className="deck-help">
観点ごとに検索を並べて、投稿を読み比べる。
</p>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready}
onClick={() => setRenaming(!renaming)}
>
名前を変更
</button>
<button
className="deck-primary"
type="button"
disabled={
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
}
onClick={() => setEditing('new')}
>
カラムを追加
<Icon name="edit" />
</button>
</div>
</div>
<p className="deck-save-status">
{!ready
? 'デッキを読み込んでいます…'
: storageError
? 'デッキの保存状態を確認してください。'
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
</p>
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}>
再試行
</button>
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
</p>
) : null}
{renaming ? (
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
</Dialog>
) : null}
{editing !== null ? (
<ColumnEditor
key={editing === 'new' ? 'new' : editing.id}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
profiles={profiles.data?.profiles ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
@@ -139,9 +234,10 @@ export function DeckPage() {
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<h2>最初の観点を追加しましょう。</h2>
<Icon name="columns" />
<h2>調査のカラムを並べましょう</h2>
<p>
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。
</p>
<button
className="deck-primary"
@@ -157,7 +253,8 @@ export function DeckPage() {
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={column.id}
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
first={index === 0}
last={index === deck.columns.length - 1}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { Deck } from './model'
export function DeckSwitcher({
decks,
activeId,
ready,
onSelect,
onCreate,
onDelete,
}: {
decks: Deck[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => void
onDelete: () => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
return (
<div className="deck-workspaces">
<div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label>
<select
id="deck-selection"
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
setCreating(false)
setDeleting(false)
}}
>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.title}
</option>
))}
</select>
<button
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
type="button"
disabled={!ready}
onClick={() => {
setCreating(!creating)
setDeleting(false)
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
</button>
<button
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready || decks.length <= 1}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
onCreate(title)
setCreating(false)
}}
>
<label htmlFor="new-deck-title">
新しいデッキ名
<input
id="new-deck-title"
name="title"
maxLength={120}
required
/>
</label>
<button type="submit">作成</button>
<button type="button" onClick={() => setCreating(false)}>
キャンセル
</button>
</form>
</Dialog>
) : null}
{deleting ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<button
type="button"
onClick={() => {
onDelete()
setDeleting(false)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
キャンセル
</button>
</div>
</Dialog>
) : null}
</div>
)
}
+381 -142
View File
@@ -1,61 +1,68 @@
/* Hallmark · existing Garden tokens · research deck: horizontal columns
* User-requested application layout; inherit the reader's typography and color.
* pre-emit critique: P4 H4 E4 S5 R5 V4
*/
.app-deck .reader {
min-width: 0;
}
.app-deck button,
.app-deck input,
.app-deck select {
font: inherit;
}
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--ink);
background: var(--paper);
background: var(--surface);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--dur-short) var(--ease-out);
}
.app-deck button:hover:not(:disabled) {
background: var(--surface);
}
.app-deck button:active:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.5;
opacity: 0.45;
cursor: default;
}
.app-deck :focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 3px;
.app-deck .icon-button {
width: 36px;
height: 36px;
padding: var(--space-xs);
border-color: transparent;
background: transparent;
}
.app-deck .deck-primary {
color: var(--color-accent-ink);
background: var(--accent);
border-color: var(--accent);
font-weight: 650;
}
.app-deck .deck-primary:hover:not(:disabled),
.app-deck .deck-primary:active:not(:disabled) {
background: var(--accent-hover);
color: var(--color-accent-ink);
}
.deck-heading {
display: flex;
align-items: start;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
flex-shrink: 0;
gap: var(--space-sm);
min-height: 58px;
padding: var(--space-xs) var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-actions {
display: flex;
@@ -67,131 +74,82 @@
.deck-save-status,
.deck-column-status {
color: var(--secondary);
font-size: var(--text-sm);
line-height: 1.6;
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: var(--space-md) 0;
}
.deck-editor {
margin-block: var(--space-lg);
padding-block: var(--space-lg);
border-block: var(--rule-hairline) solid var(--border);
}
.deck-editor h2,
.deck-empty h2 {
font-size: var(--text-md);
margin: 0 0 var(--space-md);
overflow-wrap: anywhere;
}
.deck-editor-fields {
display: grid;
gap: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input {
min-width: 0;
width: 100%;
min-height: 44px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 44px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin-block: var(--space-md);
}
.deck-rename label {
flex: 1 1 15rem;
}
.deck-error {
color: var(--error);
line-height: 1.6;
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-block: var(--space-md);
overflow-wrap: anywhere;
}
.deck-empty {
max-width: 40rem;
padding-block: var(--space-2xl);
}
.deck-empty p {
line-height: 1.8;
margin-bottom: var(--space-lg);
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(100%, 23rem);
gap: var(--space-md);
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
overflow-y: hidden;
padding: 0 var(--space-xs) var(--space-xs);
scroll-snap-type: x proximity;
scrollbar-color: var(--control-border) var(--canvas);
}
.deck-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: max(30rem, calc(100svh - 20rem));
border: var(--rule-hairline) solid var(--border);
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
padding: var(--space-md);
position: relative;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--surface);
}
.deck-column-title {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.deck-column-title > svg {
color: var(--accent);
}
.deck-column h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-base);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-label {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-xs);
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column h2 {
margin: var(--space-sm) 0;
font-size: var(--text-md);
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-query {
margin: 0 0 var(--space-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
max-height: 4.5em;
overflow-y: auto;
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-status {
margin-bottom: 0;
margin: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
@@ -199,27 +157,70 @@
overflow-y: auto;
flex: 1;
overscroll-behavior-y: contain;
scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper);
}
.column-menu {
position: relative;
}
.column-menu summary {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
list-style: none;
cursor: pointer;
}
.column-menu summary::-webkit-details-marker {
display: none;
}
.column-menu summary:hover,
.column-menu[open] summary {
background: var(--color-paper-3);
}
.column-menu-panel {
position: absolute;
top: 100%;
right: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 158px;
padding: var(--space-2xs);
border: var(--rule-hairline) solid var(--control-border);
background: var(--surface);
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
}
.column-menu-panel button {
justify-content: flex-start;
border: 0;
}
.deck-message {
padding: var(--space-md);
color: var(--secondary);
line-height: 1.7;
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
padding: var(--space-md);
padding: var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-sm);
gap: var(--space-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post header img {
border-radius: 50%;
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
@@ -227,20 +228,22 @@
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.8;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
margin: var(--space-md) 0;
padding-left: var(--space-md);
border-left: var(--rule-hairline) solid var(--border);
margin: var(--space-sm) 0;
padding: var(--space-sm);
border: var(--rule-hairline) solid var(--border);
border-radius: var(--radius-sm);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-md);
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--secondary);
font-size: var(--text-xs);
}
@@ -253,22 +256,258 @@
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-sm);
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
@media (min-width: 52rem) {
.app.app-deck {
width: 100%;
grid-template-columns: 11rem minmax(0, 1fr);
.deck-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: var(--space-lg);
overflow-y: auto;
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-md);
background: var(--paper);
color: var(--ink);
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
}
.deck-dialog::backdrop {
background: var(--color-overlay);
}
.dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.dialog-heading h2,
.deck-editor h2,
.deck-empty h2 {
margin: 0;
font-size: var(--text-md);
overflow-wrap: anywhere;
}
.deck-editor {
margin: 0;
padding: 0;
}
.deck-editor-fields,
.deck-source-fields {
display: grid;
gap: var(--space-md);
}
.deck-source-fields {
margin-block: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input,
.deck-workspaces select {
min-width: 0;
width: 100%;
min-height: 40px;
padding: var(--space-xs);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--canvas);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin: 0;
}
.deck-rename label {
flex: 1 1 100%;
}
.deck-error {
color: var(--error);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.reader > .deck-error {
margin: 0;
padding: var(--space-xs) var(--space-md);
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.deck-undo p {
margin: 0;
}
.deck-empty {
margin: auto;
max-width: 32rem;
padding: var(--space-lg);
}
.deck-empty p {
color: var(--secondary);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
.deck-workspaces {
min-width: 0;
}
.deck-workspaces > .deck-actions {
align-items: stretch;
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > select {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
flex: 1;
}
.deck-list-choices {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.app-deck .sidebar-add {
width: 100%;
}
@media (max-width: 767px) {
.deck-workspaces {
flex: 1;
}
.app-deck .reader {
padding: var(--space-xl) var(--space-lg);
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.deck-editor-fields {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
.deck-workspaces > .deck-actions > label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.deck-workspaces > .deck-actions > select {
flex: 1;
width: 0;
}
.deck-workspaces > .deck-actions > button {
flex: none;
}
.app-deck .sidebar-add {
width: 36px;
flex-shrink: 0;
padding: var(--space-xs);
}
.sidebar-add span {
display: none;
}
.deck-heading {
min-height: 52px;
padding-inline: var(--space-sm);
}
.deck-heading .deck-actions {
flex-wrap: nowrap;
}
.deck-board {
grid-auto-columns: min(328px, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.app-deck button {
transition: none;
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count {
color: var(--secondary);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
@media (max-width: 767px) {
.workspace-create span,
.workspace-delete span {
display: none;
}
.app-deck .workspace-create,
.app-deck .workspace-delete {
width: 36px;
padding: var(--space-xs);
}
}
.sidebar-columns {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
min-height: 0;
overflow-y: auto;
}
.app-deck .sidebar-columns button {
flex-shrink: 0;
justify-content: flex-start;
width: 100%;
background: transparent;
border-color: transparent;
}
.sidebar-columns span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.research-post header > div {
flex: 1;
}
.research-post header time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.research-post footer a {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.research-post footer svg {
width: 14px;
height: 14px;
}
.column-menu-panel .danger {
color: var(--error);
}
@media (max-width: 767px) {
.sidebar-columns {
display: none;
}
}
+64 -10
View File
@@ -1,26 +1,67 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
const deck: Deck = {
version: 1,
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id,
title: id,
profileName: 'main',
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native Twitter search conditions when serialized', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('rejects duplicate column identities and unsupported platforms', () => {
it('normalizes user and list targets and rejects invalid targets', () => {
const column = deck.columns[0]
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' },
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: {
platform: 'twitter',
kind: 'list',
target: 'https://x.com/i/lists/123',
},
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
},
],
}).success,
).toBe(false)
})
it('rejects duplicate identities and missing profiles', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -30,13 +71,25 @@ describe('deck definitions', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
],
columns: [{ ...deck.columns[0], profileName: '' }],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: 'missing',
decks: [deck],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: deck.id,
decks: [deck, deck],
}).success,
).toBe(false)
})
it('rejects queries that exceed the upstream limit after adding follows', () => {
it('rejects queries exceeding the upstream limit after adding follows', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -45,6 +98,7 @@ describe('deck definitions', () => {
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
following: true,
@@ -54,7 +108,7 @@ describe('deck definitions', () => {
}).success,
).toBe(false)
})
it('moves a column without changing its query or the previous deck', () => {
it('moves a column without changing its conditions or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
+82 -8
View File
@@ -1,13 +1,16 @@
import { z } from 'zod'
import { buildSearchQuery } from '#/features/posts/inputs'
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const MAX_COLUMNS = 6
// Queries stay native to the provider. Add a discriminated-union member when
// the next connector is implemented instead of inventing a shared search DSL.
const columnSourceSchema = z
const searchSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
following: z.boolean(),
@@ -27,17 +30,57 @@ const columnSourceSchema = z
}
})
function targetSchema(normalize: (target: string) => string) {
return z
.string()
.trim()
.min(1)
.max(256)
.transform((target, ctx) => {
try {
return normalize(target)
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : '対象を確認してください。',
})
return z.NEVER
}
})
}
const userSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('user'),
target: targetSchema(normalizeUserTarget),
})
.strict()
const listSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('list'),
target: targetSchema(normalizeListTarget),
})
.strict()
export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
source: columnSourceSchema,
profileName: z.string().min(1),
source: z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
})
.strict()
export const deckSchema = z
.object({
version: z.literal(1),
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(120),
columns: z.array(columnSchema).max(MAX_COLUMNS),
})
@@ -49,10 +92,41 @@ export const deckSchema = z
'カラム ID が重複しています。',
)
export const workspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: deckSchema.shape.id,
decks: z.array(deckSchema).min(1),
})
.strict()
.refine(
(workspace) =>
new Set(workspace.decks.map((deck) => deck.id)).size ===
workspace.decks.length,
'デッキ ID が重複しています。',
)
.refine(
(workspace) =>
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
'選択したデッキが見つかりません。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
export type Workspace = z.infer<typeof workspaceSchema>
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
version: 2,
activeDeckId: EMPTY_DECK.id,
decks: [EMPTY_DECK],
}
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
}
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
+16 -11
View File
@@ -1,9 +1,12 @@
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'short',
timeStyle: 'short',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
@@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
<img
src={post.author.avatarUrl}
alt=""
width={32}
height={32}
width={36}
height={36}
loading="lazy"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">{post.author.handle}</span>
<span className="handle">
@{post.author.handle.replace(/^@/, '')}
</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
</header>
<p className="research-post-text">
<PostText text={post.text} />
@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</blockquote>
) : null}
<footer>
{post.createdAt ? (
<time dateTime={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く
元の投稿を開く <Icon name="arrow" />
</a>
</footer>
</article>
+36 -15
View File
@@ -1,20 +1,20 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK } from './model'
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('saved deck', () => {
describe('saved decks', () => {
it('restores saved conditions without overwriting them during mount', () => {
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
const { result } = renderHook(useDeck)
expect(result.current.ready).toBe(true)
expect(result.current.deck).toEqual(saved)
expect(result.current.deck).toEqual(deck)
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
)
@@ -25,21 +25,42 @@ describe('saved deck', () => {
expect(result.current.storageError).toContain('読み込めません')
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
})
it('persists edits and restores them on remount', () => {
const first = renderHook(useDeck)
act(() =>
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
)
first.unmount()
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
})
it('reports failed writes instead of claiming that changes were saved', () => {
it('reports failed writes while keeping changes usable in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
let persisted = true
act(() => {
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
})
expect(persisted).toBe(false)
expect(result.current.deck.title).toBe('未保存')
expect(result.current.storageError).toContain('保存できません')
})
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
const { result, unmount } = renderHook(useDeck)
const firstId = result.current.deck.id
let secondId = ''
act(() => {
result.current.create('第二の調査')
secondId = result.current.getWorkspace().activeDeckId
result.current.save({
id: secondId,
title: '変更した第二の調査',
columns: [],
})
result.current.select(firstId)
})
expect(result.current.deck.title).toBe('新しい調査')
expect(result.current.workspace.decks).toHaveLength(2)
unmount()
const restored = renderHook(useDeck)
act(() => restored.result.current.select(secondId))
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
act(() => restored.result.current.remove(secondId))
expect(restored.result.current.deck.id).toBe(firstId)
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
expect(() => restored.result.current.select('missing')).toThrow()
})
})
+73 -8
View File
@@ -1,17 +1,30 @@
import { useEffect, useState } from 'react'
import { type Deck, deckSchema, EMPTY_DECK } from './model'
import { useEffect, useRef, useState } from 'react'
import {
type Deck,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
workspaceSchema,
} from './model'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export function useDeck() {
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const [storageError, setStorageError] = useState<string>()
useEffect(() => {
try {
const saved = localStorage.getItem(DECK_STORAGE_KEY)
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
if (saved !== null) {
const restored = workspaceSchema.parse(JSON.parse(saved))
current.current = restored
setWorkspace(restored)
}
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
@@ -20,9 +33,10 @@ export function useDeck() {
setReady(true)
}, [])
function save(next: Deck) {
const validated = deckSchema.parse(next)
setDeck(validated)
function persist(next: Workspace) {
const validated = workspaceSchema.parse(next)
current.current = validated
setWorkspace(validated)
try {
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
setStorageError(undefined)
@@ -35,5 +49,56 @@ export function useDeck() {
}
}
return { deck, ready, storageError, save }
function save(next: Deck) {
const validated = deckSchema.parse(next)
const previous = current.current
const exists = previous.decks.some((deck) => deck.id === validated.id)
return persist({
...previous,
activeDeckId: validated.id,
decks: exists
? previous.decks.map((deck) =>
deck.id === validated.id ? validated : deck,
)
: [...previous.decks, validated],
})
}
function create(title: string) {
return save({ id: newId(), title, columns: [] })
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
return persist({ ...current.current, activeDeckId: id })
}
function remove(id: string) {
const previous = current.current
if (!previous.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
const decks = previous.decks.filter((deck) => deck.id !== id)
const first = decks[0]
if (!first) throw new Error('最後のデッキは削除できません。')
return persist({
...previous,
decks,
activeDeckId:
previous.activeDeckId === id ? first.id : previous.activeDeckId,
})
}
const deck =
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
EMPTY_DECK
return {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace: () => current.current,
}
}
@@ -0,0 +1,8 @@
import { useEffect, useState } from 'react'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
+86 -38
View File
@@ -1,60 +1,108 @@
import { describe, expect, it } from 'vitest'
import { prepareDeck } from './webmcp-contracts'
const column = {
title: '観点',
profileName: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
const deck = prepareDeck({
title: '調査',
columns: [
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
{ title: '海外', source: { query: 'WebMCP lang:en' } },
],
})
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
'main',
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
query: 'WebMCP lang:ja',
kind: 'search',
query: 'AI',
product: 'Latest',
following: false,
})
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
})
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
expect(
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
).toBe('existing')
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
).toBe(3)
})
it('keeps supplied identities and rejects duplicates', () => {
const identified = { ...column, id: 'existing' }
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
['main'],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [column, column] }),
prepareDeck({ title: '調査', columns: [identified, identified] }, [
'main',
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, ['work']),
).toThrow('Unknown relay profile')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
{
title: '調査',
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
profileName: 'work',
source: { kind: 'list', target: '123' },
},
],
},
['main', 'work'],
)
expect(deck.columns.map((column) => column.profileName)).toEqual([
'main',
'work',
])
expect(deck.columns.map((column) => column.source.kind)).toEqual([
'user',
'list',
])
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
expect(() =>
prepareDeck({
title: '調査',
columns: [
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: [
{
title: '観点',
source: { query: 'a'.repeat(512), following: true },
},
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
following: true,
},
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: Array.from({ length: 7 }, () => ({
title: '観点',
source: { query: 'AI' },
})),
}),
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
['main'],
),
).toThrow()
})
})
+78 -19
View File
@@ -1,43 +1,102 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
const source = columnSchema.shape.source
const toolColumn = columnSchema.omit({ id: true }).extend({
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing ID from get_deck when editing; omit for a new column.',
'Keep an existing column ID when editing; omit for a new column.',
),
source: z
.object({
...source.shape,
platform: source.shape.platform.default('twitter'),
product: source.shape.product.default('Latest'),
following: source.shape.following.default(false),
})
.strict(),
source: z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
target: z.string().min(1).max(256),
})
.strict(),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
export const setDeckInput = z
.object({
title: deckSchema.shape.title.describe('The research topic or deck title.'),
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(input: unknown) {
export function prepareDeck(input: unknown, profiles: readonly string[]) {
const parsed = setDeckInput.parse(input)
return deckSchema.parse({
version: 1,
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
id: column.id ?? newId(),
})),
})
for (const column of deck.columns) {
if (!profiles.includes(column.profileName))
throw new InputError(`Unknown relay profile: ${column.profileName}`)
}
return deck
}
export async function toolResult(run: () => unknown | Promise<unknown>) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
} catch (error) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
retryable: false,
}),
},
] as const,
}
}
}
+101 -27
View File
@@ -1,63 +1,137 @@
import { useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
import type { Deck } from './model'
import { prepareDeck, setDeckInput } from './webmcp-contracts'
import type { Deck, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
emptyToolInput,
getDeckInput,
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
export function useDeckTools({
deck,
ready,
storageError,
save,
select,
remove,
getWorkspace,
profiles,
onApplied,
}: {
deck: Deck
ready: boolean
storageError?: string
save: (deck: Deck) => boolean
select: (id: string) => boolean
remove: (id: string) => boolean
getWorkspace: () => Workspace
profiles: string[] | undefined
onApplied: () => void
}) {
const supported = useWebMCPSupported()
const current = useRef({ deck, storageError })
current.current = { deck, storageError }
const enabled = supported && ready
function applied(persisted: boolean) {
onApplied()
if (!persisted)
throw new Error(
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
)
}
useWebMCP({
name: 'get_deck',
name: 'list_decks',
description:
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
inputSchema: emptyToolInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
const workspace = getWorkspace()
return {
deck: current.current.deck,
storageError: current.current.storageError ?? null,
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
profiles: profiles ?? null,
storageError: storageError ?? null,
}
}),
})
useWebMCP({
name: 'get_deck',
description:
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = getDeckInput.parse(input)
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return { deck, storageError: storageError ?? null }
}),
})
useWebMCP({
name: 'set_deck',
description:
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
inputSchema: setDeckInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
// Validate the whole definition before modifying either storage or UI.
const next = prepareDeck(input)
const persisted = save(next)
onApplied()
const error = persisted
? undefined
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
current.current = { deck: next, storageError: error }
if (error) throw new Error(error)
const parsed = setDeckInput.parse(input)
if (
parsed.deckId &&
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
)
throw new Error('Deck not found.')
if (!profiles) throw new Error('Relay profiles are not available yet.')
const next = prepareDeck(parsed, profiles)
applied(save(next))
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
}),
})
useWebMCP({
name: 'select_deck',
description:
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
useWebMCP({
name: 'delete_deck',
description:
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(remove(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
}