mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
💄 style: improve todo list (#11533)
* fix todo with wip * update * fix tests * fix url
This commit is contained in:
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user