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", 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..431346b 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,16 @@ export default function ConversationPanel() { const messagesEndRef = useRef(null); const textareaRef = useRef(null); const prevStatusRef = useRef(null); + // 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'); // Single subscription to the interface store for our sidebar's active // state; the hook below reuses this rather than subscribing separately. @@ -164,15 +179,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 +237,15 @@ 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. 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); } }, [isSidebarActive, activeCommand, isCommandActive, cancel]); @@ -260,15 +292,30 @@ export default function ConversationPanel() { const handleApprove = () => { if (activeCommand && !isResponding) { + const approvedCommandId = activeCommand.id; void Promise.resolve( - respondToCommand(activeCommand.id, 'approve') - ).catch(() => { - createNotice( - 'error', - __('Failed to approve outline.', 'claudaborative-editing'), - { type: 'snackbar' } - ); - }); + respondToCommand(approvedCommandId, 'approve') + ).then( + () => { + postApproveSwitchCommandIdRef.current = approvedCommandId; + 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..d08c907 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,395 @@ 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('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) => { + 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) => {