diff --git a/src/renderer/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts index 415aa2706..f91986020 100644 --- a/src/renderer/features/details-sidebar/atoms/index.ts +++ b/src/renderer/features/details-sidebar/atoms/index.ts @@ -9,6 +9,7 @@ import { FileDiff, ListTodo, GitPullRequest, + Activity, } from "lucide-react" import { OriginalMCPIcon } from "../../../components/ui/icons" @@ -16,7 +17,7 @@ import { OriginalMCPIcon } from "../../../components/ui/icons" // Widget System Types & Registry // ============================================================================ -export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr" +export type WidgetId = "info" | "tasks" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr" export interface WidgetConfig { id: WidgetId @@ -29,6 +30,7 @@ export interface WidgetConfig { export const WIDGET_REGISTRY: WidgetConfig[] = [ { id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true }, { id: "pr", label: "Pull Request", icon: GitPullRequest, canExpand: false, defaultVisible: false }, + { id: "tasks", label: "Tasks", icon: Activity, canExpand: false, defaultVisible: true }, { id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true }, { id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true }, { id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false }, diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx index ecae42d69..8835eecf2 100644 --- a/src/renderer/features/details-sidebar/details-sidebar.tsx +++ b/src/renderer/features/details-sidebar/details-sidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { ArrowUpRight, TerminalSquare, Box, ListTodo, GitPullRequest } from "lucide-react" +import { ArrowUpRight, TerminalSquare, Box, ListTodo, GitPullRequest, Activity } from "lucide-react" import { ResizableSidebar } from "@/components/ui/resizable-sidebar" import { Button } from "@/components/ui/button" import { @@ -35,6 +35,7 @@ import { import { WidgetSettingsPopup } from "./widget-settings-popup" import { InfoSection } from "./sections/info-section" import { TodoWidget } from "./sections/todo-widget" +import { TasksWidget } from "./sections/tasks-widget" import { PlanWidget } from "./sections/plan-widget" import { TerminalWidget } from "./sections/terminal-widget" import { ChangesWidget } from "./sections/changes-widget" @@ -56,6 +57,8 @@ function getWidgetIcon(widgetId: WidgetId) { switch (widgetId) { case "info": return Box + case "tasks": + return Activity case "todo": return ListTodo case "plan": @@ -442,6 +445,11 @@ export function DetailsSidebar({ ) + case "tasks": + return ( + + ) + case "todo": return ( diff --git a/src/renderer/features/details-sidebar/sections/tasks-widget.tsx b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx new file mode 100644 index 000000000..170df242a --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx @@ -0,0 +1,258 @@ +"use client" + +import { memo, useEffect, useMemo, useRef, useState } from "react" +import { atom, useAtomValue } from "jotai" +import { atomFamily } from "jotai/utils" +import { Activity, Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { + getPerChatMessageKey, + messageAtomFamily, + messageIdsPerChatAtom, + type Message, +} from "@/features/agents/stores/message-store" +import { useStreamingStatusStore } from "@/features/agents/stores/streaming-status-store" + +interface TasksWidgetProps { + subChatId: string | null +} + +interface RunningTask { + toolCallId: string + toolName: string + summary: string + startedAt: number + parentId: string | null + children: RunningTask[] +} + +// Tools that are tracked elsewhere (Todo widget / plan approvals) or are not real work. +const EXCLUDED_TOOL_NAMES = new Set([ + "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + "TaskOutput", + "ExitPlanMode", + "Thinking", +]) + +function summarizeInput(input: unknown): string { + if (!input || typeof input !== "object") return "" + const rec = input as Record + const preferredKeys = [ + "command", + "file_path", + "path", + "pattern", + "description", + "url", + "query", + "prompt", + "subagent_type", + ] + for (const key of preferredKeys) { + const v = rec[key] + if (typeof v === "string" && v.length > 0) return v + } + for (const key in rec) { + const v = rec[key] + if (typeof v === "string" && v.length > 0) return v + } + return "" +} + +function formatElapsed(ms: number): string { + const sec = Math.max(0, Math.floor(ms / 1000)) + if (sec < 60) return `${sec}s` + const min = Math.floor(sec / 60) + const rem = sec % 60 + return `${min}m ${rem.toString().padStart(2, "0")}s` +} + +// Derived atom: the last assistant Message for a given subChatId. +// Scans from the end of messageIdsPerChatAtom; returns null if none found. +const lastAssistantMessageForSubChatAtomFamily = atomFamily((subChatId: string) => + atom((get) => { + const ids = get(messageIdsPerChatAtom(subChatId)) + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i] + if (!id) continue + const msg = get(messageAtomFamily(getPerChatMessageKey(subChatId, id))) + if (msg && msg.role === "assistant") return msg + } + return null + }), +) + +export const TasksWidget = memo(function TasksWidget({ + subChatId, +}: TasksWidgetProps) { + const key = subChatId || "default" + + const isStreaming = useStreamingStatusStore((s) => s.isStreaming(key)) + + const lastAssistantAtom = useMemo( + () => lastAssistantMessageForSubChatAtomFamily(key), + [key], + ) + const lastAssistant = useAtomValue(lastAssistantAtom) + + const startedAtRef = useRef>(new Map()) + + const tasks = useMemo(() => { + if (!isStreaming || !lastAssistant) return [] + + const parts = lastAssistant.parts || [] + const byId = new Map() + + for (const part of parts) { + if (!part?.type || typeof part.type !== "string") continue + if (!part.type.startsWith("tool-")) continue + if (!part.toolCallId) continue + + const st = part.state + const isRunning = + st !== "output-available" && + st !== "output-error" && + st !== "result" && + st !== "input-error" + if (!isRunning) continue + + const toolName = part.type.slice(5) + if (EXCLUDED_TOOL_NAMES.has(toolName)) continue + + const colonIdx = part.toolCallId.indexOf(":") + const parentId = + colonIdx > -1 ? part.toolCallId.slice(0, colonIdx) : null + + // AI SDK exposes transform-provided startedAt on `callProviderMetadata`; + // fall back to `providerMetadata` and then to first-sighting time. + const metaStart = + (part.callProviderMetadata?.custom?.startedAt as number | undefined) ?? + (part.providerMetadata?.custom?.startedAt as number | undefined) ?? + (part.startedAt as number | undefined) + let startedAt = + typeof metaStart === "number" + ? metaStart + : startedAtRef.current.get(part.toolCallId) + if (typeof startedAt !== "number") { + startedAt = Date.now() + } + startedAtRef.current.set(part.toolCallId, startedAt) + + byId.set(part.toolCallId, { + toolCallId: part.toolCallId, + toolName, + summary: summarizeInput(part.input).slice(0, 80), + startedAt, + parentId, + children: [], + }) + } + + const roots: RunningTask[] = [] + for (const task of byId.values()) { + if (task.parentId && byId.has(task.parentId)) { + byId.get(task.parentId)!.children.push(task) + } else { + roots.push(task) + } + } + return roots + }, [isStreaming, lastAssistant]) + + // Prune startedAt entries that no longer correspond to a running tool. + useEffect(() => { + if (tasks.length === 0) { + startedAtRef.current.clear() + return + } + const live = new Set() + const walk = (list: RunningTask[]) => { + for (const t of list) { + live.add(t.toolCallId) + walk(t.children) + } + } + walk(tasks) + for (const id of Array.from(startedAtRef.current.keys())) { + if (!live.has(id)) startedAtRef.current.delete(id) + } + }, [tasks]) + + // Tick once per second while the list is non-empty to update elapsed times. + const [, setTick] = useState(0) + useEffect(() => { + if (tasks.length === 0) return + const h = setInterval(() => setTick((n) => n + 1), 1000) + return () => clearInterval(h) + }, [tasks.length]) + + const total = useMemo(() => { + let n = 0 + const walk = (list: RunningTask[]) => { + for (const t of list) { + n++ + walk(t.children) + } + } + walk(tasks) + return n + }, [tasks]) + + if (tasks.length === 0) return null + + return ( +
+
+
+ + Tasks + + Running now + + + {total} + +
+
+
+ {tasks.map((task) => ( + + ))} +
+
+ ) +}) + +function TaskRow({ task, depth }: { task: RunningTask; depth: number }) { + const elapsed = formatElapsed(Date.now() - task.startedAt) + return ( + <> +
0 && "pl-6 ml-3 border-l border-border/30", + )} + > + + + {task.toolName} + + {task.summary ? ( + + {task.summary} + + ) : null} + + {elapsed} + +
+ {task.children.map((child) => ( + + ))} + + ) +}