From a9a71f7676cbfb456d3a3652e04862abc6862814 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Wed, 22 Apr 2026 11:03:56 +1000 Subject: [PATCH 1/3] feat: open notes sidebar after approving a compose outline. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user approves a compose outline, the plugin now switches the active complementary area to Gutenberg's Unresolved notes sidebar (or All notes on small viewports) so the editorial notes that land with the scaffold are immediately visible. The cancel-on-close watcher is taught to ignore this intentional switch, and the auto-open effect no longer re-fires on the awaiting_input → running transition that arrives on the /respond round-trip. --- .../components/ConversationPanel/constants.ts | 8 + .../components/ConversationPanel/index.tsx | 59 ++- .../ConversationPanel/test/index.test.tsx | 394 ++++++++++++++++++ 3 files changed, 451 insertions(+), 10 deletions(-) diff --git a/wordpress-plugin/src/components/ConversationPanel/constants.ts b/wordpress-plugin/src/components/ConversationPanel/constants.ts index 5462b2c..61124f5 100644 --- a/wordpress-plugin/src/components/ConversationPanel/constants.ts +++ b/wordpress-plugin/src/components/ConversationPanel/constants.ts @@ -1,3 +1,11 @@ // `getActiveComplementaryArea` returns this identifier (slash form), // which is distinct from the DOM id attribute (colon form). export const SIDEBAR_ID = 'claudaborative-editing-conversation/conversation'; + +// Gutenberg's collab-sidebar identifiers. Sourced from +// @wordpress/editor/src/components/collab-sidebar/constants.js — the floating +// sidebar shows unresolved notes anchored to blocks (large viewports only); +// the history sidebar is the "All notes" panel and is the small-viewport +// fallback. +export const FLOATING_NOTES_SIDEBAR = 'edit-post/collab-sidebar'; +export const ALL_NOTES_SIDEBAR = 'edit-post/collab-history-sidebar'; diff --git a/wordpress-plugin/src/components/ConversationPanel/index.tsx b/wordpress-plugin/src/components/ConversationPanel/index.tsx index edd3448..b829dde 100644 --- a/wordpress-plugin/src/components/ConversationPanel/index.tsx +++ b/wordpress-plugin/src/components/ConversationPanel/index.tsx @@ -14,6 +14,7 @@ */ import { __ } from '@wordpress/i18n'; import { Button, TextareaControl } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; import { useState, useEffect, useRef, RawHTML } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; @@ -28,7 +29,11 @@ import { getCommandLabel } from '../../utils/command-i18n'; import aiActionsStore from '../../store'; import SparkleIcon from '../SparkleIcon'; import { useResizableSidebar } from './use-resizable-sidebar'; -import { SIDEBAR_ID } from './constants'; +import { + SIDEBAR_ID, + FLOATING_NOTES_SIDEBAR, + ALL_NOTES_SIDEBAR, +} from './constants'; import { TERMINAL_STATUSES, type CommandSlug } from '#shared/commands'; import type { ConversationMessage, @@ -94,6 +99,11 @@ export default function ConversationPanel() { const messagesEndRef = useRef(null); const textareaRef = useRef(null); const prevStatusRef = useRef(null); + // Set when approve triggers a sidebar switch so the cancel-on-close + // watcher below skips cancelling the command that the user just approved. + const postApproveSwitchRef = useRef(false); + + const isLargeViewport = useViewportMatch('medium'); // Single subscription to the interface store for our sidebar's active // state; the hook below reuses this rather than subscribing separately. @@ -164,15 +174,23 @@ export default function ConversationPanel() { currentStatus === 'awaiting_input' && prevStatus !== 'awaiting_input'; // Fire once when a conversational command first enters an in-flight - // status; don't re-fire on pending → running transitions. + // status; don't re-fire on pending → running or awaiting_input → + // running. The latter matters because handleApprove switches the + // sidebar to the notes panel on approve, and an awaiting_input → + // running transition arriving on the /respond round-trip would + // otherwise re-open the conversation panel and undo the switch. const inFlightStatuses: readonly (string | null)[] = [ 'pending', 'running', ]; + const alreadyInFlightStatuses: readonly (string | null)[] = [ + ...inFlightStatuses, + 'awaiting_input', + ]; const startedConversationalCommand = isConversationalCommand && inFlightStatuses.includes(currentStatus) && - !inFlightStatuses.includes(prevStatus); + !alreadyInFlightStatuses.includes(prevStatus); if (enteredAwaitingInput || startedConversationalCommand) { enableComplementaryArea?.('core', SIDEBAR_ID); @@ -214,6 +232,13 @@ export default function ConversationPanel() { const wasActive = prevSidebarActiveRef.current; prevSidebarActiveRef.current = isSidebarActive; if (wasActive && !isSidebarActive && activeCommand && isCommandActive) { + // Approve intentionally switches away to the notes sidebar while + // the command is still running the scaffold — don't treat that + // as a user-initiated close and cancel the command. + if (postApproveSwitchRef.current) { + postApproveSwitchRef.current = false; + return; + } cancel(activeCommand.id); } }, [isSidebarActive, activeCommand, isCommandActive, cancel]); @@ -262,13 +287,27 @@ export default function ConversationPanel() { if (activeCommand && !isResponding) { void Promise.resolve( respondToCommand(activeCommand.id, 'approve') - ).catch(() => { - createNotice( - 'error', - __('Failed to approve outline.', 'claudaborative-editing'), - { type: 'snackbar' } - ); - }); + ).then( + () => { + postApproveSwitchRef.current = true; + enableComplementaryArea?.( + 'core', + isLargeViewport + ? FLOATING_NOTES_SIDEBAR + : ALL_NOTES_SIDEBAR + ); + }, + () => { + createNotice( + 'error', + __( + 'Failed to approve outline.', + 'claudaborative-editing' + ), + { type: 'snackbar' } + ); + } + ); } }; diff --git a/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx b/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx index 4821be2..6f4b1f7 100644 --- a/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx +++ b/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx @@ -57,6 +57,10 @@ jest.mock('@wordpress/editor', () => { }; }); +jest.mock('@wordpress/compose', () => ({ + useViewportMatch: jest.fn(() => true), +})); + jest.mock('@wordpress/data', () => ({ useSelect: jest.fn(), useDispatch: jest.fn(() => ({})), @@ -127,6 +131,7 @@ jest.mock('../../../utils/command-i18n', () => ({ })); import { render, screen, fireEvent, act } from '@testing-library/react'; +import { useViewportMatch } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; import { useCommands } from '../../../hooks/use-commands'; import ConversationPanel from '..'; @@ -134,6 +139,7 @@ import ConversationPanel from '..'; const mockedUseSelect = useSelect as jest.Mock; const mockedUseDispatch = useDispatch as jest.Mock; const mockedUseCommands = useCommands as jest.Mock; +const mockedUseViewportMatch = useViewportMatch as unknown as jest.Mock; import aiActionsStore from '../../../store'; @@ -150,6 +156,7 @@ describe('ConversationPanel', () => { beforeEach(() => { jest.clearAllMocks(); window.localStorage.clear(); + mockedUseViewportMatch.mockReturnValue(true); mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { if ( @@ -255,6 +262,94 @@ describe('ConversationPanel', () => { expect(screen.queryByTestId('conversation-textarea')).toBeNull(); }); + it('auto-opens the conversation sidebar on mount when recovering an awaiting_input command', () => { + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + // Simulates a page reload mid-conversation: fetchActiveCommand + // populates activeCommand from REST before the ConversationPanel + // mounts, so prevStatus (null on first render) → awaiting_input + // must still trigger the auto-open. + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'What is the topic?', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + input_prompt: 'Tell me more…', + }, + }, + isResponding: false, + respondToCommand: jest.fn(), + cancel: jest.fn(), + }); + + render(); + + expect(enableComplementaryArea).toHaveBeenCalledWith( + 'core', + 'claudaborative-editing-conversation/conversation' + ); + }); + + it('auto-opens the conversation sidebar on mount when recovering a running compose command', () => { + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + // A refresh that lands in a running state (the MCP is actively + // working) must still re-open the sidebar so the user sees the + // processing indicator instead of a blank editor. + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'running', + post_id: 100, + result_data: null, + }, + isResponding: false, + respondToCommand: jest.fn(), + cancel: jest.fn(), + }); + + render(); + + expect(enableComplementaryArea).toHaveBeenCalledWith( + 'core', + 'claudaborative-editing-conversation/conversation' + ); + }); + it('renders message history when command is awaiting_input with messages', () => { mockedUseCommands.mockReturnValue({ activeCommand: { @@ -945,6 +1040,305 @@ describe('ConversationPanel', () => { expect(respondToCommand).toHaveBeenCalledWith(1, 'approve'); }); + it('opens the Unresolved notes sidebar after a successful approve on large viewports', async () => { + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + const respondToCommand = jest.fn().mockResolvedValue(undefined); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel: jest.fn(), + }); + + render(); + fireEvent.click(screen.getByText('Approve outline')); + await new Promise(process.nextTick); + + expect(enableComplementaryArea).toHaveBeenCalledWith( + 'core', + 'edit-post/collab-sidebar' + ); + }); + + it('opens the All notes sidebar after a successful approve on small viewports', async () => { + mockedUseViewportMatch.mockReturnValue(false); + + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + const respondToCommand = jest.fn().mockResolvedValue(undefined); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel: jest.fn(), + }); + + render(); + fireEvent.click(screen.getByText('Approve outline')); + await new Promise(process.nextTick); + + expect(enableComplementaryArea).toHaveBeenCalledWith( + 'core', + 'edit-post/collab-history-sidebar' + ); + }); + + it('does not switch sidebars when approve fails', async () => { + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + const respondToCommand = jest.fn().mockRejectedValue(new Error('fail')); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel: jest.fn(), + }); + + render(); + fireEvent.click(screen.getByText('Approve outline')); + await new Promise(process.nextTick); + + // The conversation panel's own auto-open may call enableComplementaryArea + // on mount with SIDEBAR_ID — the assertion here is specifically that + // neither notes sidebar got opened as a result of the failed approve. + const noteSidebarCalls = enableComplementaryArea.mock.calls.filter( + ([, id]) => + id === 'edit-post/collab-sidebar' || + id === 'edit-post/collab-history-sidebar' + ); + expect(noteSidebarCalls).toHaveLength(0); + }); + + it('does not cancel the command when the sidebar closes as a result of approve', async () => { + const cancel = jest.fn(); + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + const respondToCommand = jest.fn().mockResolvedValue(undefined); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel, + }); + + // Start with the conversation sidebar active. + const getActiveComplementaryArea = jest + .fn() + .mockReturnValue( + 'claudaborative-editing-conversation/conversation' + ); + mockUseSelect( + new Map any>>([ + [aiActionsStore, { getCurrentPostId: () => 100 }], + ['core/interface', { getActiveComplementaryArea }], + ]) + ); + + const { rerender } = render(); + fireEvent.click(screen.getByText('Approve outline')); + await new Promise(process.nextTick); + + // The sidebar is now the Unresolved notes panel; the command moved + // to `running` while MCP scaffolds. Re-select state to match. + getActiveComplementaryArea.mockReturnValue('edit-post/collab-sidebar'); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'running', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel, + }); + + rerender(); + + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not re-open the conversation sidebar on awaiting_input → running', async () => { + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }); + + const { rerender } = render(); + enableComplementaryArea.mockClear(); + + // /respond resolves and the command transitions to running. + // The auto-open effect must NOT re-fire for this transition — + // doing so would re-open the conversation sidebar and undo the + // approve-triggered switch to the notes sidebar. + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'running', + post_id: 100, + result_data: null, + }, + isResponding: false, + respondToCommand: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }); + + rerender(); + + expect(enableComplementaryArea).not.toHaveBeenCalled(); + }); + it('calls createNotice when respondToCommand rejects on approve', async () => { const mockCreateNotice = jest.fn(); mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { From 6fe807931d3fc81e5e3a61ab5b2c34ab90be7587 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Wed, 22 Apr 2026 11:09:12 +1000 Subject: [PATCH 2/3] chore: add @wordpress/compose to plugin dependencies. useViewportMatch is imported from @wordpress/compose by the conversation panel; declare it as a direct dependency so the lint rule import/no-extraneous-dependencies passes. --- wordpress-plugin/package-lock.json | 1 + wordpress-plugin/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/wordpress-plugin/package-lock.json b/wordpress-plugin/package-lock.json index 42f6c49..a060cb5 100644 --- a/wordpress-plugin/package-lock.json +++ b/wordpress-plugin/package-lock.json @@ -13,6 +13,7 @@ "@types/jest": "^29.5.14", "@wordpress/api-fetch": "^7.44.0", "@wordpress/components": "^32.6.0", + "@wordpress/compose": "^7.44.0", "@wordpress/core-data": "^7.44.0", "@wordpress/data": "^10.44.0", "@wordpress/editor": "^14.44.0", diff --git a/wordpress-plugin/package.json b/wordpress-plugin/package.json index aa072a9..5ba5892 100644 --- a/wordpress-plugin/package.json +++ b/wordpress-plugin/package.json @@ -9,6 +9,7 @@ "@types/jest": "^29.5.14", "@wordpress/api-fetch": "^7.44.0", "@wordpress/components": "^32.6.0", + "@wordpress/compose": "^7.44.0", "@wordpress/core-data": "^7.44.0", "@wordpress/data": "^10.44.0", "@wordpress/editor": "^14.44.0", From 71378acac3f1467774837aa550874e98f0c9b9cb Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Wed, 22 Apr 2026 11:13:19 +1000 Subject: [PATCH 3/3] fix: scope the post-approve close-watcher signal to the approved command id. Replaces the boolean post-approve ref with a command-id-scoped ref so a signal that never gets consumed (e.g. if the approved command reaches terminal status before the close-watcher effect fires) cannot cause a subsequent unrelated command's close to silently skip cancel(). --- .../components/ConversationPanel/index.tsx | 24 +++-- .../ConversationPanel/test/index.test.tsx | 90 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/wordpress-plugin/src/components/ConversationPanel/index.tsx b/wordpress-plugin/src/components/ConversationPanel/index.tsx index b829dde..431346b 100644 --- a/wordpress-plugin/src/components/ConversationPanel/index.tsx +++ b/wordpress-plugin/src/components/ConversationPanel/index.tsx @@ -99,9 +99,14 @@ export default function ConversationPanel() { const messagesEndRef = useRef(null); const textareaRef = useRef(null); const prevStatusRef = useRef(null); - // Set when approve triggers a sidebar switch so the cancel-on-close - // watcher below skips cancelling the command that the user just approved. - const postApproveSwitchRef = useRef(false); + // Holds the id of the command whose approval triggered a sidebar + // switch, so the cancel-on-close watcher below can skip cancelling + // that specific command. Scoping by id (rather than a boolean) + // ensures a stale signal can't leak into a future command's close + // event — if the approved command transitions to terminal before + // the watcher fires, the id simply won't match any subsequent + // active command. + const postApproveSwitchCommandIdRef = useRef(null); const isLargeViewport = useViewportMatch('medium'); @@ -234,9 +239,11 @@ export default function ConversationPanel() { if (wasActive && !isSidebarActive && activeCommand && isCommandActive) { // Approve intentionally switches away to the notes sidebar while // the command is still running the scaffold — don't treat that - // as a user-initiated close and cancel the command. - if (postApproveSwitchRef.current) { - postApproveSwitchRef.current = false; + // as a user-initiated close and cancel the command. Match by + // id so an unconsumed signal from a prior approve can't cause + // an unrelated command's close to silently skip cancel. + if (postApproveSwitchCommandIdRef.current === activeCommand.id) { + postApproveSwitchCommandIdRef.current = null; return; } cancel(activeCommand.id); @@ -285,11 +292,12 @@ export default function ConversationPanel() { const handleApprove = () => { if (activeCommand && !isResponding) { + const approvedCommandId = activeCommand.id; void Promise.resolve( - respondToCommand(activeCommand.id, 'approve') + respondToCommand(approvedCommandId, 'approve') ).then( () => { - postApproveSwitchRef.current = true; + postApproveSwitchCommandIdRef.current = approvedCommandId; enableComplementaryArea?.( 'core', isLargeViewport diff --git a/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx b/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx index 6f4b1f7..d08c907 100644 --- a/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx +++ b/wordpress-plugin/src/components/ConversationPanel/test/index.test.tsx @@ -1277,6 +1277,96 @@ describe('ConversationPanel', () => { expect(cancel).not.toHaveBeenCalled(); }); + it('still cancels a different command whose sidebar closes after a prior approve', async () => { + const cancel = jest.fn(); + const enableComplementaryArea = jest.fn(); + mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => { + if ( + storeNameOrDescriptor === 'core/interface' || + storeNameOrDescriptor?.name === 'core/interface' + ) { + return { + enableComplementaryArea, + disableComplementaryArea: jest.fn(), + }; + } + return { createNotice: jest.fn() }; + }); + + const respondToCommand = jest.fn().mockResolvedValue(undefined); + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 1, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'Here is the outline.', + timestamp: '2026-04-06T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel, + }); + + const getActiveComplementaryArea = jest + .fn() + .mockReturnValue( + 'claudaborative-editing-conversation/conversation' + ); + mockUseSelect( + new Map any>>([ + [aiActionsStore, { getCurrentPostId: () => 100 }], + ['core/interface', { getActiveComplementaryArea }], + ]) + ); + + const { rerender } = render(); + fireEvent.click(screen.getByText('Approve outline')); + await new Promise(process.nextTick); + + // Simulates the edge case where command 1 completes before the + // close-watcher effect fires — the id-scoped signal is left + // uncleared. A *different* compose command (id 2) then opens, + // and the user manually cancels its sidebar. The id mismatch + // must let cancel() through for command 2. + mockedUseCommands.mockReturnValue({ + activeCommand: { + id: 2, + prompt: 'compose', + status: 'awaiting_input', + post_id: 100, + result_data: { + messages: [ + { + role: 'assistant', + content: 'A new outline.', + timestamp: '2026-04-07T10:00:00Z', + }, + ], + planReady: true, + }, + }, + isResponding: false, + respondToCommand, + cancel, + }); + rerender(); + + // User closes command 2's sidebar. + getActiveComplementaryArea.mockReturnValue(null); + rerender(); + + expect(cancel).toHaveBeenCalledWith(2); + }); + it('does not re-open the conversation sidebar on awaiting_input → running', async () => { const enableComplementaryArea = jest.fn(); mockedUseDispatch.mockImplementation((storeNameOrDescriptor?: any) => {