Skip to content

Commit 43a47d1

Browse files
authored
🤖 Pin TODOs to chat bottom with Aggregator state (#257)
Refactors TODO display to pin at bottom of chat (before StreamingBarrier) with state managed by Aggregator. ## Changes **Shared TODO List Component:** - Extract TODO items rendering from TodoToolCall - Reusable for both pinned display and tool call history **Backend - Aggregator:** - Track current TODO state in StreamAggregator - Expose via IPC for efficient rendering - Automatic cleanup when stream ends **Frontend - WorkspaceStore:** - Cache TODO state by stream token - Subscribe to Aggregator updates - Filter TODOs by current stream **UI:** - PinnedTodoList at bottom of chat (before StreamingBarrier) - TodoToolCall collapsed by default, uses shared component when expanded - Only shows TODOs from active stream _Generated with `cmux`_
1 parent b84f658 commit 43a47d1

File tree

10 files changed

+279
-157
lines changed

10 files changed

+279
-157
lines changed

src/components/AIView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer";
44
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
55
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
66
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
7+
import { PinnedTodoList } from "./PinnedTodoList";
78
import { getAutoRetryKey } from "@/constants/storage";
89
import { ChatInput, type ChatInputAPI } from "./ChatInput";
910
import { ChatMetaSidebar } from "./ChatMetaSidebar";
@@ -487,6 +488,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
487488
)}
488489
</>
489490
)}
491+
<PinnedTodoList workspaceId={workspaceId} />
490492
{canInterrupt && (
491493
<StreamingBarrier
492494
statusText={

src/components/PinnedTodoList.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { useSyncExternalStore } from "react";
2+
import styled from "@emotion/styled";
3+
import { TodoList } from "./TodoList";
4+
import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore";
5+
import { usePersistedState } from "@/hooks/usePersistedState";
6+
7+
const PinnedContainer = styled.div`
8+
background: var(--color-panel-background);
9+
border-top: 1px dashed hsl(0deg 0% 28.64%);
10+
margin: 0;
11+
max-height: 300px;
12+
overflow-y: auto;
13+
`;
14+
15+
const TodoHeader = styled.div`
16+
padding: 4px 8px 2px 8px;
17+
font-family: var(--font-monospace);
18+
font-size: 10px;
19+
color: var(--color-text-secondary);
20+
font-weight: 600;
21+
letter-spacing: 0.05em;
22+
cursor: pointer;
23+
user-select: none;
24+
display: flex;
25+
align-items: center;
26+
gap: 4px;
27+
28+
&:hover {
29+
opacity: 0.8;
30+
}
31+
`;
32+
33+
const Caret = styled.span<{ expanded: boolean }>`
34+
display: inline-block;
35+
transition: transform 0.2s;
36+
transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")};
37+
font-size: 8px;
38+
`;
39+
40+
interface PinnedTodoListProps {
41+
workspaceId: string;
42+
}
43+
44+
/**
45+
* Pinned TODO list displayed at bottom of chat (before StreamingBarrier).
46+
* Shows current TODOs from active stream only.
47+
* Reuses TodoList component for consistent styling.
48+
*/
49+
export const PinnedTodoList: React.FC<PinnedTodoListProps> = ({ workspaceId }) => {
50+
const workspaceStore = useWorkspaceStoreRaw();
51+
const [expanded, setExpanded] = usePersistedState("pinnedTodoExpanded", true);
52+
53+
// Subscribe to workspace state changes to re-render when TODOs update
54+
useSyncExternalStore(
55+
(callback) => workspaceStore.subscribeKey(workspaceId, callback),
56+
() => workspaceStore.getWorkspaceState(workspaceId)
57+
);
58+
59+
// Get current TODOs (uses latest aggregator state)
60+
const todos = workspaceStore.getTodos(workspaceId);
61+
62+
// Don't render if no TODOs
63+
if (todos.length === 0) {
64+
return null;
65+
}
66+
67+
return (
68+
<PinnedContainer>
69+
<TodoHeader onClick={() => setExpanded(!expanded)}>
70+
<Caret expanded={expanded}></Caret>
71+
TODO{expanded ? ":" : ""}
72+
</TodoHeader>
73+
{expanded && <TodoList todos={todos} />}
74+
</PinnedContainer>
75+
);
76+
};

src/components/TodoList.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import type { TodoItem } from "@/types/tools";
4+
5+
const TodoListContainer = styled.div`
6+
display: flex;
7+
flex-direction: column;
8+
gap: 3px;
9+
padding: 6px 8px;
10+
`;
11+
12+
const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>`
13+
display: flex;
14+
align-items: flex-start;
15+
gap: 6px;
16+
padding: 4px 8px;
17+
background: ${(props) => {
18+
switch (props.status) {
19+
case "completed":
20+
return "color-mix(in srgb, #4caf50, transparent 92%)";
21+
case "in_progress":
22+
return "color-mix(in srgb, #2196f3, transparent 92%)";
23+
case "pending":
24+
default:
25+
return "color-mix(in srgb, #888, transparent 96%)";
26+
}
27+
}};
28+
border-left: 2px solid
29+
${(props) => {
30+
switch (props.status) {
31+
case "completed":
32+
return "#4caf50";
33+
case "in_progress":
34+
return "#2196f3";
35+
case "pending":
36+
default:
37+
return "#666";
38+
}
39+
}};
40+
border-radius: 3px;
41+
font-family: var(--font-monospace);
42+
font-size: 11px;
43+
line-height: 1.35;
44+
color: var(--color-text);
45+
`;
46+
47+
const TodoIcon = styled.div`
48+
font-size: 12px;
49+
flex-shrink: 0;
50+
margin-top: 1px;
51+
opacity: 0.8;
52+
`;
53+
54+
const TodoContent = styled.div`
55+
flex: 1;
56+
min-width: 0;
57+
`;
58+
59+
const TodoText = styled.div<{ status: TodoItem["status"] }>`
60+
color: ${(props) => {
61+
switch (props.status) {
62+
case "completed":
63+
return "#888";
64+
case "in_progress":
65+
return "#2196f3";
66+
default:
67+
return "var(--color-text)";
68+
}
69+
}};
70+
text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")};
71+
opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")};
72+
font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")};
73+
white-space: nowrap;
74+
75+
${(props) =>
76+
props.status === "in_progress" &&
77+
`
78+
&::after {
79+
content: "...";
80+
display: inline;
81+
overflow: hidden;
82+
animation: ellipsis 1.5s steps(4, end) infinite;
83+
}
84+
85+
@keyframes ellipsis {
86+
0% {
87+
content: "";
88+
}
89+
25% {
90+
content: ".";
91+
}
92+
50% {
93+
content: "..";
94+
}
95+
75% {
96+
content: "...";
97+
}
98+
}
99+
`}
100+
`;
101+
102+
interface TodoListProps {
103+
todos: TodoItem[];
104+
}
105+
106+
function getStatusIcon(status: TodoItem["status"]): string {
107+
switch (status) {
108+
case "completed":
109+
return "✓";
110+
case "in_progress":
111+
return "⏳";
112+
case "pending":
113+
default:
114+
return "○";
115+
}
116+
}
117+
118+
/**
119+
* Shared TODO list component used by:
120+
* - TodoToolCall (in expanded tool history)
121+
* - PinnedTodoList (pinned at bottom of chat)
122+
*/
123+
export const TodoList: React.FC<TodoListProps> = ({ todos }) => {
124+
return (
125+
<TodoListContainer>
126+
{todos.map((todo, index) => (
127+
<TodoItemContainer key={index} status={todo.status}>
128+
<TodoIcon>{getStatusIcon(todo.status)}</TodoIcon>
129+
<TodoContent>
130+
<TodoText status={todo.status}>{todo.content}</TodoText>
131+
</TodoContent>
132+
</TodoItemContainer>
133+
))}
134+
</TodoListContainer>
135+
);
136+
};

src/components/tools/TodoToolCall.tsx

Lines changed: 4 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from "react";
2-
import styled from "@emotion/styled";
3-
import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools";
2+
import type { TodoWriteToolArgs, TodoWriteToolResult } from "@/types/tools";
43
import {
54
ToolContainer,
65
ToolHeader,
@@ -10,121 +9,20 @@ import {
109
} from "./shared/ToolPrimitives";
1110
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
1211
import { TooltipWrapper, Tooltip } from "../Tooltip";
13-
14-
const TodoList = styled.div`
15-
display: flex;
16-
flex-direction: column;
17-
gap: 3px;
18-
padding: 6px 8px;
19-
`;
20-
21-
const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>`
22-
display: flex;
23-
align-items: flex-start;
24-
gap: 6px;
25-
padding: 4px 8px;
26-
background: ${(props) => {
27-
switch (props.status) {
28-
case "completed":
29-
return "color-mix(in srgb, #4caf50, transparent 92%)";
30-
case "in_progress":
31-
return "color-mix(in srgb, #2196f3, transparent 92%)";
32-
case "pending":
33-
default:
34-
return "color-mix(in srgb, #888, transparent 96%)";
35-
}
36-
}};
37-
border-left: 2px solid
38-
${(props) => {
39-
switch (props.status) {
40-
case "completed":
41-
return "#4caf50";
42-
case "in_progress":
43-
return "#2196f3";
44-
case "pending":
45-
default:
46-
return "#666";
47-
}
48-
}};
49-
border-radius: 3px;
50-
font-family: var(--font-monospace);
51-
font-size: 11px;
52-
line-height: 1.35;
53-
color: var(--color-text);
54-
`;
55-
56-
const TodoIcon = styled.div`
57-
font-size: 12px;
58-
flex-shrink: 0;
59-
margin-top: 1px;
60-
opacity: 0.8;
61-
`;
62-
63-
const TodoContent = styled.div`
64-
flex: 1;
65-
min-width: 0;
66-
`;
67-
68-
const TodoText = styled.div<{ status: TodoItem["status"] }>`
69-
color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")};
70-
text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")};
71-
opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")};
72-
`;
73-
74-
const TodoActiveForm = styled.div`
75-
color: #2196f3;
76-
font-weight: 500;
77-
font-size: 11px;
78-
opacity: 0.95;
79-
white-space: nowrap;
80-
81-
&::after {
82-
content: "...";
83-
display: inline;
84-
overflow: hidden;
85-
animation: ellipsis 1.5s steps(4, end) infinite;
86-
}
87-
88-
@keyframes ellipsis {
89-
0% {
90-
content: "";
91-
}
92-
25% {
93-
content: ".";
94-
}
95-
50% {
96-
content: "..";
97-
}
98-
75% {
99-
content: "...";
100-
}
101-
}
102-
`;
12+
import { TodoList } from "../TodoList";
10313

10414
interface TodoToolCallProps {
10515
args: TodoWriteToolArgs;
10616
result?: TodoWriteToolResult;
10717
status?: ToolStatus;
10818
}
10919

110-
function getStatusIcon(status: TodoItem["status"]): string {
111-
switch (status) {
112-
case "completed":
113-
return "✓";
114-
case "in_progress":
115-
return "⏳";
116-
case "pending":
117-
default:
118-
return "○";
119-
}
120-
}
121-
12220
export const TodoToolCall: React.FC<TodoToolCallProps> = ({
12321
args,
12422
result: _result,
12523
status = "pending",
12624
}) => {
127-
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
25+
const { expanded, toggleExpanded } = useToolExpansion(false); // Collapsed by default
12826
const statusDisplay = getStatusDisplay(status);
12927

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

14139
{expanded && (
14240
<ToolDetails>
143-
<TodoList>
144-
{args.todos.map((todo, index) => (
145-
<TodoItemContainer key={index} status={todo.status}>
146-
<TodoIcon>{getStatusIcon(todo.status)}</TodoIcon>
147-
<TodoContent>
148-
{todo.status === "in_progress" ? (
149-
<TodoActiveForm>{todo.activeForm}</TodoActiveForm>
150-
) : (
151-
<TodoText status={todo.status}>{todo.content}</TodoText>
152-
)}
153-
</TodoContent>
154-
</TodoItemContainer>
155-
))}
156-
</TodoList>
41+
<TodoList todos={args.todos} />
15742
</ToolDetails>
15843
)}
15944
</ToolContainer>

0 commit comments

Comments
 (0)