From f4d8fe7f06dc522f082fd89a160fcce83e5e4b8d Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 10:20:04 +0800 Subject: [PATCH] fix: add new-entry cancel, full-width note content, and restore focus in magic notes - Add a Cancel action to the new-entry composer with discard confirmation - Remove the 880px cap so note content matches the toolbar width - Focus the chat composer only after a deferred workspace leave commits - Return focus to a sensible target after cancelling new/edited entries and switching entry type --- src/renderer/src/App.test.tsx | 22 ++++++++++ src/renderer/src/App.tsx | 22 ++++++---- src/renderer/src/MagicNotesWorkspace.test.tsx | 31 ++++++++++++- src/renderer/src/MagicNotesWorkspace.tsx | 43 +++++++++++++++++++ src/renderer/src/styles.css | 10 ----- 5 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 0cae2f8e..00b11afa 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -14770,6 +14770,28 @@ describe("App", () => { expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() }) + it('focuses the chat composer after a guarded Magic Notes leave to a new conversation', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + const note = { + id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '', + entryCount: 0, pinned: false, tags: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', entries: [], + } + vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] }) + vi.mocked(api.magicNotes.get).mockResolvedValue(note) + render() + fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' })) + fireEvent.click(await screen.findByRole('button', { name: /Focus note.*条记录/ })) + fireEvent.change(await screen.findByLabelText('笔记标题'), { target: { value: 'Unsaved focus title' } }) + fireEvent.click(screen.getByRole('button', { name: /新建对话/ })) + const discard = await screen.findByRole('button', { name: '放弃草稿并切换' }) + await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)) }) + // A real pointer click moves focus to the confirmation button before the leave completes. + discard.focus() + fireEvent.click(discard) + await waitFor(() => expect(screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })).toHaveFocus()) + }) + it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => { await api.updates!.updateSettings({ magicNotesEnabled: true }) const note = { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f513323a..9b6155a2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2937,6 +2937,14 @@ function App(): React.JSX.Element { }, [setConversationInput], ); + // Leaving a workspace can be deferred (draft flush or discard confirmation), + // so focus the composer only after the chat view is actually committed. + const showChatAndFocusComposer = useCallback((): void => { + requestWorkspaceLeave("chat", () => { + commitView("chat"); + requestAnimationFrame(() => inputRef.current?.focus()); + }); + }, [commitView, requestWorkspaceLeave]); const setQuickActionInput = useCallback((value: string): void => { setConversationInput(activeConversationIdRef.current, value); requestAnimationFrame(() => inputRef.current?.focus()); @@ -4049,7 +4057,7 @@ function App(): React.JSX.Element { isUnusedConversation(currentConversation) ) { if (preview) preview.ready(currentConversation); - else { setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); } + else showChatAndFocusComposer(); return true; } const conversation = createConversation( @@ -4064,10 +4072,10 @@ function App(): React.JSX.Element { }; setConversations(nextConversations); if (preview) preview.ready(conversation); - else { setActiveId(conversation.id); setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); } + else { setActiveId(conversation.id); showChatAndFocusComposer(); } return true; }, - [notify, projects, setActiveId, setView], + [notify, projects, setActiveId, setView, showChatAndFocusComposer], ); const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name @@ -7184,7 +7192,7 @@ function App(): React.JSX.Element { ]); setSelectedAssistantTaskId(undefined); setActiveId(nextBranch.id); - setView("chat"); + showChatAndFocusComposer(); if (narrowWindow) { closeNarrowSidebar(); } @@ -7192,7 +7200,6 @@ function App(): React.JSX.Element { tone: "success", message: t("notices.conversationBranched"), }); - requestAnimationFrame(() => inputRef.current?.focus()); } catch (error) { notify({ tone: "error", @@ -10890,10 +10897,7 @@ function App(): React.JSX.Element { ? current : [...current, libraryId], ); - setView("chat"); - requestAnimationFrame(() => - inputRef.current?.focus(), - ); + showChatAndFocusComposer(); }} onPauseSource={(sourceId) => runKnowledgeSourceAction(() => diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 7f698c0a..71d9326a 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -822,6 +822,33 @@ describe('MagicNotesWorkspace overview navigation', () => { await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content })) }) + it('cancels a new entry only after confirming that its draft will be discarded', async () => { + render() + await openNote() + newEntry() + const composer = document.querySelector('.magic-note-composer')! + fireEvent.click(screen.getByTestId('magic-note-editor')) + fireEvent.click(within(composer as HTMLElement).getByRole('button', { name: '取消' })) + expect(await screen.findByRole('alertdialog')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: '继续编辑' })) + expect(document.querySelector('.magic-note-composer')).toHaveClass('magic-note-composer--active') + fireEvent.click(within(document.querySelector('.magic-note-composer') as HTMLElement).getByRole('button', { name: '取消' })) + fireEvent.click(await screen.findByRole('button', { name: '放弃草稿并切换' })) + await waitFor(() => expect(document.querySelector('.magic-note-composer')).not.toHaveClass('magic-note-composer--active')) + await waitFor(() => expect(document.getElementById(`magic-note-entry-${entryId}`)).toHaveFocus()) + expect(createEntry).not.toHaveBeenCalled() + }) + + it('returns focus to the entry edit button after cancelling an inline edit', async () => { + render() + await openNote() + const article = document.getElementById(`magic-note-entry-${entryId}`)! + fireEvent.click(within(article).getByRole('button', { name: '编辑' })) + await within(article).findByTestId('magic-note-editor') + fireEvent.click(within(article).getByRole('button', { name: '取消' })) + await waitFor(() => expect(within(article).getByRole('button', { name: '编辑' })).toHaveFocus()) + }) + it('keeps todo filters and search when opening its source note and returning', async () => { render() await screen.findByText(detail.title) @@ -1655,9 +1682,9 @@ describe('MagicNotesWorkspace canvas integration', () => { showAi() const header = document.querySelector('.magic-note-composer__header')! const actions = header.querySelector('.magic-note-canvas-actions')! - expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '保存记录']) + expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '取消', '保存记录']) expect(header.nextElementSibling).toBe(screen.getByTestId('canvas-editor')) - expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument() + expect(screen.getAllByRole('button', { name: '取消' })).toHaveLength(1) expect(document.querySelector('.magic-note-composer > footer')).toBeNull() expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument() // An empty flush permits switching to the saved record without a dirty draft. diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 10d741c4..88f90d0c 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -101,6 +101,7 @@ type DraftSwitchTarget = | { kind: 'select-entry'; entry: MagicNoteEntry } | { kind: 'entry-type'; value: 'text' | 'canvas' } | { kind: 'cancel-edit' } + | { kind: 'cancel-new-entry' } | { kind: 'overview' } | { kind: 'library-view'; value: LibraryView } | { kind: 'create-note'; title: string } @@ -1125,11 +1126,40 @@ export function MagicNotesWorkspace({ if (target.kind === 'entry-type') { discardComposerDraft() setEntryType(target.value) + // The composer remounts with the new type; move focus into the new editor. + requestAnimationFrame(() => { + if (target.value === 'canvas' && composerCanvasRef.current) { composerCanvasRef.current.focus(); return } + composerRef.current?.querySelector('.ql-editor, [data-testid="magic-note-editor"]')?.focus({ preventScroll: true }) + }) + return + } + if (target.kind === 'cancel-new-entry') { + discardComposerDraft() + setEntryType('text') + clearValidation('new-entry') + // Move focus out of the composer after any confirmation restores it, so the + // composer collapses without dropping focus onto the document body. + const fallbackEntryId = detail?.entries.some((entry) => entry.id === selectedEntryId) ? selectedEntryId : detail?.entries.at(-1)?.id + requestAnimationFrame(() => { + const active = document.activeElement + if (active instanceof HTMLElement && active !== document.body && !composerRef.current?.contains(active)) return + const entry = fallbackEntryId ? document.getElementById(`magic-note-entry-${fallbackEntryId}`) : null + if (entry) { entry.focus({ preventScroll: true }); return } + if (active instanceof HTMLElement) active.blur() + }) return } if (target.kind === 'cancel-edit') { + const editedEntryId = editingEntry?.id discardEditingDraft() setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '') + // The inline editor unmounts; return focus to the entry that was being edited. + requestAnimationFrame(() => { + const article = editedEntryId ? document.getElementById(`magic-note-entry-${editedEntryId}`) : null + if (!article) return + const editButton = article.querySelector('button[data-entry-edit]:not(:disabled)') + ;(editButton ?? article).focus({ preventScroll: true }) + }) return } if (target.kind === 'overview') { @@ -1183,10 +1213,13 @@ export function MagicNotesWorkspace({ void loadDetail(target.noteId, target.entryId) }, [ + clearValidation, createNote, detail, discardComposerDraft, discardEditingDraft, + editingEntry?.id, + selectedEntryId, focusSwitchTarget, loadDetail, libraryView, @@ -2812,6 +2845,7 @@ export function MagicNotesWorkspace({ {entryType === 'canvas' &&
+
} @@ -2868,6 +2902,14 @@ export function MagicNotesWorkspace({

)} {entryType === 'text' &&