Skip to content

Commit f8948b6

Browse files
committed
Stream workspace startup logs in TaskDetailView
When a task's workspace is building or its agent is initializing, stream build/agent logs via WebSocket and display them in the detail view instead of "Logs not available." - Add LazyStream class and generalize stream functions to accept onOutput callbacks; refactor WorkspaceStateMachine to use it - Add streamWorkspaceLogs to TasksPanel with ANSI stripping and phase-aware stream selection (build vs agent logs) - Extract LogViewer/LogViewerPlaceholder shared components from AgentChatHistory; add WorkspaceLogs with dynamic headers - Add useWorkspaceLogs hook for log accumulation and socket cleanup - Add isBuildingWorkspace, isAgentStarting, isWorkspaceStarting helpers - Add workspace test factory with full type coverage
1 parent 60598e7 commit f8948b6

File tree

21 files changed

+774
-125
lines changed

21 files changed

+774
-125
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.3",
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
@@ -54,10 +54,12 @@ const sendTaskMessage = defineRequest<
5454

5555
const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder");
5656
const viewLogs = defineCommand<{ taskId: string }>("viewLogs");
57+
const closeWorkspaceLogs = defineCommand("closeWorkspaceLogs");
5758

5859
const taskUpdated = defineNotification<Task>("taskUpdated");
5960
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
6061
const logsAppend = defineNotification<TaskLogEntry[]>("logsAppend");
62+
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
6163
const refresh = defineNotification<void>("refresh");
6264
const showCreateForm = defineNotification<void>("showCreateForm");
6365

@@ -77,10 +79,12 @@ export const TasksApi = {
7779
// Commands
7880
viewInCoder,
7981
viewLogs,
82+
closeWorkspaceLogs,
8083
// Notifications
8184
taskUpdated,
8285
tasksUpdated,
8386
logsAppend,
87+
workspaceLogsAppend,
8488
refresh,
8589
showCreateForm,
8690
} as const;

packages/shared/src/tasks/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,21 @@ export function isStableTask(task: Task): boolean {
5050
(task.current_state !== null && task.current_state.state !== "working")
5151
);
5252
}
53+
54+
/** Whether the task's workspace is building (provisioner running). */
55+
export function isBuildingWorkspace(task: Task): boolean {
56+
const ws = task.workspace_status;
57+
return ws === "pending" || ws === "starting";
58+
}
59+
60+
/** Whether the workspace is running but the agent hasn't reached "ready" yet. */
61+
export function isAgentStarting(task: Task): boolean {
62+
if (task.workspace_status !== "running") return false;
63+
const lc = task.workspace_agent_lifecycle;
64+
return lc === "created" || lc === "starting";
65+
}
66+
67+
/** Whether the task's workspace is still starting up (building or agent initializing). */
68+
export function isWorkspaceStarting(task: Task): boolean {
69+
return isBuildingWorkspace(task) || isAgentStarting(task);
70+
}

packages/tasks/src/components/AgentChatHistory.tsx

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { VscodeScrollable } from "@vscode-elements/react-elements";
2-
3-
import { useFollowScroll } from "../hooks/useFollowScroll";
1+
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";
42

53
import type { LogsStatus, TaskLogEntry } from "@repo/shared";
64

@@ -34,37 +32,29 @@ export function AgentChatHistory({
3432
logsStatus,
3533
isThinking,
3634
}: AgentChatHistoryProps) {
37-
const bottomRef = useFollowScroll();
35+
const isEmpty = logs.length === 0 && (logsStatus !== "ok" || !isThinking);
3836

3937
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) => (
38+
<LogViewer header="Agent chat history">
39+
{isEmpty ? (
40+
<LogViewerPlaceholder error={logsStatus === "error"}>
41+
{getEmptyMessage(logsStatus)}
42+
</LogViewerPlaceholder>
43+
) : (
44+
<>
45+
{logs.map((log, index) => (
5546
<LogEntry
5647
key={log.id}
5748
log={log}
5849
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
5950
/>
60-
))
61-
)}
62-
{isThinking && (
63-
<div className="log-entry log-entry-thinking">Thinking...</div>
64-
)}
65-
<div ref={bottomRef} />
66-
</VscodeScrollable>
67-
</div>
51+
))}
52+
{isThinking && (
53+
<div className="log-entry log-entry-thinking">Thinking...</div>
54+
)}
55+
</>
56+
)}
57+
</LogViewer>
6858
);
6959
}
7060

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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: ReactNode;
31+
error?: boolean;
32+
}) {
33+
return (
34+
<div
35+
className={
36+
error ? "log-viewer-empty log-viewer-error" : "log-viewer-empty"
37+
}
38+
>
39+
{children}
40+
</div>
41+
);
42+
}

