@alpaca-editor/core 1.0.4123 β†’ 1.0.4128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import React, {
4
4
  useRef,
5
5
  useCallback,
6
6
  useLayoutEffect,
7
+ useMemo,
7
8
  } from "react";
8
9
  import {
9
10
  Send,
@@ -14,6 +15,9 @@ import {
14
15
  Square,
15
16
  Mic,
16
17
  MicOff,
18
+ ChevronDown,
19
+ ChevronUp,
20
+ ListTodo,
17
21
  } from "lucide-react";
18
22
  import { DancingDots } from "./DancingDots";
19
23
  import {
@@ -78,6 +82,220 @@ const UserMessage = ({ message }: { message: AgentChatMessage }) => {
78
82
  );
79
83
  };
80
84
 
85
+ // Extract all todos from messages
86
+ interface TodoItem {
87
+ id?: string;
88
+ text: string;
89
+ done?: boolean;
90
+ note?: string;
91
+ messageId: string;
92
+ sourceTitle?: string;
93
+ }
94
+
95
+ const extractTodosFromMessages = (messages: AgentChatMessage[]): TodoItem[] => {
96
+ const todos: TodoItem[] = [];
97
+ const fencedTodoToken = "```todo_list";
98
+ const plainTodoToken = "todo_list";
99
+
100
+ for (const message of messages) {
101
+ if (message.role !== "assistant" || !message.content) continue;
102
+
103
+ const content = message.content;
104
+ let cursor = 0;
105
+
106
+ while (cursor < content.length) {
107
+ const nextFenced = content.indexOf(fencedTodoToken, cursor);
108
+ const nextPlain = content.indexOf(plainTodoToken, cursor);
109
+
110
+ let todoStart = -1;
111
+ let isFenced = false;
112
+
113
+ if (nextFenced !== -1 && (nextPlain === -1 || nextFenced < nextPlain)) {
114
+ todoStart = nextFenced;
115
+ isFenced = true;
116
+ } else if (nextPlain !== -1) {
117
+ // Check if it's at line start
118
+ const before = nextPlain > 0 ? content[nextPlain - 1] : "\n";
119
+ if (before === "\n" || before === "\r" || nextPlain === 0) {
120
+ todoStart = nextPlain;
121
+ isFenced = false;
122
+ }
123
+ }
124
+
125
+ if (todoStart === -1) break;
126
+
127
+ try {
128
+ let jsonText = "";
129
+ if (isFenced) {
130
+ const afterToken = todoStart + fencedTodoToken.length;
131
+ const closePos = content.indexOf("```", afterToken);
132
+ if (closePos === -1) break;
133
+ jsonText = content.slice(afterToken, closePos).trim();
134
+ cursor = closePos + 3;
135
+ } else {
136
+ const afterToken = todoStart + plainTodoToken.length;
137
+ const braceStart = content.indexOf("{", afterToken);
138
+ if (braceStart === -1) break;
139
+
140
+ let depth = 0;
141
+ let braceEnd = -1;
142
+ for (let i = braceStart; i < content.length; i++) {
143
+ if (content[i] === "{") depth++;
144
+ if (content[i] === "}") {
145
+ depth--;
146
+ if (depth === 0) {
147
+ braceEnd = i;
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ if (braceEnd === -1) break;
153
+ jsonText = content.slice(braceStart, braceEnd + 1).trim();
154
+ cursor = braceEnd + 1;
155
+ }
156
+
157
+ const parsed = JSON.parse(jsonText);
158
+ const todoItems = Array.isArray(parsed) ? parsed : parsed?.items || [];
159
+ const title = Array.isArray(parsed) ? undefined : parsed?.title;
160
+
161
+ todoItems.forEach((item: any) => {
162
+ if (!item) return;
163
+ const text =
164
+ item.text || item.label || String(item.task || item.title || "");
165
+ if (!text) return;
166
+ todos.push({
167
+ id: item.id,
168
+ text,
169
+ done: !!(item.done ?? item.completed ?? item.checked),
170
+ note: item.note || item.description,
171
+ messageId: message.id,
172
+ sourceTitle: title,
173
+ });
174
+ });
175
+ } catch (e) {
176
+ cursor++;
177
+ continue;
178
+ }
179
+ }
180
+ }
181
+
182
+ return todos;
183
+ };
184
+
185
+ // TodoListPanel component
186
+ const TodoListPanel = ({ messages }: { messages: AgentChatMessage[] }) => {
187
+ const [isExpanded, setIsExpanded] = useState(true);
188
+ const todos = useMemo(() => extractTodosFromMessages(messages), [messages]);
189
+
190
+ // Check if there's an active streaming message with todo content
191
+ const isUpdating = useMemo(() => {
192
+ return messages.some((msg) => {
193
+ if (msg.role !== "assistant" || msg.isCompleted) return false;
194
+ const content = msg.content || "";
195
+ return content.includes("```todo_list") || content.includes("todo_list");
196
+ });
197
+ }, [messages]);
198
+
199
+ if (todos.length === 0 && !isUpdating) return null;
200
+
201
+ const completedCount = todos.filter((t) => t.done).length;
202
+ const totalCount = todos.length;
203
+
204
+ return (
205
+ <div className="border-t border-gray-200 bg-gray-50">
206
+ <button
207
+ onClick={() => setIsExpanded(!isExpanded)}
208
+ className="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-left transition-colors hover:bg-gray-100"
209
+ >
210
+ <div className="flex items-center gap-2">
211
+ <ListTodo className="h-4 w-4 text-gray-500" strokeWidth={1} />
212
+ <span className="text-xs font-medium text-gray-700">Todo List</span>
213
+ {isUpdating ? (
214
+ <span className="flex items-center gap-1 text-xs text-blue-600">
215
+ <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1} />
216
+ Updating...
217
+ </span>
218
+ ) : (
219
+ <span className="text-xs text-gray-500">
220
+ {completedCount}/{totalCount} completed
221
+ </span>
222
+ )}
223
+ </div>
224
+ {isExpanded ? (
225
+ <ChevronUp className="h-4 w-4 text-gray-500" strokeWidth={1} />
226
+ ) : (
227
+ <ChevronDown className="h-4 w-4 text-gray-500" strokeWidth={1} />
228
+ )}
229
+ </button>
230
+ {isExpanded && (
231
+ <div className="max-h-64 overflow-y-auto px-4 pb-3">
232
+ {isUpdating && todos.length === 0 ? (
233
+ <div className="flex items-center justify-center gap-2 py-4 text-xs text-gray-500">
234
+ <Loader2 className="h-4 w-4 animate-spin" strokeWidth={1} />
235
+ <span>Loading todo list...</span>
236
+ </div>
237
+ ) : (
238
+ <>
239
+ <div className="space-y-1.5">
240
+ {todos.map((todo, idx) => (
241
+ <div
242
+ key={todo.id || `${todo.messageId}-${idx}`}
243
+ className="flex items-start gap-2 rounded bg-white p-2 text-xs"
244
+ >
245
+ <div className="flex-shrink-0 pt-0.5">
246
+ {todo.done ? (
247
+ <div className="flex h-4 w-4 items-center justify-center rounded border-2 border-green-500 bg-green-500">
248
+ <svg
249
+ className="h-3 w-3 text-white"
250
+ fill="none"
251
+ strokeWidth={2}
252
+ stroke="currentColor"
253
+ viewBox="0 0 24 24"
254
+ >
255
+ <path
256
+ strokeLinecap="round"
257
+ strokeLinejoin="round"
258
+ d="M5 13l4 4L19 7"
259
+ />
260
+ </svg>
261
+ </div>
262
+ ) : (
263
+ <div className="h-4 w-4 rounded border-2 border-gray-300" />
264
+ )}
265
+ </div>
266
+ <div className="min-w-0 flex-1">
267
+ <div
268
+ className={`${
269
+ todo.done
270
+ ? "text-gray-500 line-through"
271
+ : "text-gray-900"
272
+ }`}
273
+ >
274
+ {todo.text}
275
+ </div>
276
+ {todo.note && (
277
+ <div className="mt-0.5 text-xs text-gray-500">
278
+ {todo.note}
279
+ </div>
280
+ )}
281
+ </div>
282
+ </div>
283
+ ))}
284
+ </div>
285
+ {isUpdating && todos.length > 0 && (
286
+ <div className="mt-2 flex items-center gap-2 rounded bg-blue-50 px-3 py-2 text-xs text-blue-700">
287
+ <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1} />
288
+ <span>Updating todo list...</span>
289
+ </div>
290
+ )}
291
+ </>
292
+ )}
293
+ </div>
294
+ )}
295
+ </div>
296
+ );
297
+ };
298
+
81
299
  // Group consecutive assistant messages together for bundling
82
300
  interface MessageGroup {
83
301
  type: "user" | "assistant-group";
@@ -172,6 +390,8 @@ const convertAgentMessagesToAiFormat = (
172
390
  result: toolCall.functionResult,
173
391
  error: toolCall.functionError,
174
392
  },
393
+ // Pass through approval info if present on the tool call
394
+ requiresApproval: (toolCall as any).requiresApproval,
175
395
  }))
