feat: migrate workspace to shadcn sidebar and chat components
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
.connection-manager-description,
|
||||
.connection-manager-note {
|
||||
margin: 0;
|
||||
color: #aab8c2;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.connection-manager-list {
|
||||
@@ -21,7 +21,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid #38444d;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.connection-manager-account {
|
||||
display: grid;
|
||||
@@ -29,15 +29,9 @@
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.connection-manager-account > span {
|
||||
.connection-manager-account > span:not([data-slot="badge"]) {
|
||||
font-size: 0.8rem;
|
||||
color: #aab8c2;
|
||||
}
|
||||
.connection-manager-account > .connection-manager-status-connected {
|
||||
color: #71d6ad;
|
||||
}
|
||||
.connection-manager-account > .connection-manager-status-expired {
|
||||
color: #f2c66d;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.connection-manager-actions {
|
||||
display: flex;
|
||||
@@ -53,18 +47,6 @@
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.connection-manager select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #15202b;
|
||||
color: #e7e9ea;
|
||||
border: 1px solid #536471;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.connection-manager button {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.connection-manager-list > li {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { useId, useState } from 'react'
|
||||
import { Badge } from '#/components/ui/badge'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Label } from '#/components/ui/label'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import {
|
||||
disconnectMastodonAccount,
|
||||
loadMastodonInstances,
|
||||
@@ -92,15 +96,18 @@ export function ConnectionManager({
|
||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
|
||||
{new URL(connection.origin).hostname}
|
||||
</span>
|
||||
<span
|
||||
className={`connection-manager-status connection-manager-status-${connection.status}`}
|
||||
<Badge
|
||||
variant={
|
||||
connection.status === 'connected' ? 'secondary' : 'outline'
|
||||
}
|
||||
>
|
||||
{statusNames[connection.status]}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{connection.platform === 'mastodon' && (
|
||||
<div className="connection-manager-actions">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-label={`${connection.displayName}を再接続`}
|
||||
@@ -109,16 +116,17 @@ export function ConnectionManager({
|
||||
}
|
||||
>
|
||||
再接続
|
||||
</button>
|
||||
</Button>
|
||||
{connection.status !== 'disconnected' && (
|
||||
<button
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-label={`${connection.displayName}の接続を解除`}
|
||||
onClick={() => void remove(connection.id)}
|
||||
>
|
||||
接続解除
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -139,30 +147,35 @@ export function ConnectionManager({
|
||||
) : instances.isError ? (
|
||||
<p role="alert">
|
||||
接続先を取得できませんでした。
|
||||
<button type="button" onClick={() => void instances.refetch()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => void instances.refetch()}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
</Button>
|
||||
</p>
|
||||
) : instances.data.origins.length === 0 ? (
|
||||
<p>利用するMastodonサーバーがまだ設定されていません。</p>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={selectId}>Mastodonサーバー</label>
|
||||
<select
|
||||
<Label htmlFor={selectId}>Mastodonサーバー</Label>
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id={selectId}
|
||||
value={origin}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{instances.data.origins.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{new URL(value).hostname}
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={busy || !origin}>
|
||||
</NativeSelect>
|
||||
<Button type="submit" disabled={busy || !origin}>
|
||||
Mastodonで認可する
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasTemporaryDecks && (
|
||||
|
||||
@@ -162,7 +162,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
|
||||
fireEvent.change(screen.getByLabelText('表示順'), {
|
||||
target: { value: 'Top' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledOnce())
|
||||
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
|
||||
@@ -170,7 +170,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
|
||||
'from:owner WebMCP',
|
||||
)
|
||||
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
|
||||
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
|
||||
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { Label } from '#/components/ui/label'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import type { Connection } from '#/features/connections/model'
|
||||
import {
|
||||
ColumnSourceEditor,
|
||||
@@ -60,9 +64,9 @@ export function ColumnEditor({
|
||||
}}
|
||||
>
|
||||
<div className="deck-editor-fields">
|
||||
<label htmlFor={`${formId}-title`}>
|
||||
<Label htmlFor={`${formId}-title`}>
|
||||
カラム名
|
||||
<input
|
||||
<Input
|
||||
id={`${formId}-title`}
|
||||
name="title"
|
||||
defaultValue={column?.title ?? ''}
|
||||
@@ -70,10 +74,11 @@ export function ColumnEditor({
|
||||
placeholder="例:発表元の投稿"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-profile`}>
|
||||
</Label>
|
||||
<Label htmlFor={`${formId}-profile`}>
|
||||
接続プロファイル
|
||||
<select
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id={`${formId}-profile`}
|
||||
value={connectionId}
|
||||
required
|
||||
@@ -86,22 +91,22 @@ export function ColumnEditor({
|
||||
setSource(rebindColumnSource(source, connection.platform))
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
<NativeSelectOption value="" disabled>
|
||||
選択してください
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
{connectionId && !validConnection && (
|
||||
<option value={connectionId} disabled>
|
||||
<NativeSelectOption value={connectionId} disabled>
|
||||
{connectionId}(利用不可)
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
)}
|
||||
{available.map((connection) => (
|
||||
<option key={connection.id} value={connection.id}>
|
||||
<NativeSelectOption key={connection.id} value={connection.id}>
|
||||
{connection.displayName}
|
||||
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</NativeSelect>
|
||||
</Label>
|
||||
</div>
|
||||
<ColumnSourceEditor
|
||||
source={source}
|
||||
@@ -117,16 +122,12 @@ export function ColumnEditor({
|
||||
</p>
|
||||
)}
|
||||
<div className="deck-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="deck-primary"
|
||||
disabled={!validConnection}
|
||||
>
|
||||
<Button type="submit" disabled={!validConnection}>
|
||||
{column ? '変更を保存' : '追加して検索'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
キャンセル
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import { Badge } from '#/components/ui/badge'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '#/components/ui/dropdown-menu'
|
||||
import {
|
||||
flattenResearchPages,
|
||||
useResearchFeed,
|
||||
@@ -53,8 +62,9 @@ export function ResearchColumn({
|
||||
<div className="deck-column-title">
|
||||
<PlatformIcon platform={column.source.platform} />
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="更新"
|
||||
title="更新"
|
||||
type="button"
|
||||
@@ -62,55 +72,46 @@ export function ResearchColumn({
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<details className="column-menu">
|
||||
<summary
|
||||
className="icon-button"
|
||||
aria-label={`${column.title}の操作`}
|
||||
title="カラムの操作"
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
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')
|
||||
}}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
← 左へ移動
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMove(1)}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
右へ移動 →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
カラムを削除
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="deck-column-label">
|
||||
<span
|
||||
@@ -118,10 +119,10 @@ export function ResearchColumn({
|
||||
>
|
||||
{connectionLabel ?? '接続が見つかりません'}
|
||||
</span>
|
||||
<span className="deck-source-label">
|
||||
<Badge variant="outline" className="deck-source-label">
|
||||
<Icon name={column.source.kind} />
|
||||
{sourceDescription.label}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="deck-query">{sourceDescription.query}</p>
|
||||
<p className="deck-column-status" role="status">
|
||||
@@ -152,7 +153,8 @@ export function ResearchColumn({
|
||||
<div className="deck-message" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() =>
|
||||
@@ -162,7 +164,7 @@ export function ResearchColumn({
|
||||
}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -171,13 +173,14 @@ export function ResearchColumn({
|
||||
) : null}
|
||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||
<div className="deck-message">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
||||
>
|
||||
続きを読み込む
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isPending &&
|
||||
|
||||
+363
-304
@@ -5,6 +5,16 @@ import { useRef, useState } from 'react'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '#/components/ui/empty'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { ConnectionManager } from '#/features/connections/connection-manager'
|
||||
import { loadConnections } from '#/features/connections/server-functions'
|
||||
import { syncResearchDeck } from '#/features/research/research-deck-sync'
|
||||
@@ -32,7 +42,9 @@ export function DeckPage() {
|
||||
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [managingConnections, setManagingConnections] = useState(false)
|
||||
const [switcherEditing, setSwitcherEditing] = useState(false)
|
||||
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
|
||||
const [newDeckTitle, setNewDeckTitle] = useState('')
|
||||
const switcherEditing = deckDialog !== null
|
||||
const {
|
||||
deck,
|
||||
workspace,
|
||||
@@ -83,6 +95,8 @@ export function DeckPage() {
|
||||
})
|
||||
|
||||
function clearEditors() {
|
||||
setDeckDialog(null)
|
||||
setNewDeckTitle('')
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
@@ -102,312 +116,357 @@ export function DeckPage() {
|
||||
return saved
|
||||
}
|
||||
|
||||
const storageErrorNotice = storageError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{storageError}
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
clearEditors()
|
||||
void refresh(true)
|
||||
}}
|
||||
>
|
||||
最新のデッキを再読み込み
|
||||
</Button>
|
||||
</p>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
researchChat={
|
||||
<ResearchPanel
|
||||
onNavigateCitation={citationNavigation.navigate}
|
||||
ready={ready}
|
||||
contextDeck={{
|
||||
id: deck.id,
|
||||
title: deck.title,
|
||||
columns: deck.columns,
|
||||
}}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
onOpenDeck={async (plan, activate = false) => {
|
||||
await syncResearchDeck(plan, activate, researchDeckBindings, {
|
||||
getWorkspace,
|
||||
createTemporary,
|
||||
save,
|
||||
select,
|
||||
})
|
||||
if (activate) clearEditors()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
<DeckSwitcher
|
||||
key={deck.id}
|
||||
decks={workspace.decks}
|
||||
activeId={deck.id}
|
||||
ready={ready && !saving}
|
||||
onEditingChange={setSwitcherEditing}
|
||||
onSelect={(id) => {
|
||||
select(id)
|
||||
clearEditors()
|
||||
}}
|
||||
onCreate={async (title) => {
|
||||
const saved = await create(title)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
onDelete={async () => {
|
||||
const saved = await remove(deck.id)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="deck-primary sidebar-add"
|
||||
aria-label="カラムを追加"
|
||||
title="カラムを追加"
|
||||
type="button"
|
||||
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>カラムを追加</span>
|
||||
</button>
|
||||
<p className="sidebar-note">
|
||||
{deck.persisted
|
||||
? 'サーバーに保存・端末間で共有'
|
||||
: '一時ビュー · このタブのみ'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setManagingConnections(true)}
|
||||
>
|
||||
接続アカウントを管理
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
<ResearchPanel
|
||||
onNavigateCitation={citationNavigation.navigate}
|
||||
ready={ready}
|
||||
contextDeck={{
|
||||
id: deck.id,
|
||||
title: deck.title,
|
||||
columns: deck.columns,
|
||||
}}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
onOpenDeck={async (plan, activate = false) => {
|
||||
await syncResearchDeck(plan, activate, researchDeckBindings, {
|
||||
getWorkspace,
|
||||
createTemporary,
|
||||
save,
|
||||
select,
|
||||
})
|
||||
if (activate) clearEditors()
|
||||
}}
|
||||
>
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<span className="deck-count">
|
||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||
</span>
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
{deck.persisted ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
createTemporary(`${deck.title}のコピー`, deck.columns)
|
||||
clearEditors()
|
||||
}}
|
||||
>
|
||||
一時コピーを作成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => void persist(deck.id)}
|
||||
>
|
||||
デッキとして保存
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="名前を変更"
|
||||
title="名前を変更"
|
||||
type="button"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{saving ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
保存しています…
|
||||
</p>
|
||||
) : null}
|
||||
{legacyAvailable ? (
|
||||
<p className="deck-save-status">
|
||||
このブラウザに旧デッキがあります。
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !profiles.data}
|
||||
onClick={() => void importLegacy()}
|
||||
>
|
||||
旧デッキを取り込む
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{!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}
|
||||
{oauthResult === 'failed' ? (
|
||||
<p className="deck-error" role="alert">
|
||||
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
|
||||
</p>
|
||||
) : null}
|
||||
{oauthResult === 'connected' ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
Mastodonアカウントを接続しました。カラムの接続先に選べます。
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.data?.relayError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{profiles.data.relayError}
|
||||
</p>
|
||||
) : null}
|
||||
{managingConnections ? (
|
||||
<Dialog
|
||||
title="接続アカウント"
|
||||
onClose={() => setManagingConnections(false)}
|
||||
>
|
||||
<ConnectionManager
|
||||
connections={profiles.data?.connections ?? []}
|
||||
onChanged={() => profiles.refetch()}
|
||||
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{profiles.isPending ? (
|
||||
<p role="status">接続プロファイルを取得しています…</p>
|
||||
) : null}
|
||||
{storageError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{storageError}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
clearEditors()
|
||||
void refresh(true)
|
||||
}}
|
||||
>
|
||||
最新のデッキを再読み込み
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{renaming ? (
|
||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
if (await save({ ...deck, title })) setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
{(renderResearchPanel) => (
|
||||
<AppShell
|
||||
researchChat={renderResearchPanel}
|
||||
sidebar={
|
||||
<>
|
||||
<DeckSwitcher
|
||||
key={deck.id}
|
||||
errorNotice={storageErrorNotice}
|
||||
decks={workspace.decks}
|
||||
activeId={deck.id}
|
||||
ready={ready && !saving}
|
||||
dialog={deckDialog}
|
||||
newTitle={newDeckTitle}
|
||||
onNewTitleChange={setNewDeckTitle}
|
||||
onDialogChange={(dialog) => {
|
||||
setDeckDialog(dialog)
|
||||
if (dialog !== 'create') setNewDeckTitle('')
|
||||
}}
|
||||
onSelect={(id) => {
|
||||
select(id)
|
||||
clearEditors()
|
||||
}}
|
||||
onCreate={async (title) => {
|
||||
const saved = await create(title)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
onDelete={async () => {
|
||||
const saved = await remove(deck.id)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<Dialog
|
||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||
onClose={() => setEditing(null)}
|
||||
<Button
|
||||
className="w-full"
|
||||
aria-label="カラムを追加"
|
||||
title="カラムを追加"
|
||||
type="button"
|
||||
disabled={
|
||||
!ready || saving || deck.columns.length >= MAX_COLUMNS
|
||||
}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>カラムを追加</span>
|
||||
</Button>
|
||||
<p className="sidebar-note">
|
||||
{deck.persisted
|
||||
? 'サーバーに保存・端末間で共有'
|
||||
: '一時ビュー · このタブのみ'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setManagingConnections(true)}
|
||||
>
|
||||
接続アカウントを管理
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ColumnEditor
|
||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
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">
|
||||
<span>「{removed.column.title}」を削除しました。</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
|
||||
onClick={async () => {
|
||||
const columns = [...deck.columns]
|
||||
columns.splice(removed.index, 0, removed.column)
|
||||
if (await save({ ...deck, columns })) setRemoved(undefined)
|
||||
}}
|
||||
>
|
||||
元に戻す
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<div className="deck-empty">
|
||||
<Icon name="columns" />
|
||||
<h2>調べたいことから、デッキを作りましょう</h2>
|
||||
<p>
|
||||
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
||||
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
||||
</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={`${deck.id}:${column.id}`}
|
||||
registry={registry}
|
||||
column={column}
|
||||
citation={
|
||||
citationNavigation.target?.deckId === deck.id &&
|
||||
citationNavigation.target.column.id === column.id &&
|
||||
sameCitationSource(citationNavigation.target.column, column)
|
||||
? citationNavigation.target
|
||||
: undefined
|
||||
}
|
||||
connectionLabel={
|
||||
profiles.data?.connections.find(
|
||||
(connection) => connection.id === column.connectionId,
|
||||
)?.displayName
|
||||
}
|
||||
first={index === 0}
|
||||
last={index === deck.columns.length - 1}
|
||||
onEdit={() => setEditing({ id: column.id })}
|
||||
onMove={(direction) =>
|
||||
save(moveColumn(deck, column.id, direction))
|
||||
}
|
||||
onRemove={async () => {
|
||||
const saved = await save({
|
||||
...deck,
|
||||
columns: deck.columns.filter(
|
||||
(current) => current.id !== column.id,
|
||||
),
|
||||
})
|
||||
if (saved) setRemoved({ column, index })
|
||||
if (typeof editing === 'object' && editing?.id === column.id)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</AppShell>
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<span className="deck-count">
|
||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||
</span>
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
{deck.persisted ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
createTemporary(`${deck.title}のコピー`, deck.columns)
|
||||
clearEditors()
|
||||
}}
|
||||
>
|
||||
一時コピーを作成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => void persist(deck.id)}
|
||||
>
|
||||
デッキとして保存
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="icon-button"
|
||||
aria-label="名前を変更"
|
||||
title="名前を変更"
|
||||
type="button"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{saving ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
保存しています…
|
||||
</p>
|
||||
) : null}
|
||||
{legacyAvailable ? (
|
||||
<p className="deck-save-status">
|
||||
このブラウザに旧デッキがあります。
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={saving || !profiles.data}
|
||||
onClick={() => void importLegacy()}
|
||||
>
|
||||
旧デッキを取り込む
|
||||
</Button>
|
||||
</p>
|
||||
) : null}
|
||||
{!ready ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
デッキを読み込んでいます…
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isError ? (
|
||||
<p role="alert" className="deck-error">
|
||||
接続プロファイルを取得できませんでした。
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => void profiles.refetch()}
|
||||
>
|
||||
再試行
|
||||
</Button>
|
||||
</p>
|
||||
) : null}
|
||||
{oauthResult === 'failed' ? (
|
||||
<p className="deck-error" role="alert">
|
||||
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
|
||||
</p>
|
||||
) : null}
|
||||
{oauthResult === 'connected' ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
Mastodonアカウントを接続しました。カラムの接続先に選べます。
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.data?.relayError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{profiles.data.relayError}
|
||||
</p>
|
||||
) : null}
|
||||
{managingConnections ? (
|
||||
<Dialog
|
||||
title="接続アカウント"
|
||||
onClose={() => setManagingConnections(false)}
|
||||
>
|
||||
<ConnectionManager
|
||||
connections={profiles.data?.connections ?? []}
|
||||
onChanged={() => profiles.refetch()}
|
||||
hasTemporaryDecks={workspace.decks.some(
|
||||
(deck) => !deck.persisted,
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{profiles.isPending ? (
|
||||
<p role="status">接続プロファイルを取得しています…</p>
|
||||
) : null}
|
||||
{!renaming && editing === null && !switcherEditing
|
||||
? storageErrorNotice
|
||||
: null}
|
||||
{renaming ? (
|
||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
if (await save({ ...deck, title })) setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<Input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<Button variant="outline" type="submit">
|
||||
名前を保存
|
||||
</Button>
|
||||
</form>
|
||||
{storageErrorNotice}
|
||||
</Dialog>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<Dialog
|
||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||
onClose={() => setEditing(null)}
|
||||
>
|
||||
<ColumnEditor
|
||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
{storageErrorNotice}
|
||||
</Dialog>
|
||||
) : null}
|
||||
{removed ? (
|
||||
<div className="deck-undo" role="status">
|
||||
<span>「{removed.column.title}」を削除しました。</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={
|
||||
deck.columns.length >= MAX_COLUMNS || editing === 'new'
|
||||
}
|
||||
onClick={async () => {
|
||||
const columns = [...deck.columns]
|
||||
columns.splice(removed.index, 0, removed.column)
|
||||
if (await save({ ...deck, columns })) setRemoved(undefined)
|
||||
}}
|
||||
>
|
||||
元に戻す
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Icon name="columns" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
<h2>調べたいことから、デッキを作りましょう</h2>
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
||||
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button type="button" onClick={() => setEditing('new')}>
|
||||
手動でカラムを追加
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : null}
|
||||
{ready && deck.columns.length > 0 ? (
|
||||
<section
|
||||
className="deck-board"
|
||||
aria-label="調査カラム"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
||||
tabIndex={0}
|
||||
>
|
||||
{deck.columns.map((column, index) => (
|
||||
<ResearchColumn
|
||||
key={`${deck.id}:${column.id}`}
|
||||
registry={registry}
|
||||
column={column}
|
||||
citation={
|
||||
citationNavigation.target?.deckId === deck.id &&
|
||||
citationNavigation.target.column.id === column.id &&
|
||||
sameCitationSource(citationNavigation.target.column, column)
|
||||
? citationNavigation.target
|
||||
: undefined
|
||||
}
|
||||
connectionLabel={
|
||||
profiles.data?.connections.find(
|
||||
(connection) => connection.id === column.connectionId,
|
||||
)?.displayName
|
||||
}
|
||||
first={index === 0}
|
||||
last={index === deck.columns.length - 1}
|
||||
onEdit={() => setEditing({ id: column.id })}
|
||||
onMove={(direction) =>
|
||||
save(moveColumn(deck, column.id, direction))
|
||||
}
|
||||
onRemove={async () => {
|
||||
const saved = await save({
|
||||
...deck,
|
||||
columns: deck.columns.filter(
|
||||
(current) => current.id !== column.id,
|
||||
),
|
||||
})
|
||||
if (saved) setRemoved({ column, index })
|
||||
if (
|
||||
typeof editing === 'object' &&
|
||||
editing?.id === column.id
|
||||
)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)}
|
||||
</ResearchPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { Label } from '#/components/ui/label'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import type { DeckView } from './model'
|
||||
|
||||
export function DeckSwitcher({
|
||||
@@ -10,7 +14,11 @@ export function DeckSwitcher({
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onEditingChange,
|
||||
dialog,
|
||||
onDialogChange,
|
||||
newTitle,
|
||||
onNewTitleChange,
|
||||
errorNotice,
|
||||
}: {
|
||||
decks: DeckView[]
|
||||
activeId: string
|
||||
@@ -18,109 +26,118 @@ export function DeckSwitcher({
|
||||
onSelect: (id: string) => void
|
||||
onCreate: (title: string) => Promise<boolean>
|
||||
onDelete: () => Promise<boolean>
|
||||
onEditingChange: (editing: boolean) => void
|
||||
dialog: 'create' | 'delete' | null
|
||||
onDialogChange: (dialog: 'create' | 'delete' | null) => void
|
||||
newTitle: string
|
||||
onNewTitleChange: (title: string) => void
|
||||
errorNotice?: ReactNode
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
useEffect(() => {
|
||||
onEditingChange(creating || deleting)
|
||||
return () => onEditingChange(false)
|
||||
}, [creating, deleting, onEditingChange])
|
||||
return (
|
||||
<div className="deck-workspaces">
|
||||
<div className="deck-actions">
|
||||
<label htmlFor="deck-selection">デッキプロファイル</label>
|
||||
<select
|
||||
<Label htmlFor="deck-selection">デッキプロファイル</Label>
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id="deck-selection"
|
||||
value={activeId}
|
||||
disabled={!ready}
|
||||
onChange={(event) => {
|
||||
onSelect(event.target.value)
|
||||
setCreating(false)
|
||||
setDeleting(false)
|
||||
onDialogChange(null)
|
||||
}}
|
||||
>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
<NativeSelectOption key={deck.id} value={deck.id}>
|
||||
{deck.title}
|
||||
{deck.persisted ? '' : '(一時)'}
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
</NativeSelect>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="workspace-create"
|
||||
aria-label="デッキを作成"
|
||||
title="デッキを作成"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setCreating(!creating)
|
||||
setDeleting(false)
|
||||
onDialogChange('create')
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>デッキを作成</span>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="workspace-delete"
|
||||
aria-label="デッキを削除"
|
||||
title="デッキを削除"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setDeleting(!deleting)
|
||||
setCreating(false)
|
||||
onDialogChange('delete')
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
<span>デッキを削除</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{creating ? (
|
||||
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
|
||||
{dialog === 'create' ? (
|
||||
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
const title = newTitle.trim()
|
||||
if (!title) return
|
||||
if (await onCreate(title)) setCreating(false)
|
||||
if (await onCreate(title)) onDialogChange(null)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="new-deck-title">
|
||||
<Label htmlFor="new-deck-title">
|
||||
新しいデッキ名
|
||||
<input
|
||||
<Input
|
||||
id="new-deck-title"
|
||||
name="title"
|
||||
value={newTitle}
|
||||
onChange={(event) => onNewTitleChange(event.target.value)}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">作成</button>
|
||||
<button type="button" onClick={() => setCreating(false)}>
|
||||
</Label>
|
||||
<Button type="submit">作成</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => onDialogChange(null)}
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
{errorNotice}
|
||||
</Dialog>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
|
||||
{dialog === 'delete' ? (
|
||||
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
|
||||
<div className="deck-undo">
|
||||
<p>このデッキとカラムの設定を削除します。</p>
|
||||
<button
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await onDelete()) setDeleting(false)
|
||||
if (await onDelete()) onDialogChange(null)
|
||||
}}
|
||||
>
|
||||
削除する
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleting(false)}>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => onDialogChange(null)}
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{errorNotice}
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+14
-197
@@ -1,47 +1,3 @@
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||
.app-deck button {
|
||||
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(--surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-deck button:hover:not(:disabled) {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-deck button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.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: center;
|
||||
@@ -61,7 +17,7 @@
|
||||
}
|
||||
.deck-heading p {
|
||||
margin: var(--space-2xs) 0 0;
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-actions {
|
||||
@@ -73,7 +29,7 @@
|
||||
.deck-help,
|
||||
.deck-save-status,
|
||||
.deck-column-status {
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -130,7 +86,7 @@
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column-label span {
|
||||
@@ -149,7 +105,7 @@
|
||||
.deck-query {
|
||||
margin: var(--space-2xs) 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -166,47 +122,9 @@
|
||||
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);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -223,11 +141,6 @@
|
||||
.research-post header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.research-post header img {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.research-post strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -250,11 +163,11 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post a {
|
||||
color: var(--accent);
|
||||
color: var(--primary);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
.research-media {
|
||||
@@ -265,34 +178,6 @@
|
||||
margin-block: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.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;
|
||||
@@ -312,30 +197,12 @@
|
||||
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;
|
||||
@@ -347,7 +214,7 @@
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
color: var(--destructive);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -374,7 +241,7 @@
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.deck-empty p {
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
@@ -386,10 +253,10 @@
|
||||
}
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
width: 100%;
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
@@ -404,35 +271,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.deck-workspaces {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-workspaces > .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.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);
|
||||
@@ -444,37 +282,19 @@
|
||||
grid-auto-columns: min(328px, 100%);
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.app-deck .icon-button,
|
||||
.column-menu summary {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
.deck-count {
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post header time {
|
||||
display: block;
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
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);
|
||||
}
|
||||
}
|
||||
.research-post header > div {
|
||||
.research-post header > div:not([data-slot="avatar"]) {
|
||||
flex: 1;
|
||||
}
|
||||
.research-post header time {
|
||||
@@ -490,6 +310,3 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.column-menu-panel .danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { type ReactElement, useId } from 'react'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { Label } from '#/components/ui/label'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
|
||||
import type { MastodonSource } from '../platforms/mastodon-source'
|
||||
|
||||
@@ -32,9 +36,10 @@ export function MastodonSourceEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
カラムの種類
|
||||
<select
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id={id}
|
||||
value={source.kind}
|
||||
onChange={(event) =>
|
||||
@@ -45,12 +50,12 @@ export function MastodonSourceEditor({
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
<option value="hashtag">ハッシュタグ</option>
|
||||
</select>
|
||||
</label>
|
||||
<NativeSelectOption value="search">検索</NativeSelectOption>
|
||||
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
|
||||
<NativeSelectOption value="list">リスト</NativeSelectOption>
|
||||
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Label>
|
||||
<MastodonSourceFields
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
@@ -90,9 +95,9 @@ function MastodonSearchEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
Mastodonの検索条件
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.query}
|
||||
onChange={(event) =>
|
||||
@@ -102,7 +107,7 @@ function MastodonSearchEditor({
|
||||
placeholder="例:WebMCP"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
<p className="deck-help">
|
||||
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
|
||||
</p>
|
||||
@@ -117,9 +122,9 @@ function MastodonUserEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
ユーザー
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
@@ -129,7 +134,7 @@ function MastodonUserEditor({
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -141,9 +146,9 @@ function MastodonHashtagEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
ハッシュタグ
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
@@ -153,7 +158,7 @@ function MastodonHashtagEditor({
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -168,9 +173,9 @@ function MastodonListEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
リスト
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
@@ -180,7 +185,7 @@ function MastodonListEditor({
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
{connectionId && (
|
||||
<MastodonListChoices
|
||||
connectionId={connectionId}
|
||||
@@ -209,9 +214,13 @@ function MastodonListChoices({
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => void lists.refetch()}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
</Button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
@@ -220,9 +229,14 @@ function MastodonListChoices({
|
||||
<p>このアカウントのリストはありません。</p>
|
||||
) : (
|
||||
lists.data.lists.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
key={list.id}
|
||||
onClick={() => onSelect(list.id)}
|
||||
>
|
||||
{list.name}
|
||||
</button>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { PostText } from '#/components/post-text'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
|
||||
import { Badge } from '#/components/ui/badge'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import type { ResearchPost } from '#/features/platforms/types'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
@@ -49,20 +52,17 @@ export function ResearchPostCard({
|
||||
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
|
||||
data-post-url={post.url}
|
||||
>
|
||||
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
|
||||
{citationSnapshot ? (
|
||||
<Badge variant="secondary">調査時に取得した投稿</Badge>
|
||||
) : null}
|
||||
{post.boostedBy ? (
|
||||
<p className="handle">{post.boostedBy.name} がブースト</p>
|
||||
) : null}
|
||||
<header>
|
||||
{post.author.avatarUrl ? (
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
|
||||
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">
|
||||
@@ -78,13 +78,15 @@ export function ResearchPostCard({
|
||||
{concealed ? (
|
||||
<div>
|
||||
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-expanded={revealed}
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
>
|
||||
{revealed ? '内容を隠す' : '内容を表示'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{!concealed || revealed ? (
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type ReactElement, useId } from 'react'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Checkbox } from '#/components/ui/checkbox'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { Label } from '#/components/ui/label'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import { useListChoices } from '../posts/use-list-choices'
|
||||
import type { DeckColumn } from './model'
|
||||
|
||||
@@ -33,20 +38,21 @@ export function TwitterSourceEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
カラムの種類
|
||||
<select
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id={id}
|
||||
value={source.kind}
|
||||
onChange={(event) =>
|
||||
onChange(defaultTwitterSource(event.target.value as Source['kind']))
|
||||
}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
</select>
|
||||
</label>
|
||||
<NativeSelectOption value="search">検索</NativeSelectOption>
|
||||
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
|
||||
<NativeSelectOption value="list">リスト</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Label>
|
||||
<TwitterSourceFields
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
@@ -84,9 +90,9 @@ function TwitterSearchEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={`${id}-query`}>
|
||||
<Label htmlFor={`${id}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
<Input
|
||||
id={`${id}-query`}
|
||||
value={source.query}
|
||||
onChange={(event) =>
|
||||
@@ -96,10 +102,11 @@ function TwitterSearchEditor({
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${id}-product`}>
|
||||
</Label>
|
||||
<Label htmlFor={`${id}-product`}>
|
||||
表示順
|
||||
<select
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
id={`${id}-product`}
|
||||
value={source.product}
|
||||
onChange={(event) =>
|
||||
@@ -109,23 +116,22 @@ function TwitterSearchEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
</select>
|
||||
</label>
|
||||
<NativeSelectOption value="Latest">最新</NativeSelectOption>
|
||||
<NativeSelectOption value="Top">話題</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Label>
|
||||
<p className="deck-help">
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Label className="deck-checkbox">
|
||||
<Checkbox
|
||||
checked={source.following}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, following: event.target.checked })
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...source, following: checked })
|
||||
}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -137,9 +143,9 @@ function TwitterUserEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
ユーザー
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
@@ -149,7 +155,7 @@ function TwitterUserEditor({
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -162,9 +168,9 @@ function TwitterListEditor({
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
<Label htmlFor={id}>
|
||||
リスト
|
||||
<input
|
||||
<Input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
@@ -174,7 +180,7 @@ function TwitterListEditor({
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
{connectionId && (
|
||||
<TwitterListChoices
|
||||
connectionId={connectionId}
|
||||
@@ -198,9 +204,13 @@ function TwitterListChoices({
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => void lists.refetch()}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
</Button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
@@ -212,10 +222,15 @@ function TwitterListChoices({
|
||||
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
||||
) : (
|
||||
lists.data.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
key={list.id}
|
||||
onClick={() => onSelect(list.id)}
|
||||
>
|
||||
{list.name}
|
||||
{list.isPrivate ? ' · 非公開' : ''}
|
||||
</button>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.research-post.citation-highlight {
|
||||
outline: 2px solid var(--accent, #67b7ff);
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
background-color: color-mix(in srgb, var(--accent, #67b7ff) 14%, transparent);
|
||||
background-color: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.research-post:focus-visible {
|
||||
outline: 2px solid var(--accent, #67b7ff);
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
.app.app-deck {
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
}
|
||||
.research-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
}
|
||||
.research-heading {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -23,13 +21,11 @@
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.research-heading button {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.research-context {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--secondary);
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-history {
|
||||
display: flex;
|
||||
@@ -38,20 +34,14 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.research-history span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.research-history select {
|
||||
.research-history > [data-slot="native-select-wrapper"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.research-panel > p {
|
||||
padding: 0.5rem 1rem;
|
||||
@@ -61,45 +51,32 @@
|
||||
.research-conversation {
|
||||
flex: 1;
|
||||
min-height: 80px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 1rem;
|
||||
}
|
||||
.research-empty {
|
||||
color: var(--secondary);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.research-message {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-message strong {
|
||||
font-size: 0.7rem;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.research-message p {
|
||||
margin: 0.35rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.research-message-user {
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
.research-message-tool {
|
||||
color: var(--secondary);
|
||||
.research-tool-message {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-start-form {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.research-context {
|
||||
margin: 0 0 0.75rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-start-form > fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
@@ -111,21 +88,9 @@
|
||||
.research-start-form label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.research-start-form textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.research-account-picker {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.research-account-picker summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.research-accounts {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
@@ -141,10 +106,8 @@
|
||||
align-items: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-accounts input {
|
||||
width: auto;
|
||||
}
|
||||
.research-run {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
@@ -159,73 +122,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.deck-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-toolbar .deck-workspaces {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-toolbar .deck-workspaces > .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.deck-toolbar .deck-workspaces > .deck-actions > label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
.deck-toolbar .deck-workspaces > .deck-actions > select {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
.deck-toolbar .deck-workspaces > .deck-actions > button {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.deck-toolbar .workspace-create span,
|
||||
.deck-toolbar .workspace-delete span,
|
||||
.deck-toolbar .sidebar-add span,
|
||||
.deck-toolbar .sidebar-note {
|
||||
display: none;
|
||||
}
|
||||
.app-deck .deck-toolbar .sidebar-add {
|
||||
width: 36px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.app.app-deck {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 44dvh minmax(0, 1fr);
|
||||
}
|
||||
.research-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.research-heading {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.research-conversation {
|
||||
flex: 1 0 80px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.research-start-form {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.research-run {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-toolbar > button:last-child {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.research-markdown {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -238,26 +134,33 @@
|
||||
.research-markdown h5,
|
||||
.research-markdown h6 {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.4em;
|
||||
}
|
||||
.research-markdown ul,
|
||||
.research-markdown ol {
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.research-markdown ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.research-markdown ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.research-markdown li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.research-markdown blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--control-border);
|
||||
color: var(--secondary);
|
||||
border-left: 2px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.research-markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem;
|
||||
background: var(--canvas);
|
||||
background: var(--muted);
|
||||
white-space: pre;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -281,7 +184,7 @@
|
||||
text-align: left;
|
||||
}
|
||||
.research-markdown a {
|
||||
color: var(--accent);
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ function show(connections = [connection], ready = true) {
|
||||
const ui = (
|
||||
ready = true,
|
||||
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
|
||||
presentation = 'desktop',
|
||||
) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ResearchPanel
|
||||
@@ -91,16 +92,23 @@ function show(connections = [connection], ready = true) {
|
||||
connections={connections}
|
||||
onOpenDeck={onOpenDeck}
|
||||
onNavigateCitation={onNavigateCitation}
|
||||
/>
|
||||
>
|
||||
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>}
|
||||
</ResearchPanel>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation }
|
||||
}
|
||||
|
||||
it('retains input and uses the same request ID when retrying a failed start', async () => {
|
||||
it('retains input, selected accounts, and the request ID across responsive view remounts after a failed start', async () => {
|
||||
actions.start.mockRejectedValue(new Error('internal credential detail'))
|
||||
show()
|
||||
const view = show([
|
||||
connection,
|
||||
{ ...connection, id: 'second', displayName: 'second' },
|
||||
])
|
||||
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
|
||||
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||
target: { value: 'WebMCPの反応' },
|
||||
})
|
||||
@@ -110,6 +118,15 @@ it('retains input and uses the same request ID when retrying a failed start', as
|
||||
)
|
||||
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
|
||||
expect(screen.queryByText(/internal credential/)).toBeNull()
|
||||
view.rerender(view.ui(true, undefined, 'mobile'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています')
|
||||
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
|
||||
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' }))
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'second · Twitter' }),
|
||||
).not.toBeChecked()
|
||||
expect(MockEventSource.instances).toHaveLength(1)
|
||||
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '送信' }))
|
||||
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
|
||||
expect(actions.start.mock.calls[1]?.[0]).toEqual(
|
||||
@@ -197,9 +214,13 @@ it('limits account selection to six and shows the host report path on completion
|
||||
)
|
||||
await screen.findByText('/home/owner/reports/webmcp.md')
|
||||
fireEvent.click(screen.getByText('接続アカウント · 6件'))
|
||||
expect(screen.getByLabelText('account6 · Twitter')).toBeDisabled()
|
||||
fireEvent.click(screen.getByLabelText('account0 · Twitter'))
|
||||
expect(screen.getByLabelText('account6 · Twitter')).toBeEnabled()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
|
||||
).toHaveAttribute('aria-disabled', 'true')
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'account0 · Twitter' }))
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
|
||||
).not.toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('waits for the workspace before restoring a completed plan from the stream', async () => {
|
||||
@@ -369,6 +390,8 @@ it('shows streamed conversation and sends follow-up input with the current deck
|
||||
|
||||
it('selects newly loaded connected accounts until the user changes their selection', async () => {
|
||||
const view = show([])
|
||||
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
|
||||
await screen.findByText('接続アカウントを先に追加してください。')
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
@@ -595,9 +618,15 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
|
||||
expect(screen.getByLabelText('メッセージ')).toHaveValue('')
|
||||
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
|
||||
fireEvent.click(screen.getByText('接続アカウント · 1件'))
|
||||
expect(screen.getByLabelText('調査用アカウント · Twitter')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('account2 · Twitter')).toBeChecked()
|
||||
fireEvent.click(screen.getByLabelText('調査用アカウント · Twitter'))
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||
).not.toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'account2 · Twitter' }),
|
||||
).toBeChecked()
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||
)
|
||||
act(() =>
|
||||
MockEventSource.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
@@ -612,7 +641,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
|
||||
run: { message: '更新' },
|
||||
}),
|
||||
)
|
||||
expect(screen.getByLabelText('調査用アカウント · Twitter')).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||
).toBeChecked()
|
||||
expect(view.onOpenDeck).toHaveBeenCalledOnce()
|
||||
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||
target: { value: '続きを調べて' },
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Bubble, BubbleContent } from '#/components/ui/bubble'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Checkbox } from '#/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '#/components/ui/collapsible'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '#/components/ui/input-group'
|
||||
import { Marker, MarkerContent } from '#/components/ui/marker'
|
||||
import { Message, MessageContent, MessageHeader } from '#/components/ui/message'
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from '#/components/ui/message-scroller'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import type { Connection } from '../connections/model'
|
||||
import type { Deck } from '../decks/model'
|
||||
import {
|
||||
@@ -28,6 +60,12 @@ const statusNames: Record<ResearchRun['status'], string> = {
|
||||
interrupted: '中断しました・続きから再開できます',
|
||||
}
|
||||
type ResearchInput = { topic: string; connectionIds: string[] }
|
||||
type ResearchDraft = {
|
||||
runId: string | null
|
||||
topic: string
|
||||
selected: string[] | null
|
||||
}
|
||||
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode
|
||||
|
||||
export function ResearchPanel({
|
||||
ready,
|
||||
@@ -35,12 +73,14 @@ export function ResearchPanel({
|
||||
connections,
|
||||
onOpenDeck,
|
||||
onNavigateCitation,
|
||||
children,
|
||||
}: {
|
||||
ready: boolean
|
||||
contextDeck: Deck
|
||||
connections: Connection[]
|
||||
onOpenDeck: (deck: Deck, activate?: boolean) => unknown
|
||||
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
||||
children?: (renderPanel: RenderResearchPanel) => ReactNode
|
||||
}) {
|
||||
const fetchStatus = useServerFn(loadResearchStatus)
|
||||
const start = useServerFn(startResearch)
|
||||
@@ -48,16 +88,33 @@ export function ResearchPanel({
|
||||
const reset = useServerFn(resetResearch)
|
||||
const select = useServerFn(selectResearch)
|
||||
const client = useQueryClient()
|
||||
const historyId = useId()
|
||||
const status = useQuery({
|
||||
queryKey: ['research-status'],
|
||||
queryFn: () => fetchStatus(),
|
||||
retry: false,
|
||||
})
|
||||
const run = status.data?.run ?? null
|
||||
const runId = run?.id ?? null
|
||||
const [draft, setDraft] = useState<ResearchDraft>({
|
||||
runId,
|
||||
topic: '',
|
||||
selected: run?.connectionIds ?? null,
|
||||
})
|
||||
// Conversation changes reset the draft; responsive view remounts do not.
|
||||
if (draft.runId !== runId) {
|
||||
setDraft({ runId, topic: '', selected: run?.connectionIds ?? null })
|
||||
}
|
||||
function updateDraft(
|
||||
update: Partial<Pick<ResearchDraft, 'topic' | 'selected'>>,
|
||||
) {
|
||||
setDraft((current) =>
|
||||
current.runId === runId ? { ...current, ...update } : current,
|
||||
)
|
||||
}
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [reconnecting, setReconnecting] = useState(false)
|
||||
const [draftResetVersion, setDraftResetVersion] = useState(0)
|
||||
const previousRunId = useRef<string | null>(null)
|
||||
const syncedRunId = useRef<string | null>(null)
|
||||
const attemptedVersion = useRef<string | null>(null)
|
||||
@@ -79,7 +136,6 @@ export function ResearchPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (previousRunId.current && previousRunId.current !== run?.id) {
|
||||
setDraftResetVersion((value) => value + 1)
|
||||
request.current = null
|
||||
pendingTurn.current = null
|
||||
}
|
||||
@@ -209,7 +265,7 @@ export function ResearchPanel({
|
||||
)
|
||||
request.current = null
|
||||
pendingTurn.current = null
|
||||
setDraftResetVersion((value) => value + 1)
|
||||
updateDraft({ topic: '' })
|
||||
} catch {
|
||||
setError(
|
||||
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
|
||||
@@ -252,11 +308,13 @@ export function ResearchPanel({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
||||
<aside className="research-panel" aria-label="調査チャット">
|
||||
<header className="research-heading">
|
||||
<h2>調査チャット</h2>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={busy || !run || isResearchActive(run)}
|
||||
title={
|
||||
@@ -267,24 +325,26 @@ export function ResearchPanel({
|
||||
onClick={() => void newChat()}
|
||||
>
|
||||
新しいチャット
|
||||
</button>
|
||||
</Button>
|
||||
{(status.data?.history?.length ?? 0) > 0 && (
|
||||
<label className="research-history">
|
||||
<label className="research-history" htmlFor={historyId}>
|
||||
<span>チャット履歴</span>
|
||||
<select
|
||||
<NativeSelect
|
||||
id={historyId}
|
||||
size="sm"
|
||||
value={run?.id ?? ''}
|
||||
disabled={busy || isResearchActive(run)}
|
||||
onChange={(event) => void selectChat(event.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
<NativeSelectOption value="" disabled>
|
||||
新しいチャット
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
{status.data?.history?.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
<NativeSelectOption key={entry.id} value={entry.id}>
|
||||
{entry.title}
|
||||
</option>
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
)}
|
||||
</header>
|
||||
@@ -296,9 +356,9 @@ export function ResearchPanel({
|
||||
{status.isError && (
|
||||
<p className="deck-error" role="alert">
|
||||
調査状況を取得できませんでした。
|
||||
<button type="button" onClick={() => void status.refetch()}>
|
||||
<Button type="button" onClick={() => void status.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
@@ -315,7 +375,11 @@ export function ResearchPanel({
|
||||
key={`conversation:${run?.id ?? 'new'}`}
|
||||
messages={run?.messages ?? []}
|
||||
citations={run?.citations ?? []}
|
||||
onNavigateCitation={onNavigateCitation}
|
||||
onNavigateCitation={(url, citations) => {
|
||||
const navigated = onNavigateCitation?.(url, citations) ?? false
|
||||
if (navigated) onCitationNavigated?.()
|
||||
return navigated
|
||||
}}
|
||||
/>
|
||||
{run && (
|
||||
<ResearchRunStatus
|
||||
@@ -327,8 +391,10 @@ export function ResearchPanel({
|
||||
)}
|
||||
<ResearchComposer
|
||||
key={run?.id ?? 'new'}
|
||||
initialConnectionIds={run?.connectionIds}
|
||||
resetVersion={draftResetVersion}
|
||||
topic={draft.topic}
|
||||
selected={draft.selected}
|
||||
onTopicChange={(topic) => updateDraft({ topic })}
|
||||
onSelectionChange={(selected) => updateDraft({ selected })}
|
||||
connections={connections}
|
||||
contextTitle={contextDeck.title}
|
||||
disabled={
|
||||
@@ -338,6 +404,7 @@ export function ResearchPanel({
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
return children ? children(renderPanel) : renderPanel()
|
||||
}
|
||||
|
||||
function ResearchConversation({
|
||||
@@ -349,66 +416,81 @@ function ResearchConversation({
|
||||
citations: ResearchCitation[]
|
||||
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const follow = useRef(true)
|
||||
useEffect(() => {
|
||||
const element = container.current
|
||||
if (element && follow.current && messages.length > 0)
|
||||
element.scrollTop = element.scrollHeight
|
||||
}, [messages])
|
||||
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
|
||||
return (
|
||||
<div
|
||||
className="research-conversation"
|
||||
role="log"
|
||||
aria-label="調査の会話"
|
||||
aria-live="polite"
|
||||
ref={container}
|
||||
onScroll={() => {
|
||||
const element = container.current
|
||||
if (element)
|
||||
follow.current =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight < 60
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p className="research-empty">
|
||||
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
||||
</p>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<article
|
||||
className={`research-message research-message-${message.role}`}
|
||||
key={message.id}
|
||||
>
|
||||
<strong>{names[message.role]}</strong>
|
||||
{message.role === 'assistant' ? (
|
||||
<ResearchMarkdown
|
||||
text={message.text}
|
||||
onNavigateCitation={(url) =>
|
||||
onNavigateCitation?.(url, citations) ?? false
|
||||
}
|
||||
/>
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller className="research-conversation">
|
||||
<MessageScrollerViewport aria-label="調査メッセージ">
|
||||
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<MessageScrollerItem messageId="empty">
|
||||
<p className="research-empty">
|
||||
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
||||
</p>
|
||||
</MessageScrollerItem>
|
||||
) : (
|
||||
<p>{message.text}</p>
|
||||
messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === 'user'}
|
||||
>
|
||||
{message.role === 'tool' ? (
|
||||
<Marker className="research-tool-message">
|
||||
<MarkerContent>
|
||||
<span className="sr-only">{names.tool}: </span>
|
||||
{message.text}
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
) : (
|
||||
<Message
|
||||
align={message.role === 'user' ? 'end' : 'start'}
|
||||
className="research-message"
|
||||
>
|
||||
<MessageContent>
|
||||
<MessageHeader>{names[message.role]}</MessageHeader>
|
||||
{message.role === 'assistant' ? (
|
||||
<ResearchMarkdown
|
||||
text={message.text}
|
||||
onNavigateCitation={(url) =>
|
||||
onNavigateCitation?.(url, citations) ?? false
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Bubble align="end">
|
||||
<BubbleContent>
|
||||
<p>{message.text}</p>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</MessageScrollerItem>
|
||||
))
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton aria-label="最新のメッセージへ" />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ResearchComposer({
|
||||
initialConnectionIds,
|
||||
resetVersion,
|
||||
topic,
|
||||
selected,
|
||||
onTopicChange,
|
||||
onSelectionChange,
|
||||
connections,
|
||||
contextTitle,
|
||||
disabled,
|
||||
onSend,
|
||||
}: {
|
||||
initialConnectionIds?: string[]
|
||||
resetVersion: number
|
||||
topic: string
|
||||
selected: string[] | null
|
||||
onTopicChange: (topic: string) => void
|
||||
onSelectionChange: (selected: string[]) => void
|
||||
connections: Connection[]
|
||||
contextTitle: string
|
||||
disabled: boolean
|
||||
@@ -417,13 +499,6 @@ function ResearchComposer({
|
||||
const available = connections.filter(
|
||||
(connection) => connection.status === 'connected',
|
||||
)
|
||||
const [topic, setTopic] = useState('')
|
||||
useEffect(() => {
|
||||
if (resetVersion > 0) setTopic('')
|
||||
}, [resetVersion])
|
||||
const [selected, setSelected] = useState<string[] | null>(
|
||||
initialConnectionIds ?? null,
|
||||
)
|
||||
const selectedIds =
|
||||
selected ?? available.slice(0, 6).map((connection) => connection.id)
|
||||
const id = useId()
|
||||
@@ -441,7 +516,7 @@ function ResearchComposer({
|
||||
topic: topic.trim(),
|
||||
connectionIds: validSelection,
|
||||
}).then((sent) => {
|
||||
if (sent) setTopic('')
|
||||
if (sent) onTopicChange('')
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -449,56 +524,69 @@ function ResearchComposer({
|
||||
送信するデッキ: <strong>{contextTitle}</strong>
|
||||
</p>
|
||||
<fieldset disabled={disabled}>
|
||||
<details className="research-account-picker">
|
||||
<summary>接続アカウント · {validSelection.length}件</summary>
|
||||
<fieldset className="research-accounts">
|
||||
<legend>調査に使う接続アカウント(最大6件)</legend>
|
||||
{available.length === 0 ? (
|
||||
<p>接続アカウントを先に追加してください。</p>
|
||||
) : (
|
||||
available.map((connection) => (
|
||||
<label key={connection.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(connection.id)}
|
||||
disabled={
|
||||
!selectedIds.includes(connection.id) &&
|
||||
validSelection.length >= 6
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelected(
|
||||
event.target.checked
|
||||
? [...selectedIds, connection.id]
|
||||
: selectedIds.filter(
|
||||
(value) => value !== connection.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connection.displayName} ·{' '}
|
||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</fieldset>
|
||||
</details>
|
||||
<Collapsible className="research-account-picker" disabled={disabled}>
|
||||
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
|
||||
接続アカウント · {validSelection.length}件
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<fieldset className="research-accounts">
|
||||
<legend>調査に使う接続アカウント(最大6件)</legend>
|
||||
{available.length === 0 ? (
|
||||
<p>接続アカウントを先に追加してください。</p>
|
||||
) : (
|
||||
available.map((connection) => (
|
||||
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
|
||||
<Checkbox
|
||||
id={`${id}-${connection.id}`}
|
||||
checked={selectedIds.includes(connection.id)}
|
||||
disabled={
|
||||
disabled ||
|
||||
(!selectedIds.includes(connection.id) &&
|
||||
validSelection.length >= 6)
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelectionChange(
|
||||
checked
|
||||
? [...selectedIds, connection.id]
|
||||
: selectedIds.filter(
|
||||
(value) => value !== connection.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connection.displayName} ·{' '}
|
||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</fieldset>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<label htmlFor={id}>メッセージ</label>
|
||||
<textarea
|
||||
id={id}
|
||||
value={topic}
|
||||
onChange={(event) => setTopic(event.target.value)}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="deck-primary"
|
||||
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
||||
>
|
||||
送信
|
||||
</button>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
id={id}
|
||||
value={topic}
|
||||
onChange={(event) => onTopicChange(event.target.value)}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
<InputGroupAddon align="block-end" className="justify-end">
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={
|
||||
disabled || !topic.trim() || validSelection.length === 0
|
||||
}
|
||||
>
|
||||
送信
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
)
|
||||
@@ -518,7 +606,9 @@ function ResearchRunStatus({
|
||||
const deck = run.deck ?? run.contextDeck
|
||||
return (
|
||||
<section className="research-run" aria-label="調査状況">
|
||||
<p role="status">{statusNames[run.status]}</p>
|
||||
<Marker role="status">
|
||||
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
||||
</Marker>
|
||||
{run.error && (
|
||||
<p className="deck-error" role="alert">
|
||||
{run.error}
|
||||
@@ -533,19 +623,27 @@ function ResearchRunStatus({
|
||||
)}
|
||||
<div className="deck-actions">
|
||||
{isResearchActive(run) && (
|
||||
<button type="button" disabled={busy} onClick={onCancel}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
調査を中止
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{deck && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenDeck(deck)
|
||||
}}
|
||||
>
|
||||
一時デッキで開く
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user