feat: implement todo tracking with collapsible status row and update event handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-16 02:34:29 +02:00
parent ca898cf321
commit 8f6e6db5c5
7 changed files with 421 additions and 51 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.1.6"
version = "1.2.0"
dependencies = [
"anyhow",
"axum",
+31 -50
View File
@@ -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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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 ? (
<button
type='button'
onClick={handleAbort}
className={cn(
iconButtonBaseClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label='Stop generating'
>
<RiCloseCircleLine className={cn(iconSizeClass)} />
</button>
) : (
<button
type='submit'
disabled={!hasContent || !currentSessionId}
@@ -880,57 +894,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
};
}, []);
const shouldRenderPlaceholder = !showAbortStatus && (working.wasAborted || !working.abortActive);
// For mobile only, show abort in StatusRow; desktop and vscode show in footer (Esc-triggered)
const showAbortInStatusRow = isMobile && canAbort;
return (
<form onSubmit={handleSubmit} className="pt-0 pb-4 bottom-safe-area">
<div className="chat-column mb-1.5 h-[1.2rem] flex items-center justify-between gap-2 overflow-visible">
<div className="flex-1 flex items-center overflow-hidden">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
<span className="flex items-center gap-1.5 typography-ui-header">
<RiCloseCircleLine size={18} aria-hidden="true" />
Aborted
</span>
</div>
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? 'no-session'}
statusText={workingStatusText}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
completionId={working.lastCompletionId}
isComplete={working.isComplete}
/>
) : null}
</div>
{canShowAbortButton ? (
<div className="flex-shrink-0 pr-[2ch]">
{isMobile ? (
<button
type='button'
onClick={handleAbort}
className='flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none'
aria-label='Stop generating'
>
<RiCloseCircleLine size={18} aria-hidden='true' />
</button>
) : (
<button
type='button'
onClick={handleAbort}
className='inline-flex h-[1.2rem] items-center gap-0.5 rounded-md bg-[var(--status-error)]/70 px-1 text-[0.65rem] font-medium text-white hover:bg-[var(--status-error)]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--status-error)]/40'
aria-label='Stop generating'
>
<RiCloseCircleLine size={11} className='text-white' aria-hidden='true' />
Abort
</button>
)}
</div>
) : null}
</div>
<StatusRow
isWorking={working.isWorking}
statusText={workingStatusText}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
abortActive={working.abortActive}
completionId={working.lastCompletionId}
isComplete={working.isComplete}
showAbort={showAbortInStatusRow}
onAbort={handleAbort}
showAbortStatus={showAbortStatus}
/>
<div
ref={dropZoneRef}
className={cn(
@@ -0,0 +1,249 @@
import React from "react";
import { RiArrowUpSLine, RiArrowDownSLine, RiCloseCircleLine } from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useTodoStore, type TodoItem, type TodoStatus } from "@/stores/useTodoStore";
import { useSessionStore } from "@/stores/useSessionStore";
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
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<TodoItemRowProps> = ({ todo }) => {
const config = statusConfig[todo.status] || statusConfig.pending;
return (
<div className="flex items-start min-w-0 py-0.5">
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
</div>
);
};
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<StatusRowProps> = ({
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<TodoStatus, number> = {
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<HTMLDivElement>(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 ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodos ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
)}
<span className="typography-meta">
{progress.completed}/{progress.total}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5" />
)}
</button>
) : null;
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
{/* Main status row */}
<div className="flex items-center justify-between pr-[2ch] py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
</span>
</div>
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
statusText={statusText}
isWaitingForPermission={isWaitingForPermission}
wasAborted={wasAborted}
completionId={completionId ?? null}
isComplete={isComplete}
/>
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodos && (
<div
style={{ maxWidth: "calc(100cqw - 4ch)" }}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-muted shadow-md",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="typography-ui-label text-muted-foreground">Tasks</span>
<span className="typography-meta text-muted-foreground">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
{visibleTodos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
+13
View File
@@ -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,
+31
View File
@@ -349,6 +349,37 @@ class OpencodeService {
return response.data || [];
}
async getSessionTodos(sessionId: string): Promise<Array<{ id: string; content: string; status: string; priority: string }>> {
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;
+94
View File
@@ -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<string, TodoItem[]>;
isLoading: boolean;
// Actions
loadTodos: (sessionId: string) => Promise<void>;
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<TodoStore>()(
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);
};