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() 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) => { 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 }) await api.updates!.updateSettings({ magicNotesEnabled: true })
const note = { const note = {
+13 -9
View File
@@ -2937,6 +2937,14 @@ function App(): React.JSX.Element {
}, },
[setConversationInput], [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 => { const setQuickActionInput = useCallback((value: string): void => {
setConversationInput(activeConversationIdRef.current, value); setConversationInput(activeConversationIdRef.current, value);
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
@@ -4049,7 +4057,7 @@ function App(): React.JSX.Element {
isUnusedConversation(currentConversation) isUnusedConversation(currentConversation)
) { ) {
if (preview) preview.ready(currentConversation); if (preview) preview.ready(currentConversation);
else { setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); } else showChatAndFocusComposer();
return true; return true;
} }
const conversation = createConversation( const conversation = createConversation(
@@ -4064,10 +4072,10 @@ function App(): React.JSX.Element {
}; };
setConversations(nextConversations); setConversations(nextConversations);
if (preview) preview.ready(conversation); if (preview) preview.ready(conversation);
else { setActiveId(conversation.id); setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); } else { setActiveId(conversation.id); showChatAndFocusComposer(); }
return true; return true;
}, },
[notify, projects, setActiveId, setView], [notify, projects, setActiveId, setView, showChatAndFocusComposer],
); );
const activeProjectDisplayName = activeProject const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name ? getProjectDisplayText(activeProject, tWorkspace).name
@@ -7184,7 +7192,7 @@ function App(): React.JSX.Element {
]); ]);
setSelectedAssistantTaskId(undefined); setSelectedAssistantTaskId(undefined);
setActiveId(nextBranch.id); setActiveId(nextBranch.id);
setView("chat"); showChatAndFocusComposer();
if (narrowWindow) { if (narrowWindow) {
closeNarrowSidebar(); closeNarrowSidebar();
} }
@@ -7192,7 +7200,6 @@ function App(): React.JSX.Element {
tone: "success", tone: "success",
message: t("notices.conversationBranched"), message: t("notices.conversationBranched"),
}); });
requestAnimationFrame(() => inputRef.current?.focus());
} catch (error) { } catch (error) {
notify({ notify({
tone: "error", tone: "error",
@@ -10890,10 +10897,7 @@ function App(): React.JSX.Element {
? current ? current
: [...current, libraryId], : [...current, libraryId],
); );
setView("chat"); showChatAndFocusComposer();
requestAnimationFrame(() =>
inputRef.current?.focus(),
);
}} }}
onPauseSource={(sourceId) => onPauseSource={(sourceId) =>
runKnowledgeSourceAction(() => runKnowledgeSourceAction(() =>
+29 -2
View File
@@ -822,6 +822,33 @@ describe('MagicNotesWorkspace overview navigation', () => {
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content })) 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 () => { it('keeps todo filters and search when opening its source note and returning', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />) render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText(detail.title) await screen.findByText(detail.title)
@@ -1655,9 +1682,9 @@ describe('MagicNotesWorkspace canvas integration', () => {
showAi() showAi()
const header = document.querySelector('.magic-note-composer__header')! const header = document.querySelector('.magic-note-composer__header')!
const actions = header.querySelector('.magic-note-canvas-actions')! 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(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(document.querySelector('.magic-note-composer > footer')).toBeNull()
expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument() expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument()
// An empty flush permits switching to the saved record without a dirty draft. // 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: 'select-entry'; entry: MagicNoteEntry }
| { kind: 'entry-type'; value: 'text' | 'canvas' } | { kind: 'entry-type'; value: 'text' | 'canvas' }
| { kind: 'cancel-edit' } | { kind: 'cancel-edit' }
| { kind: 'cancel-new-entry' }
| { kind: 'overview' } | { kind: 'overview' }
| { kind: 'library-view'; value: LibraryView } | { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string } | { kind: 'create-note'; title: string }
@@ -1125,11 +1126,40 @@ export function MagicNotesWorkspace({
if (target.kind === 'entry-type') { if (target.kind === 'entry-type') {
discardComposerDraft() discardComposerDraft()
setEntryType(target.value) 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 return
} }
if (target.kind === 'cancel-edit') { if (target.kind === 'cancel-edit') {
const editedEntryId = editingEntry?.id
discardEditingDraft() discardEditingDraft()
setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '') 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 return
} }
if (target.kind === 'overview') { if (target.kind === 'overview') {
@@ -1183,10 +1213,13 @@ export function MagicNotesWorkspace({
void loadDetail(target.noteId, target.entryId) void loadDetail(target.noteId, target.entryId)
}, },
[ [
clearValidation,
createNote, createNote,
detail, detail,
discardComposerDraft, discardComposerDraft,
discardEditingDraft, discardEditingDraft,
editingEntry?.id,
selectedEntryId,
focusSwitchTarget, focusSwitchTarget,
loadDetail, loadDetail,
libraryView, libraryView,
@@ -2812,6 +2845,7 @@ export function MagicNotesWorkspace({
</div> </div>
{entryType === 'canvas' && <div className="magic-note-canvas-actions"> {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 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> <button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
</div>} </div>}
</div> </div>
@@ -2868,6 +2902,14 @@ export function MagicNotesWorkspace({
</p> </p>
)} )}
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}> {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 <button
className="primary-button" className="primary-button"
disabled={Boolean(busy)} disabled={Boolean(busy)}
@@ -2964,6 +3006,7 @@ export function MagicNotesWorkspace({
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')} aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
title={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} disabled={Boolean(busy) || editingEntry?.id === entry.id}
data-entry-edit=""
type="button" type="button"
onClick={() => onClick={() =>
requestDraftSwitch({ requestDraftSwitch({
-10
View File
@@ -2467,16 +2467,6 @@
background: var(--surface-raised); 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) { @container magic-notes-detail (max-width: 600px) {
.magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); } .magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); }
} }