Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
"id": "coder.tasksPanel",
"name": "Coder Tasks",
"icon": "media/tasks-logo.svg",
"when": "coder.authenticated && coder.tasksEnabled"
"when": "coder.tasksEnabled"
}
]
},
Expand Down
30 changes: 15 additions & 15 deletions packages/shared/src/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -24,11 +24,15 @@ export interface InitResponse {
tasksSupported: boolean;
}

export interface TaskIdParams {
taskId: string;
}

const init = defineRequest<void, InitResponse>("init");
const getTasks = defineRequest<void, Task[]>("getTasks");
const getTemplates = defineRequest<void, TaskTemplate[]>("getTemplates");
const getTask = defineRequest<{ taskId: string }, Task>("getTask");
const getTaskDetails = defineRequest<{ taskId: string }, TaskDetails>(
const getTask = defineRequest<TaskIdParams, Task>("getTask");
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
"getTaskDetails",
);

Expand All @@ -39,25 +43,22 @@ export interface CreateTaskParams {
}
const createTask = defineRequest<CreateTaskParams, Task>("createTask");

export interface TaskActionParams {
taskId: string;
export interface TaskActionParams extends TaskIdParams {
taskName: string;
}
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
const downloadLogs = defineRequest<{ taskId: string }, void>("downloadLogs");
const downloadLogs = defineRequest<TaskIdParams, void>("downloadLogs");
const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
"sendTaskMessage",
);

const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder");
const viewLogs = defineCommand<{ taskId: string }>("viewLogs");
const sendTaskMessage = defineCommand<{
taskId: string;
message: string;
}>("sendTaskMessage");
const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
const viewLogs = defineCommand<TaskIdParams>("viewLogs");

const taskUpdated = defineNotification<Task>("taskUpdated");
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
const logsAppend = defineNotification<TaskLogEntry[]>("logsAppend");
const refresh = defineNotification<void>("refresh");
const showCreateForm = defineNotification<void>("showCreateForm");

Expand All @@ -73,14 +74,13 @@ export const TasksApi = {
pauseTask,
resumeTask,
downloadLogs,
sendTaskMessage,
// Commands
viewInCoder,
viewLogs,
sendTaskMessage,
// Notifications
taskUpdated,
tasksUpdated,
logsAppend,
refresh,
showCreateForm,
} as const;
1 change: 1 addition & 0 deletions packages/shared/src/tasks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ export interface TaskPermissions {
canPause: boolean;
pauseDisabled: boolean;
canResume: boolean;
canSendMessage: boolean;
}
9 changes: 9 additions & 0 deletions packages/shared/src/tasks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
};
}

Expand Down
32 changes: 23 additions & 9 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,14 +32,17 @@ 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<CollapsibleElement>(restored?.createExpanded ?? true);
const [historyRef, historyOpen] = useCollapsibleToggle<CollapsibleElement>(
restored?.historyExpanded ?? true,
);

const createScrollRef = useRef<ScrollableElement>(null);
const historyScrollRef = useRef<ScrollableElement>(null);
const historyScrollRef = useRef<HTMLDivElement>(null);
useScrollableHeight(createRef, createScrollRef);
useScrollableHeight(historyRef, historyScrollRef);

Expand All @@ -56,6 +61,20 @@ export default function App() {
}
}, [data, createOpen, historyOpen]);

function renderHistory() {
if (selectedTask) {
return <TaskDetailView details={selectedTask} onBack={deselectTask} />;
}
if (isLoadingDetails) {
return (
<div className="loading-container">
<VscodeProgressRing />
</div>
);
}
return <TaskList tasks={tasks} onSelectTask={selectTask} />;
}

if (isLoading) {
return (
<div className="loading-container">
Expand Down Expand Up @@ -95,14 +114,9 @@ export default function App() {
heading="Task History"
open={historyOpen}
>
<VscodeScrollable ref={historyScrollRef}>
<TaskList
tasks={tasks}
onSelectTask={(_taskId: string) => {
// Task detail view will be added in next PR
}}
/>
</VscodeScrollable>
<div ref={historyScrollRef} className="collapsible-content">
{renderHistory()}
</div>
</VscodeCollapsible>
</div>
);
Expand Down
80 changes: 80 additions & 0 deletions packages/tasks/src/components/AgentChatHistory.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`log-entry log-entry-${log.type}`}>
{isGroupStart && (
<div className="log-entry-role">
{log.type === "input" ? "You" : "Agent"}
</div>
)}
{log.content}
</div>
);
}

export function AgentChatHistory({
logs,
logsStatus,
isThinking,
}: AgentChatHistoryProps) {
const bottomRef = useFollowScroll();

return (
<div className="agent-chat-history">
<div className="chat-history-header">Agent chat history</div>
<VscodeScrollable className="chat-history-content">
{logs.length === 0 ? (
<div
className={
logsStatus === "error"
? "chat-history-empty chat-history-error"
: "chat-history-empty"
}
>
{getEmptyMessage(logsStatus)}
</div>
) : (
logs.map((log, index) => (
<LogEntry
key={log.id}
log={log}
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
/>
))
)}
{isThinking && (
<div className="log-entry log-entry-thinking">Thinking...</div>
)}
<div ref={bottomRef} />
</VscodeScrollable>
</div>
);
}

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";
}
}
4 changes: 4 additions & 0 deletions packages/tasks/src/components/CreateTaskSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -50,6 +51,9 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) {
onChange={setPrompt}
onSubmit={handleSubmit}
loading={isPending}
actionIcon="send"
actionLabel="Send"
actionEnabled={canSubmit === true}
/>
{error && <div className="create-task-error">{error.message}</div>}
<div className="create-task-options">
Expand Down
28 changes: 28 additions & 0 deletions packages/tasks/src/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="error-banner">
<VscodeIcon name="warning" />
<span>{message}</span>
<button
type="button"
className="text-link"
onClick={() => api.viewLogs(task.id)}
>
View logs <VscodeIcon name="link-external" />
</button>
</div>
);
}
18 changes: 11 additions & 7 deletions packages/tasks/src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface PromptInputProps {
disabled?: boolean;
loading?: boolean;
placeholder?: string;
actionIcon: "send" | "debug-pause";
actionLabel: string;
actionEnabled: boolean;
}

export function PromptInput({
Expand All @@ -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 (
<div className="prompt-input-container">
<textarea
Expand All @@ -34,7 +38,7 @@ export function PromptInput({
onKeyDown={(e) => {
if (isSubmit(e)) {
e.preventDefault();
if (canSubmit) {
if (actionEnabled) {
onSubmit();
}
}
Expand All @@ -47,10 +51,10 @@ export function PromptInput({
) : (
<VscodeIcon
actionIcon
name="send"
label="Send"
onClick={() => canSubmit && onSubmit()}
className={canSubmit ? "" : "disabled"}
name={actionIcon}
label={actionLabel}
onClick={() => actionEnabled && onSubmit()}
className={actionEnabled ? "" : "disabled"}
/>
)}
</div>
Expand Down
Loading