feat: pin todo list (#464)
* wip: backend todo normalisation * fe implementation * remove unused dep * cursor return ActionType::TodoManagement * use lucide icons rather than emojis in the todo list * review comments
This commit is contained in:
committed by
GitHub
parent
e9882b23b9
commit
ba8650cfca
@@ -35,7 +35,7 @@
|
||||
"click-to-react-component": "^1.1.2",
|
||||
"clsx": "^2.0.0",
|
||||
"diff": "^8.0.2",
|
||||
"lucide-react": "^0.303.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"react": "^18.2.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -45,12 +45,13 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||
|
||||
// Special handling for TODO tools
|
||||
if (
|
||||
tool_name &&
|
||||
(tool_name.toLowerCase() === 'todowrite' ||
|
||||
tool_name.toLowerCase() === 'todoread' ||
|
||||
tool_name.toLowerCase() === 'todo_write' ||
|
||||
tool_name.toLowerCase() === 'todo_read' ||
|
||||
tool_name.toLowerCase() === 'todo')
|
||||
action_type.action === 'todo_management' ||
|
||||
(tool_name &&
|
||||
(tool_name.toLowerCase() === 'todowrite' ||
|
||||
tool_name.toLowerCase() === 'todoread' ||
|
||||
tool_name.toLowerCase() === 'todo_write' ||
|
||||
tool_name.toLowerCase() === 'todo_read' ||
|
||||
tool_name.toLowerCase() === 'todo'))
|
||||
) {
|
||||
return <CheckSquare className="h-4 w-4 text-purple-600" />;
|
||||
}
|
||||
@@ -92,14 +93,15 @@ const getContentClassName = (entryType: NormalizedEntryType) => {
|
||||
// Special styling for TODO lists
|
||||
if (
|
||||
entryType.type === 'tool_use' &&
|
||||
entryType.tool_name &&
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo')
|
||||
(entryType.action_type.action === 'todo_management' ||
|
||||
(entryType.tool_name &&
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo')))
|
||||
) {
|
||||
return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`;
|
||||
return `${baseClasses} font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-900/40 px-2 py-1 rounded`;
|
||||
}
|
||||
|
||||
// Special styling for plan presentations
|
||||
|
||||
81
frontend/src/components/PinnedTodoBox.tsx
Normal file
81
frontend/src/components/PinnedTodoBox.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckSquare,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDotDashed,
|
||||
} from 'lucide-react';
|
||||
import type { TodoItem } from 'shared/types';
|
||||
|
||||
interface PinnedTodoBoxProps {
|
||||
todos: TodoItem[];
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string): React.ReactNode => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'completed':
|
||||
return <CircleCheck className="h-4 w-4 text-green-500" />;
|
||||
case 'in_progress':
|
||||
case 'in-progress':
|
||||
return <CircleDotDashed className="h-4 w-4 text-blue-500" />;
|
||||
case 'pending':
|
||||
case 'todo':
|
||||
return <Circle className="h-4 w-4 text-gray-400" />;
|
||||
default:
|
||||
return <Circle className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const PinnedTodoBox: React.FC<PinnedTodoBoxProps> = ({ todos }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 bg-zinc-50 dark:bg-zinc-900/40 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-900/60"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-4 w-4 text-zinc-700 dark:text-zinc-300" />
|
||||
<span className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
TODOs
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-700 dark:text-zinc-300" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-zinc-700 dark:text-zinc-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div className="px-4 py-3 space-y-2 max-h-64 overflow-y-auto">
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={`${todo.content}-${index}`}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="mt-0.5 flex-shrink-0">
|
||||
{getStatusIcon(todo.status)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-zinc-900 dark:text-zinc-100 break-words">
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
TaskSelectedAttemptContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
|
||||
import { usePinnedTodos } from '@/hooks/usePinnedTodos';
|
||||
import LogEntryRow from '@/components/logs/LogEntryRow';
|
||||
import { PinnedTodoBox } from '@/components/PinnedTodoBox';
|
||||
import {
|
||||
shouldShowInLogs,
|
||||
isAutoCollapsibleProcess,
|
||||
@@ -137,6 +139,9 @@ function LogsTab() {
|
||||
|
||||
const { entries } = useProcessesLogs(filteredProcesses, true);
|
||||
|
||||
// Extract todos from entries using the usePinnedTodos hook
|
||||
const { todos, lastUpdated } = usePinnedTodos(entries);
|
||||
|
||||
// Combined collapsed processes (auto + user)
|
||||
const allCollapsedProcesses = useMemo(() => {
|
||||
const combined = new Set(state.autoCollapsed);
|
||||
@@ -276,19 +281,22 @@ function LogsTab() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: '100%' }}
|
||||
data={visibleEntries}
|
||||
itemContent={itemContent}
|
||||
followOutput={true}
|
||||
increaseViewportBy={200}
|
||||
overscan={5}
|
||||
components={{
|
||||
Footer: () => <div style={{ height: '50px' }} />,
|
||||
}}
|
||||
/>
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<PinnedTodoBox todos={todos} lastUpdated={lastUpdated} />
|
||||
<div className="flex-1">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: '100%' }}
|
||||
data={visibleEntries}
|
||||
itemContent={itemContent}
|
||||
followOutput={true}
|
||||
increaseViewportBy={200}
|
||||
overscan={5}
|
||||
components={{
|
||||
Footer: () => <div style={{ height: '50px' }} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/src/hooks/usePinnedTodos.ts
Normal file
57
frontend/src/hooks/usePinnedTodos.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TodoItem } from 'shared/types';
|
||||
|
||||
interface UsePinnedTodosResult {
|
||||
todos: TodoItem[];
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that extracts and maintains the latest TODO state from normalized conversation entries.
|
||||
* Filters for TodoManagement ActionType entries and returns the most recent todo list.
|
||||
*/
|
||||
export const usePinnedTodos = (entries: any[]): UsePinnedTodosResult => {
|
||||
return useMemo(() => {
|
||||
let latestTodos: TodoItem[] = [];
|
||||
let lastUpdatedTime: string | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.channel === 'normalized' && entry.payload) {
|
||||
const normalizedEntry = entry.payload as any;
|
||||
|
||||
if (
|
||||
normalizedEntry.entry_type?.type === 'tool_use' &&
|
||||
normalizedEntry.entry_type?.action_type?.action === 'todo_management'
|
||||
) {
|
||||
const actionType = normalizedEntry.entry_type.action_type;
|
||||
const partialTodos = actionType.todos || [];
|
||||
const currentTimestamp =
|
||||
normalizedEntry.timestamp || new Date().toISOString();
|
||||
|
||||
// Only update latestTodos if we have meaningful content OR this is our first entry
|
||||
const hasMeaningfulTodos =
|
||||
partialTodos.length > 0 &&
|
||||
partialTodos.every(
|
||||
(todo: TodoItem) =>
|
||||
todo.content && todo.content.trim().length > 0 && todo.status
|
||||
);
|
||||
const isNewerThanLatest =
|
||||
!lastUpdatedTime || currentTimestamp >= lastUpdatedTime;
|
||||
|
||||
if (
|
||||
hasMeaningfulTodos ||
|
||||
(isNewerThanLatest && latestTodos.length === 0)
|
||||
) {
|
||||
latestTodos = partialTodos;
|
||||
lastUpdatedTime = currentTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
todos: latestTodos,
|
||||
lastUpdated: lastUpdatedTime,
|
||||
};
|
||||
}, [entries]);
|
||||
};
|
||||
Reference in New Issue
Block a user