diff --git a/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md b/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md index 00fd11a..a7bce5a 100644 --- a/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md +++ b/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md @@ -95,14 +95,17 @@ Gate result: - Replaced rollback-like branch language with `从这里创建新对话分支`; no file rollback behavior or implication was added. - Kept share/unshare, todo, global runtime, revert/unrevert, direct IPC, direct runtime HTTP, and legacy OpenCode imports out of the new Coding UI. - Windowed streaming thinking DOM output to the latest 16 KB while streaming, restoring the complete text when settled; this keeps the existing REN-008 100 KB pressure budget passing without changing the protocol or test threshold. +- Isolated Conversation-local async UI state by remounting Header, pending interactions, and the tools inspector on Conversation changes. A pending fork only selects its result while the source project, Agent, and Conversation remain selected; a completed archive no longer clears a newer selection. +- Added regression coverage for pending fork, archive, interaction response, and tools-load completion across Conversation switches. - Updated `README.md` to describe the now-shipped Coding UI state and retained the explicit unverified shared Provider/runtime concurrency boundary. ## Verification - `pnpm run typecheck` — Pass. - `pnpm run lint:check` — Pass with 0 errors and 6 pre-existing warnings outside PI-130-owned files. -- Focused PI-130 Vitest coverage — Pass, including facade routes, queue modes/positions, interactions, Conversation controls, metadata isolation, nested subagents, compaction retry, inline tool output, fork wording, and removed UI entries. -- `pnpm test` — Pass: 219 files; 2328 tests passed; 2 skipped. +- Focused PI-130 Vitest coverage — Pass: 17/17 across the two changed UI suites, including facade routes, queue modes/positions, interactions, Conversation controls, metadata and async-switch isolation, nested subagents, compaction retry, inline tool output, fork wording, and removed UI entries. +- Isolated REN-008 100 KB pressure test — Pass: 20 patch batches, 20 React commits, 34.8 ms Main-to-React p95 against the 50 ms budget. An earlier run was intentionally discarded after it shared CPU with full-repository lint and measured 112.9 ms; the serial full-suite rerun below passed. +- `pnpm test` — Pass: 219 files; 2332 tests passed; 2 skipped. - `pnpm run build:vite` — Pass as part of the scoped Electron E2E command; only existing Vite chunk/dynamic-import warnings were reported. - `pnpm run test:e2e -- tests/e2e/pi-coding-first-chat.spec.ts` — Pass: 2/2, covering first-Conversation editability, two Conversation isolation, queue, interaction, model, abort, subagent, files/changes/commands tools, and removed share/revert/todo/global-runtime entries. - `git diff --check` — Pass. diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 0f7a8e6..2bd753f 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -335,12 +335,21 @@ export function CodingChatPanel({ ]); const handleForkConversation = useCallback(async (sourceEntryId?: string) => { - if (!activeProject || !targetConversationId) return; - const forked = await forkCodingConversation(targetConversationId, sourceEntryId); + if (!activeProject || !selectedAgent || !targetConversationId) return; + const sourceProjectId = activeProject.id; + const sourceAgentId = selectedAgent.id; + const sourceConversationId = targetConversationId; + const forked = await forkCodingConversation(sourceConversationId, sourceEntryId); upsertConversation(forked); - primeConversation(createLocalConversationSnapshot(activeProject.id, forked)); - await selectConversation(forked.id); - }, [activeProject, primeConversation, selectConversation, targetConversationId, upsertConversation]); + primeConversation(createLocalConversationSnapshot(sourceProjectId, forked)); + const workspace = codingWorkspaceStore.getState(); + const selectedId = codingConversationStore.getState().selectedConversationId; + if (workspace.activeProjectId === sourceProjectId + && workspace.selectedAgentId === sourceAgentId + && selectedId === sourceConversationId) { + await selectConversation(forked.id); + } + }, [activeProject, primeConversation, selectConversation, selectedAgent, targetConversationId, upsertConversation]); const handleAddFiles = useCallback((files: File[]) => { if (!draftKey @@ -615,6 +624,7 @@ export function CodingChatPanel({
{ if (!targetConversationId) return; - await patchConversation(targetConversationId, { archived: true }); - clearConversationSelection(); + const sourceConversationId = targetConversationId; + await patchConversation(sourceConversationId, { archived: true }); + if (codingConversationStore.getState().selectedConversationId === sourceConversationId) { + clearConversationSelection(); + } }} onToggleUnread={async () => { if (!targetConversationId || !selectedConversation) return; @@ -683,6 +696,7 @@ export function CodingChatPanel({ {targetConversationId && ( loadConversationSnapshot(targetConversationId, true).then(() => undefined)} @@ -730,6 +744,7 @@ export function CodingChatPanel({ onRemoveAttachment={handleRemoveAttachment} /> { expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); }); + it('does not let a pending fork steal selection or Header busy state after a Conversation switch', async () => { + const forkFlight = deferred(); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) }); + projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + conversationApi.recover.mockResolvedValue(undefined); + conversationApi.fork.mockReturnValue(forkFlight.promise); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( + createLocalConversationSnapshot( + project.id, + conversationId === reviewerConversation.id ? reviewerConversation : conversation, + ) + )); + render(); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(conversation.id)); + + fireEvent.click(screen.getByRole('button', { name: '创建分支' })); + await waitFor(() => expect(conversationApi.fork).toHaveBeenCalledWith(conversation.id, undefined)); + fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(reviewerConversation.id)); + expect(screen.getByRole('button', { name: '创建分支' })).toBeEnabled(); + + await act(async () => { + forkFlight.resolve({ + ...conversation, + id: 'conversation-forked', + title: 'Feature branch', + }); + await forkFlight.promise; + }); + expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); + }); + + it('does not clear a newly selected Conversation when an old archive request finishes', async () => { + const archiveFlight = deferred(); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) }); + projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]); + projectApi.patch.mockReturnValue(archiveFlight.promise); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + conversationApi.recover.mockResolvedValue(undefined); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + conversationApi.snapshot.mockImplementation(async (conversationId: string) => ( + createLocalConversationSnapshot( + project.id, + conversationId === reviewerConversation.id ? reviewerConversation : conversation, + ) + )); + render(); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(conversation.id)); + + fireEvent.click(screen.getByRole('button', { name: '归档' })); + await waitFor(() => expect(projectApi.patch).toHaveBeenCalledWith(conversation.id, { archived: true })); + fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(reviewerConversation.id)); + expect(screen.getByRole('button', { name: '归档' })).toBeEnabled(); + + await act(async () => { + archiveFlight.resolve({ ...conversation, archivedAt: '2026-08-24T01:00:00.000Z' }); + await archiveFlight.promise; + }); + expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); + }); + + it('remounts pending interaction state when switching Conversations', async () => { + const respondFlight = deferred(); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) }); + projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + conversationApi.recover.mockResolvedValue(undefined); + conversationApi.respond.mockReturnValue(respondFlight.promise); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + conversationApi.snapshot.mockImplementation(async (conversationId: string) => { + const metadata = conversationId === reviewerConversation.id ? reviewerConversation : conversation; + return { + ...createLocalConversationSnapshot(project.id, metadata), + pendingInteractions: [{ + id: `interaction-${conversationId}`, + conversationId, + runId: `run-${conversationId}`, + kind: 'confirm' as const, + title: `Confirm ${conversationId}`, + status: 'pending' as const, + }], + }; + }); + render(); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(conversation.id)); + + fireEvent.click(await screen.findByRole('button', { name: '确认' })); + await waitFor(() => expect(conversationApi.respond).toHaveBeenCalledWith(conversation.id, { + interactionId: `interaction-${conversation.id}`, + confirmed: true, + })); + fireEvent.click(screen.getByRole('button', { name: /Reviewer/ })); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) + .toBe(reviewerConversation.id)); + expect(await screen.findByText(`Confirm ${reviewerConversation.id}`)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '确认' })).toBeEnabled(); + + await act(async () => { + respondFlight.resolve(); + await respondFlight.promise; + }); + expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id); + }); + it('keeps submission state and rejection scoped to the originating Conversation', async () => { const pendingSubmit = deferred(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); diff --git a/tests/unit/coding-feature-ui.test.tsx b/tests/unit/coding-feature-ui.test.tsx index d4c04e1..c8c6bad 100644 --- a/tests/unit/coding-feature-ui.test.tsx +++ b/tests/unit/coding-feature-ui.test.tsx @@ -8,6 +8,15 @@ const interactionApi = vi.hoisted(() => ({ compact: vi.fn(), model: vi.fn(), thinking: vi.fn(), + diagnostics: vi.fn(), +})); +const inspectorApi = vi.hoisted(() => ({ + changes: vi.fn(), + commands: vi.fn(), + content: vi.fn(), + files: vi.fn(), + find: vi.fn(), + skills: vi.fn(), })); vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ @@ -17,6 +26,16 @@ vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ compactCodingConversation: interactionApi.compact, setCodingConversationModel: interactionApi.model, setCodingConversationThinking: interactionApi.thinking, + getCodingRuntimeDiagnostics: interactionApi.diagnostics, +})); + +vi.mock('@/lib/coding-product-tools', () => ({ + getCodingConversationChanges: inspectorApi.changes, + getCodingConversationCommands: inspectorApi.commands, + getCodingFileContent: inspectorApi.content, + getCodingFileStatus: inspectorApi.files, + findCodingFiles: inspectorApi.find, + getCodingSkills: inspectorApi.skills, })); describe('PI-130 feature-complete Coding UI', () => { @@ -217,4 +236,56 @@ describe('PI-130 feature-complete Coding UI', () => { await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce()); expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); }); + + it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => { + let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void; + const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => { + resolveOld = resolve; + }); + inspectorApi.changes.mockResolvedValue({ changes: null }); + inspectorApi.files.mockResolvedValue({ files: [] }); + inspectorApi.skills.mockResolvedValue({ skills: [] }); + inspectorApi.commands.mockImplementation((conversationId: string) => ( + conversationId === 'conversation-old' + ? oldCommands + : Promise.resolve({ commands: [{ name: 'new-command', title: 'New command', description: 'New', source: 'makelore' as const }] }) + )); + interactionApi.diagnostics.mockResolvedValue({ revision: { provider: 1, resources: 1 }, workers: [] }); + const { CodingWorkspaceInspector } = await import('@/pages/Chat/CodingWorkspaceInspector'); + const view = render( + , + ); + await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old')); + + view.rerender( + , + ); + await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-new')); + await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()); + const commandsTab = screen.getByRole('tab', { name: '命令' }); + fireEvent.mouseDown(commandsTab, { button: 0, ctrlKey: false }); + await waitFor(() => expect(commandsTab).toHaveAttribute('aria-selected', 'true')); + expect(await screen.findByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); + + resolveOld({ commands: [{ name: 'old-command', title: 'Old command', description: 'Old', source: 'makelore' }] }); + await oldCommands; + await waitFor(() => expect(screen.queryByRole('button', { name: /\/old-command/ })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); + }); });