Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1b950c5
🤖 Add TODO list feature to track multi-step tasks
ammar-agent Oct 14, 2025
bf86404
🤖 Fix formatting
ammar-agent Oct 14, 2025
00f3010
🤖 Refactor TODO storage to use stream tmpdir
ammar-agent Oct 14, 2025
6d7466a
🤖 Add TODO sequencing validation
ammar-agent Oct 14, 2025
5cf2de1
🤖 Fix GitStatusStore test type annotation
ammar-agent Oct 14, 2025
31492e7
🤖 Fix lint error: cast status to string in error message
ammar-agent Oct 14, 2025
476f400
🤖 Make TODO display more compact and elegant
ammar-agent Oct 14, 2025
a14aa13
🤖 Show only activeForm for in_progress tasks with animated ellipsis
ammar-agent Oct 14, 2025
f98ce29
🤖 Fix ellipsis animation causing line breaks
ammar-agent Oct 14, 2025
4893856
🤖 Reverse TODO order to completed → in_progress → pending
ammar-agent Oct 14, 2025
b80066f
🤖 Add 📋 emoji and complete tool description updates
ammar-agent Oct 14, 2025
1ffdaf8
🤖 Extract TODO list rendering to shared component
ammar-agent Oct 15, 2025
9643098
🤖 Add TODO state tracking to StreamingMessageAggregator
ammar-agent Oct 15, 2025
b986afe
🤖 Add getTodos() method to WorkspaceStore
ammar-agent Oct 15, 2025
3ab3313
🤖 Create PinnedTodoList component
ammar-agent Oct 15, 2025
acf3382
🤖 Position PinnedTodoList before StreamingBarrier in chat
ammar-agent Oct 15, 2025
991091a
Merge main into pinned-todos
ammar-agent Oct 15, 2025
d8a6c97
🤖 Fix lint errors and formatting
ammar-agent Oct 15, 2025
70a63d5
🤖 Refactor: use shared cleanupStreamState() method
ammar-agent Oct 15, 2025
46505ed
🤖 Add 'TODO:' header to pinned list
ammar-agent Oct 15, 2025
51a0a1f
🤖 Change pinned TODO border to dashed style
ammar-agent Oct 15, 2025
d80299e
🤖 Remove activeForm field and make pinned TODO collapsible
ammar-agent Oct 15, 2025
03c13eb
🤖 Improve tool description wording
ammar-agent Oct 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer";
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
import { PinnedTodoList } from "./PinnedTodoList";
import { getAutoRetryKey } from "@/constants/storage";
import { ChatInput, type ChatInputAPI } from "./ChatInput";
import { ChatMetaSidebar } from "./ChatMetaSidebar";
Expand Down Expand Up @@ -487,6 +488,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
)}
</>
)}
<PinnedTodoList workspaceId={workspaceId} />
{canInterrupt && (
<StreamingBarrier
statusText={
Expand Down
76 changes: 76 additions & 0 deletions src/components/PinnedTodoList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useSyncExternalStore } from "react";
import styled from "@emotion/styled";
import { TodoList } from "./TodoList";
import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore";
import { usePersistedState } from "@/hooks/usePersistedState";

const PinnedContainer = styled.div`
background: var(--color-panel-background);
border-top: 1px dashed hsl(0deg 0% 28.64%);
margin: 0;
max-height: 300px;
overflow-y: auto;
`;

const TodoHeader = styled.div`
padding: 4px 8px 2px 8px;
font-family: var(--font-monospace);
font-size: 10px;
color: var(--color-text-secondary);
font-weight: 600;
letter-spacing: 0.05em;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;

&:hover {
opacity: 0.8;
}
`;

const Caret = styled.span<{ expanded: boolean }>`
display: inline-block;
transition: transform 0.2s;
transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")};
font-size: 8px;
`;

interface PinnedTodoListProps {
workspaceId: string;
}

/**
* Pinned TODO list displayed at bottom of chat (before StreamingBarrier).
* Shows current TODOs from active stream only.
* Reuses TodoList component for consistent styling.
*/
export const PinnedTodoList: React.FC<PinnedTodoListProps> = ({ workspaceId }) => {
const workspaceStore = useWorkspaceStoreRaw();
const [expanded, setExpanded] = usePersistedState("pinnedTodoExpanded", true);

// Subscribe to workspace state changes to re-render when TODOs update
useSyncExternalStore(
(callback) => workspaceStore.subscribeKey(workspaceId, callback),
() => workspaceStore.getWorkspaceState(workspaceId)
);

// Get current TODOs (uses latest aggregator state)
const todos = workspaceStore.getTodos(workspaceId);

// Don't render if no TODOs
if (todos.length === 0) {
return null;
}

return (
<PinnedContainer>
<TodoHeader onClick={() => setExpanded(!expanded)}>
<Caret expanded={expanded}>▶</Caret>
TODO{expanded ? ":" : ""}
</TodoHeader>
{expanded && <TodoList todos={todos} />}
</PinnedContainer>
);
};
136 changes: 136 additions & 0 deletions src/components/TodoList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React from "react";
import styled from "@emotion/styled";
import type { TodoItem } from "@/types/tools";

const TodoListContainer = styled.div`
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 8px;
`;

const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>`
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
background: ${(props) => {
switch (props.status) {
case "completed":
return "color-mix(in srgb, #4caf50, transparent 92%)";
case "in_progress":
return "color-mix(in srgb, #2196f3, transparent 92%)";
case "pending":
default:
return "color-mix(in srgb, #888, transparent 96%)";
}
}};
border-left: 2px solid
${(props) => {
switch (props.status) {
case "completed":
return "#4caf50";
case "in_progress":
return "#2196f3";
case "pending":
default:
return "#666";
}
}};
border-radius: 3px;
font-family: var(--font-monospace);
font-size: 11px;
line-height: 1.35;
color: var(--color-text);
`;

const TodoIcon = styled.div`
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
opacity: 0.8;
`;

const TodoContent = styled.div`
flex: 1;
min-width: 0;
`;

const TodoText = styled.div<{ status: TodoItem["status"] }>`
color: ${(props) => {
switch (props.status) {
case "completed":
return "#888";
case "in_progress":
return "#2196f3";
default:
return "var(--color-text)";
}
}};
text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")};
opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")};
font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")};
white-space: nowrap;

${(props) =>
props.status === "in_progress" &&
`
&::after {
content: "...";
display: inline;
overflow: hidden;
animation: ellipsis 1.5s steps(4, end) infinite;
}

@keyframes ellipsis {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
}
`}
`;

interface TodoListProps {
todos: TodoItem[];
}

function getStatusIcon(status: TodoItem["status"]): string {
switch (status) {
case "completed":
return "✓";
case "in_progress":
return "⏳";
case "pending":
default:
return "○";
}
}

/**
* Shared TODO list component used by:
* - TodoToolCall (in expanded tool history)
* - PinnedTodoList (pinned at bottom of chat)
*/
export const TodoList: React.FC<TodoListProps> = ({ todos }) => {
return (
<TodoListContainer>
{todos.map((todo, index) => (
<TodoItemContainer key={index} status={todo.status}>
<TodoIcon>{getStatusIcon(todo.status)}</TodoIcon>
<TodoContent>
<TodoText status={todo.status}>{todo.content}</TodoText>
</TodoContent>
</TodoItemContainer>
))}
</TodoListContainer>
);
};
123 changes: 4 additions & 119 deletions src/components/tools/TodoToolCall.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from "react";
import styled from "@emotion/styled";
import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools";
import type { TodoWriteToolArgs, TodoWriteToolResult } from "@/types/tools";
import {
ToolContainer,
ToolHeader,
Expand All @@ -10,121 +9,20 @@ import {
} from "./shared/ToolPrimitives";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { TooltipWrapper, Tooltip } from "../Tooltip";

const TodoList = styled.div`
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 8px;
`;

const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>`
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
background: ${(props) => {
switch (props.status) {
case "completed":
return "color-mix(in srgb, #4caf50, transparent 92%)";
case "in_progress":
return "color-mix(in srgb, #2196f3, transparent 92%)";
case "pending":
default:
return "color-mix(in srgb, #888, transparent 96%)";
}
}};
border-left: 2px solid
${(props) => {
switch (props.status) {
case "completed":
return "#4caf50";
case "in_progress":
return "#2196f3";
case "pending":
default:
return "#666";
}
}};
border-radius: 3px;
font-family: var(--font-monospace);
font-size: 11px;
line-height: 1.35;
color: var(--color-text);
`;

const TodoIcon = styled.div`
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
opacity: 0.8;
`;

const TodoContent = styled.div`
flex: 1;
min-width: 0;
`;

const TodoText = styled.div<{ status: TodoItem["status"] }>`
color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")};
text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")};
opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")};
`;

const TodoActiveForm = styled.div`
color: #2196f3;
font-weight: 500;
font-size: 11px;
opacity: 0.95;
white-space: nowrap;

&::after {
content: "...";
display: inline;
overflow: hidden;
animation: ellipsis 1.5s steps(4, end) infinite;
}

@keyframes ellipsis {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
}
`;
import { TodoList } from "../TodoList";

interface TodoToolCallProps {
args: TodoWriteToolArgs;
result?: TodoWriteToolResult;
status?: ToolStatus;
}

function getStatusIcon(status: TodoItem["status"]): string {
switch (status) {
case "completed":
return "✓";
case "in_progress":
return "⏳";
case "pending":
default:
return "○";
}
}

export const TodoToolCall: React.FC<TodoToolCallProps> = ({
args,
result: _result,
status = "pending",
}) => {
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
const { expanded, toggleExpanded } = useToolExpansion(false); // Collapsed by default
const statusDisplay = getStatusDisplay(status);

return (
Expand All @@ -140,20 +38,7 @@ export const TodoToolCall: React.FC<TodoToolCallProps> = ({

{expanded && (
<ToolDetails>
<TodoList>
{args.todos.map((todo, index) => (
<TodoItemContainer key={index} status={todo.status}>
<TodoIcon>{getStatusIcon(todo.status)}</TodoIcon>
<TodoContent>
{todo.status === "in_progress" ? (
<TodoActiveForm>{todo.activeForm}</TodoActiveForm>
) : (
<TodoText status={todo.status}>{todo.content}</TodoText>
)}
</TodoContent>
</TodoItemContainer>
))}
</TodoList>
<TodoList todos={args.todos} />
</ToolDetails>
)}
</ToolContainer>
Expand Down
Loading