From 8cbf83e68cce5cc4e205878f68a9e1c4df70da56 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 9 Feb 2026 15:16:19 +0300 Subject: [PATCH 1/3] Simplify status indicator component --- CLAUDE.md | 1 - CONTRIBUTING.md | 3 +- package.json | 1 - .../tasks/src/components/StatusIndicator.tsx | 8 +-- packages/tasks/src/index.css | 11 +-- test/webview/tasks/StatusIndicator.test.tsx | 67 ++++++------------- test/webview/tasks/useTaskMenuItems.test.tsx | 4 +- 7 files changed, 34 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 71de7050..33b99155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,6 @@ Comments explain what code does or why it exists: - All unit tests: `pnpm test` - Extension tests: `pnpm test:extension` - Webview tests: `pnpm test:webview` -- CI mode: `pnpm test:ci` - Integration tests: `pnpm test:integration` - Run specific extension test: `pnpm test:extension ./test/unit/filename.test.ts` - Run specific webview test: `pnpm test:webview ./test/webview/filename.test.ts` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fad6e01..7d6fabd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,8 +128,7 @@ The project uses Vitest with separate test configurations for extension and webv ```bash pnpm test:extension # Extension tests (runs in Electron with mocked VS Code APIs) pnpm test:webview # Webview tests (runs in jsdom) -pnpm test # Both extension and webview tests -pnpm test:ci # CI mode (same as test with CI=true) +pnpm test # Both extension and webview tests (CI mode) ``` Test files are organized by type: diff --git a/package.json b/package.json index 222ba0fe..014811d9 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "package": "vsce package --no-dependencies", "package:prerelease": "vsce package --pre-release --no-dependencies", "test": "CI=true pnpm test:extension && CI=true pnpm test:webview", - "test:ci": "pnpm test", "test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", "test:integration": "tsc -p test --outDir out --noCheck && node esbuild.mjs && vscode-test", "test:webview": "vitest --project webview", diff --git a/packages/tasks/src/components/StatusIndicator.tsx b/packages/tasks/src/components/StatusIndicator.tsx index ec3fb41a..32037736 100644 --- a/packages/tasks/src/components/StatusIndicator.tsx +++ b/packages/tasks/src/components/StatusIndicator.tsx @@ -1,12 +1,10 @@ -import { getTaskUIState, type Task } from "@repo/shared"; +import { type Task } from "@repo/shared"; interface StatusIndicatorProps { task: Task; } export function StatusIndicator({ task }: StatusIndicatorProps) { - const uiState = getTaskUIState(task); - const title = uiState.charAt(0).toUpperCase() + uiState.slice(1); - - return ; + const title = task.status.charAt(0).toUpperCase() + task.status.slice(1); + return ; } diff --git a/packages/tasks/src/index.css b/packages/tasks/src/index.css index 6a99b01b..1d0e053e 100644 --- a/packages/tasks/src/index.css +++ b/packages/tasks/src/index.css @@ -193,9 +193,7 @@ vscode-collapsible::part(body) { background: var(--status-color); } -.status-dot.working, -.status-dot.idle, -.status-dot.complete { +.status-dot.active { --status-color: var(--vscode-testing-iconPassed); } @@ -203,7 +201,8 @@ vscode-collapsible::part(body) { --status-color: var(--vscode-testing-iconFailed); } -.status-dot.initializing { +.status-dot.initializing, +.status-dot.pending { --status-color: var(--vscode-testing-iconQueued); } @@ -211,6 +210,10 @@ vscode-collapsible::part(body) { --status-color: var(--vscode-testing-iconSkipped); } +.status-dot.unknown { + --status-color: var(--vscode-testing-iconErrored); +} + .text-link { color: var(--vscode-textLink-foreground); display: inline-flex; diff --git a/test/webview/tasks/StatusIndicator.test.tsx b/test/webview/tasks/StatusIndicator.test.tsx index 328ff286..297dd90c 100644 --- a/test/webview/tasks/StatusIndicator.test.tsx +++ b/test/webview/tasks/StatusIndicator.test.tsx @@ -1,57 +1,32 @@ import { render, screen } from "@testing-library/react"; +import { TaskStatuses } from "coder/site/src/api/typesGenerated"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { StatusIndicator } from "@repo/tasks/components"; -import { minimalTask, task, taskState } from "../../mocks/tasks"; +import { task } from "../../mocks/tasks"; -import type { Task } from "@repo/shared"; +import type { TaskStatus } from "@repo/shared"; + +const css = readFileSync(resolve("packages/tasks/src/index.css"), "utf-8"); describe("StatusIndicator", () => { - interface StatusTestCase { - name: string; - task: Task; - title: string; - } - it.each([ - { - name: "error task status", - task: task({ status: "error", current_state: null }), - title: "Error", - }, - { - name: "failed task state", - task: task({ current_state: taskState("failed") }), - title: "Error", - }, - { - name: "stopped workspace", - task: task({ workspace_status: "stopped", current_state: null }), - title: "Paused", - }, - { - name: "pending workspace", - task: task({ workspace_status: "pending", current_state: null }), - title: "Initializing", + it.each(TaskStatuses)( + "renders status '%s' with matching class and title", + (status) => { + const expectedTitle = status.charAt(0).toUpperCase() + status.slice(1); + render(); + const dot = screen.getByTitle(expectedTitle); + expect(dot.classList).toContain(status); }, - { - name: "working state", - task: task({ current_state: taskState("working") }), - title: "Working", - }, - { - name: "complete state", - task: task({ current_state: taskState("complete") }), - title: "Complete", - }, - { - name: "no workspace or state (idle)", - task: minimalTask(), - title: "Idle", + ); + + it.each(TaskStatuses)( + "has a CSS rule for status '%s'", + (status) => { + expect(css).toMatch(new RegExp(`\\.status-dot\\.${status}\\b`)); }, - ])("$name", ({ task, title }) => { - render(); - const dot = screen.getByTitle(title); - expect(dot.classList).toContain(title.toLowerCase()); - }); + ); }); diff --git a/test/webview/tasks/useTaskMenuItems.test.tsx b/test/webview/tasks/useTaskMenuItems.test.tsx index 03606479..e51d3e5b 100644 --- a/test/webview/tasks/useTaskMenuItems.test.tsx +++ b/test/webview/tasks/useTaskMenuItems.test.tsx @@ -46,8 +46,8 @@ function clickItem(items: ActionMenuItem[], label: string): void { }); } -const pausableTask = () => task({ workspace_status: "running" }); -const resumableTask = () => task({ workspace_status: "stopped" }); +const pausableTask = () => task({ status: "active" }); +const resumableTask = () => task({ status: "paused" }); function deferPause() { let resolve: () => void = () => {}; From c8b2f6e6b928418ee6df455231e1b44af82c9628 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 4 Feb 2026 00:26:43 +0300 Subject: [PATCH 2/3] 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 | 42 +++++++ .../tasks/src/components/TaskDetailView.tsx | 43 +++++++ packages/tasks/src/components/TaskInput.tsx | 118 ++++++++++++++++++ packages/tasks/src/components/TaskItem.tsx | 16 +-- packages/tasks/src/components/index.ts | 5 + .../tasks/src/components/useTaskMenuItems.ts | 3 +- packages/tasks/src/components/utils.ts | 17 +++ packages/tasks/src/config.ts | 5 + packages/tasks/src/hooks/useSelectedTask.ts | 84 +++++++++++++ 12 files changed, 456 insertions(+), 20 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/components/utils.ts create mode 100644 packages/tasks/src/hooks/useSelectedTask.ts diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index f0f21690..83f31d00 100644 --- a/packages/tasks/src/App.tsx +++ b/packages/tasks/src/App.tsx @@ -12,10 +12,12 @@ import { ErrorState, NoTemplateState, NotSupportedState, + TaskDetailView, TaskList, } from "./components"; import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle"; import { useScrollableHeight } from "./hooks/useScrollableHeight"; +import { useSelectedTask } from "./hooks/useSelectedTask"; import { useTasksData } from "./hooks/useTasksData"; type CollapsibleElement = React.ComponentRef; @@ -34,6 +36,9 @@ export default function App() { persistUiState, } = useTasksData(); + const { selectedTask, isLoadingDetails, selectTask, deselectTask } = + useSelectedTask(tasks); + const [createRef, createOpen, setCreateOpen] = useCollapsibleToggle(initialCreateExpanded); const [historyRef, historyOpen, _setHistoryOpen] = @@ -46,8 +51,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(() => { persistUiState({ @@ -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..0a2bfe80 --- /dev/null +++ b/packages/tasks/src/components/TaskDetailHeader.tsx @@ -0,0 +1,42 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; + +import { ActionMenu, type ActionMenuItem } from "./ActionMenu"; +import { StatusIndicator } from "./StatusIndicator"; +import { getDisplayName } from "./utils"; + +import type { Task } from "@repo/shared"; + +interface TaskDetailHeaderProps { + task: Task; + menuItems: ActionMenuItem[]; + onBack: () => void; + loadingAction?: string | null; +} + +export function TaskDetailHeader({ + task, + menuItems, + onBack, + loadingAction, +}: TaskDetailHeaderProps) { + const displayName = getDisplayName(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..b420a5ce --- /dev/null +++ b/packages/tasks/src/components/TaskDetailView.tsx @@ -0,0 +1,43 @@ +import { getTaskActions, type TaskDetails } from "@repo/shared"; + +import { AgentChatHistory } from "./AgentChatHistory"; +import { ErrorBanner } from "./ErrorBanner"; +import { TaskDetailHeader } from "./TaskDetailHeader"; +import { TaskInput } from "./TaskInput"; +import { useTaskMenuItems } from "./useTaskMenuItems"; +import { getActionLabel } from "./utils"; + +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..22ff31f5 --- /dev/null +++ b/packages/tasks/src/components/TaskInput.tsx @@ -0,0 +1,118 @@ +import { + VscodeIcon, + VscodeProgressRing, +} from "@vscode-elements/react-elements"; +import { useState } from "react"; + +import { useTasksApi } from "../hooks/useTasksApi"; + +import type { Task } from "@repo/shared"; + +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); + } 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 ( +
+