feat: start fresh research chats while preserving deck context
This commit is contained in:
@@ -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,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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user