packages/tasks/src/components/TaskDetailView.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { isWorkspaceStarting, type TaskDetails } from "@repo/shared";
2+
3+
import { useWorkspaceLogs } from "../hooks/useWorkspaceLogs";
4+
15
import { AgentChatHistory } from "./AgentChatHistory";
26
import { ErrorBanner } from "./ErrorBanner";
37
import { TaskDetailHeader } from "./TaskDetailHeader";
48
import { TaskMessageInput } from "./TaskMessageInput";
5-
6-
import type { TaskDetails } from "@repo/shared";
9+
import { WorkspaceLogs } from "./WorkspaceLogs";
710

811
interface TaskDetailViewProps {
912
details: TaskDetails;
@@ -13,18 +16,25 @@ interface TaskDetailViewProps {
1316
export function TaskDetailView({ details, onBack }: TaskDetailViewProps) {
1417
const { task, logs, logsStatus } = details;
1518

19+
const starting = isWorkspaceStarting(task);
20+
const workspaceLines = useWorkspaceLogs(starting);
21+
1622
const isThinking =
1723
task.status === "active" && task.current_state?.state === "working";
1824

1925
return (
2026
<div className="task-detail-view">
2127
<TaskDetailHeader task={task} onBack={onBack} />
2228
{task.status === "error" && <ErrorBanner task={task} />}
23-
<AgentChatHistory
24-
logs={logs}
25-
logsStatus={logsStatus}
26-
isThinking={isThinking}
27-
/>
29+
{starting ? (
30+
<WorkspaceLogs task={task} lines={workspaceLines} />
31+
) : (
32+
<AgentChatHistory
33+
logs={logs}
34+
logsStatus={logsStatus}
35+
isThinking={isThinking}
36+
/>
37+
)}
2838
<TaskMessageInput task={task} />
2939
</div>
3040
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { isBuildingWorkspace, type Task } from "@repo/shared";
2+
3+
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";
4+
5+
export function WorkspaceLogs({
6+
task,
7+
lines,
8+
}: {
9+
task: Task;
10+
lines: string[];
11+
}) {
12+
const header = isBuildingWorkspace(task)
13+
? "Building workspace..."
14+
: "Running startup scripts...";
15+
16+
return (
17+
<LogViewer header={header}>
18+
{lines.length === 0 ? (
19+
<LogViewerPlaceholder>Waiting for logs...</LogViewerPlaceholder>
20+
) : (
21+
lines.map((line, i) => (
22+
<div key={i} className="log-entry">
23+
{line}
24+
</div>
25+
))
26+
)}
27+
</LogViewer>
28+
);
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { TasksApi } from "@repo/shared";
2+
import { useIpc } from "@repo/webview-shared/react";
3+
import { useEffect, useState } from "react";
4+
5+
export function useWorkspaceLogs(active: boolean): string[] {
6+
const { command, onNotification } = useIpc();
7+
const [lines, setLines] = useState<string[]>([]);
8+
const [prevActive, setPrevActive] = useState(active);
9+
10+
// Reset lines when active flag changes (React "adjusting state during render" pattern)
11+
if (active !== prevActive) {
12+
setPrevActive(active);
13+
setLines([]);
14+
}
15+
16+
useEffect(() => {
17+
if (!active) return;
18+
const unsubscribe = onNotification(
19+
TasksApi.workspaceLogsAppend,
20+
(newLines) => {
21+
setLines((prev) => [...prev, ...newLines]);
22+
},
23+
);
24+
return () => {
25+
unsubscribe();
26+
command(TasksApi.closeWorkspaceLogs);
27+
};
28+
}, [active, command, onNotification]);
29+
30+
return lines;
31+
}

packages/tasks/src/index.css

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ vscode-icon.disabled {
443443

444444
/* Chat history */
445445

446-
.agent-chat-history {
446+
.log-viewer {
447447
flex: 1;
448448
display: flex;
449449
flex-direction: column;
@@ -453,28 +453,28 @@ vscode-icon.disabled {
453453
border-radius: 4px;
454454
}
455455

456-
.chat-history-header {
456+
.log-viewer-header {
457457
padding: 6px 8px;
458458
font-size: 0.8em;
459459
color: var(--vscode-descriptionForeground);
460460
border-bottom: 1px solid var(--vscode-input-border);
461461
}
462462

463-
.chat-history-content {
463+
.log-viewer-content {
464464
flex: 1;
465465
min-height: 0;
466466
padding: 4px 8px;
467467
font-family: var(--vscode-editor-font-family, monospace);
468468
font-size: 0.85em;
469469
}
470470

471-
.chat-history-empty {
471+
.log-viewer-empty {
472472
padding: 16px 0;
473473
color: var(--vscode-descriptionForeground);
474474
text-align: center;
475475
}
476476

477-
.chat-history-error {
477+
.log-viewer-error {
478478
color: var(--vscode-errorForeground);
479479
}
480480

0 commit comments

Comments
 (0)