fix: refresh magic notes after external updates
Subscribe the open Magic Notes workspace to committed database changes from desktop and Agent/MCP writes. Preserve selections and unsaved drafts during refresh and external deletion. File-level commit includes existing runtime edits in shared files; their remaining dependencies are still in the working tree. Release note: Magic Notes now updates automatically after Agent/MCP edits while preserving your selection and unsaved drafts.
This commit is contained in:
@@ -811,6 +811,7 @@ const api: DesktopApi = {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
onAnalysisEvent: vi.fn(() => vi.fn()),
|
||||
onChanged: vi.fn(() => vi.fn()),
|
||||
onTodoStatusChanged: vi.fn((listener) => {
|
||||
magicTodoStatusChangedListener = listener;
|
||||
return () => {
|
||||
|
||||
@@ -201,10 +201,17 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
|
||||
magicNoteCommentFormat: 'combined'
|
||||
}))
|
||||
const onNotify = vi.fn()
|
||||
let changeListener: (() => void) | undefined
|
||||
const unsubscribeChanges = vi.fn()
|
||||
const onChanged = vi.fn<DesktopApi['magicNotes']['onChanged']>((listener) => {
|
||||
changeListener = listener
|
||||
return unsubscribeChanges
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
analysisEventListener = undefined
|
||||
changeListener = undefined
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
@@ -350,6 +357,7 @@ beforeEach(() => {
|
||||
updateTodo,
|
||||
analyzeTodo,
|
||||
analyzeDraft,
|
||||
onChanged,
|
||||
onAnalysisEvent
|
||||
},
|
||||
updates: {
|
||||
@@ -366,6 +374,180 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('MagicNotesWorkspace', () => {
|
||||
it('coalesces external writes, keeps selection and refreshes the selected detail', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
const added = alternateDetail(secondNoteId, 'Agent-created note')
|
||||
const updated = { ...detail, title: 'Agent-renamed note', pinned: true, revision: 2 }
|
||||
list.mockResolvedValue({ notes: [added, updated] })
|
||||
get.mockResolvedValue(updated)
|
||||
act(() => {
|
||||
changeListener?.()
|
||||
changeListener?.()
|
||||
changeListener?.()
|
||||
})
|
||||
await screen.findByDisplayValue(updated.title)
|
||||
expect(screen.getByText(added.title)).toBeInTheDocument()
|
||||
expect(get).toHaveBeenLastCalledWith(noteId)
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: '取消置顶' })).toBeInTheDocument()
|
||||
|
||||
list.mockResolvedValue({ notes: [added] })
|
||||
get.mockResolvedValue(added)
|
||||
act(() => changeListener?.())
|
||||
await screen.findByDisplayValue(added.title)
|
||||
expect(screen.queryByDisplayValue(updated.title)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves title and composer drafts when external entries arrive', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const title = await screen.findByDisplayValue(detail.title)
|
||||
fireEvent.change(title, { target: { value: 'Unsaved title' } })
|
||||
const composer = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(composer)
|
||||
const updated = {
|
||||
...detail,
|
||||
title: 'External title',
|
||||
revision: 2,
|
||||
entries: [...detail.entries, { ...detail.entries[0]!, id: createdEntryId, comments: [] }]
|
||||
}
|
||||
list.mockResolvedValue({ notes: [updated] })
|
||||
get.mockResolvedValue(updated)
|
||||
act(() => changeListener?.())
|
||||
await waitFor(() => expect(screen.getAllByText('记录正文')).toHaveLength(2))
|
||||
expect(title).toHaveValue('Unsaved title')
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(composer)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({
|
||||
noteId,
|
||||
content: { version: 1, ops: [{ insert: '新的句子\n' }] }
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['updated', 'deleted'] as const)('keeps an entry draft mounted when externally %s', async (change) => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
const editor = container.querySelector<HTMLButtonElement>('.magic-note-entry__editor [data-testid="magic-note-editor"]')!
|
||||
fireEvent.click(editor)
|
||||
const updated = {
|
||||
...detail,
|
||||
title: 'External change',
|
||||
revision: 2,
|
||||
entries: change === 'deleted' ? [] : detail.entries.map((entry) => ({ ...entry, revision: 2 }))
|
||||
}
|
||||
list.mockResolvedValue({ notes: [updated] })
|
||||
get.mockResolvedValue(updated)
|
||||
act(() => changeListener?.())
|
||||
await screen.findByDisplayValue(updated.title)
|
||||
expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
|
||||
if (change === 'deleted') {
|
||||
expect(screen.getByText(/正在编辑的记录已在其他入口删除/)).toBeInTheDocument()
|
||||
}
|
||||
updateEntry.mockRejectedValueOnce(new Error('Revision conflict or deleted entry'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({
|
||||
entryId,
|
||||
content: { version: 1, ops: [{ insert: '新的句子\n' }] },
|
||||
expectedRevision: 1
|
||||
}))
|
||||
expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
|
||||
})
|
||||
|
||||
it('retains a deleted note with drafts instead of moving them into another note', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const title = await screen.findByDisplayValue(detail.title)
|
||||
fireEvent.change(title, { target: { value: 'Unsaved title' } })
|
||||
const composer = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(composer)
|
||||
list.mockResolvedValue({ notes: [] })
|
||||
listTodos.mockResolvedValue({ todos: [] })
|
||||
act(() => changeListener?.())
|
||||
await screen.findByText(/这篇笔记已在其他入口删除/)
|
||||
expect(title).toHaveValue('Unsaved title')
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(composer)
|
||||
expect(screen.queryByRole('button', { name: /发布笔记.*整理发布清单/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the selected todo and reloads its source after external writes', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /准备演示.*演示笔记/ }))
|
||||
await waitFor(() => expect(get).toHaveBeenLastCalledWith(secondNoteId))
|
||||
const source = alternateDetail(secondNoteId, '演示笔记')
|
||||
get.mockResolvedValue(source)
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo, { ...manualTodo, title: 'External todo' }] })
|
||||
act(() => changeListener?.())
|
||||
await screen.findByRole('heading', { name: 'External todo' })
|
||||
expect(screen.getByRole('button', { name: /External todo.*演示笔记/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
await waitFor(() => expect(get).toHaveBeenLastCalledWith(secondNoteId))
|
||||
})
|
||||
|
||||
it('unsubscribes and cancels queued refreshes on unmount', async () => {
|
||||
const { unmount } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
vi.useFakeTimers()
|
||||
act(() => changeListener?.())
|
||||
unmount()
|
||||
await act(() => vi.advanceTimersByTimeAsync(200))
|
||||
expect(unsubscribeChanges).toHaveBeenCalledOnce()
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not let an in-flight external refresh undo a newer note selection', async () => {
|
||||
const second = alternateDetail(secondNoteId, 'Second note')
|
||||
list.mockResolvedValue({ notes: [detail, second] })
|
||||
get.mockImplementation(async (id) => id === secondNoteId ? second : detail)
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
let resolveList!: (snapshot: MagicNotesSnapshot) => void
|
||||
list.mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve }))
|
||||
act(() => changeListener?.())
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second note/ }))
|
||||
await screen.findByDisplayValue(second.title)
|
||||
await act(async () => resolveList({ notes: [detail, second] }))
|
||||
expect(screen.getByDisplayValue(second.title)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps drafts after an event refresh fails and on retry while the user keeps typing', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const title = await screen.findByDisplayValue(detail.title)
|
||||
const composer = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(composer)
|
||||
list.mockRejectedValueOnce(new Error('External refresh failed'))
|
||||
act(() => changeListener?.())
|
||||
await screen.findByText(/External refresh failed/)
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(composer)
|
||||
let resolveDetail!: (note: MagicNoteDetail) => void
|
||||
get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
|
||||
fireEvent.change(title, { target: { value: 'Typed during refresh' } })
|
||||
await act(async () => resolveDetail({ ...detail, title: 'External title', revision: 2 }))
|
||||
expect(title).toHaveValue('Typed during refresh')
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(composer)
|
||||
expect(screen.queryByText(/External refresh failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defers event refreshes until an active save completes without losing the event', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
let resolveSave!: (note: MagicNoteDetail) => void
|
||||
createEntry.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
||||
fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
|
||||
vi.useFakeTimers()
|
||||
act(() => changeListener?.())
|
||||
await act(() => vi.advanceTimersByTimeAsync(300))
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
await act(async () => resolveSave(detail))
|
||||
await act(() => vi.advanceTimersByTimeAsync(100))
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows a retryable EmptyState when the initial load fails', async () => {
|
||||
get.mockRejectedValueOnce(new Error('详情暂时不可用'))
|
||||
|
||||
|
||||
@@ -544,6 +544,10 @@ export function MagicNotesWorkspace({
|
||||
const refreshRequestRef = useRef(0)
|
||||
const hasLoadedRef = useRef(false)
|
||||
const busyRef = useRef('')
|
||||
const refreshContextRef = useRef({ detail, titleDraft, editingEntry })
|
||||
useEffect(() => {
|
||||
refreshContextRef.current = { detail, titleDraft, editingEntry }
|
||||
}, [detail, titleDraft, editingEntry])
|
||||
const composerContentRef = useRef<MagicNoteRichContent | undefined>(
|
||||
undefined
|
||||
)
|
||||
@@ -1273,7 +1277,7 @@ export function MagicNotesWorkspace({
|
||||
}, [pendingDraftSwitch])
|
||||
|
||||
const refreshNotes = useCallback(
|
||||
async (preferredId?: string): Promise<void> => {
|
||||
async (preferredId?: string, background = false): Promise<void> => {
|
||||
const requestId = ++refreshRequestRef.current
|
||||
const detailRequestAtStart = detailRequestRef.current
|
||||
await Promise.resolve()
|
||||
@@ -1307,17 +1311,44 @@ export function MagicNotesWorkspace({
|
||||
snapshot.notes.some((note) => note.id === requestedNoteId)
|
||||
setNotes(snapshot.notes)
|
||||
setTodos(todoSnapshot.todos)
|
||||
setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '')
|
||||
setSelectedTodoId((current) =>
|
||||
todoSnapshot.todos.some((todo) => todo.id === current)
|
||||
? current
|
||||
: todoSnapshot.todos[0]?.id ?? ''
|
||||
)
|
||||
hasLoadedRef.current = true
|
||||
setLoadStatus('ready')
|
||||
if (preserveNewerSelection) {
|
||||
return
|
||||
}
|
||||
const current = refreshContextRef.current
|
||||
const titleDirty = current.detail &&
|
||||
current.titleDraft !== current.detail.title
|
||||
if (background && current.detail?.id !== nextId) {
|
||||
if (
|
||||
titleDirty ||
|
||||
hasContent(composerContentRef.current) ||
|
||||
current.editingEntry
|
||||
) {
|
||||
setRefreshError(tRef.current('errors.noteDeletedExternally'))
|
||||
return
|
||||
}
|
||||
discardComposerDraft()
|
||||
discardEditingDraft()
|
||||
}
|
||||
detailRequestRef.current += 1
|
||||
requestedNoteIdRef.current = nextId
|
||||
setSelectedNoteId(nextId)
|
||||
setDetail(nextDetail)
|
||||
setTitleDraft(nextDetail?.title ?? '')
|
||||
if (!background || !titleDirty) {
|
||||
setTitleDraft(nextDetail?.title ?? '')
|
||||
}
|
||||
if (
|
||||
background && current.editingEntry && nextDetail &&
|
||||
!nextDetail.entries.some((entry) => entry.id === current.editingEntry?.id)
|
||||
) {
|
||||
setRefreshError(tRef.current('errors.entryDeletedExternally'))
|
||||
}
|
||||
setDetailLoadError(undefined)
|
||||
} catch (loadError) {
|
||||
if (refreshRequestRef.current === requestId) {
|
||||
@@ -1334,7 +1365,7 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
[discardComposerDraft, discardEditingDraft]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1348,6 +1379,28 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}, [refreshNotes])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: number | undefined
|
||||
const refresh = (): void => {
|
||||
if (busyRef.current) {
|
||||
timeout = window.setTimeout(refresh, 100)
|
||||
return
|
||||
}
|
||||
timeout = undefined
|
||||
void refreshNotes(requestedNoteIdRef.current, true)
|
||||
}
|
||||
const unsubscribe = window.goodbuddy.magicNotes.onChanged(() => {
|
||||
refreshRequestRef.current += 1
|
||||
window.clearTimeout(timeout)
|
||||
timeout = window.setTimeout(refresh, 100)
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.clearTimeout(timeout)
|
||||
refreshRequestRef.current += 1
|
||||
}
|
||||
}, [refreshNotes])
|
||||
|
||||
const visibleNotes = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase()
|
||||
return query
|
||||
@@ -1424,7 +1477,7 @@ export function MagicNotesWorkspace({
|
||||
return () => {
|
||||
todoSourceRequestRef.current += 1
|
||||
}
|
||||
}, [libraryView, notifyError, selectedTodoNoteId])
|
||||
}, [libraryView, notifyError, selectedTodoNoteId, todos])
|
||||
|
||||
const selectedTodoSourceEntry = useMemo(
|
||||
() =>
|
||||
@@ -1452,6 +1505,13 @@ export function MagicNotesWorkspace({
|
||||
.reverse(),
|
||||
[detail]
|
||||
)
|
||||
const displayedEntries = useMemo(() => {
|
||||
const entries = detail?.entries ?? []
|
||||
return editingEntry && editingEntry.noteId === detail?.id &&
|
||||
!entries.some((entry) => entry.id === editingEntry.id)
|
||||
? [...entries, editingEntry]
|
||||
: entries
|
||||
}, [detail, editingEntry])
|
||||
const listPaneWidthLimits = getListPaneWidthLimits(
|
||||
magicNotesLayoutWidth,
|
||||
aiPaneWidth,
|
||||
@@ -1578,7 +1638,6 @@ export function MagicNotesWorkspace({
|
||||
applyDetail(updated)
|
||||
await reloadTodos()
|
||||
} catch (updateError) {
|
||||
setTitleDraft(detail.title)
|
||||
notifyError(updateError)
|
||||
} finally {
|
||||
endBusy(operation)
|
||||
@@ -1840,7 +1899,7 @@ export function MagicNotesWorkspace({
|
||||
</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void refreshNotes(selectedNoteId)}
|
||||
onClick={() => void refreshNotes(selectedNoteId, true)}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.retry')}
|
||||
@@ -2466,12 +2525,12 @@ export function MagicNotesWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="magic-note-entry-stream">
|
||||
{detail.entries.length === 0 ? (
|
||||
{displayedEntries.length === 0 ? (
|
||||
<p className="magic-notes-muted">
|
||||
{t('notes.emptyEntries')}
|
||||
</p>
|
||||
) : (
|
||||
[...detail.entries].reverse().map((entry) => (
|
||||
[...displayedEntries].reverse().map((entry) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
id={`magic-note-entry-${entry.id}`}
|
||||
@@ -2566,7 +2625,7 @@ export function MagicNotesWorkspace({
|
||||
{editingEntry?.id === entry.id ? (
|
||||
<div className="magic-note-entry__editor">
|
||||
<MagicNoteEditor
|
||||
key={`${entry.id}-${entry.revision}`}
|
||||
key={`${editingEntry.id}-${editingEntry.revision}`}
|
||||
ariaDescribedBy={
|
||||
validation?.target === 'edit-entry'
|
||||
? 'magic-note-entry-edit-error'
|
||||
@@ -2576,7 +2635,7 @@ export function MagicNotesWorkspace({
|
||||
validation?.target === 'edit-entry'
|
||||
}
|
||||
ariaLabel={t('notes.editEntryLabel')}
|
||||
initialContent={entry.content}
|
||||
initialContent={editingEntry.content}
|
||||
onChange={(content) => {
|
||||
editingContentRef.current = content
|
||||
clearValidation('edit-entry')
|
||||
|
||||
@@ -59,6 +59,8 @@ export const magicNotes = {
|
||||
todoReopened: 'To-do marked incomplete'
|
||||
},
|
||||
errors: {
|
||||
noteDeletedExternally: 'This note was deleted elsewhere. Your unsaved drafts are still here; copy them before switching notes.',
|
||||
entryDeletedExternally: 'The entry being edited was deleted elsewhere. Your draft is still here; copy it before leaving the editor.',
|
||||
operationFailed: 'The operation failed. Try again.',
|
||||
initialLoadTitle: 'Could not load Magic Notes',
|
||||
initialLoadDescription: 'Could not load Magic Notes: {{error}}',
|
||||
|
||||
@@ -55,6 +55,8 @@ export const magicNotes = {
|
||||
todoReopened: '待办已恢复为未完成'
|
||||
},
|
||||
errors: {
|
||||
noteDeletedExternally: '这篇笔记已在其他入口删除。未保存的草稿仍保留在此,切换笔记前请复制草稿。',
|
||||
entryDeletedExternally: '正在编辑的记录已在其他入口删除。草稿仍保留在此,退出编辑前请复制草稿。',
|
||||
operationFailed: '操作失败,请重试',
|
||||
initialLoadTitle: '魔法笔记加载失败',
|
||||
initialLoadDescription: '无法加载魔法笔记:{{error}}',
|
||||
|
||||
Reference in New Issue
Block a user