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
This commit is contained in:
mesalogo
2026-10-02 10:20:04 +08:00
parent dd29f309f1
commit f4d8fe7f06
5 changed files with 107 additions and 21 deletions
+22
View File
@@ -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(<App />)
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 = {
+13 -9
View File
@@ -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(() =>
+29 -2
View File
@@ -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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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.
+43
View File
@@ -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<HTMLElement>('.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<HTMLButtonElement>('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({
</div>
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}>{t('actions.cancel')}</button>
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
</div>}
</div>
@@ -2868,6 +2902,14 @@ export function MagicNotesWorkspace({
</p>
)}
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
<button
className="secondary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}
>
{t('actions.cancel')}
</button>
<button
className="primary-button"
disabled={Boolean(busy)}
@@ -2964,6 +3006,7 @@ export function MagicNotesWorkspace({
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
disabled={Boolean(busy) || editingEntry?.id === entry.id}
data-entry-edit=""
type="button"
onClick={() =>
requestDraftSwitch({
-10
View File
@@ -2467,16 +2467,6 @@
background: var(--surface-raised);
}
/* Keep a readable line length on wide windows. */
.magic-notes-stream-pane > :not(.magic-note-detail-header, .magic-note-detail-toolbar),
.magic-notes-stream-pane > .magic-note-detail-header > .magic-note-detail-title {
max-width: 880px;
}
.magic-notes-stream-pane > * {
margin-inline: auto;
}
@container magic-notes-detail (max-width: 600px) {
.magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); }
}