From 9c3917541b8ddbe7372c04a89eef2763239c615e Mon Sep 17 00:00:00 2001 From: yuta Date: Thu, 24 Sep 2026 18:03:29 +0900 Subject: [PATCH] feat: start fresh research chats while preserving deck context --- README.md | 4 + docs/plans/2026-09-24-home-agent-research.md | 17 ++++ src/features/research/research-panel.css | 11 +++ src/features/research/research-panel.test.tsx | 90 +++++++++++++++++++ src/features/research/research-panel.tsx | 70 +++++++++++++-- src/features/research/runner.server.ts | 14 +++ src/features/research/runner.test.ts | 41 ++++++++- src/features/research/server-functions.ts | 7 ++ 8 files changed, 246 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2ea32fd..9cf5612 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/plans/2026-09-24-home-agent-research.md b/docs/plans/2026-09-24-home-agent-research.md index ee46097..2b73ffb 100644 --- a/docs/plans/2026-09-24-home-agent-research.md +++ b/docs/plans/2026-09-24-home-agent-research.md @@ -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. diff --git a/src/features/research/research-panel.css b/src/features/research/research-panel.css index 610940b..dab796d 100644 --- a/src/features/research/research-panel.css +++ b/src/features/research/research-panel.css @@ -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; diff --git a/src/features/research/research-panel.test.tsx b/src/features/research/research-panel.test.tsx index 15e2f36..fe679d0 100644 --- a/src/features/research/research-panel.test.tsx +++ b/src/features/research/research-panel.test.tsx @@ -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((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() +}) diff --git a/src/features/research/research-panel.tsx b/src/features/research/research-panel.tsx index ced2dc2..33c99c2 100644 --- a/src/features/research/research-panel.tsx +++ b/src/features/research/research-panel.tsx @@ -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() const [reconnecting, setReconnecting] = useState(false) + const [draftResetVersion, setDraftResetVersion] = useState(0) + const previousRunId = useRef(null) const attemptedVersion = useRef(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({