feat: migrate workspace to shadcn sidebar and chat components

This commit is contained in:
2026-09-28 17:29:08 +09:00
parent 2346848839
commit 96e545401e
61 changed files with 6727 additions and 1146 deletions
@@ -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;
}
+26 -123
View File
@@ -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;
}
+41 -10
View File
@@ -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: '続きを調べて' },
+226 -128
View File
@@ -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>