176
396
  : [],
177
397
  };
@@ -697,6 +917,7 @@ export function AgentTerminal({
697
917
  isCompleted: false,
698
918
  responseTimeMs: message.data.responseTimeMs,
699
919
  createdDate: new Date().toISOString(),
920
+ requiresApproval: message.data?.requiresApproval,
700
921
  };
701
922
 
702
923
  // Check for duplicates using the current messages ref
@@ -838,6 +1059,18 @@ export function AgentTerminal({
838
1059
  messagesRef.current = updated;
839
1060
  return updated;
840
1061
  });
1062
+
1063
+ // Dispatch a local event so the UI can attempt reconnect when approvals get resolved while paused
1064
+ try {
1065
+ const ev = new CustomEvent("agent:toolApprovalResolved", {
1066
+ detail: {
1067
+ messageId: resultMessageId,
1068
+ toolCallId: resultToolCallId,
1069
+ approved: !(message.data?.functionError || message.data?.error),
1070
+ },
1071
+ } as any);
1072
+ window.dispatchEvent(ev);
1073
+ } catch {}
841
1074
  // Tool result activity; reset idle timer
842
1075
  resetDotsTimer();
843
1076
  },
@@ -863,9 +1096,21 @@ export function AgentTerminal({
863
1096
 
864
1097
  console.log("πŸ”Œ connectToStream: Starting stream connection");
865
1098
 
1099
+ // Expose agent id globally for approval actions
1100
+ (window as any).currentAgentId = currentAgent.id;
1101
+ console.log("πŸ”— Setting currentAgentId:", currentAgent.id);
1102
+
1103
+ console.log(
1104
+ "🌐 Attempting to connect to agent stream for:",
1105
+ currentAgent.id,
1106
+ );
866
1107
  await connectToAgentStream(
867
1108
  currentAgent.id,
868
1109
  (message: AgentStreamMessage) => {
1110
+ console.log("πŸ“¨ Received stream message:", {
1111
+ type: message.type,
1112
+ data: message.data,
1113
+ });
869
1114
  switch (message.type) {
870
1115
  case "contentChunk":
871
1116
  handleContentChunk(message, currentAgent);
@@ -882,6 +1127,53 @@ export function AgentTerminal({
882
1127
  case "statusUpdate":
883
1128
  try {
884
1129
  const kind = (message as any)?.data?.kind;
1130
+ console.log("πŸ“‘ Received status update:", {
1131
+ kind,
1132
+ data: (message as any).data,
1133
+ });
1134
+ if (kind === "toolApprovalsRequired") {
1135
+ const data = (message as any).data || {};
1136
+ const msgId: string | undefined = data.messageId;
1137
+ const ids: string[] = data.toolCallIds || [];
1138
+ console.log(
1139
+ "⏸️ Approvals required; pausing stream until approval:",
1140
+ { msgId, ids },
1141
+ );
1142
+
1143
+ // Annotate tool calls with a temporary pending marker so UI can reflect paused state on reload
1144
+ if (msgId && Array.isArray(ids) && ids.length > 0) {
1145
+ setMessages((prev) => {
1146
+ const updated = prev.map((m) => {
1147
+ if (m.id !== msgId) return m;
1148
+ const existingToolCalls = m.toolCalls || [];
1149
+ const updatedToolCalls = existingToolCalls.map(
1150
+ (tc) => {
1151
+ if (!ids.includes(tc.toolCallId)) return tc;
1152
+ const fn = tc.functionName || "";
1153
+ return {
1154
+ ...tc,
1155
+ functionName: fn.includes("(pending approval)")
1156
+ ? fn
1157
+ : fn + " (pending approval)",
1158
+ };
1159
+ },
1160
+ );
1161
+ return { ...m, toolCalls: updatedToolCalls };
1162
+ });
1163
+ messagesRef.current = updated;
1164
+ return updated;
1165
+ });
1166
+ }
1167
+
1168
+ // Proactively stop the current stream so that the page can be reloaded safely or stay idle until approval
1169
+ try {
1170
+ abortControllerRef.current?.abort();
1171
+ abortControllerRef.current = null;
1172
+ setIsConnecting(false);
1173
+ setIsWaitingForResponse(false);
1174
+ } catch {}
1175
+ break;
1176
+ }
885
1177
  if (kind === "contextWindow") {
886
1178
  const data = (message as any).data || {};
887
1179
  // Store last context window status in a ref so we can render it below
@@ -902,8 +1194,10 @@ export function AgentTerminal({
902
1194
  // Merge incoming context into local metadata
903
1195
  setAgentMetadata((prev) => {
904
1196
  const current = (prev || {}) as AgentMetadata;
1197
+ // Exclude top-level context to avoid duplicate keys when spreading
1198
+ const { context: _, ...currentWithoutContext } = current;
905
1199
  const next: AgentMetadata = {
906
- ...current,
1200
+ ...currentWithoutContext,
907
1201
  additionalData: {
908
1202
  ...(current.additionalData || {}),
909
1203
  context: nextContext,
@@ -942,6 +1236,58 @@ export function AgentTerminal({
942
1236
  return prevAgent;
943
1237
  }
944
1238
  });
1239
+ } else if (
1240
+ kind === "toolApprovalGranted" ||
1241
+ kind === "toolApprovalRejected"
1242
+ ) {
1243
+ const data = (message as any).data || {};
1244
+ const toolCallId: string | undefined = data.toolCallId;
1245
+ const msgId: string | undefined = data.messageId;
1246
+ console.log("πŸ”§ Processing tool approval:", {
1247
+ kind,
1248
+ toolCallId,
1249
+ msgId,
1250
+ data,
1251
+ });
1252
+ if (toolCallId && msgId) {
1253
+ setMessages((prev) => {
1254
+ console.log("πŸ” Looking for message:", {
1255
+ targetMsgId: msgId,
1256
+ availableMessages: prev.map((m) => ({
1257
+ id: m.id,
1258
+ toolCallsCount: m.toolCalls?.length || 0,
1259
+ })),
1260
+ });
1261
+ const updated = prev.map((m) => {
1262
+ if (m.id !== msgId) return m;
1263
+ const existingToolCalls = m.toolCalls || [];
1264
+ const updatedToolCalls = existingToolCalls.map(
1265
+ (tc) => {
1266
+ if (tc.toolCallId !== toolCallId) return tc;
1267
+ const suffix =
1268
+ kind === "toolApprovalGranted"
1269
+ ? " (approved)"
1270
+ : " (rejected)";
1271
+ const newFunctionName =
1272
+ (tc.functionName || "") + suffix;
1273
+ console.log("🏷️ Updating function name:", {
1274
+ toolCallId,
1275
+ oldName: tc.functionName,
1276
+ newName: newFunctionName,
1277
+ });
1278
+ return {
1279
+ ...tc,
1280
+ functionName: newFunctionName,
1281
+ };
1282
+ },
1283
+ );
1284
+ return { ...m, toolCalls: updatedToolCalls };
1285
+ });
1286
+ messagesRef.current = updated;
1287
+ return updated;
1288
+ });
1289
+ }
1290
+ break;
945
1291
  }
946
1292
  } catch {}
947
1293
  break;
@@ -1116,6 +1462,7 @@ export function AgentTerminal({
1116
1462
  setError("Failed to connect to agent stream");
1117
1463
  }
1118
1464
  } finally {
1465
+ console.log("πŸ”Œ Stream connection finished, cleaning up");
1119
1466
  setIsConnecting(false);
1120
1467
  // Guard: clear waiting state if connection finished without content
1121
1468
  setIsWaitingForResponse(false);
@@ -1124,11 +1471,100 @@ export function AgentTerminal({
1124
1471
  [agent?.id, handleContentChunk, handleToolCall, handleToolResult],
1125
1472
  );
1126
1473
 
1474
+ // Attempt to reconnect stream when all pending approvals are resolved
1475
+ const attemptReconnectIfNoPending = useCallback(async () => {
1476
+ try {
1477
+ const currentAgent = agent;
1478
+ if (!currentAgent) return;
1479
+
1480
+ // Check if we're already connected
1481
+ if (abortControllerRef.current) {
1482
+ console.log("πŸ”„ Already connected to stream, skipping reconnect");
1483
+ return;
1484
+ }
1485
+
1486
+ const msgs = messagesRef.current || [];
1487
+ const hasPending = msgs.some((m) =>
1488
+ (m.toolCalls || []).some((tc) =>
1489
+ (tc.functionName || "").includes("(pending approval)"),
1490
+ ),
1491
+ );
1492
+
1493
+ if (!hasPending) {
1494
+ console.log("πŸ”„ No pending approvals; reconnecting stream");
1495
+ await connectToStream(currentAgent);
1496
+ } else {
1497
+ console.log("⏸️ Still have pending approvals, not reconnecting yet");
1498
+ }
1499
+ } catch (err) {
1500
+ console.error("❌ Error attempting reconnect:", err);
1501
+ }
1502
+ }, [agent, connectToStream]);
1503
+
1504
+ // Listen for local approval resolution (when stream is paused) to update UI and reconnect
1505
+ useEffect(() => {
1506
+ const onApprovalResolved = (ev: any) => {
1507
+ try {
1508
+ const detail = ev?.detail || {};
1509
+ const messageId: string | undefined = detail.messageId;
1510
+ const toolCallId: string | undefined = detail.toolCallId;
1511
+ const approved: boolean = !!detail.approved;
1512
+ if (!messageId || !toolCallId) return;
1513
+
1514
+ console.log("πŸ”” Approval resolved:", {
1515
+ messageId,
1516
+ toolCallId,
1517
+ approved,
1518
+ });
1519
+
1520
+ setMessages((prev) => {
1521
+ const updated = prev.map((m) => {
1522
+ if (m.id !== messageId) return m;
1523
+ const updatedToolCalls = (m.toolCalls || []).map((tc) => {
1524
+ if (tc.toolCallId !== toolCallId) return tc;
1525
+ const base = (tc.functionName || "")
1526
+ .replace(" (pending approval)", "")
1527
+ .replace(" (approved)", "")
1528
+ .replace(" (rejected)", "");
1529
+ return {
1530
+ ...tc,
1531
+ functionName: base + (approved ? " (approved)" : " (rejected)"),
1532
+ };
1533
+ });
1534
+ return { ...m, toolCalls: updatedToolCalls };
1535
+ });
1536
+ messagesRef.current = updated;
1537
+ return updated;
1538
+ });
1539
+
1540
+ // Try to reconnect if no more pending approvals remain
1541
+ setTimeout(() => {
1542
+ attemptReconnectIfNoPending();
1543
+ }, 100);
1544
+ } catch (err) {
1545
+ console.error("❌ Error handling approval resolution:", err);
1546
+ }
1547
+ };
1548
+
1549
+ window.addEventListener(
1550
+ "agent:toolApprovalResolved",
1551
+ onApprovalResolved as EventListener,
1552
+ );
1553
+ return () =>
1554
+ window.removeEventListener(
1555
+ "agent:toolApprovalResolved",
1556
+ onApprovalResolved as EventListener,
1557
+ );
1558
+ }, [attemptReconnectIfNoPending]);
1559
+
1127
1560
  // Load agent data and messages
1128
1561
  const loadAgent = useCallback(async () => {
1129
1562
  try {
1130
1563
  if (agentStub.status === "new") {
1131
- console.log("βœ… Setting up new agent");
1564
+ console.log("βœ… Setting up new agent", agentStub.id);
1565
+ // Set agent ID immediately for new agents
1566
+ (window as any).currentAgentId = agentStub.id;
1567
+ console.log("πŸ”— Setting currentAgentId for new agent:", agentStub.id);
1132
1568
  // Derive initial profile from provided metadata if present
1133
1569
  const initialProfileIdFromMeta = (() => {
1134
1570
  try {
@@ -1334,12 +1770,24 @@ export function AgentTerminal({
1334
1770
  setAgent(agentData);
1335
1771
  setMessages(agentData.messages || []);
1336
1772
 
1773
+ // Set agent ID for existing agents too
1774
+ (window as any).currentAgentId = agentData.id;
1775
+ console.log(
1776
+ "πŸ”— Setting currentAgentId for existing agent:",
1777
+ agentData.id,
1778
+ );
1779
+
1337
1780
  // Parse metadata from DB if present (do not seed for existing agents)
1338
1781
  const parsedMeta: AgentMetadata | null = (() => {
1339
1782
  try {
1340
- return agentData.metadata
1341
- ? (JSON.parse(agentData.metadata) as AgentMetadata)
1342
- : null;
1783
+ if (!agentData.metadata) return null;
1784
+ const meta = JSON.parse(agentData.metadata) as AgentMetadata;
1785
+ // Clean up: remove top-level context if present (should only be in additionalData)
1786
+ if (meta && meta.context) {
1787
+ const { context: _, ...cleanMeta } = meta;
1788
+ return cleanMeta as AgentMetadata;
1789
+ }
1790
+ return meta;
1343
1791
  } catch {
1344
1792
  return null;
1345
1793
  }
@@ -1370,6 +1818,23 @@ export function AgentTerminal({
1370
1818
  // Reset streaming state for reconnection
1371
1819
  shouldCreateNewMessage.current = false;
1372
1820
 
1821
+ // If there are pending approvals in current messages, skip reconnect for now
1822
+ try {
1823
+ const hasPending = (agentData.messages || []).some((m: any) =>
1824
+ (m.toolCalls || []).some(
1825
+ (tc: any) =>
1826
+ typeof tc?.functionName === "string" &&
1827
+ tc.functionName.includes("(pending approval)"),
1828
+ ),
1829
+ );
1830
+ if (hasPending) {
1831
+ console.log(
1832
+ "⏸️ loadAgent: Pending approvals detected, delaying stream reconnect",
1833
+ );
1834
+ return;
1835
+ }
1836
+ } catch {}
1837
+
1373
1838
  // Use the existing connectToStream function with the loaded agent data
1374
1839
  await connectToStream(agentData);
1375
1840
  }, 100);
@@ -1530,13 +1995,6 @@ export function AgentTerminal({
1530
1995
  }
1531
1996
  }, [messages, scrollToBottom, shouldAutoScroll]);
1532
1997
 
1533
- // Re-apply bottom alignment when loading dots appear/disappear, as it changes the content height
1534
- useEffect(() => {
1535
- if (shouldAutoScroll) {
1536
- scrollToBottom();
1537
- }
1538
- }, [showDots, shouldAutoScroll, scrollToBottom]);
1539
-
1540
1998
  // Persist any pending settings (mode/model) once an agent exists server-side
1541
1999
  const persistPendingSettingsIfNeeded = useCallback(async () => {
1542
2000
  try {
@@ -1857,8 +2315,10 @@ export function AgentTerminal({
1857
2315
  ) => {
1858
2316
  if (!agent?.id) return;
1859
2317
  const current = agentMetadata || {};
2318
+ // Exclude top-level context to avoid duplicate keys when spreading
2319
+ const { context: _, ...currentWithoutContext } = current;
1860
2320
  const next: AgentMetadata = {
1861
- ...current,
2321
+ ...currentWithoutContext,
1862
2322
  additionalData: {
1863
2323
  ...(current.additionalData || {}),
1864
2324
  context: {
@@ -1940,8 +2400,10 @@ export function AgentTerminal({
1940
2400
  return; // Page already exists
1941
2401
  }
1942
2402
 
2403
+ // Exclude top-level context to avoid duplicate keys when spreading
2404
+ const { context: _, ...currentWithoutContext } = current;
1943
2405
  const next: AgentMetadata = {
1944
- ...current,
2406
+ ...currentWithoutContext,
1945
2407
  additionalData: {
1946
2408
  ...(current.additionalData || {}),
1947
2409
  context: {
@@ -1984,8 +2446,10 @@ export function AgentTerminal({
1984
2446
 
1985
2447
  if (newComponentIds.length === 0) return; // No new components to add
1986
2448
 
2449
+ // Exclude top-level context to avoid duplicate keys when spreading
2450
+ const { context: _, ...currentWithoutContext } = current;
1987
2451
  const next: AgentMetadata = {
1988
- ...current,
2452
+ ...currentWithoutContext,
1989
2453
  additionalData: {
1990
2454
  ...(current.additionalData || {}),
1991
2455
  context: {
@@ -2025,8 +2489,10 @@ export function AgentTerminal({
2025
2489
  const newComponentIds = ids.filter((id) => !!id && !existingIds.has(id));
2026
2490
  if (newComponentIds.length === 0) return;
2027
2491
 
2492
+ // Exclude top-level context to avoid duplicate keys when spreading
2493
+ const { context: _, ...currentWithoutContext } = current;
2028
2494
  const next: AgentMetadata = {
2029
- ...current,
2495
+ ...currentWithoutContext,
2030
2496
  additionalData: {
2031
2497
  ...(current.additionalData || {}),
2032
2498
  context: {
@@ -2077,8 +2543,10 @@ export function AgentTerminal({
2077
2543
 
2078
2544
  if (pagesToAdd.length === 0) return;
2079
2545
 
2546
+ // Exclude top-level context to avoid duplicate keys when spreading
2547
+ const { context: _, ...currentWithoutContext } = current;
2080
2548
  const next: AgentMetadata = {
2081
- ...current,
2549
+ ...currentWithoutContext,
2082
2550
  additionalData: {
2083
2551
  ...(current.additionalData || {}),
2084
2552
  context: {
@@ -2188,8 +2656,10 @@ export function AgentTerminal({
2188
2656
  })();
2189
2657
 
2190
2658
  const current = agentMetadata || {};
2659
+ // Exclude top-level context to avoid duplicate keys when spreading
2660
+ const { context: _, ...currentWithoutContext } = current;
2191
2661
  const next: AgentMetadata = {
2192
- ...current,
2662
+ ...currentWithoutContext,
2193
2663
  additionalData: {
2194
2664
  ...(current.additionalData || {}),
2195
2665
  context: {
@@ -2544,13 +3014,18 @@ export function AgentTerminal({
2544
3014
  })}
2545
3015
  </div>
2546
3016
 
2547
- {showDots && <DancingDots />}
3017
+ <div className={showDots ? "visible" : "invisible"}>
3018
+ <DancingDots />
3019
+ </div>
2548
3020
  <div ref={messagesEndRef} />
2549
3021
  </div>
2550
3022
 
2551
3023
  {/* Context Info Bar */}
2552
3024
  {renderContextInfoBar()}
2553
3025
 
3026
+ {/* Todo List Panel */}
3027
+ <TodoListPanel messages={messages} />
3028
+
2554
3029
  {/* Input */}
2555
3030
  <div className="border-t border-gray-200 p-4">
2556
3031
  <div className="flex items-stretch gap-2">