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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Todo task tracking: collapsible status row showing AI's current task and progress
|
||||||
|
|
||||||
## [1.2.0] - 2025-12-15
|
## [1.2.0] - 2025-12-15
|
||||||
|
|
||||||
- Favorite & recent models for quick access in model selection
|
- Favorite & recent models for quick access in model selection
|
||||||
|
|||||||
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.1.6"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { ServerFilePicker } from './ServerFilePicker';
|
import { ServerFilePicker } from './ServerFilePicker';
|
||||||
import { ModelControls } from './ModelControls';
|
import { ModelControls } from './ModelControls';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { WorkingPlaceholder } from './message/parts/WorkingPlaceholder';
|
import { StatusRow } from './StatusRow';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
@@ -163,7 +163,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!currentSessionId) return false;
|
if (!currentSessionId) return false;
|
||||||
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
||||||
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
||||||
const canShowAbortButton = canAbort && (isMobile || isAbortPromptActive);
|
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
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'
|
'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
|
<button
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={!hasContent || !currentSessionId}
|
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 (
|
return (
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="pt-0 pb-4 bottom-safe-area">
|
<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">
|
<StatusRow
|
||||||
<div className="flex-1 flex items-center overflow-hidden">
|
isWorking={working.isWorking}
|
||||||
{showAbortStatus ? (
|
statusText={workingStatusText}
|
||||||
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
<span className="flex items-center gap-1.5 typography-ui-header">
|
wasAborted={working.wasAborted}
|
||||||
<RiCloseCircleLine size={18} aria-hidden="true" />
|
abortActive={working.abortActive}
|
||||||
Aborted
|
completionId={working.lastCompletionId}
|
||||||
</span>
|
isComplete={working.isComplete}
|
||||||
</div>
|
showAbort={showAbortInStatusRow}
|
||||||
) : shouldRenderPlaceholder ? (
|
onAbort={handleAbort}
|
||||||
<WorkingPlaceholder
|
showAbortStatus={showAbortStatus}
|
||||||
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>
|
|
||||||
<div
|
<div
|
||||||
ref={dropZoneRef}
|
ref={dropZoneRef}
|
||||||
className={cn(
|
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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import type { Part, Session, Message, Permission } from '@opencode-ai/sdk';
|
import type { Part, Session, Message, Permission } from '@opencode-ai/sdk';
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
|
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -751,6 +752,18 @@ export const useEventStream = () => {
|
|||||||
case 'permission.replied':
|
case 'permission.replied':
|
||||||
|
|
||||||
break;
|
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,
|
currentSessionId,
|
||||||
|
|||||||
@@ -349,6 +349,37 @@ class OpencodeService {
|
|||||||
return response.data || [];
|
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: {
|
async sendMessage(params: {
|
||||||
id: string;
|
id: string;
|
||||||
providerID: 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