Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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