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
+4
View File
@@ -103,6 +103,10 @@ accounts' lists before planning new searches, then read relevant list columns
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
Manual deck creation and column editing remain
available. The backend handles tools even when the browser is closed.
Use **新しいチャット** to clear the current conversation and start a fresh Codex
thread with your next message. The open deck and host reports remain available.
If a turn is running, stop it first. The conversation reset is shared across
open devices; previous conversations are not listed in this prototype.
Each turn is limited to 6 columns, 12 upstream requests shared between list
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
The prototype accepts up to 100 messages per backend process.
@@ -75,6 +75,13 @@ editing a deck is a valid completed turn. Reports, when requested, remain host
Markdown files. SSE snapshots include the conversation and latest generated
deck so reopening a page restores both without restarting work or polling.
The chat header can reset an idle conversation. The next message starts a new
Codex thread while retaining the browser's current deck as context. Reset pushes
an empty conversation to every subscriber, preserves host reports and does not
delete deck definitions. Active turns must be stopped first; a stale reset must
not clear a newer conversation started on another device. This does not add a
conversation-history UI.
## Verification
- Protocol tests for dynamic-tool requests, failures, cancellation and process
@@ -202,3 +209,13 @@ be handled by the backend so research can continue without the initiating tab.
an upstream `Dependency: Unspecified` error; a direct retry through the same
Bird catalog API returned three lists. A follow-up message in the same Codex
conversation then retried `list_lists` successfully and also reported three.
## New-chat verification
- 284 unit/component tests and eight desktop/mobile research E2E cases passed,
along with typecheck, lint and production build.
- A real Codex check on a separate development server reset a completed chat
across two browser pages, cleared the draft and retained the current deck.
The next message used a different thread ID, contained only the new user
message and received the current deck context. The separate server avoided
interrupting research already running in the public workspace.
+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)
})