From 8f6e6db5c59ca43ac48c6e86e72af2878307839b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 16 Dec 2025 02:34:29 +0200 Subject: [PATCH] feat: implement todo tracking with collapsible status row and update event handling --- CHANGELOG.md | 2 + packages/desktop/src-tauri/Cargo.lock | 2 +- packages/ui/src/components/chat/ChatInput.tsx | 81 +++--- packages/ui/src/components/chat/StatusRow.tsx | 249 ++++++++++++++++++ packages/ui/src/hooks/useEventStream.ts | 13 + packages/ui/src/lib/opencode/client.ts | 31 +++ packages/ui/src/stores/useTodoStore.ts | 94 +++++++ 7 files changed, 421 insertions(+), 51 deletions(-) create mode 100644 packages/ui/src/components/chat/StatusRow.tsx create mode 100644 packages/ui/src/stores/useTodoStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f1f80b..75f8dab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Todo task tracking: collapsible status row showing AI's current task and progress + ## [1.2.0] - 2025-12-15 - Favorite & recent models for quick access in model selection diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index a39b7036..66afdfb0 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.1.6" +version = "1.2.0" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 970a72fa..25269ef9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -21,7 +21,7 @@ import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; -import { WorkingPlaceholder } from './message/parts/WorkingPlaceholder'; +import { StatusRow } from './StatusRow'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { toast } from 'sonner'; import { useFileStore } from '@/stores/fileStore'; @@ -163,7 +163,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!currentSessionId) return false; return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt); }, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]); - const canShowAbortButton = canAbort && (isMobile || isAbortPromptActive); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -752,7 +751,22 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0' ); - const actionButton = ( + // Desktop and VSCode: show abort button in footer when Esc triggered + const showAbortInFooter = !isMobile && isAbortPromptActive && canAbort; + + const actionButton = showAbortInFooter ? ( + + ) : ( - ) : ( - - )} - - ) : null} - +
= { + in_progress: { + textClassName: "text-foreground", + }, + pending: { + textClassName: "text-foreground", + }, + completed: { + textClassName: "text-muted-foreground line-through", + }, + cancelled: { + textClassName: "text-muted-foreground line-through", + }, +}; + +interface TodoItemRowProps { + todo: TodoItem; +} + +const TodoItemRow: React.FC = ({ todo }) => { + const config = statusConfig[todo.status] || statusConfig.pending; + + return ( +
+ + {todo.content} + +
+ ); +}; + +const EMPTY_TODOS: TodoItem[] = []; + +interface StatusRowProps { + // Working state + isWorking: boolean; + statusText: string | null; + isWaitingForPermission?: boolean; + wasAborted?: boolean; + abortActive?: boolean; + completionId?: string | null; + isComplete?: boolean; + // Abort state (for mobile/vscode) + showAbort?: boolean; + onAbort?: () => void; + // Abort status display + showAbortStatus?: boolean; +} + +export const StatusRow: React.FC = ({ + isWorking, + statusText, + isWaitingForPermission, + wasAborted, + abortActive, + completionId, + isComplete, + showAbort, + onAbort, + showAbortStatus, +}) => { + const [isExpanded, setIsExpanded] = React.useState(false); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const todos = useTodoStore((state) => + currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS + ); + const loadTodos = useTodoStore((state) => state.loadTodos); + const { isMobile } = useUIStore(); + const isCompact = isMobile || isVSCodeRuntime(); + + // Load todos when session changes + React.useEffect(() => { + if (currentSessionId) { + void loadTodos(currentSessionId); + } + }, [currentSessionId, loadTodos]); + + // Filter out cancelled todos for display, sort by status priority + const visibleTodos = React.useMemo(() => { + const statusOrder: Record = { + in_progress: 0, + pending: 1, + completed: 2, + cancelled: 3, + }; + + return [...todos] + .filter((todo) => todo.status !== "cancelled") + .sort((a, b) => statusOrder[a.status] - statusOrder[b.status]); + }, [todos]); + + // Find the current active todo (first in_progress, or first pending) + const activeTodo = React.useMemo(() => { + return ( + visibleTodos.find((t) => t.status === "in_progress") || + visibleTodos.find((t) => t.status === "pending") || + null + ); + }, [visibleTodos]); + + // Calculate progress + const progress = React.useMemo(() => { + const total = todos.filter((t) => t.status !== "cancelled").length; + const completed = todos.filter((t) => t.status === "completed").length; + return { completed, total }; + }, [todos]); + + const hasTodos = visibleTodos.length > 0; + // Original logic from ChatInput + const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); + const hasContent = isWorking || hasTodos || showAbortStatus; + + // Close popover when clicking outside + const popoverRef = React.useRef(null); + React.useEffect(() => { + if (!isExpanded) return; + + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + setIsExpanded(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isExpanded]); + + // Don't render if nothing to show + if (!hasContent) { + return null; + } + + const toggleExpanded = () => setIsExpanded((prev) => !prev); + + // Abort button for mobile/vscode + const abortButton = showAbort && onAbort ? ( + + ) : null; + + // Todo trigger button + const todoTrigger = hasTodos ? ( + + ) : null; + + return ( +
+ {/* Main status row */} +
+ {/* Left: Abort status or Working placeholder */} +
+ {showAbortStatus ? ( +
+ + +
+ ) : shouldRenderPlaceholder ? ( + + ) : null} +
+ + {/* Right: Abort (mobile only) + Todo */} +
+ {abortButton} + {todoTrigger} + + {/* Popover dropdown */} + {isExpanded && hasTodos && ( +
+ {/* Header */} +
+ Tasks + + {progress.completed}/{progress.total} + +
+ + {/* Todo list */} +
+ {visibleTodos.map((todo) => ( + + ))} +
+
+ )} +
+
+
+ ); +}; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index f6c6590f..47e78774 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -7,6 +7,7 @@ import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import type { Part, Session, Message, Permission } from '@opencode-ai/sdk'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; +import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; interface EventData { type: string; @@ -751,6 +752,18 @@ export const useEventStream = () => { case 'permission.replied': break; + + case 'todo.updated': { + const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; + const todos = Array.isArray(props.todos) ? props.todos : []; + if (sessionId && todos.length > 0) { + handleTodoUpdatedEvent( + sessionId, + todos as Array<{ id: string; content: string; status: string; priority: string }> + ); + } + break; + } } }, [ currentSessionId, diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index cbe211b7..d07a0bd7 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -349,6 +349,37 @@ class OpencodeService { return response.data || []; } + async getSessionTodos(sessionId: string): Promise> { + try { + const base = this.baseUrl.replace(/\/$/, ""); + const url = new URL(`${base}/session/${encodeURIComponent(sessionId)}/todo`); + + if (this.currentDirectory && this.currentDirectory.length > 0) { + url.searchParams.set("directory", this.currentDirectory); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + return []; + } + + const data = await response.json().catch(() => null); + if (!data || !Array.isArray(data)) { + return []; + } + + return data as Array<{ id: string; content: string; status: string; priority: string }>; + } catch { + return []; + } + } + async sendMessage(params: { id: string; providerID: string; diff --git a/packages/ui/src/stores/useTodoStore.ts b/packages/ui/src/stores/useTodoStore.ts new file mode 100644 index 00000000..e9cc4139 --- /dev/null +++ b/packages/ui/src/stores/useTodoStore.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +import { opencodeClient } from "@/lib/opencode/client"; + +export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; +export type TodoPriority = "high" | "medium" | "low"; + +export interface TodoItem { + id: string; + content: string; + status: TodoStatus; + priority: TodoPriority; +} + +interface TodoStore { + // Map of sessionId -> todos + sessionTodos: Map; + isLoading: boolean; + + // Actions + loadTodos: (sessionId: string) => Promise; + updateTodos: (sessionId: string, todos: TodoItem[]) => void; + getTodosForSession: (sessionId: string) => TodoItem[]; + clearTodos: (sessionId: string) => void; +} + +type RawTodo = { id: string; content: string; status: string; priority: string }; + +const normalizeTodo = (todo: RawTodo): TodoItem => ({ + id: todo.id, + content: todo.content, + status: (todo.status as TodoStatus) || "pending", + priority: (todo.priority as TodoPriority) || "medium", +}); + +export const useTodoStore = create()( + devtools( + (set, get) => ({ + sessionTodos: new Map(), + isLoading: false, + + loadTodos: async (sessionId: string) => { + if (!sessionId) return; + + set({ isLoading: true }); + + try { + const rawTodos = await opencodeClient.getSessionTodos(sessionId); + const todos = rawTodos.map(normalizeTodo); + + set((state) => { + const newMap = new Map(state.sessionTodos); + newMap.set(sessionId, todos); + return { sessionTodos: newMap, isLoading: false }; + }); + } catch (error) { + console.warn("[TodoStore] Failed to load todos:", error); + set({ isLoading: false }); + } + }, + + updateTodos: (sessionId: string, todos: TodoItem[]) => { + set((state) => { + const newMap = new Map(state.sessionTodos); + newMap.set(sessionId, todos); + return { sessionTodos: newMap }; + }); + }, + + getTodosForSession: (sessionId: string) => { + return get().sessionTodos.get(sessionId) || []; + }, + + clearTodos: (sessionId: string) => { + set((state) => { + const newMap = new Map(state.sessionTodos); + newMap.delete(sessionId); + return { sessionTodos: newMap }; + }); + }, + }), + { name: "todo-store" } + ) +); + +// Helper to handle SSE todo.updated events +export const handleTodoUpdatedEvent = ( + sessionId: string, + todos: RawTodo[] +): void => { + const normalizedTodos = todos.map(normalizeTodo); + useTodoStore.getState().updateTodos(sessionId, normalizedTodos); +};