feat: implement todo tracking with collapsible status row and update event handling
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.1.6"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user