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:
@@ -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 = {
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user