fix(coding): isolate async conversation actions
This commit is contained in:
@@ -95,14 +95,17 @@ Gate result:
|
|||||||
- Replaced rollback-like branch language with `从这里创建新对话分支`; no file rollback behavior or implication was added.
|
- 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.
|
- 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.
|
- 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.
|
- Updated `README.md` to describe the now-shipped Coding UI state and retained the explicit unverified shared Provider/runtime concurrency boundary.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- `pnpm run typecheck` — Pass.
|
- `pnpm run typecheck` — Pass.
|
||||||
- `pnpm run lint:check` — Pass with 0 errors and 6 pre-existing warnings outside PI-130-owned files.
|
- `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.
|
- 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.
|
||||||
- `pnpm test` — Pass: 219 files; 2328 tests passed; 2 skipped.
|
- 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 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.
|
- `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.
|
- `git diff --check` — Pass.
|
||||||
|
|||||||
@@ -335,12 +335,21 @@ export function CodingChatPanel({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
|
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
|
||||||
if (!activeProject || !targetConversationId) return;
|
if (!activeProject || !selectedAgent || !targetConversationId) return;
|
||||||
const forked = await forkCodingConversation(targetConversationId, sourceEntryId);
|
const sourceProjectId = activeProject.id;
|
||||||
|
const sourceAgentId = selectedAgent.id;
|
||||||
|
const sourceConversationId = targetConversationId;
|
||||||
|
const forked = await forkCodingConversation(sourceConversationId, sourceEntryId);
|
||||||
upsertConversation(forked);
|
upsertConversation(forked);
|
||||||
primeConversation(createLocalConversationSnapshot(activeProject.id, forked));
|
primeConversation(createLocalConversationSnapshot(sourceProjectId, forked));
|
||||||
await selectConversation(forked.id);
|
const workspace = codingWorkspaceStore.getState();
|
||||||
}, [activeProject, primeConversation, selectConversation, targetConversationId, upsertConversation]);
|
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[]) => {
|
const handleAddFiles = useCallback((files: File[]) => {
|
||||||
if (!draftKey
|
if (!draftKey
|
||||||
@@ -615,6 +624,7 @@ export function CodingChatPanel({
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CodingConversationHeader
|
<CodingConversationHeader
|
||||||
|
key={`header:${targetConversationId ?? 'none'}`}
|
||||||
conversation={selectedConversation}
|
conversation={selectedConversation}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
@@ -623,8 +633,11 @@ export function CodingChatPanel({
|
|||||||
}}
|
}}
|
||||||
onArchive={async () => {
|
onArchive={async () => {
|
||||||
if (!targetConversationId) return;
|
if (!targetConversationId) return;
|
||||||
await patchConversation(targetConversationId, { archived: true });
|
const sourceConversationId = targetConversationId;
|
||||||
clearConversationSelection();
|
await patchConversation(sourceConversationId, { archived: true });
|
||||||
|
if (codingConversationStore.getState().selectedConversationId === sourceConversationId) {
|
||||||
|
clearConversationSelection();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onToggleUnread={async () => {
|
onToggleUnread={async () => {
|
||||||
if (!targetConversationId || !selectedConversation) return;
|
if (!targetConversationId || !selectedConversation) return;
|
||||||
@@ -683,6 +696,7 @@ export function CodingChatPanel({
|
|||||||
|
|
||||||
{targetConversationId && (
|
{targetConversationId && (
|
||||||
<CodingInteractionPanel
|
<CodingInteractionPanel
|
||||||
|
key={`interaction:${targetConversationId}`}
|
||||||
conversationId={targetConversationId}
|
conversationId={targetConversationId}
|
||||||
interactions={snapshot?.pendingInteractions ?? []}
|
interactions={snapshot?.pendingInteractions ?? []}
|
||||||
onSettled={() => loadConversationSnapshot(targetConversationId, true).then(() => undefined)}
|
onSettled={() => loadConversationSnapshot(targetConversationId, true).then(() => undefined)}
|
||||||
@@ -730,6 +744,7 @@ export function CodingChatPanel({
|
|||||||
onRemoveAttachment={handleRemoveAttachment}
|
onRemoveAttachment={handleRemoveAttachment}
|
||||||
/>
|
/>
|
||||||
<CodingWorkspaceInspector
|
<CodingWorkspaceInspector
|
||||||
|
key={`inspector:${targetConversationId ?? 'none'}`}
|
||||||
open={inspectorOpen}
|
open={inspectorOpen}
|
||||||
onOpenChange={setInspectorOpen}
|
onOpenChange={setInspectorOpen}
|
||||||
conversationId={targetConversationId}
|
conversationId={targetConversationId}
|
||||||
|
|||||||
@@ -257,6 +257,127 @@ describe('CodingChatPanel first Conversation', () => {
|
|||||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
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<CodingConversationMetadata>();
|
||||||
|
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(<CodingChatPanel />);
|
||||||
|
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<CodingConversationMetadata>();
|
||||||
|
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(<CodingChatPanel />);
|
||||||
|
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<void>();
|
||||||
|
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(<CodingChatPanel />);
|
||||||
|
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 () => {
|
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
|
||||||
const pendingSubmit = deferred<never>();
|
const pendingSubmit = deferred<never>();
|
||||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ const interactionApi = vi.hoisted(() => ({
|
|||||||
compact: vi.fn(),
|
compact: vi.fn(),
|
||||||
model: vi.fn(),
|
model: vi.fn(),
|
||||||
thinking: 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) => ({
|
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
||||||
@@ -17,6 +26,16 @@ vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
|||||||
compactCodingConversation: interactionApi.compact,
|
compactCodingConversation: interactionApi.compact,
|
||||||
setCodingConversationModel: interactionApi.model,
|
setCodingConversationModel: interactionApi.model,
|
||||||
setCodingConversationThinking: interactionApi.thinking,
|
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', () => {
|
describe('PI-130 feature-complete Coding UI', () => {
|
||||||
@@ -217,4 +236,56 @@ describe('PI-130 feature-complete Coding UI', () => {
|
|||||||
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
|
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
|
||||||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
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(
|
||||||
|
<CodingWorkspaceInspector
|
||||||
|
key="conversation-old"
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
conversationId="conversation-old"
|
||||||
|
agentId="agent-old"
|
||||||
|
snapshot={null}
|
||||||
|
onUseCommand={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old'));
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<CodingWorkspaceInspector
|
||||||
|
key="conversation-new"
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
conversationId="conversation-new"
|
||||||
|
agentId="agent-new"
|
||||||
|
snapshot={null}
|
||||||
|
onUseCommand={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user