Skip to content

Commit 7b4e148

Browse files
authored
Add task detail view with navigation, log streaming, and messaging (#774)
Introduces the detail view for tasks, allowing users to select a task from the list, view its logs as a chat-style conversation, and send messages or pause the agent directly from the webview. Also added more error handling and logging. Components: - `TaskDetailView` — main detail layout composing header, chat history, error banner, and message input - `TaskDetailHeader` — displays task name, status, and back navigation - `AgentChatHistory` — renders streamed task logs in a scrollable chat view - `TaskMessageInput` — context-aware input for sending messages or pausing the agent - `ErrorBanner` — inline error display with a link to view logs externally Hooks: - `useSelectedTask` — manages task selection state and fetches task details via react-query - `useFollowScroll` — auto-scrolls a `VscodeScrollable` container to follow new content
1 parent 550f617 commit 7b4e148

33 files changed

+1679
-202
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@
219219
"id": "coder.tasksPanel",
220220
"name": "Coder Tasks",
221221
"icon": "media/tasks-logo.svg",
222-
"when": "coder.authenticated && coder.tasksEnabled"
222+
"when": "coder.tasksEnabled"
223223
}
224224
]
225225
},

packages/shared/src/tasks/api.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
defineRequest,
1616
} from "../ipc/protocol";
1717

18-
import type { Task, TaskDetails, TaskLogEntry, TaskTemplate } from "./types";
18+
import type { Task, TaskDetails, TaskTemplate } from "./types";
1919

