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' &&