Skip to content
Open
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
7 changes: 1 addition & 6 deletions 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.tasksEnabled"
"when": "coder.authenticated && coder.tasksEnabled"
}
]
},
Expand All @@ -228,11 +228,6 @@
"view": "myWorkspaces",
"contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)",
"when": "!coder.authenticated && coder.loaded"
},
{
"view": "coder.tasksPanel",
"contents": "[Login](command:coder.login) to view tasks.",
"when": "!coder.authenticated && coder.loaded && coder.tasksEnabled"
}
],
"commands": [
Expand Down
21 changes: 8 additions & 13 deletions packages/shared/src/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,14 @@ import {

import type { Task, TaskDetails, TaskTemplate } from "./types";

export interface InitResponse {
tasks: readonly Task[];
templates: readonly TaskTemplate[];
baseUrl: string;
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 getTasks = defineRequest<void, readonly Task[] | null>("getTasks");
const getTemplates = defineRequest<void, readonly TaskTemplate[] | null>(
"getTemplates",
);
const getTask = defineRequest<TaskIdParams, Task>("getTask");
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
"getTaskDetails",
Expand All @@ -56,7 +50,9 @@ const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(

const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
const closeWorkspaceLogs = defineCommand<void>("closeWorkspaceLogs");
const stopStreamingWorkspaceLogs = defineCommand<void>(
"stopStreamingWorkspaceLogs",
);

const taskUpdated = defineNotification<Task>("taskUpdated");
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
Expand All @@ -66,7 +62,6 @@ const showCreateForm = defineNotification<void>("showCreateForm");

export const TasksApi = {
// Requests
init,
getTasks,
getTemplates,
getTask,
Expand All @@ -80,7 +75,7 @@ export const TasksApi = {
// Commands
viewInCoder,
viewLogs,
closeWorkspaceLogs,
stopStreamingWorkspaceLogs,
// Notifications
taskUpdated,
tasksUpdated,
Expand Down
3 changes: 1 addition & 2 deletions packages/shared/src/tasks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ 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");
task.status === "active" && task.current_state?.state !== "working";
return {
canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status),
pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status),
Expand Down
120 changes: 14 additions & 106 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,19 @@
import { type InitResponse } from "@repo/shared";
import { getState, setState } from "@repo/webview-shared";
import {
VscodeCollapsible,
VscodeProgressRing,
VscodeScrollable,
} from "@vscode-elements/react-elements";
import { useEffect, useRef, useState } from "react";

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 { useTasksApi } from "./hooks/useTasksApi";
import { TasksPanel } from "./components/TasksPanel";
import { usePersistedState } from "./hooks/usePersistedState";
import { useTasksQuery } from "./hooks/useTasksQuery";

interface PersistedState extends InitResponse {
createExpanded: boolean;
historyExpanded: boolean;
}

type CollapsibleElement = React.ComponentRef<typeof VscodeCollapsible>;
type ScrollableElement = React.ComponentRef<typeof VscodeScrollable>;

export default function App() {
const [restored] = useState(() => getState<PersistedState>());
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<HTMLDivElement>(null);
useScrollableHeight(createRef, createScrollRef);
useScrollableHeight(historyRef, historyScrollRef);

const { onShowCreateForm } = useTasksApi();
useEffect(() => {
return onShowCreateForm(() => setCreateOpen(true));
}, [onShowCreateForm, setCreateOpen]);

useEffect(() => {
if (data) {
setState<PersistedState>({
...data,
createExpanded: createOpen,
historyExpanded: historyOpen,
});
}
}, [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} />;
}
const persisted = usePersistedState();
const { tasksSupported, tasks, templates, refreshing, error, refetch } =
useTasksQuery({
initialTasks: persisted.initialTasks,
initialTemplates: persisted.initialTemplates,
});

if (isLoading) {
return (
<div className="loading-container">
<VscodeProgressRing />
</div>
);
if (!tasksSupported) {
return <NotSupportedState />;
}

if (error && tasks.length === 0) {
Expand All @@ -89,35 +22,10 @@ export default function App() {
);
}

if (!tasksSupported) {
return <NotSupportedState />;
}

if (templates.length === 0) {
return <NoTemplateState />;
}

return (
<div className="tasks-panel">
<VscodeCollapsible
ref={createRef}
heading="Create new task"
open={createOpen}
>
<VscodeScrollable ref={createScrollRef}>
<CreateTaskSection templates={templates} />
</VscodeScrollable>
</VscodeCollapsible>

<VscodeCollapsible
ref={historyRef}
heading="Task History"
open={historyOpen}
>
<div ref={historyScrollRef} className="collapsible-content">
{renderHistory()}
</div>
</VscodeCollapsible>
</div>
<>
{refreshing && <div className="refresh-bar" />}
<TasksPanel tasks={tasks} templates={templates} persisted={persisted} />
</>
);
}
4 changes: 2 additions & 2 deletions packages/tasks/src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import {

import { isSubmit } from "../utils/keys";

interface PromptInputProps {
export interface PromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
disabled?: boolean;
loading?: boolean;
placeholder?: string;
actionIcon: "send" | "debug-pause";
actionIcon: "send" | "debug-pause" | "debug-start";
actionLabel: string;
actionEnabled: boolean;
}
Expand Down
68 changes: 53 additions & 15 deletions packages/tasks/src/components/TaskMessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import {
getTaskLabel,
getTaskPermissions,
isTaskWorking,
type Task,
getTaskPermissions,
} from "@repo/shared";
import { logger } from "@repo/webview-shared/logger";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";

import { useTasksApi } from "../hooks/useTasksApi";

import { PromptInput } from "./PromptInput";
import { PromptInput, type PromptInputProps } from "./PromptInput";

type ActionProps = Pick<
PromptInputProps,
| "onSubmit"
| "disabled"
| "loading"
| "actionIcon"
| "actionLabel"
| "actionEnabled"
>;

function getPlaceholder(task: Task): string {
switch (task.status) {
case "paused":
return "Send a message to resume the task...";
return "Resume the task to send messages";
case "initializing":
case "pending":
return "Waiting for the agent to start...";
Expand Down Expand Up @@ -46,34 +56,62 @@ export function TaskMessageInput({ task }: TaskMessageInputProps) {
const api = useTasksApi();
const [message, setMessage] = useState("");

const { canPause, canSendMessage } = getTaskPermissions(task);
const placeholder = getPlaceholder(task);
const showPauseButton = isTaskWorking(task) && canPause;
const canSubmitMessage = canSendMessage && message.trim().length > 0;

const { mutate: pauseTask, isPending: isPausing } = useMutation({
mutationFn: () =>
api.pauseTask({ taskId: task.id, taskName: getTaskLabel(task) }),
onError: (err) => logger.error("Failed to pause task", err),
});

const { mutate: resumeTask, isPending: isResuming } = useMutation({
mutationFn: () =>
api.resumeTask({ taskId: task.id, taskName: getTaskLabel(task) }),
onError: (err) => logger.error("Failed to resume task", err),
});

const { mutate: sendMessage, isPending: isSending } = useMutation({
mutationFn: (msg: string) => api.sendTaskMessage(task.id, msg),
onSuccess: () => setMessage(""),
onError: (err) => logger.error("Failed to send message", err),
});

const { canPause, canResume, canSendMessage } = getTaskPermissions(task);

let actionProps: ActionProps;
if (isTaskWorking(task) && canPause) {
actionProps = {
onSubmit: pauseTask,
loading: isPausing,
actionIcon: "debug-pause",
actionLabel: "Pause task",
disabled: false,
actionEnabled: true,
};
} else if (canResume) {
actionProps = {
onSubmit: resumeTask,
loading: isResuming,
actionIcon: "debug-start",
actionLabel: "Resume task",
disabled: true,
actionEnabled: true,
};
} else {
actionProps = {
onSubmit: () => sendMessage(message),
loading: isSending,
actionIcon: "send",
actionLabel: "Send message",
disabled: !canSendMessage,
actionEnabled: canSendMessage && message.trim().length > 0,
};
}

return (
<PromptInput
placeholder={placeholder}
placeholder={getPlaceholder(task)}
value={message}
onChange={setMessage}
onSubmit={showPauseButton ? pauseTask : () => sendMessage(message)}
disabled={!canSendMessage && !showPauseButton}
loading={showPauseButton ? isPausing : isSending}
actionIcon={showPauseButton ? "debug-pause" : "send"}
actionLabel={showPauseButton ? "Pause task" : "Send message"}
actionEnabled={showPauseButton ? true : canSubmitMessage}
{...actionProps}
/>
);
}
Loading