Skip to content

Commit f06d76d

Browse files
authored
Stream workspace startup logs in TaskDetailView (#797)
Display real-time workspace build logs within the task detail view so users can monitor startup progress without leaving the tasks panel. - Add WorkspaceLogs component and useWorkspaceLogs hook that receive log lines via a new workspaceLogsAppend notification - Batch incoming logs per animation frame to keep rendering smooth - Stream logs from the Coder API through the extension host, stripping ANSI codes before forwarding to the webview - Clean up log subscriptions when the panel or workspace changes
1 parent 26c819e commit f06d76d

28 files changed

+988
-224
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@
488488
"proper-lockfile": "^4.1.2",
489489
"proxy-agent": "^6.5.0",
490490
"semver": "^7.7.4",
491+
"strip-ansi": "^7.1.2",
491492
"ua-parser-js": "^1.0.41",
492493
"ws": "^8.19.0",
493494
"zod": "^4.3.6"

packages/shared/src/tasks/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
5656

5757
const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
5858
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
59+
const closeWorkspaceLogs = defineCommand<void>("closeWorkspaceLogs");
5960

6061
const taskUpdated = defineNotification<Task>("taskUpdated");
6162
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
63+
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
6264
const refresh = defineNotification<void>("refresh");
6365
const showCreateForm = defineNotification<void>("showCreateForm");
6466

@@ -78,9 +80,11 @@ export const TasksApi = {
7880
// Commands
7981
viewInCoder,
8082
viewLogs,
83+
closeWorkspaceLogs,
8184
// Notifications
8285
taskUpdated,
8386
tasksUpdated,
87+
workspaceLogsAppend,
8488
refresh,
8589
showCreateForm,
8690
} as const;

packages/shared/src/tasks/types.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,18 @@ export interface TaskPreset {
2929
isDefault: boolean;
3030
}
3131

32-
/** Status of log fetching */
33-
export type LogsStatus = "ok" | "not_available" | "error";
32+
/** Result of fetching task logs: either logs or an error/unavailable state. */
33+
export type TaskLogs =
34+
| { status: "ok"; logs: readonly TaskLogEntry[] }
35+
| { status: "not_available" }
36+
| { status: "error" };
3437

3538
/**
3639
* Full details for a selected task, including logs and action availability.
3740
*/
3841
export interface TaskDetails extends TaskPermissions {
3942
task: Task;
40-
logs: readonly TaskLogEntry[];
41-
logsStatus: LogsStatus;
43+
logs: TaskLogs;
4244
}
4345

4446
export interface TaskPermissions {

packages/shared/src/tasks/utils.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,6 @@ 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-
127
const PAUSABLE_STATUSES: readonly TaskStatus[] = [
138
"active",
149
"initializing",
@@ -42,6 +37,11 @@ export function getTaskPermissions(task: Task): TaskPermissions {
4237
};
4338
}
4439

40+
/** Whether the agent is actively working (status is active and state is working). */
41+
export function isTaskWorking(task: Task): boolean {
42+
return task.status === "active" && task.current_state?.state === "working";
43+
}
44+
4545
/**
4646
* Task statuses where logs won't change (stable/terminal states).
4747
* "complete" is a TaskState (sub-state of active), checked separately.
@@ -55,3 +55,23 @@ export function isStableTask(task: Task): boolean {
5555
(task.current_state !== null && task.current_state.state !== "working")
5656
);
5757
}
58+
59+
/** Whether the task's workspace is building (provisioner running). */
60+
export function isBuildingWorkspace(task: Task): boolean {
61+
const ws = task.workspace_status;
62+
return ws === "pending" || ws === "starting";
63+
}
64+
65+
/** Whether the workspace is running but the agent hasn't reached "ready" yet. */
66+
export function isAgentStarting(task: Task): boolean {
67+
if (task.workspace_status !== "running") {
68+
return false;
69+
}
70+
const lc = task.workspace_agent_lifecycle;
71+
return lc === "created" || lc === "starting";
72+
}
73+
74+
/** Whether the task's workspace is still starting up (building or agent initializing). */
75+
export function isWorkspaceStarting(task: Task): boolean {
76+
return isBuildingWorkspace(task) || isAgentStarting(task);
77+
}

packages/tasks/src/App.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { TasksApi, type InitResponse } from "@repo/shared";
1+
import { type InitResponse } from "@repo/shared";
22
import { getState, setState } from "@repo/webview-shared";
3-
import { useIpc } from "@repo/webview-shared/react";
43
import {
54
VscodeCollapsible,
65
VscodeProgressRing,
@@ -17,6 +16,7 @@ import { TaskList } from "./components/TaskList";
1716
import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle";
1817
import { useScrollableHeight } from "./hooks/useScrollableHeight";
1918
import { useSelectedTask } from "./hooks/useSelectedTask";
19+
import { useTasksApi } from "./hooks/useTasksApi";
2020
import { useTasksQuery } from "./hooks/useTasksQuery";
2121

2222
interface PersistedState extends InitResponse {
@@ -46,10 +46,10 @@ export default function App() {
4646
useScrollableHeight(createRef, createScrollRef);
4747
useScrollableHeight(historyRef, historyScrollRef);
4848

49-
const { onNotification } = useIpc();
49+
const { onShowCreateForm } = useTasksApi();
5050
useEffect(() => {
51-
return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true));
52-
}, [onNotification, setCreateOpen]);
51+
return onShowCreateForm(() => setCreateOpen(true));
52+
}, [onShowCreateForm, setCreateOpen]);
5353

5454
useEffect(() => {
5555
if (data) {

packages/tasks/src/components/AgentChatHistory.tsx

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { VscodeScrollable } from "@vscode-elements/react-elements";
1+
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";
22

3-
import { useFollowScroll } from "../hooks/useFollowScroll";
4-
5-
import type { LogsStatus, TaskLogEntry } from "@repo/shared";
3+
import type { TaskLogEntry, TaskLogs } from "@repo/shared";
64

75
interface AgentChatHistoryProps {
8-
logs: readonly TaskLogEntry[];
9-
logsStatus: LogsStatus;
6+
taskLogs: TaskLogs;
107
isThinking: boolean;
118
}
129

@@ -30,46 +27,35 @@ function LogEntry({
3027
}
3128

3229
export function AgentChatHistory({
33-
logs,
34-
logsStatus,
30+
taskLogs,
3531
isThinking,
3632
}: AgentChatHistoryProps) {
37-
const bottomRef = useFollowScroll();
33+
const logs = taskLogs.status === "ok" ? taskLogs.logs : [];
3834

3935
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>
36+
<LogViewer header="Agent chat history">
37+
{logs.length === 0 ? (
38+
<LogViewerPlaceholder error={taskLogs.status === "error"}>
39+
{getEmptyMessage(taskLogs.status)}
40+
</LogViewerPlaceholder>
41+
) : (
42+
logs.map((log, index) => (
43+
<LogEntry
44+
key={log.id}
45+
log={log}
46+
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
47+
/>
48+
))
49+
)}
50+
{isThinking && (
51+
<div className="log-entry log-entry-thinking">Thinking...</div>
52+
)}
53+
</LogViewer>
6854
);
6955
}
7056

71-
function getEmptyMessage(logsStatus: LogsStatus): string {
72-
switch (logsStatus) {
57+
function getEmptyMessage(status: TaskLogs["status"]): string {
58+
switch (status) {
7359
case "not_available":
7460
return "Logs not available in current task state";
7561
case "error":
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { VscodeScrollable } from "@vscode-elements/react-elements";
2+
3+
import { useFollowScroll } from "../hooks/useFollowScroll";
4+
5+
import type { ReactNode } from "react";
6+
7+
interface LogViewerProps {
8+
header: string;
9+
children: ReactNode;
10+
}
11+
12+
export function LogViewer({ header, children }: LogViewerProps) {
13+
const bottomRef = useFollowScroll();
14+
15+
return (
16+
<div className="log-viewer">
17+
<div className="log-viewer-header">{header}</div>
18+
<VscodeScrollable className="log-viewer-content">
19+
{children}
20+
<div ref={bottomRef} />
21+
</VscodeScrollable>
22+
</div>
23+
);
24+
}
25+
26+
export function LogViewerPlaceholder({
27+
children,
28+
error,
29+
}: {
30+
children: string;
31+
error?: boolean;
32+
}) {
33+
return (
34+
<div className={`log-viewer-empty${error ? " log-viewer-error" : ""}`}>
35+
{children}
36+
</div>
37+
);
38+
}

packages/tasks/src/components/TaskDetailView.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
1-
import { isTaskWorking, type TaskDetails } from "@repo/shared";
1+
import {
2+
isWorkspaceStarting,
3+
isTaskWorking,
4+
type TaskDetails,
5+
} from "@repo/shared";
26

37
import { AgentChatHistory } from "./AgentChatHistory";
48
import { ErrorBanner } from "./ErrorBanner";
59
import { TaskDetailHeader } from "./TaskDetailHeader";
610
import { TaskMessageInput } from "./TaskMessageInput";
11+
import { WorkspaceLogs } from "./WorkspaceLogs";
712

813
interface TaskDetailViewProps {
914
details: TaskDetails;
1015
onBack: () => void;
1116
}
1217

1318
export function TaskDetailView({ details, onBack }: TaskDetailViewProps) {
14-
const { task, logs, logsStatus } = details;
19+
const { task, logs } = details;
1520

21+
const starting = isWorkspaceStarting(task);
1622
const isThinking = isTaskWorking(task);
1723

1824
return (
1925
<div className="task-detail-view">
2026
<TaskDetailHeader task={task} onBack={onBack} />
2127
{task.status === "error" && <ErrorBanner task={task} />}
22-
<AgentChatHistory
23-
logs={logs}
24-
logsStatus={logsStatus}
25-
isThinking={isThinking}
26-
/>
28+
{starting ? (
29+
<WorkspaceLogs task={task} />
30+
) : (
31+
<AgentChatHistory taskLogs={logs} isThinking={isThinking} />
32+
)}
2733
<TaskMessageInput task={task} />
2834
</div>
2935
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { isBuildingWorkspace, type Task } from "@repo/shared";
2+
3+
import { useWorkspaceLogs } from "../hooks/useWorkspaceLogs";
4+
5+
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";
6+
7+
function LogLine({ children }: { children: string }) {
8+
return <div className="log-entry">{children}</div>;
9+
}
10+
11+
export function WorkspaceLogs({ task }: { task: Task }) {
12+
const lines = useWorkspaceLogs();
13+
const header = isBuildingWorkspace(task)
14+
? "Building workspace..."
15+
: "Running startup scripts...";
16+
17+
return (
18+
<LogViewer header={header}>
19+
{lines.length === 0 ? (
20+
<LogViewerPlaceholder>Waiting for logs...</LogViewerPlaceholder>
21+
) : (
22+
lines.map((line, i) => <LogLine key={i}>{line}</LogLine>)
23+
)}
24+
</LogViewer>
25+
);
26+
}

packages/tasks/src/hooks/useSelectedTask.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
TasksApi,
3-
isStableTask,
4-
type Task,
5-
type TaskDetails,
6-
} from "@repo/shared";
7-
import { useIpc } from "@repo/webview-shared/react";
1+
import { isStableTask, type Task, type TaskDetails } from "@repo/shared";
82
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
93
import { useEffect, useState } from "react";
104

@@ -20,7 +14,6 @@ const QUERY_KEY = "task-details";
2014
export function useSelectedTask(tasks: readonly Task[]) {
2115
const api = useTasksApi();
2216
const queryClient = useQueryClient();
23-
const { onNotification } = useIpc();
2417
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
2518

2619
// Auto-deselect when the selected task disappears from the list
@@ -48,14 +41,14 @@ export function useSelectedTask(tasks: readonly Task[]) {
4841

4942
// Keep selected task in sync with push updates between polls
5043
useEffect(() => {
51-
return onNotification(TasksApi.taskUpdated, (updatedTask) => {
44+
return api.onTaskUpdated((updatedTask) => {
5245
if (updatedTask.id !== selectedTaskId) return;
5346
queryClient.setQueryData<TaskDetails>(
5447
[QUERY_KEY, selectedTaskId],
5548
(prev) => (prev ? { ...prev, task: updatedTask } : undefined),
5649
);
5750
});
58-
}, [onNotification, selectedTaskId, queryClient]);
51+
}, [api.onTaskUpdated, selectedTaskId, queryClient]);
5952

6053
const deselectTask = () => {
6154
setSelectedTaskId(null);

0 commit comments

Comments
 (0)