From b966c9840d588d4d234e72d988afe190815daa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Mon, 23 Feb 2026 15:37:05 +0000 Subject: [PATCH] feat(acp-client): move settings and cancel into toolbar row above input Refactor the chat input area to place the settings button, plan button, and cancel button in a unified toolbar row above the text input. The cancel button is now always visible so users can force-cancel even when ACP status reporting is unreliable (e.g., with subagents). - Move settings gear from form row into toolbar with pill styling - Move cancel from conditional form button to always-visible toolbar item - Cancel is red when prompting, muted gray otherwise, always clickable - Remove mb-2 from StickyPlanButton (parent toolbar handles spacing) - Add 6 tests for toolbar layout, cancel behavior, and styling states Co-Authored-By: Claude Opus 4.6 --- .../src/components/AgentPanel.test.tsx | 117 ++++++++++++++++++ .../acp-client/src/components/AgentPanel.tsx | 82 ++++++------ .../src/components/StickyPlanButton.tsx | 2 +- 3 files changed, 165 insertions(+), 36 deletions(-) diff --git a/packages/acp-client/src/components/AgentPanel.test.tsx b/packages/acp-client/src/components/AgentPanel.test.tsx index a39e6137..2c1da882 100644 --- a/packages/acp-client/src/components/AgentPanel.test.tsx +++ b/packages/acp-client/src/components/AgentPanel.test.tsx @@ -293,3 +293,120 @@ describe('AgentPanel auto-scroll behavior', () => { expect(screen.getByText(/Partial response/)).toBeTruthy(); }); }); + +// ============================================================================= +// Toolbar row tests +// ============================================================================= + +describe('AgentPanel toolbar row', () => { + let originalResizeObserver: typeof ResizeObserver; + let originalMutationObserver: typeof MutationObserver; + + beforeEach(() => { + originalResizeObserver = globalThis.ResizeObserver; + originalMutationObserver = globalThis.MutationObserver; + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { return []; } + } as unknown as typeof MutationObserver; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(performance.now()); + return 1; + }); + }); + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + globalThis.MutationObserver = originalMutationObserver; + vi.restoreAllMocks(); + }); + + it('renders settings button in toolbar when onSaveSettings provided', () => { + const session = createMockSession(); + const messages = createMockMessages(); + + render( + + ); + + const settingsButton = screen.getByLabelText('Agent settings'); + expect(settingsButton).toBeTruthy(); + expect(screen.getByText('Settings')).toBeTruthy(); + }); + + it('cancel button is always visible even when not prompting', () => { + const session = createMockSession({ state: 'ready' }); + const messages = createMockMessages(); + + render(); + + const cancelButton = screen.getByLabelText('Cancel agent'); + expect(cancelButton).toBeTruthy(); + expect(screen.getByText('Cancel')).toBeTruthy(); + }); + + it('cancel button sends session/cancel when clicked', () => { + const sendMessage = vi.fn(); + const session = createMockSession({ sendMessage }); + const messages = createMockMessages(); + + render(); + + fireEvent.click(screen.getByLabelText('Cancel agent')); + expect(sendMessage).toHaveBeenCalledWith({ + jsonrpc: '2.0', + method: 'session/cancel', + params: {}, + }); + }); + + it('cancel button has red styling when prompting', () => { + const session = createMockSession({ state: 'prompting' }); + const messages = createMockMessages(); + + render(); + + const cancelButton = screen.getByLabelText('Cancel agent'); + expect(cancelButton.className).toContain('border-red-300'); + expect(cancelButton.className).toContain('text-red-600'); + }); + + it('cancel button has muted styling when not prompting', () => { + const session = createMockSession({ state: 'ready' }); + const messages = createMockMessages(); + + render(); + + const cancelButton = screen.getByLabelText('Cancel agent'); + expect(cancelButton.className).toContain('text-gray-400'); + }); + + it('settings button is not inside the form element', () => { + const session = createMockSession(); + const messages = createMockMessages(); + + render( + + ); + + const settingsButton = screen.getByLabelText('Agent settings'); + const form = screen.getByPlaceholderText(/type \/ for commands/i).closest('form'); + expect(form!.contains(settingsButton)).toBe(false); + }); +}); diff --git a/packages/acp-client/src/components/AgentPanel.tsx b/packages/acp-client/src/components/AgentPanel.tsx index 0d24b828..f6f0f845 100644 --- a/packages/acp-client/src/components/AgentPanel.tsx +++ b/packages/acp-client/src/components/AgentPanel.tsx @@ -279,11 +279,53 @@ export const AgentPanel = React.forwardRef(fu onClose={() => setShowSettings(false)} /> )} - {/* Sticky plan button (above input) */} - setShowPlanModal(true)} /> - {currentPlan && ( - setShowPlanModal(false)} /> - )} + {/* Toolbar row: settings, plan, cancel */} +
+ {/* Settings gear button */} + {onSaveSettings && ( + + )} + {/* Plan button */} + setShowPlanModal(true)} /> + {currentPlan && ( + setShowPlanModal(false)} /> + )} + {/* Cancel button — always visible so user can force-cancel unreported activity */} + +
{/* Slash command palette (above input, in document flow) */} (fu visible={showPalette} />
- {/* Settings gear button */} - {onSaveSettings && ( - - )}