feat: make magic todo status editable

This commit is contained in:
lofyer
2026-08-10 23:04:12 +08:00
parent 5cb99f3097
commit 6fd41d2cfd
11 changed files with 313 additions and 54 deletions
@@ -1932,6 +1932,50 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('updates a derived todo and its source checklist together', async () => {
const database = await createDatabase()
const note = database.createMagicNote({ title: '发布笔记' })
database.createMagicNoteEntry({
noteId: note.id,
content: {
version: 1,
ops: [
{ insert: '核对发布材料' },
{ insert: '\n', attributes: { list: 'unchecked' } }
]
},
plainText: '核对发布材料'
})
const todo = database.listMagicTodos()[0]!
const updated = database.updateMagicTodo({
todoId: todo.id,
completed: true,
expectedRevision: todo.revision
})
expect(updated).toMatchObject({
id: todo.id,
completed: true,
revision: todo.revision + 1
})
expect(
database.getMagicNote(note.id).entries[0]!.content.ops
).toEqual([
{ insert: '核对发布材料' },
{ insert: '\n', attributes: { list: 'checked' } }
])
expect(() =>
database.updateMagicTodo({
todoId: todo.id,
completed: false,
expectedRevision: todo.revision
})
).toThrow('待办已被更新,请刷新后重试')
database.close()
})
it('protects magic note records from stale revisions', async () => { it('protects magic note records from stale revisions', async () => {
const database = await createDatabase() const database = await createDatabase()
const note = database.createMagicNote({ title: '并发笔记' }) const note = database.createMagicNote({ title: '并发笔记' })
+85 -2
View File
@@ -51,13 +51,16 @@ import {
type MagicNoteRichContent, type MagicNoteRichContent,
type MagicNoteSearchResult, type MagicNoteSearchResult,
type MagicNoteSummary, type MagicNoteSummary,
type MagicTodoItem type MagicTodoItem,
type MagicTodoUpdateInput
} from '../../shared/magic-notes-contracts' } from '../../shared/magic-notes-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit' import type { ComputerControlAuditEvent } from '../computer-control/audit'
import { import {
magicNoteChecklistItems, magicNoteChecklistItems,
magicNoteImageBytes, magicNoteImageBytes,
magicNotePreview magicNotePlainText,
magicNotePreview,
setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content' } from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence' import { computeNextHeartbeatRun } from './heartbeat-recurrence'
@@ -2164,6 +2167,86 @@ export class AssistantDatabase {
return toMagicTodo(row) return toMagicTodo(row)
} }
updateMagicTodo(input: MagicTodoUpdateInput): MagicTodoItem {
const database = this.requireDatabase()
const now = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const existing = database
.prepare(
`SELECT t.note_id, t.entry_id, t.source_index, t.completed,
t.revision AS todo_revision,
e.content_json, e.revision AS entry_revision
FROM magic_todos t
INNER JOIN magic_note_entries e ON e.id = t.entry_id
WHERE t.id = ? AND t.source = 'note'`
)
.get(input.todoId) as
| {
note_id: string
entry_id: string
source_index: number
completed: number
todo_revision: number
content_json: string
entry_revision: number
}
| undefined
if (!existing) {
throw new Error('待办不存在')
}
if (existing.todo_revision !== input.expectedRevision) {
throw new Error('待办已被更新,请刷新后重试')
}
if (Boolean(existing.completed) === input.completed) {
database.exec('COMMIT')
return this.getMagicTodo(input.todoId)
}
const content = setMagicNoteChecklistCompletion(
JSON.parse(existing.content_json) as MagicNoteRichContent,
existing.source_index,
input.completed
)
const result = database
.prepare(
`UPDATE magic_note_entries
SET content_json = ?, plain_text = ?, comments_json = '[]',
analyzed_at = NULL, revision = revision + 1, updated_at = ?
WHERE id = ? AND revision = ?`
)
.run(
JSON.stringify(content),
magicNotePlainText(content),
now,
existing.entry_id,
existing.entry_revision
)
if (result.changes !== 1) {
throw new Error('记录已被更新,请刷新后重试')
}
database
.prepare(
`UPDATE magic_notes
SET revision = revision + 1, updated_at = ?
WHERE id = ?`
)
.run(now, existing.note_id)
this.syncMagicNoteTodos(
database,
existing.note_id,
existing.entry_id,
content,
now
)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return this.getMagicTodo(input.todoId)
}
saveMagicTodoAnalysis(input: { saveMagicTodoAnalysis(input: {
todoId: string todoId: string
expectedRevision: number expectedRevision: number
+12 -1
View File
@@ -86,7 +86,8 @@ import {
magicNoteEntryDeleteSchema, magicNoteEntryDeleteSchema,
magicNoteEntryUpdateSchema, magicNoteEntryUpdateSchema,
magicNoteUpdateSchema, magicNoteUpdateSchema,
magicTodoIdSchema magicTodoIdSchema,
magicTodoUpdateSchema
} from '../shared/magic-notes-contracts' } from '../shared/magic-notes-contracts'
import { import {
assistantIdSchema, assistantIdSchema,
@@ -3495,6 +3496,16 @@ export function registerIpcHandlers(
} }
) )
ipcMain.handle(
ipcChannels.magicTodosUpdate,
(event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.updateMagicTodo(
magicTodoUpdateSchema.parse(input)
)
}
)
ipcMain.handle( ipcMain.handle(
ipcChannels.magicTodosAnalyze, ipcChannels.magicTodosAnalyze,
async (event, input: unknown) => { async (event, input: unknown) => {
+5
View File
@@ -785,6 +785,11 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.magicTodosList ipcChannels.magicTodosList
) as Promise<MagicTodosSnapshot>, ) as Promise<MagicTodosSnapshot>,
updateTodo: (input) =>
ipcRenderer.invoke(
ipcChannels.magicTodosUpdate,
input
) as Promise<MagicTodoItem>,
analyzeTodo: (todoId, options) => analyzeTodo: (todoId, options) =>
ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, { ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, {
todoId, todoId,
+3
View File
@@ -479,6 +479,9 @@ const api: DesktopApi = {
throw new Error('not used') throw new Error('not used')
}), }),
listTodos: vi.fn(async () => ({ todos: [] })), listTodos: vi.fn(async () => ({ todos: [] })),
updateTodo: vi.fn(async () => {
throw new Error('not used')
}),
analyzeTodo: vi.fn(async () => { analyzeTodo: vi.fn(async () => {
throw new Error('not used') throw new Error('not used')
}), }),
+52 -4
View File
@@ -152,6 +152,7 @@ const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
const remove = vi.fn<DesktopApi['magicNotes']['remove']>() const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>() const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>() const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>() const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>() const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
let analysisEventListener: let analysisEventListener:
@@ -206,6 +207,11 @@ beforeEach(() => {
] ]
} }
createEntry.mockResolvedValue(createdDetail) createEntry.mockResolvedValue(createdDetail)
updateTodo.mockImplementation(async (input) => ({
...noteTodo,
completed: input.completed,
revision: noteTodo.revision + 1
}))
analyze.mockResolvedValue({ analyze.mockResolvedValue({
...createdDetail, ...createdDetail,
entries: createdDetail.entries.map((entry) => entries: createdDetail.entries.map((entry) =>
@@ -258,6 +264,7 @@ beforeEach(() => {
remove, remove,
createEntry, createEntry,
analyze, analyze,
updateTodo,
analyzeTodo, analyzeTodo,
analyzeDraft, analyzeDraft,
onAnalysisEvent onAnalysisEvent
@@ -319,8 +326,12 @@ describe('MagicNotesWorkspace', () => {
).toHaveAttribute('aria-pressed', 'true') ).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('tab', { name: '待办' })) fireEvent.click(screen.getByRole('tab', { name: '待办' }))
expect(screen.getByText('准备演示')).toBeInTheDocument() expect(screen.getByText('准备演示')).toBeInTheDocument()
const selectedTodoButton = screen
.getAllByText('核对发布材料')
.find((element) => element.tagName === 'STRONG')
?.closest('button')
expect( expect(
screen.getByRole('button', { name: /核对发布材料/ }) selectedTodoButton
).toHaveAttribute('aria-pressed', 'true') ).toHaveAttribute('aria-pressed', 'true')
expect(onNotify).not.toHaveBeenCalledWith( expect(onNotify).not.toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -355,7 +366,11 @@ describe('MagicNotesWorkspace', () => {
Node.DOCUMENT_POSITION_FOLLOWING Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy() ).toBeTruthy()
expect(screen.getByLabelText('未完成')).toBeInTheDocument() expect(
screen.getAllByRole('button', {
name: `标记为已完成:${noteTodo.title}`
})
).toHaveLength(2)
expect( expect(
screen.getByRole('button', { name: '打开原笔记修改' }) screen.getByRole('button', { name: '打开原笔记修改' })
).toBeInTheDocument() ).toBeInTheDocument()
@@ -571,7 +586,7 @@ describe('MagicNotesWorkspace', () => {
) )
}) })
it('groups note-backed todos in a directory view', async () => { it('only shows note-backed todos in a directory view', async () => {
render( render(
<MagicNotesWorkspace onNotify={onNotify} /> <MagicNotesWorkspace onNotify={onNotify} />
) )
@@ -581,12 +596,45 @@ describe('MagicNotesWorkspace', () => {
expect( expect(
screen.queryByRole('button', { name: '新建待办' }) screen.queryByRole('button', { name: '新建待办' })
).not.toBeInTheDocument() ).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '目录视图' })) expect(
screen.queryByRole('group', { name: '待办列表方式' })
).not.toBeInTheDocument()
expect(screen.getByText('发布笔记')).toBeInTheDocument() expect(screen.getByText('发布笔记')).toBeInTheDocument()
expect(screen.getByText('演示笔记')).toBeInTheDocument() expect(screen.getByText('演示笔记')).toBeInTheDocument()
expect(screen.getByText('准备演示')).toBeInTheDocument() expect(screen.getByText('准备演示')).toBeInTheDocument()
}) })
it('marks a todo completed from the standalone todo tab', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
fireEvent.click(
screen.getAllByRole('button', {
name: `标记为已完成:${noteTodo.title}`
})[0]!
)
await waitFor(() =>
expect(updateTodo).toHaveBeenCalledWith({
todoId: noteTodo.id,
completed: true,
expectedRevision: noteTodo.revision
})
)
expect(
screen.getByRole('button', {
name: `标记为未完成:${noteTodo.title}`
})
).toHaveAttribute('aria-pressed', 'true')
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '待办已完成'
})
)
})
it('reuses the AI comments pane for selected todos', async () => { it('reuses the AI comments pane for selected todos', async () => {
getApplicationSettings.mockResolvedValue({ getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
+67 -40
View File
@@ -54,7 +54,6 @@ export type MagicNotesWorkspaceProps = {
type LibraryView = 'notes' | 'todos' type LibraryView = 'notes' | 'todos'
type TodoFilter = 'active' | 'completed' | 'all' type TodoFilter = 'active' | 'completed' | 'all'
type TodoListMode = 'list' | 'directory'
type LoadStatus = 'loading' | 'ready' | 'error' type LoadStatus = 'loading' | 'ready' | 'error'
type ValidationTarget = type ValidationTarget =
| 'create-note' | 'create-note'
@@ -73,11 +72,6 @@ const todoFilters = [
{ value: 'all', label: '全部' } { value: 'all', label: '全部' }
] as const ] as const
const todoListModes = [
{ value: 'list', label: '待办视图' },
{ value: 'directory', label: '目录视图' }
] as const
const commentDirections: ReadonlyArray<{ const commentDirections: ReadonlyArray<{
value: MagicNoteCommentDirection value: MagicNoteCommentDirection
label: string label: string
@@ -211,35 +205,50 @@ function AiComment({
} }
function TodoListItem({ function TodoListItem({
disabled,
onSelect, onSelect,
onToggle,
selected, selected,
todo todo
}: { }: {
disabled: boolean
onSelect: () => void onSelect: () => void
onToggle: () => void
selected: boolean selected: boolean
todo: MagicTodoItem todo: MagicTodoItem
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<button <div
aria-pressed={selected}
className={`magic-todo-list-item ${ className={`magic-todo-list-item ${
selected ? 'magic-todo-list-item--active' : '' selected ? 'magic-todo-list-item--active' : ''
}`} }`}
onClick={onSelect}
type="button"
> >
<span aria-hidden="true" className="magic-todo-list-item__check"> <button
aria-label={`${
todo.completed ? '标记为未完成' : '标记为已完成'
}${todo.title}`}
aria-pressed={todo.completed}
className="magic-todo-list-item__check"
disabled={disabled}
onClick={onToggle}
type="button"
>
{todo.completed ? ( {todo.completed ? (
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
) : ( ) : (
<Circle size={16} /> <Circle size={16} />
)} )}
</span> </button>
<span> <button
aria-pressed={selected}
className="magic-todo-list-item__content"
onClick={onSelect}
type="button"
>
<strong>{todo.title}</strong> <strong>{todo.title}</strong>
<small>{todo.noteTitle}</small> <small>{todo.noteTitle}</small>
</span> </button>
</button> </div>
) )
} }
@@ -250,8 +259,6 @@ export function MagicNotesWorkspace({
const [todos, setTodos] = useState<MagicTodoItem[]>([]) const [todos, setTodos] = useState<MagicTodoItem[]>([])
const [libraryView, setLibraryView] = useState<LibraryView>('notes') const [libraryView, setLibraryView] = useState<LibraryView>('notes')
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active') const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
const [todoListMode, setTodoListMode] =
useState<TodoListMode>('list')
const [commentMode, setCommentMode] = const [commentMode, setCommentMode] =
useState<MagicNoteCommentMode>('immediate') useState<MagicNoteCommentMode>('immediate')
const [commentDirection, setCommentDirection] = const [commentDirection, setCommentDirection] =
@@ -873,6 +880,30 @@ export function MagicNotesWorkspace({
} }
} }
const updateTodoCompletion = async (
todo: MagicTodoItem
): Promise<void> => {
const operation = `update-todo-${todo.id}`
if (!beginBusy(operation)) {
return
}
try {
const completed = !todo.completed
applyTodo(
await window.goodbuddy.magicNotes.updateTodo({
todoId: todo.id,
completed,
expectedRevision: todo.revision
})
)
notifySuccess(completed ? '待办已完成' : '待办已恢复为未完成')
} catch (updateError) {
notifyError(updateError)
} finally {
endBusy(operation)
}
}
const updateTitle = async (): Promise<void> => { const updateTitle = async (): Promise<void> => {
if (!detail || titleDraft.trim() === detail.title) { if (!detail || titleDraft.trim() === detail.title) {
return return
@@ -1308,12 +1339,6 @@ export function MagicNotesWorkspace({
value={search} value={search}
/> />
</label> </label>
<SegmentedControl
ariaLabel="待办列表方式"
onChange={setTodoListMode}
options={todoListModes}
value={todoListMode}
/>
<SegmentedControl <SegmentedControl
ariaLabel="筛选待办" ariaLabel="筛选待办"
onChange={setTodoFilter} onChange={setTodoFilter}
@@ -1347,18 +1372,6 @@ export function MagicNotesWorkspace({
</button> </button>
)} )}
</> </>
) : todoListMode === 'list' ? (
visibleTodos.map((todo) => (
<TodoListItem
key={todo.id}
onSelect={() => {
setValidation(undefined)
setSelectedTodoId(todo.id)
}}
selected={selectedTodoId === todo.id}
todo={todo}
/>
))
) : ( ) : (
todoDirectories.map((directory) => ( todoDirectories.map((directory) => (
<section <section
@@ -1373,11 +1386,15 @@ export function MagicNotesWorkspace({
<div className="magic-todo-directory__items"> <div className="magic-todo-directory__items">
{directory.todos.map((todo) => ( {directory.todos.map((todo) => (
<TodoListItem <TodoListItem
disabled={busy === `update-todo-${todo.id}`}
key={todo.id} key={todo.id}
onSelect={() => { onSelect={() => {
setValidation(undefined) setValidation(undefined)
setSelectedTodoId(todo.id) setSelectedTodoId(todo.id)
}} }}
onToggle={() =>
void updateTodoCompletion(todo)
}
selected={selectedTodoId === todo.id} selected={selectedTodoId === todo.id}
todo={todo} todo={todo}
/> />
@@ -1780,18 +1797,28 @@ export function MagicNotesWorkspace({
) : ( ) : (
<section className="magic-todo-detail"> <section className="magic-todo-detail">
<header> <header>
<span <button
aria-label={ aria-label={`${
selectedTodo.completed ? '已完成' : '未完成' selectedTodo.completed
} ? '标记为未完成'
: '标记为已完成'
}${selectedTodo.title}`}
aria-pressed={selectedTodo.completed}
className="magic-todo-detail__check" className="magic-todo-detail__check"
disabled={
busy === `update-todo-${selectedTodo.id}`
}
onClick={() =>
void updateTodoCompletion(selectedTodo)
}
type="button"
> >
{selectedTodo.completed ? ( {selectedTodo.completed ? (
<CheckCircle2 size={24} /> <CheckCircle2 size={24} />
) : ( ) : (
<Circle size={24} /> <Circle size={24} />
)} )}
</span> </button>
<div> <div>
<h2>{selectedTodo.title}</h2> <h2>{selectedTodo.title}</h2>
<span>{selectedTodo.noteTitle}</span> <span>{selectedTodo.noteTitle}</span>
+29 -7
View File
@@ -316,7 +316,6 @@
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer;
gap: var(--space-2); gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
text-align: left; text-align: left;
@@ -333,18 +332,34 @@
.magic-todo-list-item__check { .magic-todo-list-item__check {
display: grid; display: grid;
width: 28px;
height: 28px;
padding-top: 1px; padding-top: 1px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--accent); color: var(--accent);
cursor: pointer;
place-items: center; place-items: center;
} }
.magic-todo-list-item > span:last-child { .magic-todo-list-item__check:hover {
display: grid; background: var(--accent-subtle);
min-width: 0;
gap: var(--space-1);
} }
.magic-todo-list-item strong { .magic-todo-list-item__content {
display: grid;
width: 100%;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
gap: var(--space-1);
text-align: left;
}
.magic-todo-list-item__content strong {
overflow: hidden; overflow: hidden;
color: var(--text-primary); color: var(--text-primary);
font-size: var(--font-body); font-size: var(--font-body);
@@ -352,7 +367,7 @@
white-space: nowrap; white-space: nowrap;
} }
.magic-todo-list-item small { .magic-todo-list-item__content small {
overflow: hidden; overflow: hidden;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--font-caption); font-size: var(--font-caption);
@@ -476,12 +491,19 @@
display: grid; display: grid;
width: 36px; width: 36px;
height: 36px; height: 36px;
padding: 0;
border: 0;
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--accent-subtle); background: var(--accent-subtle);
color: var(--accent); color: var(--accent);
cursor: pointer;
place-items: center; place-items: center;
} }
.magic-todo-detail__check:hover {
background: var(--accent-selected);
}
.magic-todo-detail > header > div:nth-child(2) { .magic-todo-detail > header > div:nth-child(2) {
display: grid; display: grid;
min-width: 0; min-width: 0;
+4
View File
@@ -48,6 +48,7 @@ import type {
MagicNotesSnapshot, MagicNotesSnapshot,
MagicNoteUpdateInput, MagicNoteUpdateInput,
MagicTodoItem, MagicTodoItem,
MagicTodoUpdateInput,
MagicTodosSnapshot MagicTodosSnapshot
} from './magic-notes-contracts' } from './magic-notes-contracts'
import type { import type {
@@ -1223,6 +1224,9 @@ export type DesktopApi = {
options: MagicNoteAnalysisOptions options: MagicNoteAnalysisOptions
) => Promise<MagicNoteDraftAnalysis> ) => Promise<MagicNoteDraftAnalysis>
listTodos: () => Promise<MagicTodosSnapshot> listTodos: () => Promise<MagicTodosSnapshot>
updateTodo: (
input: MagicTodoUpdateInput
) => Promise<MagicTodoItem>
analyzeTodo: ( analyzeTodo: (
todoId: string, todoId: string,
options: MagicNoteAnalysisOptions options: MagicNoteAnalysisOptions
+1
View File
@@ -130,6 +130,7 @@ export const ipcChannels = {
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft', magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
magicNotesAnalysisEvent: 'magic-notes:analysis-event', magicNotesAnalysisEvent: 'magic-notes:analysis-event',
magicTodosList: 'magic-todos:list', magicTodosList: 'magic-todos:list',
magicTodosUpdate: 'magic-todos:update',
magicTodosAnalyze: 'magic-todos:analyze', magicTodosAnalyze: 'magic-todos:analyze',
knowledgeSnapshot: 'knowledge:snapshot', knowledgeSnapshot: 'knowledge:snapshot',
knowledgeCreateLibrary: 'knowledge:library:create', knowledgeCreateLibrary: 'knowledge:library:create',
+11
View File
@@ -205,6 +205,17 @@ export const magicTodoIdSchema = z
}) })
.strict() .strict()
export const magicTodoUpdateSchema = z
.object({
todoId: magicNoteIdSchema,
completed: z.boolean(),
expectedRevision: z.number().int().nonnegative()
})
.strict()
export type MagicTodoUpdateInput = z.infer<
typeof magicTodoUpdateSchema
>
export type MagicNoteCommentKind = export type MagicNoteCommentKind =
| 'narrative' | 'narrative'
| 'summary' | 'summary'