💄 style: improve todo list (#11533)

* fix todo with wip

* update

* fix tests

* fix url
This commit is contained in:
Arvin Xu
2026-01-16 18:30:12 +08:00
committed by GitHub
parent 212d8e3630
commit a4b71e97cd
22 changed files with 358 additions and 695 deletions

View File

@@ -1607,8 +1607,8 @@ describe('AgentRuntime', () => {
const stepContext = {
todos: {
items: [
{ text: 'Buy milk', completed: false },
{ text: 'Call mom', completed: true },
{ text: 'Buy milk', status: 'todo' as const },
{ text: 'Call mom', status: 'completed' as const },
],
updatedAt: '2024-06-01T00:00:00.000Z',
},
@@ -1634,7 +1634,7 @@ describe('AgentRuntime', () => {
stepContext: expect.objectContaining({
todos: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ text: 'Buy milk', completed: false }),
expect.objectContaining({ text: 'Buy milk', status: 'todo' }),
]),
}),
}),
@@ -1666,7 +1666,7 @@ describe('AgentRuntime', () => {
const stepContext = {
todos: {
items: [{ text: 'Task 1', completed: false }],
items: [{ text: 'Task 1', status: 'todo' as const }],
updatedAt: '2024-06-01T00:00:00.000Z',
},
};
@@ -1746,7 +1746,7 @@ describe('AgentRuntime', () => {
const stepContext = {
todos: {
items: [{ text: 'Batch task', completed: false }],
items: [{ text: 'Batch task', status: 'todo' as const }],
updatedAt: '2024-06-01T00:00:00.000Z',
},
};

View File

@@ -7,8 +7,8 @@ describe('computeStepContext', () => {
it('should include todos when provided', () => {
const todos = {
items: [
{ text: 'Buy milk', completed: false },
{ text: 'Call mom', completed: true },
{ text: 'Buy milk', status: 'todo' as const },
{ text: 'Call mom', status: 'completed' as const },
],
updatedAt: '2024-06-01T00:00:00.000Z',
};
@@ -18,9 +18,9 @@ describe('computeStepContext', () => {
expect(result.todos).toBeDefined();
expect(result.todos?.items).toHaveLength(2);
expect(result.todos?.items[0].text).toBe('Buy milk');
expect(result.todos?.items[0].completed).toBe(false);
expect(result.todos?.items[0].status).toBe('todo');
expect(result.todos?.items[1].text).toBe('Call mom');
expect(result.todos?.items[1].completed).toBe(true);
expect(result.todos?.items[1].status).toBe('completed');
});
it('should not include todos key when undefined', () => {
@@ -42,7 +42,7 @@ describe('computeStepContext', () => {
// This should compile and work - object param allows future extensions
const result = computeStepContext({
todos: {
items: [{ text: 'Task', completed: false }],
items: [{ text: 'Task', status: 'todo' as const }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});

View File

@@ -1,52 +0,0 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { CheckCircle } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { oneLineEllipsis, shinyTextStyles } from '@/styles';
import type { CompleteTodosParams, CompleteTodosState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
title: css`
margin-inline-end: 8px;
color: ${cssVar.colorText};
`,
}));
export const CompleteTodosInspector = memo<
BuiltinInspectorProps<CompleteTodosParams, CompleteTodosState>
>(({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const indices = args?.indices || partialArgs?.indices || [];
const count = indices.length;
if (isArgumentsStreaming && count === 0) {
return (
<div className={cx(oneLineEllipsis, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-gtd.apiName.completeTodos')}</span>
</div>
);
}
return (
<div className={cx(oneLineEllipsis, isArgumentsStreaming && shinyTextStyles.shinyText)}>
<span className={styles.title}>{t('builtins.lobe-gtd.apiName.completeTodos')}</span>
{count > 0 && (
<Text as={'span'} code color={cssVar.colorSuccess} fontSize={12}>
<Icon icon={CheckCircle} size={12} />
{count}
</Text>
)}
</div>
);
});
CompleteTodosInspector.displayName = 'CompleteTodosInspector';
export default CompleteTodosInspector;

View File

@@ -1,52 +0,0 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Minus } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { oneLineEllipsis, shinyTextStyles } from '@/styles';
import type { RemoveTodosParams, RemoveTodosState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
title: css`
margin-inline-end: 8px;
color: ${cssVar.colorText};
`,
}));
export const RemoveTodosInspector = memo<
BuiltinInspectorProps<RemoveTodosParams, RemoveTodosState>
>(({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const indices = args?.indices || partialArgs?.indices || [];
const count = indices.length;
if (isArgumentsStreaming && count === 0) {
return (
<div className={cx(oneLineEllipsis, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-gtd.apiName.removeTodos')}</span>
</div>
);
}
return (
<div className={cx(oneLineEllipsis, isArgumentsStreaming && shinyTextStyles.shinyText)}>
<span className={styles.title}>{t('builtins.lobe-gtd.apiName.removeTodos')}</span>
{count > 0 && (
<Text as={'span'} code color={cssVar.colorError} fontSize={12}>
<Icon icon={Minus} size={12} />
{count}
</Text>
)}
</div>
);
});
RemoveTodosInspector.displayName = 'RemoveTodosInspector';
export default RemoveTodosInspector;

View File

@@ -2,12 +2,10 @@ import { type BuiltinInspector } from '@lobechat/types';
import { GTDApiName } from '../../types';
import { ClearTodosInspector } from './ClearTodos';
import { CompleteTodosInspector } from './CompleteTodos';
import { CreatePlanInspector } from './CreatePlan';
import { CreateTodosInspector } from './CreateTodos';
import { ExecTaskInspector } from './ExecTask';
import { ExecTasksInspector } from './ExecTasks';
import { RemoveTodosInspector } from './RemoveTodos';
import { UpdatePlanInspector } from './UpdatePlan';
import { UpdateTodosInspector } from './UpdateTodos';
@@ -19,12 +17,10 @@ import { UpdateTodosInspector } from './UpdateTodos';
*/
export const GTDInspectors: Record<string, BuiltinInspector> = {
[GTDApiName.clearTodos]: ClearTodosInspector as BuiltinInspector,
[GTDApiName.completeTodos]: CompleteTodosInspector as BuiltinInspector,
[GTDApiName.createPlan]: CreatePlanInspector as BuiltinInspector,
[GTDApiName.createTodos]: CreateTodosInspector as BuiltinInspector,
[GTDApiName.execTask]: ExecTaskInspector as BuiltinInspector,
[GTDApiName.execTasks]: ExecTasksInspector as BuiltinInspector,
[GTDApiName.removeTodos]: RemoveTodosInspector as BuiltinInspector,
[GTDApiName.updatePlan]: UpdatePlanInspector as BuiltinInspector,
[GTDApiName.updateTodos]: UpdateTodosInspector as BuiltinInspector,
};

View File

@@ -17,7 +17,7 @@ const AddTodoIntervention = memo<BuiltinInterventionProps<CreateTodosParams>>(
// - Initial AI input: { adds: string[] } (from AI)
// - After user edit: { items: TodoItem[] } (saved format)
const defaultItems: TodoItem[] =
args?.items || args?.adds?.map((text) => ({ completed: false, text })) || [];
args?.items || args?.adds?.map((text) => ({ status: 'todo', text })) || [];
const handleSave = useCallback(
async (items: TodoItem[]) => {

View File

@@ -1,11 +1,12 @@
'use client';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Block, Checkbox } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { Block, Checkbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { CircleArrowRight } from 'lucide-react';
import { memo } from 'react';
import type { TodoItem, TodoList as TodoListType } from '../../../types';
import type { TodoItem, TodoList as TodoListType, TodoStatus } from '../../../types';
export interface TodoListRenderState {
todos?: TodoListType;
@@ -23,30 +24,58 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
border-block-end: none;
}
`,
textChecked: css`
processingRow: css`
display: flex;
gap: 7px;
align-items: center;
`,
textCompleted: css`
color: ${cssVar.colorTextQuaternary};
text-decoration: line-through;
`,
textProcessing: css`
color: ${cssVar.colorText};
`,
textTodo: css`
color: ${cssVar.colorTextSecondary};
`,
}));
interface ReadOnlyTodoItemProps {
completed: boolean;
status: TodoStatus;
text: string;
}
/**
* Read-only todo item row, matching the style of TodoItemRow in SortableTodoList
*/
const ReadOnlyTodoItem = memo<ReadOnlyTodoItemProps>(({ text, completed }) => {
const ReadOnlyTodoItem = memo<ReadOnlyTodoItemProps>(({ text, status }) => {
const isCompleted = status === 'completed';
const isProcessing = status === 'processing';
// Processing state uses CircleArrowRight icon
if (isProcessing) {
return (
<div className={cx(styles.itemRow, styles.processingRow)}>
<Icon icon={CircleArrowRight} size={17} style={{ color: cssVar.colorTextSecondary }} />
<span className={styles.textProcessing}>{text}</span>
</div>
);
}
// Todo and completed states use Checkbox
return (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={completed}
classNames={{ text: completed ? styles.textChecked : undefined, wrapper: styles.itemRow }}
checked={isCompleted}
classNames={{
text: cx(styles.textTodo, isCompleted && styles.textCompleted),
wrapper: styles.itemRow,
}}
shape={'circle'}
style={{ borderWidth: 1.5, cursor: 'default' }}
textProps={{
type: completed ? 'secondary' : undefined,
type: isCompleted ? 'secondary' : undefined,
}}
>
{text}
@@ -73,7 +102,7 @@ const TodoListUI = memo<TodoListUIProps>(({ items }) => {
// Outer container with background - matches AddTodoIntervention
<Block variant={'outlined'} width="100%">
{items.map((item, index) => (
<ReadOnlyTodoItem completed={item.completed} key={index} text={item.text} />
<ReadOnlyTodoItem key={index} status={item.status} text={item.text} />
))}
</Block>
);

View File

@@ -14,9 +14,7 @@ import TodoListRender from './TodoList';
export const GTDRenders = {
// All todo operations render the same TodoList UI
[GTDApiName.clearTodos]: TodoListRender,
[GTDApiName.completeTodos]: TodoListRender,
[GTDApiName.createTodos]: TodoListRender,
[GTDApiName.removeTodos]: TodoListRender,
[GTDApiName.updateTodos]: TodoListRender,
// Plan operations render the PlanCard UI

View File

@@ -1,9 +1,9 @@
'use client';
import { ActionIcon, Checkbox, Flexbox, SortableList } from '@lobehub/ui';
import { ActionIcon, Checkbox, Flexbox, Icon, SortableList } from '@lobehub/ui';
import { Input, InputRef } from 'antd';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Trash2 } from 'lucide-react';
import { CircleArrowRight, Trash2 } from 'lucide-react';
import { ChangeEvent, KeyboardEvent, memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -34,10 +34,13 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}
}
`,
textChecked: css`
textCompleted: css`
color: ${cssVar.colorTextQuaternary};
text-decoration: line-through;
`,
textProcessing: css`
color: ${cssVar.colorWarningText};
`,
}));
interface TodoItemRowProps {
@@ -53,7 +56,9 @@ const TodoItemRow = memo<TodoItemRowProps>(({ id, placeholder }) => {
// Find item by stable id
const item = useTodoListStore((s) => s.items.find((item) => item.id === id));
const text = item?.text ?? '';
const completed = item?.completed ?? false;
const status = item?.status ?? 'todo';
const isCompleted = status === 'completed';
const isProcessing = status === 'processing';
const focusedId = useTodoListStore((s) => s.focusedId);
const cursorPosition = useTodoListStore((s) => s.cursorPosition);
@@ -123,15 +128,24 @@ const TodoItemRow = memo<TodoItemRowProps>(({ id, placeholder }) => {
return (
<Flexbox align="center" className={styles.itemRow} gap={4} horizontal width="100%">
<SortableList.DragHandle className={cx(styles.dragHandle, 'drag-handle')} size="small" />
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={completed}
onChange={handleToggle}
shape={'circle'}
style={{ borderWidth: 1.5 }}
/>
{isProcessing ? (
<Icon
icon={CircleArrowRight}
onClick={handleToggle}
size={16}
style={{ color: cssVar.colorInfo, cursor: 'pointer', flexShrink: 0 }}
/>
) : (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={isCompleted}
onChange={handleToggle}
shape={'circle'}
style={{ borderWidth: 1.5 }}
/>
)}
<Input
className={cx(completed && styles.textChecked)}
className={cx(isCompleted && styles.textCompleted, isProcessing && styles.textProcessing)}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}

View File

@@ -1,6 +1,6 @@
import { debounce } from 'es-toolkit/compat';
import type { TodoItem } from '../../../../types';
import { getNextTodoStatus, type TodoItem } from '../../../../types';
import { AUTO_SAVE_DELAY, AUTO_SAVE_MAX_WAIT, initialState } from './initialState';
import type { StoreInternals, TodoListItem, TodoListStore } from './types';
import { ADD_ITEM_ID } from './types';
@@ -37,7 +37,7 @@ export const createActions = (
try {
// Convert TodoListItem[] to TodoItem[] (remove id)
const todoItems: TodoItem[] = items.map(({ completed, text }) => ({ completed, text }));
const todoItems: TodoItem[] = items.map(({ status, text }) => ({ status, text }));
console.log('[performSave] calling onSave with', todoItems.length, 'items');
await internals.onSave(todoItems);
console.log('[performSave] onSave completed');
@@ -69,7 +69,7 @@ export const createActions = (
if (!newItemText.trim()) return;
set({
items: [...items, { completed: false, id: generateId(), text: newItemText.trim() }],
items: [...items, { id: generateId(), status: 'todo', text: newItemText.trim() }],
newItemText: '',
});
markDirtyAndSave();
@@ -97,7 +97,7 @@ export const createActions = (
set({ saveStatus: 'saving' });
try {
const todoItems: TodoItem[] = items.map(({ completed, text }) => ({ completed, text }));
const todoItems: TodoItem[] = items.map(({ status, text }) => ({ status, text }));
console.log('[saveNow] force saving', todoItems.length, 'items');
await internals.onSave(todoItems);
console.log('[saveNow] save completed');
@@ -160,7 +160,7 @@ export const createActions = (
const { items } = get();
set({
items: items.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item,
item.id === id ? { ...item, status: getNextTodoStatus(item.status) } : item,
),
});
markDirtyAndSave();

View File

@@ -8,7 +8,7 @@ import { ADD_ITEM_ID } from './types';
// Helper to create TodoItem array from text strings
const toTodoItems = (...texts: string[]): TodoItem[] =>
texts.map((text) => ({ completed: false, text }));
texts.map((text) => ({ status: 'todo', text }));
describe('TodoListStore', () => {
beforeEach(() => {
@@ -39,7 +39,7 @@ describe('TodoListStore', () => {
expect(state.items).toHaveLength(2);
expect(state.items[0].text).toBe('Task 1');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].status).toBe('todo');
expect(state.items[1].text).toBe('Task 2');
});
@@ -63,7 +63,7 @@ describe('TodoListStore', () => {
const state = store.getState();
expect(state.items).toHaveLength(1);
expect(state.items[0].text).toBe('New Task');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].status).toBe('todo');
expect(state.newItemText).toBe('');
});
@@ -152,23 +152,29 @@ describe('TodoListStore', () => {
});
describe('toggleItem', () => {
it('should toggle item completed state', () => {
it('should cycle through status: todo → processing → completed → todo', () => {
const store = createTodoListStore(toTodoItems('Task 1'));
const itemId = store.getState().items[0].id;
expect(store.getState().items[0].completed).toBe(false);
expect(store.getState().items[0].status).toBe('todo');
act(() => {
store.getState().toggleItem(itemId);
});
expect(store.getState().items[0].completed).toBe(true);
expect(store.getState().items[0].status).toBe('processing');
act(() => {
store.getState().toggleItem(itemId);
});
expect(store.getState().items[0].completed).toBe(false);
expect(store.getState().items[0].status).toBe('completed');
act(() => {
store.getState().toggleItem(itemId);
});
expect(store.getState().items[0].status).toBe('todo');
});
it('should mark store as dirty after toggling item', () => {
@@ -362,7 +368,7 @@ describe('TodoListStore', () => {
expect(onSave).toHaveBeenCalledTimes(1);
// onSave receives TodoItem[] (without id), not TodoListItem[]
expect(onSave).toHaveBeenCalledWith([{ completed: false, text: 'Updated' }]);
expect(onSave).toHaveBeenCalledWith([{ status: 'todo', text: 'Updated' }]);
});
it('should set saveStatus to saving during save', async () => {

View File

@@ -22,7 +22,7 @@ describe('GTDExecutor', () => {
expect(result.content).toContain('Call mom');
expect(result.state?.todos.items).toHaveLength(2);
expect(result.state?.todos.items[0].text).toBe('Buy milk');
expect(result.state?.todos.items[0].completed).toBe(false);
expect(result.state?.todos.items[0].status).toBe('todo');
expect(result.state?.todos.items[1].text).toBe('Call mom');
});
@@ -32,8 +32,8 @@ describe('GTDExecutor', () => {
const result = await gtdExecutor.createTodos(
{
items: [
{ text: 'Buy milk', completed: false },
{ text: 'Call mom', completed: true },
{ text: 'Buy milk', status: 'todo' },
{ text: 'Call mom', status: 'completed' },
],
},
ctx,
@@ -43,15 +43,15 @@ describe('GTDExecutor', () => {
expect(result.content).toContain('Added 2 items');
expect(result.state?.todos.items).toHaveLength(2);
expect(result.state?.todos.items[0].text).toBe('Buy milk');
expect(result.state?.todos.items[0].completed).toBe(false);
expect(result.state?.todos.items[0].status).toBe('todo');
expect(result.state?.todos.items[1].text).toBe('Call mom');
expect(result.state?.todos.items[1].completed).toBe(true);
expect(result.state?.todos.items[1].status).toBe('completed');
});
it('should append items to existing todo list', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Existing task', completed: false }],
items: [{ text: 'Existing task', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
@@ -89,7 +89,7 @@ describe('GTDExecutor', () => {
const result = await gtdExecutor.createTodos(
{
adds: ['AI task'],
items: [{ text: 'User edited task', completed: true }],
items: [{ text: 'User edited task', status: 'completed' }],
},
ctx,
);
@@ -97,7 +97,7 @@ describe('GTDExecutor', () => {
expect(result.success).toBe(true);
expect(result.state?.todos.items).toHaveLength(1);
expect(result.state?.todos.items[0].text).toBe('User edited task');
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[0].status).toBe('completed');
});
});
@@ -105,7 +105,7 @@ describe('GTDExecutor', () => {
it('should add new items via operations', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Existing task', completed: false }],
items: [{ text: 'Existing task', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
@@ -125,7 +125,7 @@ describe('GTDExecutor', () => {
it('should update item text via operations', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Old task', completed: false }],
items: [{ text: 'Old task', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
@@ -144,7 +144,7 @@ describe('GTDExecutor', () => {
it('should complete items via operations', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Task', completed: false }],
items: [{ text: 'Task', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
@@ -157,15 +157,15 @@ describe('GTDExecutor', () => {
);
expect(result.success).toBe(true);
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[0].status).toBe('completed');
});
it('should remove items via operations', async () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
{ text: 'Task 1', status: 'todo' },
{ text: 'Task 2', status: 'todo' },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
@@ -198,11 +198,11 @@ describe('GTDExecutor', () => {
createdItems: ['Task A', 'Task B', 'Task C', 'Task D', 'Task E'],
todos: {
items: [
{ completed: false, text: 'Task A' },
{ completed: false, text: 'Task B' },
{ completed: false, text: 'Task C' },
{ completed: false, text: 'Task D' },
{ completed: false, text: 'Task E' },
{ status: 'todo', text: 'Task A' },
{ status: 'todo', text: 'Task B' },
{ status: 'todo', text: 'Task C' },
{ status: 'todo', text: 'Task D' },
{ status: 'todo', text: 'Task E' },
],
updatedAt: '2025-01-01T00:00:00.000Z',
},
@@ -211,8 +211,8 @@ describe('GTDExecutor', () => {
const result = await gtdExecutor.updateTodos(
{
operations: [
{ completed: true, index: 5, newText: '', text: '', type: 'complete' }, // out of range
{ completed: true, index: 2, newText: '', text: '', type: 'complete' }, // valid
{ index: 5, type: 'complete' }, // out of range
{ index: 2, type: 'complete' }, // valid
],
},
ctx,
@@ -223,146 +223,12 @@ describe('GTDExecutor', () => {
expect(result.state?.todos.items).toHaveLength(5);
// Index 5 is out of range (0-4), so should be skipped
// Index 2 should be completed
expect(result.state?.todos.items[2].completed).toBe(true);
expect(result.state?.todos.items[2].status).toBe('completed');
// Other items should remain uncompleted
expect(result.state?.todos.items[0].completed).toBe(false);
expect(result.state?.todos.items[1].completed).toBe(false);
expect(result.state?.todos.items[3].completed).toBe(false);
expect(result.state?.todos.items[4].completed).toBe(false);
});
});
describe('completeTodos', () => {
it('should mark items as done by indices', async () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
{ text: 'Task 3', completed: false },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
const result = await gtdExecutor.completeTodos({ indices: [0, 2] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('Completed 2 items');
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[1].completed).toBe(false);
expect(result.state?.todos.items[2].completed).toBe(true);
});
it('should return error when no indices provided', async () => {
const ctx = createMockContext();
const result = await gtdExecutor.completeTodos({ indices: [] }, ctx);
expect(result.success).toBe(false);
expect(result.content).toContain('No indices provided');
});
it('should handle empty todo list', async () => {
const ctx = createMockContext({
todos: { items: [], updatedAt: '2024-01-01T00:00:00.000Z' },
});
const result = await gtdExecutor.completeTodos({ indices: [0] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('No todos to complete');
});
it('should return error when all indices are invalid', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Task 1', completed: false }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
const result = await gtdExecutor.completeTodos({ indices: [5, 10] }, ctx);
expect(result.success).toBe(false);
expect(result.content).toContain('Invalid indices');
});
it('should complete valid indices and warn about invalid ones', async () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
const result = await gtdExecutor.completeTodos({ indices: [0, 99] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('Completed 1 item');
expect(result.content).toContain('Ignored invalid indices');
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[1].completed).toBe(false);
});
it('should handle single item completion with correct grammar', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Task 1', completed: false }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
const result = await gtdExecutor.completeTodos({ indices: [0] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('Completed 1 item');
expect(result.content).not.toContain('items');
});
});
describe('removeTodos', () => {
it('should remove items by indices', async () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
{ text: 'Task 3', completed: false },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
const result = await gtdExecutor.removeTodos({ indices: [0, 2] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('Removed 2 items');
expect(result.state?.todos.items).toHaveLength(1);
expect(result.state?.todos.items[0].text).toBe('Task 2');
});
it('should return error when no indices provided', async () => {
const ctx = createMockContext();
const result = await gtdExecutor.removeTodos({ indices: [] }, ctx);
expect(result.success).toBe(false);
expect(result.content).toContain('No indices provided');
});
it('should handle empty todo list', async () => {
const ctx = createMockContext({
todos: { items: [], updatedAt: '2024-01-01T00:00:00.000Z' },
});
const result = await gtdExecutor.removeTodos({ indices: [0] }, ctx);
expect(result.success).toBe(true);
expect(result.content).toContain('No todos to remove');
expect(result.state?.todos.items[0].status).toBe('todo');
expect(result.state?.todos.items[1].status).toBe('todo');
expect(result.state?.todos.items[3].status).toBe('todo');
expect(result.state?.todos.items[4].status).toBe('todo');
});
});
@@ -371,8 +237,8 @@ describe('GTDExecutor', () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: true },
{ text: 'Task 1', status: 'todo' },
{ text: 'Task 2', status: 'completed' },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
@@ -389,9 +255,9 @@ describe('GTDExecutor', () => {
const ctx = createMockContext({
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: true },
{ text: 'Task 3', completed: true },
{ text: 'Task 1', status: 'todo' },
{ text: 'Task 2', status: 'completed' },
{ text: 'Task 3', status: 'completed' },
],
updatedAt: '2024-01-01T00:00:00.000Z',
},
@@ -421,7 +287,7 @@ describe('GTDExecutor', () => {
it('should handle no completed items to clear', async () => {
const ctx = createMockContext({
todos: {
items: [{ text: 'Task 1', completed: false }],
items: [{ text: 'Task 1', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
});
@@ -442,13 +308,13 @@ describe('GTDExecutor', () => {
operationId: 'test-operation-id',
pluginState: {
todos: {
items: [{ text: 'Old task from pluginState', completed: false }],
items: [{ text: 'Old task from pluginState', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
},
stepContext: {
todos: {
items: [{ text: 'New task from stepContext', completed: true }],
items: [{ text: 'New task from stepContext', status: 'completed' }],
updatedAt: '2024-06-01T00:00:00.000Z',
},
},
@@ -461,7 +327,7 @@ describe('GTDExecutor', () => {
expect(result.state?.todos.items).toHaveLength(2);
// First item should be from stepContext, not pluginState
expect(result.state?.todos.items[0].text).toBe('New task from stepContext');
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[0].status).toBe('completed');
expect(result.state?.todos.items[1].text).toBe('Another task');
});
@@ -471,7 +337,7 @@ describe('GTDExecutor', () => {
operationId: 'test-operation-id',
pluginState: {
todos: {
items: [{ text: 'Task from pluginState', completed: false }],
items: [{ text: 'Task from pluginState', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
},
},
@@ -502,50 +368,6 @@ describe('GTDExecutor', () => {
expect(result.state?.todos.items[0].text).toBe('First task');
});
it('should work with stepContext.todos for completeTodos', async () => {
const ctx: BuiltinToolContext = {
messageId: 'test-message-id',
operationId: 'test-operation-id',
stepContext: {
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
],
updatedAt: '2024-06-01T00:00:00.000Z',
},
},
};
const result = await gtdExecutor.completeTodos({ indices: [0] }, ctx);
expect(result.success).toBe(true);
expect(result.state?.todos.items[0].completed).toBe(true);
expect(result.state?.todos.items[1].completed).toBe(false);
});
it('should work with stepContext.todos for removeTodos', async () => {
const ctx: BuiltinToolContext = {
messageId: 'test-message-id',
operationId: 'test-operation-id',
stepContext: {
todos: {
items: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: false },
],
updatedAt: '2024-06-01T00:00:00.000Z',
},
},
};
const result = await gtdExecutor.removeTodos({ indices: [0] }, ctx);
expect(result.success).toBe(true);
expect(result.state?.todos.items).toHaveLength(1);
expect(result.state?.todos.items[0].text).toBe('Task 2');
});
it('should work with stepContext.todos for clearTodos', async () => {
const ctx: BuiltinToolContext = {
messageId: 'test-message-id',
@@ -553,8 +375,8 @@ describe('GTDExecutor', () => {
stepContext: {
todos: {
items: [
{ text: 'Task 1', completed: true },
{ text: 'Task 2', completed: false },
{ text: 'Task 1', status: 'completed' },
{ text: 'Task 2', status: 'todo' },
],
updatedAt: '2024-06-01T00:00:00.000Z',
},
@@ -574,27 +396,26 @@ describe('GTDExecutor', () => {
expect(gtdExecutor.identifier).toBe('lobe-gtd');
});
it('should support all MVP APIs', () => {
it('should support all APIs', () => {
expect(gtdExecutor.hasApi('createTodos')).toBe(true);
expect(gtdExecutor.hasApi('updateTodos')).toBe(true);
expect(gtdExecutor.hasApi('completeTodos')).toBe(true);
expect(gtdExecutor.hasApi('removeTodos')).toBe(true);
expect(gtdExecutor.hasApi('clearTodos')).toBe(true);
});
it('should not support non-MVP APIs', () => {
expect(gtdExecutor.hasApi('createPlan')).toBe(false);
expect(gtdExecutor.hasApi('updatePlan')).toBe(false);
expect(gtdExecutor.hasApi('createPlan')).toBe(true);
expect(gtdExecutor.hasApi('updatePlan')).toBe(true);
expect(gtdExecutor.hasApi('execTask')).toBe(true);
expect(gtdExecutor.hasApi('execTasks')).toBe(true);
});
it('should return correct API names', () => {
const apiNames = gtdExecutor.getApiNames();
expect(apiNames).toContain('createTodos');
expect(apiNames).toContain('updateTodos');
expect(apiNames).toContain('completeTodos');
expect(apiNames).toContain('removeTodos');
expect(apiNames).toContain('clearTodos');
expect(apiNames).toHaveLength(5);
expect(apiNames).toContain('createPlan');
expect(apiNames).toContain('updatePlan');
expect(apiNames).toContain('execTask');
expect(apiNames).toContain('execTasks');
expect(apiNames).toHaveLength(7);
});
});
});

View File

@@ -7,14 +7,12 @@ import { useNotebookStore } from '@/store/notebook';
import { GTDIdentifier } from '../manifest';
import {
type ClearTodosParams,
type CompleteTodosParams,
type CreatePlanParams,
type CreateTodosParams,
type ExecTaskParams,
type ExecTasksParams,
GTDApiName,
type Plan,
type RemoveTodosParams,
type TodoItem,
type TodoState,
type UpdatePlanParams,
@@ -49,12 +47,10 @@ const syncTodosToPlan = async (topicId: string, todos: TodoState): Promise<void>
// API enum for MVP (Todo + Plan)
const GTDApiNameEnum = {
clearTodos: GTDApiName.clearTodos,
completeTodos: GTDApiName.completeTodos,
createPlan: GTDApiName.createPlan,
createTodos: GTDApiName.createTodos,
execTask: GTDApiName.execTask,
execTasks: GTDApiName.execTasks,
removeTodos: GTDApiName.removeTodos,
updatePlan: GTDApiName.updatePlan,
updateTodos: GTDApiName.updateTodos,
} as const;
@@ -82,7 +78,7 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
const itemsToAdd: TodoItem[] = params.items
? params.items
: params.adds
? params.adds.map((text) => ({ completed: false, text }))
? params.adds.map((text) => ({ status: 'todo' as const, text }))
: [];
if (itemsToAdd.length === 0) {
@@ -144,7 +140,7 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
switch (op.type) {
case 'add': {
if (op.text) {
updatedTodos.push({ completed: false, text: op.text });
updatedTodos.push({ status: 'todo', text: op.text });
results.push(`Added: "${op.text}"`);
}
break;
@@ -156,8 +152,9 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
if (op.newText !== undefined) {
updatedItem.text = op.newText;
}
if (op.completed !== undefined) {
updatedItem.completed = op.completed;
// Handle status field
if (op.status !== undefined) {
updatedItem.status = op.status;
}
updatedTodos[op.index] = updatedItem;
results.push(`Updated item ${op.index + 1}`);
@@ -174,11 +171,19 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
case 'complete': {
if (op.index !== undefined && op.index >= 0 && op.index < updatedTodos.length) {
// Create a new object to avoid mutating frozen/immutable objects from store
updatedTodos[op.index] = { ...updatedTodos[op.index], completed: true };
updatedTodos[op.index] = { ...updatedTodos[op.index], status: 'completed' };
results.push(`Completed: "${updatedTodos[op.index].text}"`);
}
break;
}
case 'processing': {
if (op.index !== undefined && op.index >= 0 && op.index < updatedTodos.length) {
// Create a new object to avoid mutating frozen/immutable objects from store
updatedTodos[op.index] = { ...updatedTodos[op.index], status: 'processing' };
results.push(`In progress: "${updatedTodos[op.index].text}"`);
}
break;
}
}
}
@@ -206,154 +211,6 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
};
};
/**
* Mark todo items as done by their indices
*/
completeTodos = async (
params: CompleteTodosParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const { indices } = params;
if (!indices || indices.length === 0) {
return {
content: 'No indices provided to complete.',
success: false,
};
}
const existingTodos = getTodosFromContext(ctx);
if (existingTodos.length === 0) {
const now = new Date().toISOString();
return {
content: 'No todos to complete. The list is empty.\n\n' + formatTodoStateSummary([], now),
state: {
completedIndices: [],
todos: { items: [], updatedAt: now },
},
success: true,
};
}
// Validate indices
const validIndices = indices.filter((i: number) => i >= 0 && i < existingTodos.length);
const invalidIndices = indices.filter((i: number) => i < 0 || i >= existingTodos.length);
if (validIndices.length === 0) {
return {
content: `Invalid indices: ${indices.join(', ')}. Valid range is 0-${existingTodos.length - 1}.`,
success: false,
};
}
// Mark items as completed
const updatedTodos = existingTodos.map((todo, index) => {
if (validIndices.includes(index)) {
return { ...todo, completed: true };
}
return todo;
});
const completedItems = validIndices.map((i: number) => existingTodos[i].text);
const now = new Date().toISOString();
// Format response: action summary + todo state
let actionSummary = `✔️ Completed ${validIndices.length} item${validIndices.length > 1 ? 's' : ''}:\n`;
actionSummary += completedItems.map((text: string) => `- ${text}`).join('\n');
if (invalidIndices.length > 0) {
actionSummary += `\n\nNote: Ignored invalid indices: ${invalidIndices.join(', ')}`;
}
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
completedIndices: validIndices,
todos: todoState,
},
success: true,
};
};
/**
* Remove todo items by indices
*/
removeTodos = async (
params: RemoveTodosParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const { indices } = params;
if (!indices || indices.length === 0) {
return {
content: 'No indices provided to remove.',
success: false,
};
}
const existingTodos = getTodosFromContext(ctx);
if (existingTodos.length === 0) {
const now = new Date().toISOString();
return {
content: 'No todos to remove. The list is empty.\n\n' + formatTodoStateSummary([], now),
state: {
removedIndices: [],
todos: { items: [], updatedAt: now },
},
success: true,
};
}
// Validate indices
const validIndices = indices.filter((i: number) => i >= 0 && i < existingTodos.length);
const invalidIndices = indices.filter((i: number) => i < 0 || i >= existingTodos.length);
if (validIndices.length === 0) {
return {
content: `Invalid indices: ${indices.join(', ')}. Valid range is 0-${existingTodos.length - 1}.`,
success: false,
};
}
// Remove items
const removedItems = validIndices.map((i: number) => existingTodos[i].text);
const updatedTodos = existingTodos.filter((_, index) => !validIndices.includes(index));
const now = new Date().toISOString();
// Format response: action summary + todo state
let actionSummary = `🗑️ Removed ${validIndices.length} item${validIndices.length > 1 ? 's' : ''}:\n`;
actionSummary += removedItems.map((text: string) => `- ${text}`).join('\n');
if (invalidIndices.length > 0) {
actionSummary += `\n\nNote: Ignored invalid indices: ${invalidIndices.join(', ')}`;
}
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
removedIndices: validIndices,
todos: todoState,
},
success: true,
};
};
/**
* Clear todo items
*/
@@ -388,7 +245,7 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
actionSummary = `🧹 Cleared all ${clearedCount} item${clearedCount > 1 ? 's' : ''} from todo list.`;
} else {
// mode === 'completed'
updatedTodos = existingTodos.filter((todo) => !todo.completed);
updatedTodos = existingTodos.filter((todo) => todo.status !== 'completed');
clearedCount = existingTodos.length - updatedTodos.length;
if (clearedCount === 0) {

View File

@@ -84,22 +84,23 @@ export const GTDManifest: BuiltinToolManifest = {
{
description: `Update todo items with batch operations. Each operation type requires specific fields:
- "add": requires "text" (the todo text to add)
- "update": requires "index", optional "newText" and/or "completed"
- "update": requires "index", optional "newText" and/or "status"
- "remove": requires "index" only
- "complete": requires "index" only (marks item as completed)`,
- "complete": requires "index" only (marks item as completed)
- "processing": requires "index" only (marks item as in progress)`,
name: GTDApiName.updateTodos,
renderDisplayControl: 'expand',
parameters: {
properties: {
operations: {
description:
'Array of update operations. IMPORTANT: For "complete" and "remove" operations, only pass "type" and "index" - no other fields needed.',
'Array of update operations. IMPORTANT: For "complete", "processing" and "remove" operations, only pass "type" and "index" - no other fields needed.',
items: {
properties: {
type: {
description:
'Operation type. "add" needs text, "update" needs index + optional newText/completed, "remove" and "complete" need index only.',
enum: ['add', 'update', 'remove', 'complete'],
'Operation type. "add" needs text, "update" needs index + optional newText/status, "remove", "complete" and "processing" need index only.',
enum: ['add', 'update', 'remove', 'complete', 'processing'],
type: 'string',
},
text: {
@@ -108,16 +109,18 @@ export const GTDManifest: BuiltinToolManifest = {
},
index: {
description:
'Required for "update", "remove", "complete": the item index (0-based).',
'Required for "update", "remove", "complete", "processing": the item index (0-based).',
type: 'number',
},
newText: {
description: 'Optional for "update" only: the new text.',
type: 'string',
},
completed: {
description: 'Optional for "update" only: set completed status.',
type: 'boolean',
status: {
description:
'Optional for "update" only: set status (todo, processing, completed).',
enum: ['todo', 'processing', 'completed'],
type: 'string',
},
},
required: ['type'],
@@ -130,39 +133,6 @@ export const GTDManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description: 'Mark todo items as completed by their indices (0-based).',
name: GTDApiName.completeTodos,
renderDisplayControl: 'expand',
parameters: {
properties: {
indices: {
description: 'Array of item indices (0-based) to mark as completed.',
items: { type: 'number' },
type: 'array',
},
},
required: ['indices'],
type: 'object',
},
},
{
description: 'Remove todo items by their indices (0-based).',
name: GTDApiName.removeTodos,
humanIntervention: 'always',
renderDisplayControl: 'expand',
parameters: {
properties: {
indices: {
description: 'Array of item indices (0-based) to remove.',
items: { type: 'number' },
type: 'array',
},
},
required: ['indices'],
type: 'object',
},
},
{
description: 'Clear todo items. Can clear only completed items or all items.',
name: GTDApiName.clearTodos,

View File

@@ -11,11 +11,11 @@ export const systemPrompt = `You have GTD (Getting Things Done) tools to help ma
**Todo Tools** - For actionable execution items:
- \`createTodos\`: Create new todo items from text array
- \`updateTodos\`: Batch update todos (add, update, remove, complete operations)
- \`completeTodos\`: Mark items as done by indices
- \`removeTodos\`: Remove items by indices
- \`updateTodos\`: Batch update todos (add, update, remove, complete, processing operations)
- \`clearTodos\`: Clear completed or all items
**Todo Status Workflow:** todo → processing → completed (use "processing" when actively working on an item)
**Async Task Tools** - For long-running background tasks:
- \`execTask\`: Execute a single async task in isolated context
- \`execTasks\`: Execute multiple async tasks in parallel
@@ -98,23 +98,29 @@ Use \`execTask\` for a single task, \`execTasks\` for multiple parallel tasks.
<updateTodos_usage>
When using \`updateTodos\`, each operation type requires specific fields:
**Todo Status:**
- \`todo\`: Not started yet
- \`processing\`: Currently in progress
- \`completed\`: Done
**Minimal required fields per operation type:**
- \`{ "type": "add", "text": "todo text" }\` - only type + text
- \`{ "type": "complete", "index": 0 }\` - only type + index
- \`{ "type": "complete", "index": 0 }\` - only type + index (marks as completed)
- \`{ "type": "processing", "index": 0 }\` - only type + index (marks as in progress)
- \`{ "type": "remove", "index": 0 }\` - only type + index
- \`{ "type": "update", "index": 0, "newText": "..." }\` - type + index + optional newText/completed
- \`{ "type": "update", "index": 0, "newText": "..." }\` - type + index + optional newText/status
**Example - mark items 0 and 1 as complete:**
**Example - mark item 0 as processing, item 1 as complete:**
\`\`\`json
{
"operations": [
{ "type": "complete", "index": 0 },
{ "type": "processing", "index": 0 },
{ "type": "complete", "index": 1 }
]
}
\`\`\`
**DO NOT** add extra fields like \`"completed": true\` for complete operations - they are ignored.
**DO NOT** add extra fields like \`"status": "completed"\` for complete/processing operations - they are ignored.
</updateTodos_usage>
<todo_granularity>

View File

@@ -14,44 +14,66 @@ export const GTDApiName = {
/** Clear completed or all todos */
clearTodos: 'clearTodos',
/** Mark todo items as done by indices */
completeTodos: 'completeTodos',
// ==================== Planning ====================
/** Create a structured plan by breaking down a goal into actionable steps */
createPlan: 'createPlan',
/** Create a structured plan by breaking down a goal into actionable steps */
createPlan: 'createPlan',
/** Create new todo items */
createTodos: 'createTodos',
// ==================== Async Tasks ====================
/** Execute a single async task */
execTask: 'execTask',
/** Create new todo items */
createTodos: 'createTodos',
/** Execute one or more async tasks */
execTasks: 'execTasks',
// ==================== Async Tasks ====================
/** Execute a single async task */
execTask: 'execTask',
/** Remove todo items by indices */
removeTodos: 'removeTodos',
/** Update an existing plan */
updatePlan: 'updatePlan',
/** Update todo items with batch operations (add, update, remove, complete) */
updateTodos: 'updateTodos',
/** Execute one or more async tasks */
execTasks: 'execTasks',
/** Update an existing plan */
updatePlan: 'updatePlan',
/** Update todo items with batch operations (add, update, remove, complete, processing) */
updateTodos: 'updateTodos',
} as const;
export type GTDApiNameType = (typeof GTDApiName)[keyof typeof GTDApiName];
// ==================== Todo Item ====================
/** Status of a todo item */
export type TodoStatus = 'todo' | 'processing' | 'completed';
export interface TodoItem {
/** Whether the item is completed */
completed: boolean;
/** Status of the todo item */
status: TodoStatus;
/** The todo item text */
text: string;
}
/** Get the next status in the cycle: todo → processing → completed → todo */
export const getNextTodoStatus = (current: TodoStatus): TodoStatus => {
const cycle: TodoStatus[] = ['todo', 'processing', 'completed'];
const index = cycle.indexOf(current);
return cycle[(index + 1) % cycle.length];
};
export interface TodoList {
items: TodoItem[];
updatedAt: string;
@@ -77,18 +99,18 @@ export interface CreateTodosParams {
/**
* Update operation types for batch updates
*/
export type TodoUpdateOperationType = 'add' | 'update' | 'remove' | 'complete';
export type TodoUpdateOperationType = 'add' | 'update' | 'remove' | 'complete' | 'processing';
/**
* Single update operation
*/
export interface TodoUpdateOperation {
/** For 'update': the new completed status */
completed?: boolean;
/** For 'update', 'remove', 'complete': the index of the item (0-based) */
/** For 'update', 'remove', 'complete', 'processing': the index of the item (0-based) */
index?: number;
/** For 'update': the new text */
newText?: string;
/** For 'update': the new status */
status?: TodoStatus;
/** For 'add': the text to add */
text?: string;
/** Operation type */
@@ -97,29 +119,13 @@ export interface TodoUpdateOperation {
/**
* Update todo list with batch operations
* Supports: add, update, remove, complete
* Supports: add, update, remove, complete, processing
*/
export interface UpdateTodosParams {
/** Array of update operations to apply */
operations: TodoUpdateOperation[];
}
/**
* Mark todo items as completed by indices
*/
export interface CompleteTodosParams {
/** Indices of items to mark as completed (0-based) */
indices: number[];
}
/**
* Remove todo items by indices
*/
export interface RemoveTodosParams {
/** Indices of items to remove (0-based) */
indices: number[];
}
/**
* Clear todo items
*/

View File

@@ -5,12 +5,15 @@ import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:GTDTodoInjector');
/** Status of a todo item */
export type GTDTodoStatus = 'todo' | 'processing' | 'completed';
/**
* GTD Todo item structure
*/
export interface GTDTodoItem {
/** Whether the item is completed */
completed: boolean;
/** Status of the todo item */
status: GTDTodoStatus;
/** The todo item text */
text: string;
}
@@ -43,13 +46,15 @@ function formatGTDTodos(todos: GTDTodoList): string | null {
const lines: string[] = ['<gtd_todos>'];
items.forEach((item, index) => {
const status = item.completed ? 'done' : 'pending';
lines.push(`<todo index="${index}" status="${status}">${item.text}</todo>`);
lines.push(`<todo index="${index}" status="${item.status}">${item.text}</todo>`);
});
const completedCount = items.filter((item) => item.completed).length;
const completedCount = items.filter((item) => item.status === 'completed').length;
const processingCount = items.filter((item) => item.status === 'processing').length;
const totalCount = items.length;
lines.push(`<progress completed="${completedCount}" total="${totalCount}" />`);
lines.push(
`<progress completed="${completedCount}" processing="${processingCount}" total="${totalCount}" />`,
);
lines.push('</gtd_todos>');
@@ -117,7 +122,10 @@ export class GTDTodoInjector extends BaseLastUserContentProvider {
clonedContext.metadata.gtdTodoInjected = true;
clonedContext.metadata.gtdTodoCount = this.config.todos.items.length;
clonedContext.metadata.gtdTodoCompletedCount = this.config.todos.items.filter(
(item) => item.completed,
(item) => item.status === 'completed',
).length;
clonedContext.metadata.gtdTodoProcessingCount = this.config.todos.items.filter(
(item) => item.status === 'processing',
).length;
log('GTD Todo context appended to last user message');

View File

@@ -15,12 +15,12 @@ describe('formatTodoStateSummary', () => {
it('should format todo list with only pending items', () => {
const todos = [
{ text: 'Task A', completed: false },
{ text: 'Task B', completed: false },
{ text: 'Task C', completed: false },
{ text: 'Task A', status: 'todo' as const },
{ text: 'Task B', status: 'todo' as const },
{ text: 'Task C', status: 'todo' as const },
];
expect(formatTodoStateSummary(todos)).toMatchInlineSnapshot(`
"📋 Current Todo List (3 pending, 0 completed):
"📋 Current Todo List (3 todo, 0 processing, 0 completed):
- [ ] Task A
- [ ] Task B
- [ ] Task C"
@@ -29,11 +29,11 @@ describe('formatTodoStateSummary', () => {
it('should format todo list with only completed items', () => {
const todos = [
{ text: 'Done task 1', completed: true },
{ text: 'Done task 2', completed: true },
{ text: 'Done task 1', status: 'completed' as const },
{ text: 'Done task 2', status: 'completed' as const },
];
expect(formatTodoStateSummary(todos)).toMatchInlineSnapshot(`
"📋 Current Todo List (0 pending, 2 completed):
"📋 Current Todo List (0 todo, 0 processing, 2 completed):
- [x] Done task 1
- [x] Done task 2"
`);
@@ -41,12 +41,12 @@ describe('formatTodoStateSummary', () => {
it('should format todo list with mixed items', () => {
const todos = [
{ text: 'Pending task', completed: false },
{ text: 'Completed task', completed: true },
{ text: 'Another pending', completed: false },
{ text: 'Pending task', status: 'todo' as const },
{ text: 'Completed task', status: 'completed' as const },
{ text: 'Another pending', status: 'todo' as const },
];
expect(formatTodoStateSummary(todos)).toMatchInlineSnapshot(`
"📋 Current Todo List (2 pending, 1 completed):
"📋 Current Todo List (2 todo, 0 processing, 1 completed):
- [ ] Pending task
- [x] Completed task
- [ ] Another pending"
@@ -55,21 +55,37 @@ describe('formatTodoStateSummary', () => {
it('should format todo list with timestamp', () => {
const todos = [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: true },
{ text: 'Task 1', status: 'todo' as const },
{ text: 'Task 2', status: 'completed' as const },
];
expect(formatTodoStateSummary(todos, '2025-01-15T10:30:00.000Z')).toMatchInlineSnapshot(`
"📋 Current Todo List (1 pending, 1 completed) | Updated: 2025-01-15T10:30:00.000Z:
"📋 Current Todo List (1 todo, 0 processing, 1 completed) | Updated: 2025-01-15T10:30:00.000Z:
- [ ] Task 1
- [x] Task 2"
`);
});
it('should handle single item', () => {
const todos = [{ text: 'Only task', completed: false }];
const todos = [{ text: 'Only task', status: 'todo' as const }];
expect(formatTodoStateSummary(todos)).toMatchInlineSnapshot(`
"📋 Current Todo List (1 pending, 0 completed):
"📋 Current Todo List (1 todo, 0 processing, 0 completed):
- [ ] Only task"
`);
});
it('should format todo list with processing items', () => {
const todos = [
{ text: 'Todo task', status: 'todo' as const },
{ text: 'Processing task 1', status: 'processing' as const },
{ text: 'Processing task 2', status: 'processing' as const },
{ text: 'Done task', status: 'completed' as const },
];
expect(formatTodoStateSummary(todos)).toMatchInlineSnapshot(`
"📋 Current Todo List (1 todo, 2 processing, 1 completed):
- [ ] Todo task
- [~] Processing task 1
- [~] Processing task 2
- [x] Done task"
`);
});
});

View File

@@ -1,5 +1,7 @@
export type TodoStatus = 'todo' | 'processing' | 'completed';
export interface TodoItem {
completed: boolean;
status: TodoStatus;
text: string;
}
@@ -17,13 +19,15 @@ export const formatTodoStateSummary = (todos: TodoItem[], updatedAt?: string): s
return `📋 Current Todo List: (empty)${timeInfo}`;
}
const completed = todos.filter((t) => t.completed).length;
const pending = todos.length - completed;
const completed = todos.filter((t) => t.status === 'completed').length;
const processing = todos.filter((t) => t.status === 'processing').length;
const pending = todos.length - completed - processing;
const lines = todos.map((item) => {
const checkbox = item.completed ? '- [x]' : '- [ ]';
const checkbox =
item.status === 'completed' ? '- [x]' : item.status === 'processing' ? '- [~]' : '- [ ]';
return `${checkbox} ${item.text}`;
});
return `📋 Current Todo List (${pending} pending, ${completed} completed)${timeInfo}:\n${lines.join('\n')}`;
return `📋 Current Todo List (${pending} todo, ${processing} processing, ${completed} completed)${timeInfo}:\n${lines.join('\n')}`;
};

View File

@@ -10,12 +10,15 @@
* 3. Replaces the deprecated pluginState passing pattern
*/
/** Status of a todo item */
export type StepContextTodoStatus = 'todo' | 'processing' | 'completed';
/**
* Todo item structure
* Duplicated here to avoid circular dependency with builtin-tool-gtd
*/
export interface StepContextTodoItem {
completed: boolean;
status: StepContextTodoStatus;
text: string;
}

View File

@@ -3,7 +3,7 @@
import { type StepContextTodos } from '@lobechat/types';
import { Checkbox, Flexbox, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { ChevronDown, ChevronUp, ListTodo } from 'lucide-react';
import { ChevronDown, ChevronUp, CircleArrowRight, ListTodo } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -73,6 +73,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
opacity 0.2s ${cssVar.motionEaseInOut},
padding 0.2s ${cssVar.motionEaseInOut};
`,
processingRow: css`
display: flex;
gap: 6px;
align-items: center;
`,
progress: css`
flex: 1;
height: 4px;
@@ -85,10 +90,16 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.colorSuccess};
transition: width 0.3s ${cssVar.motionEaseInOut};
`,
textChecked: css`
textCompleted: css`
color: ${cssVar.colorTextQuaternary};
text-decoration: line-through;
`,
textProcessing: css`
color: ${cssVar.colorText};
`,
textTodo: css`
color: ${cssVar.colorTextSecondary};
`,
}));
interface TodoProgressProps {
@@ -112,11 +123,13 @@ const TodoProgress = memo<TodoProgressProps>(({ className }) => {
// Calculate progress
const items = todos?.items || [];
const total = items.length;
const completed = items.filter((item) => item.completed).length;
const completed = items.filter((item) => item.status === 'completed').length;
const progressPercent = total > 0 ? (completed / total) * 100 : 0;
// Find current pending task (first incomplete item)
const currentPendingTask = items.find((item) => !item.completed);
// Find current pending task (first non-completed item, prioritize processing)
const currentPendingTask =
items.find((item) => item.status === 'processing') ||
items.find((item) => item.status === 'todo');
// Don't render if no todos
if (total === 0) return null;
@@ -156,24 +169,44 @@ const TodoProgress = memo<TodoProgressProps>(({ className }) => {
{/* Expandable Todo List */}
<div className={cx(styles.listContainer, expanded ? styles.expanded : styles.collapsed)}>
{items.map((item, index) => (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={item.completed}
classNames={{
text: item.completed ? styles.textChecked : undefined,
wrapper: styles.itemRow,
}}
key={index}
shape="circle"
style={{ borderWidth: 1.5, cursor: 'default', pointerEvents: 'none' }}
textProps={{
type: item.completed ? 'secondary' : undefined,
}}
>
{item.text}
</Checkbox>
))}
{items.map((item, index) => {
const isCompleted = item.status === 'completed';
const isProcessing = item.status === 'processing';
// Processing state uses CircleArrowRight icon
if (isProcessing) {
return (
<div className={cx(styles.itemRow, styles.processingRow)} key={index}>
<Icon
icon={CircleArrowRight}
size={17}
style={{ color: cssVar.colorTextSecondary }}
/>
<span className={styles.textProcessing}>{item.text}</span>
</div>
);
}
// Todo and completed states use Checkbox
return (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={isCompleted}
classNames={{
text: cx(styles.textTodo, isCompleted && styles.textCompleted),
wrapper: styles.itemRow,
}}
key={index}
shape="circle"
style={{ borderWidth: 1.5, cursor: 'default', pointerEvents: 'none' }}
textProps={{
type: isCompleted ? 'secondary' : undefined,
}}
>
{item.text}
</Checkbox>
);
})}
</div>
</div>
</WideScreenContainer>

View File

@@ -5,7 +5,7 @@ import { selectTodosFromMessages } from './dbMessage';
describe('selectTodosFromMessages', () => {
const createGTDToolMessage = (todos: {
items: Array<{ text: string; completed: boolean }>;
items: Array<{ text: string; status: 'todo' | 'processing' | 'completed' }>;
updatedAt: string;
}): UIChatMessage =>
({
@@ -30,7 +30,7 @@ describe('selectTodosFromMessages', () => {
content: 'Create a todo list',
} as UIChatMessage,
createGTDToolMessage({
items: [{ text: 'Buy milk', completed: false }],
items: [{ text: 'Buy milk', status: 'todo' }],
updatedAt: '2024-06-01T00:00:00.000Z',
}),
];
@@ -40,13 +40,13 @@ describe('selectTodosFromMessages', () => {
expect(result).toBeDefined();
expect(result?.items).toHaveLength(1);
expect(result?.items[0].text).toBe('Buy milk');
expect(result?.items[0].completed).toBe(false);
expect(result?.items[0].status).toBe('todo');
});
it('should return the most recent todos when multiple GTD messages exist', () => {
const messages: UIChatMessage[] = [
createGTDToolMessage({
items: [{ text: 'Old task', completed: false }],
items: [{ text: 'Old task', status: 'todo' }],
updatedAt: '2024-01-01T00:00:00.000Z',
}),
{
@@ -56,8 +56,8 @@ describe('selectTodosFromMessages', () => {
} as UIChatMessage,
createGTDToolMessage({
items: [
{ text: 'Old task', completed: true },
{ text: 'New task', completed: false },
{ text: 'Old task', status: 'completed' },
{ text: 'New task', status: 'todo' },
],
updatedAt: '2024-06-01T00:00:00.000Z',
}),
@@ -69,7 +69,7 @@ describe('selectTodosFromMessages', () => {
expect(result?.items).toHaveLength(2);
// Should be from the latest message
expect(result?.items[0].text).toBe('Old task');
expect(result?.items[0].completed).toBe(true);
expect(result?.items[0].status).toBe('completed');
expect(result?.items[1].text).toBe('New task');
});
@@ -155,7 +155,7 @@ describe('selectTodosFromMessages', () => {
},
pluginState: {
todos: {
items: [{ text: 'Task', completed: false }],
items: [{ text: 'Task', status: 'todo' }],
// No updatedAt
},
},
@@ -184,8 +184,8 @@ describe('selectTodosFromMessages', () => {
pluginState: {
// Legacy format: direct array
todos: [
{ text: 'Task 1', completed: false },
{ text: 'Task 2', completed: true },
{ text: 'Task 1', status: 'todo' },
{ text: 'Task 2', status: 'completed' },
],
},
} as unknown as UIChatMessage,
@@ -196,6 +196,6 @@ describe('selectTodosFromMessages', () => {
expect(result).toBeDefined();
expect(result?.items).toHaveLength(2);
expect(result?.items[0].text).toBe('Task 1');
expect(result?.items[1].completed).toBe(true);
expect(result?.items[1].status).toBe('completed');
});
});