2020
export interface InitResponse {
2121
tasks: readonly Task[];
@@ -24,11 +24,15 @@ export interface InitResponse {
2424
tasksSupported: boolean;
2525
}
2626

27+
export interface TaskIdParams {
28+
taskId: string;
29+
}
30+
2731
const init = defineRequest<void, InitResponse>("init");
2832
const getTasks = defineRequest<void, Task[]>("getTasks");
2933
const getTemplates = defineRequest<void, TaskTemplate[]>("getTemplates");
30-
const getTask = defineRequest<{ taskId: string }, Task>("getTask");
31-
const getTaskDetails = defineRequest<{ taskId: string }, TaskDetails>(
34+
const getTask = defineRequest<TaskIdParams, Task>("getTask");
35+
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
3236
"getTaskDetails",
3337
);
3438

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

42-
export interface TaskActionParams {
43-
taskId: string;
46+
export interface TaskActionParams extends TaskIdParams {
4447
taskName: string;
4548
}
4649
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
4750
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
4851
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
49-
const downloadLogs = defineRequest<{ taskId: string }, void>("downloadLogs");
52+
const downloadLogs = defineRequest<TaskIdParams, void>("downloadLogs");
53+
const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
54+
"sendTaskMessage",
55+
);
5056

51-
const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder");
52-
const viewLogs = defineCommand<{ taskId: string }>("viewLogs");
53-
const sendTaskMessage = defineCommand<{
54-
taskId: string;
55-
message: string;
56-
}>("sendTaskMessage");
57+
const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
58+
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
5759

5860
const taskUpdated = defineNotification<Task>("taskUpdated");
5961
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
60-
const logsAppend = defineNotification<TaskLogEntry[]>("logsAppend");
6162
const refresh = defineNotification<void>("refresh");
6263
const showCreateForm = defineNotification<void>("showCreateForm");
6364

@@ -73,14 +74,13 @@ export const TasksApi = {
7374
pauseTask,
7475
resumeTask,
7576
downloadLogs,
77+
sendTaskMessage,
7678
// Commands
7779
viewInCoder,
7880
viewLogs,
79-
sendTaskMessage,
8081
// Notifications
8182
taskUpdated,
8283
tasksUpdated,
83-
logsAppend,
8484
refresh,
8585
showCreateForm,
8686
} as const;

packages/shared/src/tasks/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ export interface TaskPermissions {
4545
canPause: boolean;
4646
pauseDisabled: boolean;
4747
canResume: boolean;
48+
canSendMessage: boolean;
4849
}

packages/shared/src/tasks/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export function getTaskLabel(task: Task): string {
44
return task.display_name || task.name || task.id;
55
}
66

7+
/** Whether the agent is actively working (status is active and state is working). */
8+
export function isTaskWorking(task: Task): boolean {
9+
return task.status === "active" && task.current_state?.state === "working";
10+
}
11+
712
const PAUSABLE_STATUSES: readonly TaskStatus[] = [
813
"active",
914
"initializing",
@@ -26,10 +31,14 @@ const RESUMABLE_STATUSES: readonly TaskStatus[] = [
2631
export function getTaskPermissions(task: Task): TaskPermissions {
2732
const hasWorkspace = task.workspace_id !== null;
2833
const status = task.status;
34+
const canSendMessage =
35+
task.status === "paused" ||
36+
(task.status === "active" && task.current_state?.state !== "working");
2937
return {
3038
canPause: hasWorkspace && PAUSABLE_STATUSES.includes(status),
3139
pauseDisabled: PAUSE_DISABLED_STATUSES.includes(status),
3240
canResume: hasWorkspace && RESUMABLE_STATUSES.includes(status),
41+
canSendMessage,
3342
};
3443
}
3544

packages/tasks/src/App.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { CreateTaskSection } from "./components/CreateTaskSection";
1212
import { ErrorState } from "./components/ErrorState";
1313
import { NoTemplateState } from "./components/NoTemplateState";
1414
import { NotSupportedState } from "./components/NotSupportedState";
15+
import { TaskDetailView } from "./components/TaskDetailView";
1516
import { TaskList } from "./components/TaskList";
1617
import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle";
1718
import { useScrollableHeight } from "./hooks/useScrollableHeight";
19+
import { useSelectedTask } from "./hooks/useSelectedTask";
1820
import { useTasksQuery } from "./hooks/useTasksQuery";
1921

2022
interface PersistedState extends InitResponse {
@@ -30,14 +32,17 @@ export default function App() {
3032
const { tasks, templates, tasksSupported, data, isLoading, error, refetch } =
3133
useTasksQuery(restored);
3234

35+
const { selectedTask, isLoadingDetails, selectTask, deselectTask } =
36+
useSelectedTask(tasks);
37+
3338
const [createRef, createOpen, setCreateOpen] =
3439
useCollapsibleToggle<CollapsibleElement>(restored?.createExpanded ?? true);
3540
const [historyRef, historyOpen] = useCollapsibleToggle<CollapsibleElement>(
3641
restored?.historyExpanded ?? true,
3742
);
3843

3944
const createScrollRef = useRef<ScrollableElement>(null);
40-
const historyScrollRef = useRef<ScrollableElement>(null);
45+
const historyScrollRef = useRef<HTMLDivElement>(null);
4146
useScrollableHeight(createRef, createScrollRef);
4247
useScrollableHeight(historyRef, historyScrollRef);
4348

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

64+
function renderHistory() {
65+
if (selectedTask) {
66+
return <TaskDetailView details={selectedTask} onBack={deselectTask} />;
67+
}
68+
if (isLoadingDetails) {
69+
return (
70+
<div className="loading-container">
71+
<VscodeProgressRing />
72+
</div>
73+
);
74+
}
75+
return <TaskList tasks={tasks} onSelectTask={selectTask} />;
76+
}
77+
5978
if (isLoading) {
6079
return (
6180
<div className="loading-container">
@@ -95,14 +114,9 @@ export default function App() {
95114
heading="Task History"
96115
open={historyOpen}
97116
>
98-
<VscodeScrollable ref={historyScrollRef}>
99-
<TaskList
100-
tasks={tasks}
101-
onSelectTask={(_taskId: string) => {
102-
// Task detail view will be added in next PR
103-
}}
104-
/>
105-
</VscodeScrollable>
117+
<div ref={historyScrollRef} className="collapsible-content">
118+
{renderHistory()}
119+
</div>
106120
</VscodeCollapsible>
107121
</div>
108122
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { VscodeScrollable } from "@vscode-elements/react-elements";
2+
3+
import { useFollowScroll } from "../hooks/useFollowScroll";
4+
5+
import type { LogsStatus, TaskLogEntry } from "@repo/shared";
6+
7+
interface AgentChatHistoryProps {
8+
logs: TaskLogEntry[];
9+
logsStatus: LogsStatus;
10+
isThinking: boolean;
11+
}
12+
13+
function LogEntry({
14+
log,
15+
isGroupStart,
16+
}: {
17+
log: TaskLogEntry;
18+
isGroupStart: boolean;
19+
}) {
20+
return (
21+
<div className={`log-entry log-entry-${log.type}`}>
22+
{isGroupStart && (
23+
<div className="log-entry-role">
24+
{log.type === "input" ? "You" : "Agent"}
25+
</div>
26+
)}
27+
{log.content}
28+
</div>
29+
);
30+
}
31+
32+
export function AgentChatHistory({
33+
logs,
34+
logsStatus,
35+
isThinking,
36+
}: AgentChatHistoryProps) {
37+
const bottomRef = useFollowScroll();
38+
39+
return (
40+
<div className="agent-chat-history">
41+
<div className="chat-history-header">Agent chat history</div>
42+
<VscodeScrollable className="chat-history-content">
43+
{logs.length === 0 ? (
44+
<div
45+
className={
46+
logsStatus === "error"
47+
? "chat-history-empty chat-history-error"
48+
: "chat-history-empty"
49+
}
50+
>
51+
{getEmptyMessage(logsStatus)}
52+
</div>
53+
) : (
54+
logs.map((log, index) => (
55+
<LogEntry
56+
key={log.id}
57+
log={log}
58+
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
59+
/>
60+
))
61+
)}
62+
{isThinking && (
63+
<div className="log-entry log-entry-thinking">Thinking...</div>
64+
)}
65+
<div ref={bottomRef} />
66+
</VscodeScrollable>
67+
</div>
68+
);
69+
}
70+
71+
function getEmptyMessage(logsStatus: LogsStatus): string {
72+
switch (logsStatus) {
73+
case "not_available":
74+
return "Logs not available in current task state";
75+
case "error":
76+
return "Failed to load logs";
77+
case "ok":
78+
return "No messages yet";
79+
}
80+
}

packages/tasks/src/components/CreateTaskSection.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) {
2525
const { mutate, isPending, error } = useMutation({
2626
mutationFn: (params: CreateTaskParams) => api.createTask(params),
2727
onSuccess: () => setPrompt(""),
28+
onError: (err) => logger.error("Failed to create task", err),
2829
});
2930

3031
const selectedTemplate = templates.find((t) => t.id === templateId);
@@ -50,6 +51,9 @@ export function CreateTaskSection({ templates }: CreateTaskSectionProps) {
5051
onChange={setPrompt}
5152
onSubmit={handleSubmit}
5253
loading={isPending}
54+
actionIcon="send"
55+
actionLabel="Send"
56+
actionEnabled={canSubmit === true}
5357
/>
5458
{error && <div className="create-task-error">{error.message}</div>}
5559
<div className="create-task-options">
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { VscodeIcon } from "@vscode-elements/react-elements";
2+
3+
import { useTasksApi } from "../hooks/useTasksApi";
4+
5+
import type { Task } from "@repo/shared";
6+
7+
interface ErrorBannerProps {
8+
task: Task;
9+
}
10+
11+
export function ErrorBanner({ task }: ErrorBannerProps) {
12+
const api = useTasksApi();
13+
const message = task.current_state?.message || "Task failed";
14+
15+
return (
16+
<div className="error-banner">
17+
<VscodeIcon name="warning" />
18+
<span>{message}</span>
19+
<button
20+
type="button"
21+
className="text-link"
22+
onClick={() => api.viewLogs(task.id)}
23+
>
24+
View logs <VscodeIcon name="link-external" />
25+
</button>
26+
</div>
27+
);
28+
}

packages/tasks/src/components/PromptInput.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ interface PromptInputProps {
1212
disabled?: boolean;
1313
loading?: boolean;
1414
placeholder?: string;
15+
actionIcon: "send" | "debug-pause";
16+
actionLabel: string;
17+
actionEnabled: boolean;
1518
}
1619

1720
export function PromptInput({
@@ -21,9 +24,10 @@ export function PromptInput({
2124
disabled = false,
2225
loading = false,
2326
placeholder = "Prompt your AI agent to start a task...",
27+
actionIcon,
28+
actionLabel,
29+
actionEnabled,
2430
}: PromptInputProps) {
25-
const canSubmit = value.trim().length > 0 && !disabled && !loading;
26-
2731
return (
2832
<div className="prompt-input-container">
2933
<textarea
@@ -34,7 +38,7 @@ export function PromptInput({
3438
onKeyDown={(e) => {
3539
if (isSubmit(e)) {
3640
e.preventDefault();
37-
if (canSubmit) {
41+
if (actionEnabled) {
3842
onSubmit();
3943
}
4044
}
@@ -47,10 +51,10 @@ export function PromptInput({
4751
) : (
4852
<VscodeIcon
4953
actionIcon
50-
name="send"
51-
label="Send"
52-
onClick={() => canSubmit && onSubmit()}
53-
className={canSubmit ? "" : "disabled"}
54+
name={actionIcon}
55+
label={actionLabel}
56+
onClick={() => actionEnabled && onSubmit()}
57+
className={actionEnabled ? "" : "disabled"}
5458
/>
5559
)}
5660
</div>

0 commit comments

Comments
 (0)