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
+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;