diff --git a/src/__tests__/editor-store/undo-redo.test.ts b/src/__tests__/editor-store/undo-redo.test.ts index 87e76a99..36a3b387 100644 --- a/src/__tests__/editor-store/undo-redo.test.ts +++ b/src/__tests__/editor-store/undo-redo.test.ts @@ -81,6 +81,43 @@ describe('Undo / Redo — basic lifecycle', () => { expect(nodesAfterRedo).toBe(nodesBeforeUndo) }) + it('undo prunes selection that points at the reverted insertion', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().selectNode(insertedId) + expect(useEditorStore.getState().selectedNodeIds).toEqual([insertedId]) + + useEditorStore.getState().undo() + + const afterUndo = useEditorStore.getState() + expect(afterUndo.site!.pages[0].nodes[insertedId]).toBeUndefined() + expect(afterUndo.selectedNodeIds).toEqual([]) + expect(afterUndo.selectedNodeId).toBeNull() + + const nextId = afterUndo.insertNode('base.text', {}, rootId) + expect(nextId).not.toBe('') + expect(useEditorStore.getState().site!.pages[0].nodes[nextId]).toBeDefined() + }) + + it('redo prunes selection when replaying a deletion', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().deleteNode(insertedId) + useEditorStore.getState().undo() + useEditorStore.getState().selectNode(insertedId) + + useEditorStore.getState().redo() + + const afterRedo = useEditorStore.getState() + expect(afterRedo.site!.pages[0].nodes[insertedId]).toBeUndefined() + expect(afterRedo.selectedNodeIds).toEqual([]) + expect(afterRedo.selectedNodeId).toBeNull() + }) + it('canRedo is true after undo', () => { const s = getStore() const site = s.createSite('Test SiteDocument') diff --git a/src/__tests__/panels/agentPanel.test.tsx b/src/__tests__/panels/agentPanel.test.tsx index 11f0f161..af554aa1 100644 --- a/src/__tests__/panels/agentPanel.test.tsx +++ b/src/__tests__/panels/agentPanel.test.tsx @@ -345,6 +345,35 @@ describe('AgentPanel', () => { expect(screen.getByTestId('agent-new-chat-header-button')).toBeTruthy() }) + it('autofocuses the composer when the open panel has no focused control', async () => { + installModelFetch(true) + renderAgentPanel({ + agentActiveCredentialId: TEST_CREDENTIAL.id, + agentActiveModelId: 'model-1', + }) + + const textarea = await screen.findByLabelText('Message to AI assistant') + await waitFor(() => expect(document.activeElement).toBe(textarea)) + }) + + it('does not steal focus from New chat when its deferred autofocus runs', async () => { + installModelFetch(true) + renderAgentPanel({ + agentActiveCredentialId: TEST_CREDENTIAL.id, + agentActiveModelId: 'model-1', + }) + + const newChat = screen.getByRole('button', { name: 'New chat' }) + newChat.focus() + fireEvent.click(newChat) + expect(document.activeElement).toBe(newChat) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 75)) + }) + expect(document.activeElement).toBe(newChat) + }) + it('shows compact context, token, and cost detail beside the image action', async () => { const credential = { ...TEST_CREDENTIAL, id: 'cred_context_meter' } installModelFetch(true, true, 128_000, credential) diff --git a/src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx b/src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx index a6f724b0..908b7fd6 100644 --- a/src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx +++ b/src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx @@ -64,7 +64,15 @@ export function AgentComposer({ useEffect(() => { if (!isOpen) return - const id = setTimeout(() => inputRef.current?.focus(), 50) + const id = setTimeout(() => { + const input = inputRef.current + if (!input) return + const panel = input.closest('[data-panel]') + // A header or composer control may have received an explicit click + // while this deferred autofocus was waiting. + if (panel?.contains(document.activeElement)) return + input.focus() + }, 50) return () => clearTimeout(id) }, [isOpen]) diff --git a/src/admin/pages/site/store/slices/site/undoRedoActions.ts b/src/admin/pages/site/store/slices/site/undoRedoActions.ts index 18e5c8ae..8910c9a2 100644 --- a/src/admin/pages/site/store/slices/site/undoRedoActions.ts +++ b/src/admin/pages/site/store/slices/site/undoRedoActions.ts @@ -12,6 +12,7 @@ import { apply } from 'mutative' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' +import { pruneCanvasSelectionDraft } from '../selectionSlice' import { collectDirtyFromSitePatches, mergeDirtyMarks } from './dirtyTracking' import type { SiteSlice, SiteSliceHelpers } from './types' @@ -47,6 +48,7 @@ export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoA if (!state.site.pages.find((p) => p.id === state.activePageId)) { state.activePageId = state.site.pages[0]?.id ?? null } + pruneCanvasSelectionDraft(state) }) }, @@ -75,6 +77,7 @@ export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoA if (!state.site.pages.find((p) => p.id === state.activePageId)) { state.activePageId = state.site.pages[0]?.id ?? null } + pruneCanvasSelectionDraft(state) }) }, }