feat: start fresh research chats while preserving deck context

This commit is contained in:
2026-09-24 18:03:29 +09:00
parent b2db67ea1c
commit 9c3917541b
8 changed files with 246 additions and 8 deletions
+11
View File
@@ -11,6 +11,11 @@
overflow: hidden;
}
.research-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
@@ -18,6 +23,12 @@
margin: 0;
font-size: 1rem;
}
.research-heading p {
width: 100%;
}
.research-heading button {
font-size: 0.75rem;
}
.research-heading p,
.research-context {
margin: 0.5rem 0 0;
@@ -9,12 +9,14 @@ const actions = vi.hoisted(() => ({
status: vi.fn(),
start: vi.fn(),
cancel: vi.fn(),
reset: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadResearchStatus: actions.status,
startResearch: actions.start,
cancelResearch: actions.cancel,
resetResearch: actions.reset,
}))
const connection: Connection = {
@@ -374,3 +376,91 @@ it('selects newly loaded connected accounts until the user changes their selecti
})
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
})
it('starts a new chat without changing the deck and clears the draft only after success', async () => {
current = {
configured: true,
run: { ...running, status: 'complete', threadId: 'old-thread' },
}
actions.reset
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue({ configured: true, run: null })
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: '下書き' },
})
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'会話と入力内容は残っています',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('下書き')
const callsBeforeReset = view.onOpenDeck.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
expect(screen.queryByText('WebMCPの反応')).toBeNull()
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
expect(screen.getByText('手動デッキ')).toBeVisible()
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: '新しい話題' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce())
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId')
expect(actions.reset).toHaveBeenCalledWith({ data: { id: running.id } })
})
it('disables new chat during a running turn and receives another device reset over SSE', async () => {
current = { configured: true, run: running }
const view = show()
await screen.findByText('WebMCPの反応')
expect(screen.getByRole('button', { name: '新しいチャット' })).toBeDisabled()
const callsBeforeReset = view.onOpenDeck.mock.calls.length
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ configured: true, run: null }),
}),
)
await waitFor(() => expect(screen.queryByText('WebMCPの反応')).toBeNull())
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
expect(actions.reset).not.toHaveBeenCalled()
})
it('does not let a late reset response erase a newer streamed conversation', async () => {
current = { configured: true, run: { ...running, status: 'complete' } }
let finish!: (snapshot: typeof current) => void
actions.reset.mockImplementation(
() =>
new Promise<typeof current>((resolve) => {
finish = resolve
}),
)
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
)
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
const newer = {
configured: true,
run: {
...running,
id: 'new-run',
messages: [{ id: 'new-user', role: 'user', text: '別端末の新しい話題' }],
},
}
act(() =>
MockEventSource.instances[0]?.onmessage?.({ data: JSON.stringify(newer) }),
)
await screen.findByText('別端末の新しい話題')
await act(async () => finish({ configured: true, run: null }))
expect(view.client.getQueryData(['research-status'])).toEqual(newer)
expect(screen.getByText('別端末の新しい話題')).toBeVisible()
})
+63 -7
View File
@@ -7,6 +7,7 @@ import { isResearchActive, type ResearchRun } from './model'
import {
cancelResearch,
loadResearchStatus,
resetResearch,
startResearch,
} from './server-functions'
import './research-panel.css'
@@ -34,6 +35,7 @@ export function ResearchPanel({
const fetchStatus = useServerFn(loadResearchStatus)
const start = useServerFn(startResearch)
const cancel = useServerFn(cancelResearch)
const reset = useServerFn(resetResearch)
const client = useQueryClient()
const status = useQuery({
queryKey: ['research-status'],
@@ -44,6 +46,8 @@ export function ResearchPanel({
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 attemptedVersion = useRef<string | null>(null)
const pendingTurn = useRef<{
runId?: string
@@ -61,6 +65,15 @@ export function ResearchPanel({
)
const request = useRef<{ signature: string; id: string } | null>(null)
useEffect(() => {
if (!run && previousRunId.current) {
setDraftResetVersion((value) => value + 1)
request.current = null
pendingTurn.current = null
}
previousRunId.current = run?.id ?? null
}, [run])
useEffect(() => {
const events = new EventSource('/api/research/events')
events.onopen = () => setReconnecting(false)
@@ -128,9 +141,7 @@ export function ResearchPanel({
client.setQueryData<{ configured: boolean; run: ResearchRun | null }>(
['research-status'],
(existing) =>
existing !== beforeStart && existing?.run?.id === next.id
? existing
: { configured: true, run: next },
existing !== beforeStart ? existing : { configured: true, run: next },
)
return true
} catch {
@@ -146,14 +157,19 @@ export function ResearchPanel({
async function stop() {
if (!run) return
const beforeCancel = client.getQueryData(['research-status'])
setBusy(true)
setError(undefined)
try {
const next = await cancel({ data: { id: run.id } })
client.setQueryData(['research-status'], {
configured: status.data?.configured ?? true,
run: next,
})
client.setQueryData(['research-status'], (existing: unknown) =>
existing === beforeCancel
? {
configured: status.data?.configured ?? true,
run: next,
}
: existing,
)
} catch {
setError('調査を中止できませんでした。もう一度お試しください。')
} finally {
@@ -161,6 +177,28 @@ export function ResearchPanel({
}
}
async function newChat() {
if (!run || isResearchActive(run)) return
const beforeReset = client.getQueryData(['research-status'])
setBusy(true)
setError(undefined)
try {
const next = await reset({ data: { id: run.id } })
client.setQueryData(['research-status'], (existing: unknown) =>
existing === beforeReset ? next : existing,
)
request.current = null
pendingTurn.current = null
setDraftResetVersion((value) => value + 1)
} catch {
setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
)
} finally {
setBusy(false)
}
}
async function openDeck(deck: Deck) {
setBusy(true)
setError(undefined)
@@ -179,6 +217,18 @@ export function ResearchPanel({
<aside className="research-panel" aria-label="調査チャット">
<header className="research-heading">
<h2>調査チャット</h2>
<button
type="button"
disabled={busy || !run || isResearchActive(run)}
title={
isResearchActive(run)
? '調査を中止してから新しいチャットを開始できます'
: undefined
}
onClick={() => void newChat()}
>
新しいチャット
</button>
<p>会話に合わせて右のデッキが変わります。</p>
</header>
{reconnecting && (
@@ -214,6 +264,7 @@ export function ResearchPanel({
/>
)}
<ResearchComposer
resetVersion={draftResetVersion}
connections={connections}
contextTitle={contextDeck.title}
disabled={
@@ -272,11 +323,13 @@ function ResearchConversation({
}
function ResearchComposer({
resetVersion,
connections,
contextTitle,
disabled,
onSend,
}: {
resetVersion: number
connections: Connection[]
contextTitle: string
disabled: boolean
@@ -286,6 +339,9 @@ function ResearchComposer({
(connection) => connection.status === 'connected',
)
const [topic, setTopic] = useState('')
useEffect(() => {
if (resetVersion > 0) setTopic('')
}, [resetVersion])
const [selected, setSelected] = useState<string[] | null>(null)
const selectedIds =
selected ?? available.slice(0, 6).map((connection) => connection.id)
+14
View File
@@ -339,6 +339,20 @@ export function createResearchService(
return {
status: snapshot,
reset(id: string) {
if (isResearchActive(run) || state?.stopping)
throw new Error(
'調査中です。中止してから新しいチャットを開始してください。',
)
if (run && run.id !== id)
throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。',
)
run = null
state = undefined
emit()
return snapshot()
},
subscribe(listener: (snapshot: ResearchSnapshot) => void) {
listeners.add(listener)
publish(listener)
+40 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -418,6 +418,45 @@ it('rejects unknown continuation IDs', () => {
)
})
it('resets a completed conversation for every viewer and starts a fresh thread without deleting its report', async () => {
const first = service.start(input())
const old = await running()
const reportPath = join(directory, first.id, 'report.md')
await writeFile(reportPath, '# Saved research')
old.completed()
await vi.waitFor(() => expect(service.status().run?.status).toBe('complete'))
const listener = vi.fn()
service.subscribe(listener)
expect(service.reset(first.id)).toEqual({ configured: true, run: null })
expect(listener).toHaveBeenLastCalledWith({ configured: true, run: null })
expect(service.reset(first.id).run).toBeNull()
expect(await readFile(reportPath, 'utf8')).toBe('# Saved research')
expect(() => service.start({ ...input(), runId: first.id })).toThrow(
'続ける会話が見つかりません',
)
const next = service.start(input())
const current = await running()
expect(next.id).not.toBe(first.id)
expect(current.calls).toHaveBeenCalledWith('thread/start', expect.anything())
expect(current.calls).not.toHaveBeenCalledWith(
'thread/resume',
expect.anything(),
)
expect(service.status().run?.messages).toHaveLength(1)
old.completed()
expect(service.status().run?.id).toBe(next.id)
})
it('requires an active turn to stop before resetting and rejects a stale reset from another viewer', async () => {
const first = service.start(input())
await running()
expect(() => service.reset(first.id)).toThrow('中止してから')
await service.cancel(first.id)
expect(() => service.reset(randomUUID())).toThrow('別のチャット')
expect(service.status().run?.id).toBe(first.id)
expect(service.reset(first.id).run).toBeNull()
})
it('keeps the generated deck identity across turns while advancing its version', async () => {
const first = service.start(input())
const old = await running()
@@ -20,3 +20,10 @@ export const cancelResearch = createServerFn({ method: 'POST' })
const { researchService } = await import('./runner.server')
return researchService().cancel(data.id)
})
export const resetResearch = createServerFn({ method: 'POST' })
.validator(z.object({ id: z.string().uuid() }).strict())
.handler(async ({ data }) => {
const { researchService } = await import('./runner.server')
return researchService().reset(data.id)
})