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:
Gabriel Gordon-Hall
2025-08-15 10:25:06 +01:00
committed by GitHub
parent e9882b23b9
commit ba8650cfca
14 changed files with 305 additions and 196 deletions

View File

@@ -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",

View File

@@ -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

View 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>
);
};

View File

@@ -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>
);
}

View 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]);
};