From 2e5d1750a291f9d0efe16d23f89a0487659cf0a8 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 4 Feb 2026 00:26:43 +0300 Subject: [PATCH 1/6] Add task detail view with navigation and log streaming Adds the task detail view layer including: Components: - TaskDetailView as the main detail view container - TaskDetailHeader with back button, status, and action menu - AgentChatHistory for displaying task log entries with scroll tracking - TaskInput with pause button and state-aware placeholder - ErrorBanner for displaying task errors with link to logs App.tsx enhancements: - Navigation between task list and detail view (inline in Task History) - Selected task state persistence and validation - Adaptive polling intervals based on task state (active vs idle) - Real-time log streaming via logsAppend push messages - refs to avoid stale closures in message handlers - Transition animation when switching views Config additions: - TASK_ACTIVE_INTERVAL_MS for faster updates when task is working - TASK_IDLE_INTERVAL_MS for slower updates when task is idle/complete Also adds codicons CSS import for icon rendering. --- packages/tasks/src/App.tsx | 27 ++-- .../tasks/src/components/AgentChatHistory.tsx | 87 +++++++++++++ packages/tasks/src/components/ErrorBanner.tsx | 29 +++++ .../tasks/src/components/TaskDetailHeader.tsx | 40 ++++++ .../tasks/src/components/TaskDetailView.tsx | 44 +++++++ packages/tasks/src/components/TaskInput.tsx | 117 ++++++++++++++++++ packages/tasks/src/hooks/useSelectedTask.ts | 87 +++++++++++++ packages/tasks/src/utils/config.ts | 5 + 8 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 packages/tasks/src/components/AgentChatHistory.tsx create mode 100644 packages/tasks/src/components/ErrorBanner.tsx create mode 100644 packages/tasks/src/components/TaskDetailHeader.tsx create mode 100644 packages/tasks/src/components/TaskDetailView.tsx create mode 100644 packages/tasks/src/components/TaskInput.tsx create mode 100644 packages/tasks/src/hooks/useSelectedTask.ts diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index b507dc5f..8a88a896 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -12,9 +12,11 @@ import { CreateTaskSection } from "./components/CreateTaskSection"; import { ErrorState } from "./components/ErrorState"; import { NoTemplateState } from "./components/NoTemplateState"; import { NotSupportedState } from "./components/NotSupportedState"; +import { TaskDetailView } from "./components/TaskDetailView"; import { TaskList } from "./components/TaskList"; import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle"; import { useScrollableHeight } from "./hooks/useScrollableHeight"; +import { useSelectedTask } from "./hooks/useSelectedTask"; import { useTasksQuery } from "./hooks/useTasksQuery"; interface PersistedState extends InitResponse { @@ -30,6 +32,9 @@ export default function App() { const { tasks, templates, tasksSupported, data, isLoading, error, refetch } = useTasksQuery(restored); + const { selectedTask, isLoadingDetails, selectTask, deselectTask } = + useSelectedTask(tasks); + const [createRef, createOpen, setCreateOpen] = useCollapsibleToggle(restored?.createExpanded ?? true); const [historyRef, historyOpen] = useCollapsibleToggle( @@ -43,8 +48,11 @@ export default function App() { const { onNotification } = useIpc(); useEffect(() => { - return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true)); - }, [onNotification, setCreateOpen]); + return onNotification(TasksApi.showCreateForm, () => { + deselectTask(); + setCreateOpen(true); + }); + }, [onNotification, setCreateOpen, deselectTask]); useEffect(() => { if (data) { @@ -96,12 +104,15 @@ export default function App() { open={historyOpen} > - { - // Task detail view will be added in next PR - }} - /> + {selectedTask ? ( + + ) : isLoadingDetails ? ( +
+ +
+ ) : ( + + )}
diff --git a/packages/tasks/src/components/AgentChatHistory.tsx b/packages/tasks/src/components/AgentChatHistory.tsx new file mode 100644 index 00000000..2500c1f6 --- /dev/null +++ b/packages/tasks/src/components/AgentChatHistory.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useCallback } from "react"; + +import type { LogsStatus, TaskLogEntry } from "@repo/shared"; + +interface AgentChatHistoryProps { + logs: TaskLogEntry[]; + logsStatus: LogsStatus; + isThinking: boolean; +} + +function getEmptyMessage(logsStatus: LogsStatus): string { + switch (logsStatus) { + case "not_available": + return "Logs not available in current task state"; + case "error": + return "Failed to load logs"; + default: + return "No messages yet"; + } +} + +export function AgentChatHistory({ + logs, + logsStatus, + isThinking, +}: AgentChatHistoryProps) { + const containerRef = useRef(null); + const isAtBottomRef = useRef(true); + const isInitialMountRef = useRef(true); + + const checkIfAtBottom = useCallback(() => { + const container = containerRef.current; + if (!container) return true; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + return distanceFromBottom <= 50; + }, []); + + const handleScroll = useCallback(() => { + isAtBottomRef.current = checkIfAtBottom(); + }, [checkIfAtBottom]); + + useEffect(() => { + if (isInitialMountRef.current && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + isInitialMountRef.current = false; + } + }, []); + + useEffect(() => { + if (containerRef.current && isAtBottomRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [logs]); + + const emptyMessage = getEmptyMessage(logsStatus); + + return ( +
+
Agent chat history
+
+ {logs.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + logs.map((log) => ( +
+ {log.content} +
+ )) + )} + {isThinking && ( +
+ *Thinking... +
+ )} +
+
+ ); +} diff --git a/packages/tasks/src/components/ErrorBanner.tsx b/packages/tasks/src/components/ErrorBanner.tsx new file mode 100644 index 00000000..1de29a7a --- /dev/null +++ b/packages/tasks/src/components/ErrorBanner.tsx @@ -0,0 +1,29 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; +import { useCallback } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +import type { Task } from "@repo/shared"; + +interface ErrorBannerProps { + task: Task; +} + +export function ErrorBanner({ task }: ErrorBannerProps) { + const api = useTasksApi(); + const message = task.current_state?.message || "Build failed"; + + const handleViewLogs = useCallback(() => { + void api.viewLogs(task.id); + }, [api, task.id]); + + return ( +
+ + {message}. + +
+ ); +} diff --git a/packages/tasks/src/components/TaskDetailHeader.tsx b/packages/tasks/src/components/TaskDetailHeader.tsx new file mode 100644 index 00000000..52e379ad --- /dev/null +++ b/packages/tasks/src/components/TaskDetailHeader.tsx @@ -0,0 +1,40 @@ +import { getTaskLabel, type Task } from "@repo/shared"; +import { VscodeIcon } from "@vscode-elements/react-elements"; + +import { ActionMenu, type ActionMenuItem } from "./ActionMenu"; +import { StatusIndicator } from "./StatusIndicator"; + +interface TaskDetailHeaderProps { + task: Task; + menuItems: ActionMenuItem[]; + onBack: () => void; + loadingAction?: string | null; +} + +export function TaskDetailHeader({ + task, + menuItems, + onBack, + loadingAction, +}: TaskDetailHeaderProps) { + const displayName = getTaskLabel(task); + + return ( +
+ + + + {displayName} + {loadingAction && ( + {loadingAction} + )} + + +
+ ); +} diff --git a/packages/tasks/src/components/TaskDetailView.tsx b/packages/tasks/src/components/TaskDetailView.tsx new file mode 100644 index 00000000..6e1de0ed --- /dev/null +++ b/packages/tasks/src/components/TaskDetailView.tsx @@ -0,0 +1,44 @@ +import { getTaskActions, type TaskDetails } from "@repo/shared"; + +import { getActionLabel } from "../utils/taskAction"; + +import { AgentChatHistory } from "./AgentChatHistory"; +import { ErrorBanner } from "./ErrorBanner"; +import { TaskDetailHeader } from "./TaskDetailHeader"; +import { TaskInput } from "./TaskInput"; +import { useTaskMenuItems } from "./useTaskMenuItems"; + +interface TaskDetailViewProps { + details: TaskDetails; + onBack: () => void; +} + +export function TaskDetailView({ details, onBack }: TaskDetailViewProps) { + const { task, logs, logsStatus } = details; + const { canPause } = getTaskActions(task); + + const isWorking = + task.status === "active" && + task.current_state?.state === "working" && + task.workspace_agent_lifecycle === "ready"; + + const { menuItems, action } = useTaskMenuItems({ task }); + + return ( +
+ + {task.status === "error" && } + + +
+ ); +} diff --git a/packages/tasks/src/components/TaskInput.tsx b/packages/tasks/src/components/TaskInput.tsx new file mode 100644 index 00000000..7ab47acb --- /dev/null +++ b/packages/tasks/src/components/TaskInput.tsx @@ -0,0 +1,117 @@ +import { getTaskLabel, type Task } from "@repo/shared"; +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; +import { useState } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +interface TaskInputProps { + taskId: string; + task: Task; + canPause: boolean; +} + +function getPlaceholder(task: Task): string { + if (task.status === "error" || task.current_state?.state === "failed") { + return task.current_state?.message || "Error occurred..."; + } + if (task.status === "paused") { + return "Task paused"; + } + if (task.status === "pending" || task.status === "initializing") { + return "Initializing..."; + } + if (task.current_state?.state === "working") { + return "Agent is working..."; + } + if (task.current_state?.state === "complete") { + return "Task completed"; + } + return "Type a message to the agent..."; +} + +function isInputEnabled(task: Task): boolean { + const state = task.current_state?.state; + return state === "idle" || state === "complete" || task.status === "paused"; +} + +export function TaskInput({ taskId, task, canPause }: TaskInputProps) { + const api = useTasksApi(); + const [message, setMessage] = useState(""); + const [isPausing, setIsPausing] = useState(false); + const [isSending, setIsSending] = useState(false); + + const inputEnabled = isInputEnabled(task); + const showPauseButton = task.current_state?.state === "working" && canPause; + const placeholder = getPlaceholder(task); + + const handleSend = () => { + if (!message.trim() || !inputEnabled || isSending) return; + setIsSending(true); + api.sendTaskMessage(taskId, message.trim()); + setMessage(""); + setTimeout(() => setIsSending(false), 500); + }; + + const handlePause = async () => { + if (isPausing) return; + setIsPausing(true); + try { + await api.pauseTask({ taskId: task.id, taskName: getTaskLabel(task) }); + } catch { + // Extension shows error notification + } finally { + setIsPausing(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && inputEnabled) { + e.preventDefault(); + void handleSend(); + } + }; + + return ( +
+