diff --git a/package.json b/package.json index 5a15b205..61a63fbb 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "id": "coder.tasksPanel", "name": "Coder Tasks", "icon": "media/tasks-logo.svg", - "when": "coder.authenticated && coder.tasksEnabled" + "when": "coder.tasksEnabled" } ] }, diff --git a/packages/shared/src/tasks/api.ts b/packages/shared/src/tasks/api.ts index 80ec0c26..bc96bfbf 100644 --- a/packages/shared/src/tasks/api.ts +++ b/packages/shared/src/tasks/api.ts @@ -15,7 +15,7 @@ import { defineRequest, } from "../ipc/protocol"; -import type { Task, TaskDetails, TaskLogEntry, TaskTemplate } from "./types"; +import type { Task, TaskDetails, TaskTemplate } from "./types"; export interface InitResponse { tasks: readonly Task[]; @@ -24,11 +24,15 @@ export interface InitResponse { tasksSupported: boolean; } +export interface TaskIdParams { + taskId: string; +} + const init = defineRequest("init"); const getTasks = defineRequest("getTasks"); const getTemplates = defineRequest("getTemplates"); -const getTask = defineRequest<{ taskId: string }, Task>("getTask"); -const getTaskDetails = defineRequest<{ taskId: string }, TaskDetails>( +const getTask = defineRequest("getTask"); +const getTaskDetails = defineRequest( "getTaskDetails", ); @@ -39,25 +43,22 @@ export interface CreateTaskParams { } const createTask = defineRequest("createTask"); -export interface TaskActionParams { - taskId: string; +export interface TaskActionParams extends TaskIdParams { taskName: string; } const deleteTask = defineRequest("deleteTask"); const pauseTask = defineRequest("pauseTask"); const resumeTask = defineRequest("resumeTask"); -const downloadLogs = defineRequest<{ taskId: string }, void>("downloadLogs"); +const downloadLogs = defineRequest("downloadLogs"); +const sendTaskMessage = defineRequest( + "sendTaskMessage", +); -const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder"); -const viewLogs = defineCommand<{ taskId: string }>("viewLogs"); -const sendTaskMessage = defineCommand<{ - taskId: string; - message: string; -}>("sendTaskMessage"); +const viewInCoder = defineCommand("viewInCoder"); +const viewLogs = defineCommand("viewLogs"); const taskUpdated = defineNotification("taskUpdated"); const tasksUpdated = defineNotification("tasksUpdated"); -const logsAppend = defineNotification("logsAppend"); const refresh = defineNotification("refresh"); const showCreateForm = defineNotification("showCreateForm"); @@ -73,14 +74,13 @@ export const TasksApi = { pauseTask, resumeTask, downloadLogs, + sendTaskMessage, // Commands viewInCoder, viewLogs, - sendTaskMessage, // Notifications taskUpdated, tasksUpdated, - logsAppend, refresh, showCreateForm, } as const; diff --git a/packages/shared/src/tasks/types.ts b/packages/shared/src/tasks/types.ts index 95654b5f..b30546c7 100644 --- a/packages/shared/src/tasks/types.ts +++ b/packages/shared/src/tasks/types.ts @@ -45,4 +45,5 @@ export interface TaskPermissions { canPause: boolean; pauseDisabled: boolean; canResume: boolean; + canSendMessage: boolean; } diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/utils.ts index 16f42098..736a8570 100644 --- a/packages/shared/src/tasks/utils.ts +++ b/packages/shared/src/tasks/utils.ts @@ -4,6 +4,11 @@ export function getTaskLabel(task: Task): string { return task.display_name || task.name || task.id; } +/** Whether the agent is actively working (status is active and state is working). */ +export function isTaskWorking(task: Task): boolean { + return task.status === "active" && task.current_state?.state === "working"; +} + const PAUSABLE_STATUSES: readonly TaskStatus[] = [ "active", "initializing", @@ -26,10 +31,14 @@ const RESUMABLE_STATUSES: readonly TaskStatus[] = [ export function getTaskPermissions(task: Task): TaskPermissions { const hasWorkspace = task.workspace_id !== null; const status = task.status; + const canSendMessage = + task.status === "paused" || + (task.status === "active" && task.current_state?.state !== "working"); return { canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status), pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status), canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status), + canSendMessage, }; } diff --git a/packages/tasks/src/App.tsx b/packages/tasks/src/App.tsx index b507dc5f..7a1609db 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( @@ -37,7 +42,7 @@ export default function App() { ); const createScrollRef = useRef(null); - const historyScrollRef = useRef(null); + const historyScrollRef = useRef(null); useScrollableHeight(createRef, createScrollRef); useScrollableHeight(historyRef, historyScrollRef); @@ -56,6 +61,20 @@ export default function App() { } }, [data, createOpen, historyOpen]); + function renderHistory() { + if (selectedTask) { + return ; + } + if (isLoadingDetails) { + return ( +
+ +
+ ); + } + return ; + } + if (isLoading) { return (
@@ -95,14 +114,9 @@ export default function App() { heading="Task History" open={historyOpen} > - - { - // Task detail view will be added in next PR - }} - /> - +
+ {renderHistory()} +
); diff --git a/packages/tasks/src/components/AgentChatHistory.tsx b/packages/tasks/src/components/AgentChatHistory.tsx new file mode 100644 index 00000000..08ae381b --- /dev/null +++ b/packages/tasks/src/components/AgentChatHistory.tsx @@ -0,0 +1,80 @@ +import { VscodeScrollable } from "@vscode-elements/react-elements"; + +import { useFollowScroll } from "../hooks/useFollowScroll"; + +import type { LogsStatus, TaskLogEntry } from "@repo/shared"; + +interface AgentChatHistoryProps { + logs: TaskLogEntry[]; + logsStatus: LogsStatus; + isThinking: boolean; +} + +function LogEntry({ + log, + isGroupStart, +}: { + log: TaskLogEntry; + isGroupStart: boolean; +}) { + return ( +
+ {isGroupStart && ( +
+ {log.type === "input" ? "You" : "Agent"} +
+ )} + {log.content} +
+ ); +} + +export function AgentChatHistory({ + logs, + logsStatus, + isThinking, +}: AgentChatHistoryProps) { + const bottomRef = useFollowScroll(); + + return ( +
+
Agent chat history
+ + {logs.length === 0 ? ( +
+ {getEmptyMessage(logsStatus)} +
+ ) : ( + logs.map((log, index) => ( + + )) + )} + {isThinking && ( +
Thinking...
+ )} +
+ +
+ ); +} + +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"; + case "ok": + return "No messages yet"; + } +} diff --git a/packages/tasks/src/components/CreateTaskSection.tsx b/packages/tasks/src/components/CreateTaskSection.tsx index a7e8b061..f97ffe52 100644 --- a/packages/tasks/src/components/CreateTaskSection.tsx +++ b/packages/tasks/src/components/CreateTaskSection.tsx @@ -25,6 +25,7 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) { const { mutate, isPending, error } = useMutation({ mutationFn: (params: CreateTaskParams) => api.createTask(params), onSuccess: () => setPrompt(""), + onError: (err) => logger.error("Failed to create task", err), }); const selectedTemplate = templates.find((t) => t.id === templateId); @@ -50,6 +51,9 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) { onChange={setPrompt} onSubmit={handleSubmit} loading={isPending} + actionIcon="send" + actionLabel="Send" + actionEnabled={canSubmit === true} /> {error &&
{error.message}
}
diff --git a/packages/tasks/src/components/ErrorBanner.tsx b/packages/tasks/src/components/ErrorBanner.tsx new file mode 100644 index 00000000..17b262f3 --- /dev/null +++ b/packages/tasks/src/components/ErrorBanner.tsx @@ -0,0 +1,28 @@ +import { VscodeIcon } from "@vscode-elements/react-elements"; + +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 || "Task failed"; + + return ( +
+ + {message} + +
+ ); +} diff --git a/packages/tasks/src/components/PromptInput.tsx b/packages/tasks/src/components/PromptInput.tsx index b454d17d..67f6f482 100644 --- a/packages/tasks/src/components/PromptInput.tsx +++ b/packages/tasks/src/components/PromptInput.tsx @@ -12,6 +12,9 @@ interface PromptInputProps { disabled?: boolean; loading?: boolean; placeholder?: string; + actionIcon: "send" | "debug-pause"; + actionLabel: string; + actionEnabled: boolean; } export function PromptInput({ @@ -21,9 +24,10 @@ export function PromptInput({ disabled = false, loading = false, placeholder = "Prompt your AI agent to start a task...", + actionIcon, + actionLabel, + actionEnabled, }: PromptInputProps) { - const canSubmit = value.trim().length > 0 && !disabled && !loading; - return (