feat: migrate workspace to shadcn sidebar and chat components
This commit is contained in:
@@ -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