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()
})
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 () => {
const database = await createDatabase()
const note = database.createMagicNote({ title: '并发笔记' })
+85 -2
View File
@@ -51,13 +51,16 @@ import {
type MagicNoteRichContent,
type MagicNoteSearchResult,
type MagicNoteSummary,
type MagicTodoItem
type MagicTodoItem,
type MagicTodoUpdateInput
} from '../../shared/magic-notes-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import {
magicNoteChecklistItems,
magicNoteImageBytes,
magicNotePreview
magicNotePlainText,
magicNotePreview,
setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
@@ -2164,6 +2167,86 @@ export class AssistantDatabase {
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: {
todoId: string
expectedRevision: number
+12 -1
View File
@@ -86,7 +86,8 @@ import {
magicNoteEntryDeleteSchema,
magicNoteEntryUpdateSchema,
magicNoteUpdateSchema,
magicTodoIdSchema
magicTodoIdSchema,
magicTodoUpdateSchema
} from '../shared/magic-notes-contracts'
import {
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(
ipcChannels.magicTodosAnalyze,
async (event, input: unknown) => {
+5
View File
@@ -785,6 +785,11 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.magicTodosList
) as Promise<MagicTodosSnapshot>,
updateTodo: (input) =>
ipcRenderer.invoke(
ipcChannels.magicTodosUpdate,
input
) as Promise<MagicTodoItem>,
analyzeTodo: (todoId, options) =>
ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, {
todoId,
+3
View File
@@ -479,6 +479,9 @@ const api: DesktopApi = {
throw new Error('not used')
}),
listTodos: vi.fn(async () => ({ todos: [] })),
updateTodo: vi.fn(async () => {
throw new Error('not used')
}),
analyzeTodo: vi.fn(async () => {
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 createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
let analysisEventListener:
@@ -206,6 +207,11 @@ beforeEach(() => {
]
}
createEntry.mockResolvedValue(createdDetail)
updateTodo.mockImplementation(async (input) => ({
...noteTodo,
completed: input.completed,
revision: noteTodo.revision + 1
}))
analyze.mockResolvedValue({
...createdDetail,
entries: createdDetail.entries.map((entry) =>
@@ -258,6 +264,7 @@ beforeEach(() => {
remove,
createEntry,
analyze,
updateTodo,
analyzeTodo,
analyzeDraft,
onAnalysisEvent
@@ -319,8 +326,12 @@ describe('MagicNotesWorkspace', () => {
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
expect(screen.getByText('准备演示')).toBeInTheDocument()
const selectedTodoButton = screen
.getAllByText('核对发布材料')
.find((element) => element.tagName === 'STRONG')
?.closest('button')
expect(
screen.getByRole('button', { name: /核对发布材料/ })
selectedTodoButton
).toHaveAttribute('aria-pressed', 'true')
expect(onNotify).not.toHaveBeenCalledWith(
expect.objectContaining({
@@ -355,7 +366,11 @@ describe('MagicNotesWorkspace', () => {
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(screen.getByLabelText('未完成')).toBeInTheDocument()
expect(
screen.getAllByRole('button', {
name: `标记为已完成:${noteTodo.title}`
})
).toHaveLength(2)
expect(
screen.getByRole('button', { name: '打开原笔记修改' })
).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(
<MagicNotesWorkspace onNotify={onNotify} />
)
@@ -581,12 +596,45 @@ describe('MagicNotesWorkspace', () => {
expect(
screen.queryByRole('button', { name: '新建待办' })
).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()
})
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 () => {
getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false,
+67 -40
View File
@@ -54,7 +54,6 @@ export type MagicNotesWorkspaceProps = {
type LibraryView = 'notes' | 'todos'
type TodoFilter = 'active' | 'completed' | 'all'
type TodoListMode = 'list' | 'directory'
type LoadStatus = 'loading' | 'ready' | 'error'
type ValidationTarget =
| 'create-note'
@@ -73,11 +72,6 @@ const todoFilters = [
{ value: 'all', label: '全部' }
] as const
const todoListModes = [
{ value: 'list', label: '待办视图' },
{ value: 'directory', label: '目录视图' }
] as const
const commentDirections: ReadonlyArray<{
value: MagicNoteCommentDirection
label: string
@@ -211,35 +205,50 @@ function AiComment({
}
function TodoListItem({
disabled,
onSelect,
onToggle,
selected,
todo
}: {
disabled: boolean
onSelect: () => void
onToggle: () => void
selected: boolean
todo: MagicTodoItem
}): React.JSX.Element {
return (
<button
aria-pressed={selected}
<div
className={`magic-todo-list-item ${
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 ? (
<CheckCircle2 size={16} />
) : (
<Circle size={16} />
)}
</span>
<span>
</button>
<button
aria-pressed={selected}
className="magic-todo-list-item__content"
onClick={onSelect}
type="button"
>
<strong>{todo.title}</strong>
<small>{todo.noteTitle}</small>
</span>
</button>
</button>
</div>
)
}
@@ -250,8 +259,6 @@ export function MagicNotesWorkspace({
const [todos, setTodos] = useState<MagicTodoItem[]>([])
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
const [todoListMode, setTodoListMode] =
useState<TodoListMode>('list')
const [commentMode, setCommentMode] =
useState<MagicNoteCommentMode>('immediate')
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> => {
if (!detail || titleDraft.trim() === detail.title) {
return
@@ -1308,12 +1339,6 @@ export function MagicNotesWorkspace({
value={search}
/>
</label>
<SegmentedControl
ariaLabel="待办列表方式"
onChange={setTodoListMode}
options={todoListModes}
value={todoListMode}
/>
<SegmentedControl
ariaLabel="筛选待办"
onChange={setTodoFilter}
@@ -1347,18 +1372,6 @@ export function MagicNotesWorkspace({
</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) => (
<section
@@ -1373,11 +1386,15 @@ export function MagicNotesWorkspace({
<div className="magic-todo-directory__items">
{directory.todos.map((todo) => (
<TodoListItem
disabled={busy === `update-todo-${todo.id}`}
key={todo.id}
onSelect={() => {
setValidation(undefined)
setSelectedTodoId(todo.id)
}}
onToggle={() =>
void updateTodoCompletion(todo)
}
selected={selectedTodoId === todo.id}
todo={todo}
/>
@@ -1780,18 +1797,28 @@ export function MagicNotesWorkspace({
) : (
<section className="magic-todo-detail">
<header>
<span
aria-label={
selectedTodo.completed ? '已完成' : '未完成'
}
<button
aria-label={`${
selectedTodo.completed
? '标记为未完成'
: '标记为已完成'
}${selectedTodo.title}`}
aria-pressed={selectedTodo.completed}
className="magic-todo-detail__check"
disabled={
busy === `update-todo-${selectedTodo.id}`
}
onClick={() =>
void updateTodoCompletion(selectedTodo)
}
type="button"
>
{selectedTodo.completed ? (
<CheckCircle2 size={24} />
) : (
<Circle size={24} />
)}
</span>
</button>
<div>
<h2>{selectedTodo.title}</h2>
<span>{selectedTodo.noteTitle}</span>
+29 -7
View File
@@ -316,7 +316,6 @@
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr);
text-align: left;
@@ -333,18 +332,34 @@
.magic-todo-list-item__check {
display: grid;
width: 28px;
height: 28px;
padding-top: 1px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--accent);
cursor: pointer;
place-items: center;
}
.magic-todo-list-item > span:last-child {
display: grid;
min-width: 0;
gap: var(--space-1);
.magic-todo-list-item__check:hover {
background: var(--accent-subtle);
}
.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;
color: var(--text-primary);
font-size: var(--font-body);
@@ -352,7 +367,7 @@
white-space: nowrap;
}
.magic-todo-list-item small {
.magic-todo-list-item__content small {
overflow: hidden;
color: var(--text-muted);
font-size: var(--font-caption);
@@ -476,12 +491,19 @@
display: grid;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: var(--accent-subtle);
color: var(--accent);
cursor: pointer;
place-items: center;
}
.magic-todo-detail__check:hover {
background: var(--accent-selected);
}
.magic-todo-detail > header > div:nth-child(2) {
display: grid;
min-width: 0;
+4
View File
@@ -48,6 +48,7 @@ import type {
MagicNotesSnapshot,
MagicNoteUpdateInput,
MagicTodoItem,
MagicTodoUpdateInput,
MagicTodosSnapshot
} from './magic-notes-contracts'
import type {
@@ -1223,6 +1224,9 @@ export type DesktopApi = {
options: MagicNoteAnalysisOptions
) => Promise<MagicNoteDraftAnalysis>
listTodos: () => Promise<MagicTodosSnapshot>
updateTodo: (
input: MagicTodoUpdateInput
) => Promise<MagicTodoItem>
analyzeTodo: (
todoId: string,
options: MagicNoteAnalysisOptions
+1
View File
@@ -130,6 +130,7 @@ export const ipcChannels = {
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
magicNotesAnalysisEvent: 'magic-notes:analysis-event',
magicTodosList: 'magic-todos:list',
magicTodosUpdate: 'magic-todos:update',
magicTodosAnalyze: 'magic-todos:analyze',
knowledgeSnapshot: 'knowledge:snapshot',
knowledgeCreateLibrary: 'knowledge:library:create',
+11
View File
@@ -205,6 +205,17 @@ export const magicTodoIdSchema = z
})
.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 =
| 'narrative'
| 'summary'