fix: exit note editing after saving changes

Return text and canvas records to read-only content after a successful save. Automatic analysis no longer reopens the editor; failed saves preserve drafts for retry. Update regression coverage and UI behavior documentation.

Validation: 118 MagicNotesWorkspace tests, typecheck and lint passed. Full test suite exceeded the 120-second timeout.

Release note: 笔记中的文字和画布记录保存修改成功后自动退出编辑状态,自动评论完成后不再重新打开编辑器;保存失败时保留草稿以便重试。
This commit is contained in:
mesalogo
2026-09-30 10:57:25 +08:00
parent 6293ef105d
commit 2d8e58e995
3 changed files with 91 additions and 18 deletions
+1 -1
View File
@@ -808,7 +808,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
- 外部删除当前笔记时,无草稿则返回总览;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
- 当前记录草稿非空或标题尚未保存时,返回总览必须先就地确认;编辑另一条记录等会丢弃正文草稿的操作沿用同一保护。继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
- 保存成功后,以该修订的编辑器初始化并完成 flush 后的内容作为修改比较基线;没有新编辑时返回不再提示。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。
- 文字和画布记录保存修改成功后退出编辑状态,显示已保存正文;后续自动评论成功或失败均不重新打开编辑器。再次修改须点击编辑入口,以该修订的编辑器初始化内容作为修改比较基线。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
+87 -12
View File
@@ -351,12 +351,13 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await openNote()
newEntry()
fireEvent.click(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' }))
for (let count = 1; count <= 2; count++) {
fireEvent.click(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' }))
fireEvent.click(screen.getByTestId('magic-note-editor'))
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledTimes(count))
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' })).toBeEnabled())
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
}
expect(createEntry).toHaveBeenCalledOnce()
expect(updateEntry).toHaveBeenLastCalledWith({ entryId: createdEntryId, content, expectedRevision: auto ? 4 : 2 })
@@ -515,6 +516,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
get.mockResolvedValue({ ...detail, entries: [...detail.entries, anotherEntry] })
act(() => changeListener?.())
await waitFor(() => expect(document.querySelectorAll('.magic-note-record')).toHaveLength(2))
fireEvent.click(original.getByRole('button', { name: '编辑' }))
fireEvent.click(original.getByTestId('magic-note-editor'))
fireEvent.click(document.querySelectorAll<HTMLButtonElement>('.magic-note-record')[1]!)
back()
@@ -677,6 +679,70 @@ describe('MagicNotesWorkspace overview navigation', () => {
})
describe('MagicNotesWorkspace detail behavior', () => {
it.each(['text', 'canvas'] as const)('exits %s editing after saving and stays out when automatic analysis completes', async (kind) => {
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
const originalContent = kind === 'canvas' ? canvasContent : detail.entries[0]!.content
const editedContent = kind === 'canvas' ? { ...canvasContent, flow: { ops: [{ insert: 'Changed body\n' }] } } : content
get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, content: originalContent }] })
const saved = { ...detail, revision: 2, entries: [{ ...detail.entries[0]!, content: editedContent, revision: 2 }] }
updateEntry.mockResolvedValue(saved)
canvas.flush.mockResolvedValue(editedContent)
let finish!: (value: MagicNoteDetail) => void
analyze.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
const entry = within(screen.getByRole('article'))
const editLabel = kind === 'canvas' ? '展开编辑画布' : '编辑'
const editorId = kind === 'canvas' ? 'canvas-editor' : 'magic-note-editor'
fireEvent.click(entry.getByRole('button', { name: editLabel }))
fireEvent.click(entry.getByTestId(editorId))
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(analyze).toHaveBeenCalledWith(entryId, expect.objectContaining({ expectedRevision: 2 })))
expect(updateEntry).toHaveBeenCalledWith({ entryId, content: editedContent, expectedRevision: 1 })
expect(entry.queryByTestId(editorId)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(entry.getByRole('button', { name: editLabel })).toBeVisible()
await act(async () => finish({ ...saved, revision: 3, entries: [{ ...saved.entries[0]!, revision: 3 }] }))
expect(entry.queryByTestId(editorId)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(entry.getByRole('button', { name: editLabel })).toBeEnabled()
fireEvent.click(entry.getByRole('button', { name: editLabel }))
expect(entry.getByTestId(editorId)).toBeVisible()
})
it.each(['text', 'canvas'] as const)('retains the %s editor and draft after a failed save and exits after retry', async (kind) => {
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
const originalContent = kind === 'canvas' ? canvasContent : detail.entries[0]!.content
const editedContent = kind === 'canvas' ? { ...canvasContent, flow: { ops: [{ insert: 'Unsaved body\n' }] } } : content
get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, content: originalContent }] })
const saved = { ...detail, revision: 2, entries: [{ ...detail.entries[0]!, content: editedContent, revision: 2 }] }
updateEntry.mockRejectedValueOnce(new Error('save failed')).mockResolvedValue(saved)
analyze.mockResolvedValue(saved)
canvas.flush.mockResolvedValue(editedContent)
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
const entry = within(screen.getByRole('article'))
const editLabel = kind === 'canvas' ? '展开编辑画布' : '编辑'
const editorId = kind === 'canvas' ? 'canvas-editor' : 'magic-note-editor'
fireEvent.click(entry.getByRole('button', { name: editLabel }))
const editor = entry.getByTestId(editorId)
fireEvent.click(editor)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'save failed' })))
expect(entry.getByTestId(editorId)).toBe(editor)
expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()
if (kind === 'text') expect(entry.getByRole('button', { name: editLabel })).toBeDisabled()
else expect(entry.queryByRole('button', { name: editLabel })).not.toBeInTheDocument()
expect(analyze).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(entry.getByRole('button', { name: editLabel })).toBeEnabled())
expect(entry.queryByTestId(editorId)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenNthCalledWith(1, { entryId, content: editedContent, expectedRevision: 1 })
expect(updateEntry).toHaveBeenNthCalledWith(2, { entryId, content: editedContent, expectedRevision: 1 })
})
it('selects the explicitly created ID when an Agent appends before the local create completes', async () => {
const agentEntry = { ...detail.entries[0]!, id: 'agent-entry', plainText: 'Agent A' }
const localEntry = { ...detail.entries[0]!, id: createdEntryId, content, plainText: 'Local B' }
@@ -711,7 +777,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
} else await act(async () => { fireEvent.click(screen.getByRole('button', { name: '保存修改' })) })
await act(() => vi.advanceTimersByTimeAsync(6000))
expect(analyzeDraft).not.toHaveBeenCalled()
if (action === 'save') fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
newEntry()
fireEvent.click(screen.getByTestId('magic-note-editor'))
await act(() => vi.advanceTimersByTimeAsync(5000))
@@ -1037,7 +1103,8 @@ describe('MagicNotesWorkspace detail behavior', () => {
expect(screen.getByRole('button', { name: '保存记录' })).toBeVisible()
} else {
expect(updateEntry).toHaveBeenCalledWith({ entryId, content, expectedRevision: 1 })
expect(screen.getByRole('button', { name: '保存修改' })).toBeVisible()
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '编辑' })).toBeEnabled()
}
})
@@ -1191,7 +1258,9 @@ describe('MagicNotesWorkspace canvas integration', () => {
await openNote()
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled())
expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
const normalized = { ...canvasContent, flow: { version: 1 as const, ops: [{ insert: '\n' }] } }
act(() => canvas.ready?.(normalized))
canvas.flush.mockResolvedValue(normalized)
@@ -1376,7 +1445,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: '记录已保存,但自动分析失败:capture failed。可稍后重新分析。' })))
expect(updateEntry).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '保存修改' })).toBeVisible()
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument()
expect(analyze).not.toHaveBeenCalled()
})
@@ -1420,7 +1490,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
await screen.findByTestId('canvas-editor')
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(screen.getByRole('button', { name: '保存记录' })).toBeEnabled())
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(analyze).toHaveBeenCalledTimes(expected ? 1 : 0)
expect(canvas.capture).toHaveBeenCalledTimes(expected ? 1 : 0)
})
@@ -1438,7 +1509,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
await screen.findByTestId('canvas-editor')
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(screen.getByRole('button', { name: '保存记录' })).toBeEnabled())
expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
expect(analyze).toHaveBeenCalledTimes(semanticChange ? 1 : 0)
})
@@ -1480,9 +1552,10 @@ describe('MagicNotesWorkspace canvas integration', () => {
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
expect(analyze).not.toHaveBeenCalled()
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled())
expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument()
showAi()
await screen.findByTestId('canvas-editor')
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
get.mockResolvedValue({ ...detail, entries: [{ ...entry, comments: [{ ...entry.comments[0]!, inputMode: 'text-fallback' }] }] })
act(() => changeListener?.())
await screen.findByText('仅文字分析(未使用画布图像)')
@@ -1491,7 +1564,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
canvas.flush.mockResolvedValue(moved)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled())
await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled())
expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument()
expect(analyze).not.toHaveBeenCalled()
})
@@ -1681,7 +1755,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
canvas.flush.mockResolvedValue(moved)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({ entryId, content: moved, expectedRevision: 1 }))
await screen.findByTestId('canvas-editor')
await screen.findByTestId('canvas-content')
expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument()
expect(analyze).toHaveBeenCalledOnce()
expect(canvas.capture).not.toHaveBeenCalled()
})
+3 -5
View File
@@ -1869,9 +1869,9 @@ export function MagicNotesWorkspace({
})
applyDetail(updated)
const savedEntry = updated.entries.find((entry) => entry.id === editingEntry.id)
setEditingEntry(savedEntry)
editingBaselineRef.current = savedEntry?.content
editingContentRef.current = savedEntry?.content
setEditingEntry(undefined)
editingBaselineRef.current = undefined
editingContentRef.current = undefined
clearDraftAnalysis()
notifySuccess(t('notifications.entryUpdated'))
if (analysisPreparationError !== undefined) notifyError(t('canvas.savedAnalysisFailed', { error: analysisPreparationError }))
@@ -1892,8 +1892,6 @@ export function MagicNotesWorkspace({
...options, expectedRevision: savedEntry.revision
})
applyDetail(analyzed)
const analyzedEntry = analyzed.entries.find((entry) => entry.id === editingEntry.id)
setEditingEntry(analyzedEntry)
notifySuccess(t('notifications.aiCommentAdded'))
} catch (analysisError) {
notifyError(analysisError)