Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@
"proper-lockfile": "^4.1.2",
"proxy-agent": "^6.5.0",
"semver": "^7.7.4",
"strip-ansi": "^7.1.2",
"ua-parser-js": "^1.0.41",
"ws": "^8.19.0",
"zod": "^4.3.6"
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(

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

const taskUpdated = defineNotification<Task>("taskUpdated");
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
const refresh = defineNotification<void>("refresh");
const showCreateForm = defineNotification<void>("showCreateForm");

Expand All @@ -78,9 +80,11 @@ export const TasksApi = {
// Commands
viewInCoder,
viewLogs,
closeWorkspaceLogs,
// Notifications
taskUpdated,
tasksUpdated,
workspaceLogsAppend,
refresh,
showCreateForm,
} as const;
10 changes: 6 additions & 4 deletions packages/shared/src/tasks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@ export interface TaskPreset {
isDefault: boolean;
}

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

/**
* Full details for a selected task, including logs and action availability.
*/
export interface TaskDetails extends TaskPermissions {
task: Task;
logs: readonly TaskLogEntry[];
logsStatus: LogsStatus;
logs: TaskLogs;
}

export interface TaskPermissions {
Expand Down
30 changes: 25 additions & 5 deletions packages/shared/src/tasks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ export function getTaskLabel(task: Task): string {
return task.display_name || task.name || task.id;
}

/** Whether the agent is actively working (status is active and state is working). */
export function isTaskWorking(task: Task): boolean {
return task.status === "active" && task.current_state?.state === "working";
}

const PAUSABLE_STATUSES: readonly TaskStatus[] = [
"active",
"initializing",
Expand Down Expand Up @@ -42,6 +37,11 @@ export function getTaskPermissions(task: Task): TaskPermissions {
};
}

/** Whether the agent is actively working (status is active and state is working). */
export function isTaskWorking(task: Task): boolean {
return task.status === "active" && task.current_state?.state === "working";
}

/**
* Task statuses where logs won't change (stable/terminal states).
* "complete" is a TaskState (sub-state of active), checked separately.
Expand All @@ -55,3 +55,23 @@ export function isStableTask(task: Task): boolean {
(task.current_state !== null && task.current_state.state !== "working")
);
}

/** Whether the task's workspace is building (provisioner running). */
export function isBuildingWorkspace(task: Task): boolean {
const ws = task.workspace_status;
return ws === "pending" || ws === "starting";
}

/** Whether the workspace is running but the agent hasn't reached "ready" yet. */
export function isAgentStarting(task: Task): boolean {
if (task.workspace_status !== "running") {
return false;
}
const lc = task.workspace_agent_lifecycle;
return lc === "created" || lc === "starting";
}

/** Whether the task's workspace is still starting up (building or agent initializing). */
export function isWorkspaceStarting(task: Task): boolean {
return isBuildingWorkspace(task) || isAgentStarting(task);
}
10 changes: 5 additions & 5 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { TasksApi, type InitResponse } from "@repo/shared";
import { type InitResponse } from "@repo/shared";
import { getState, setState } from "@repo/webview-shared";
import { useIpc } from "@repo/webview-shared/react";
import {
VscodeCollapsible,
VscodeProgressRing,
Expand All @@ -17,6 +16,7 @@ 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 { useTasksQuery } from "./hooks/useTasksQuery";

interface PersistedState extends InitResponse {
Expand Down Expand Up @@ -46,10 +46,10 @@ export default function App() {
useScrollableHeight(createRef, createScrollRef);
useScrollableHeight(historyRef, historyScrollRef);

const { onNotification } = useIpc();
const { onShowCreateForm } = useTasksApi();
useEffect(() => {
return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true));
}, [onNotification, setCreateOpen]);
return onShowCreateForm(() => setCreateOpen(true));
}, [onShowCreateForm, setCreateOpen]);

useEffect(() => {
if (data) {
Expand Down
64 changes: 25 additions & 39 deletions packages/tasks/src/components/AgentChatHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { VscodeScrollable } from "@vscode-elements/react-elements";
import { LogViewer, LogViewerPlaceholder } from "./LogViewer";

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

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

interface AgentChatHistoryProps {
logs: readonly TaskLogEntry[];
logsStatus: LogsStatus;
taskLogs: TaskLogs;
isThinking: boolean;
}

Expand All @@ -30,46 +27,35 @@ function LogEntry({
}

export function AgentChatHistory({
logs,
logsStatus,
taskLogs,
isThinking,
}: AgentChatHistoryProps) {
const bottomRef = useFollowScroll();
const logs = taskLogs.status === "ok" ? taskLogs.logs : [];

return (
<div className="agent-chat-history">
<div className="chat-history-header">Agent chat history</div>
<VscodeScrollable className="chat-history-content">
{logs.length === 0 ? (
<div
className={
logsStatus === "error"
? "chat-history-empty chat-history-error"
: "chat-history-empty"
}
>
{getEmptyMessage(logsStatus)}
</div>
) : (
logs.map((log, index) => (
<LogEntry
key={log.id}
log={log}
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
/>
))
)}
{isThinking && (
<div className="log-entry log-entry-thinking">Thinking...</div>
)}
<div ref={bottomRef} />
</VscodeScrollable>
</div>
<LogViewer header="Agent chat history">
{logs.length === 0 ? (
<LogViewerPlaceholder error={taskLogs.status === "error"}>
{getEmptyMessage(taskLogs.status)}
</LogViewerPlaceholder>
) : (
logs.map((log, index) => (
<LogEntry
key={log.id}
log={log}
isGroupStart={index === 0 || log.type !== logs[index - 1].type}
/>
))
)}
{isThinking && (
<div className="log-entry log-entry-thinking">Thinking...</div>
)}
</LogViewer>
);
}

function getEmptyMessage(logsStatus: LogsStatus): string {
switch (logsStatus) {
function getEmptyMessage(status: TaskLogs["status"]): string {
switch (status) {
case "not_available":
return "Logs not available in current task state";
case "error":
Expand Down
38 changes: 38 additions & 0 deletions packages/tasks/src/components/LogViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { VscodeScrollable } from "@vscode-elements/react-elements";

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

import type { ReactNode } from "react";

interface LogViewerProps {
header: string;
children: ReactNode;
}

export function LogViewer({ header, children }: LogViewerProps) {
const bottomRef = useFollowScroll();

return (
<div className="log-viewer">
<div className="log-viewer-header">{header}</div>
<VscodeScrollable className="log-viewer-content">
{children}
<div ref={bottomRef} />
</VscodeScrollable>
</div>
);
}

export function LogViewerPlaceholder({
children,
error,
}: {
children: string;
error?: boolean;
}) {
return (
<div className={`log-viewer-empty${error ? " log-viewer-error" : ""}`}>
{children}
</div>
);
}
20 changes: 13 additions & 7 deletions packages/tasks/src/components/TaskDetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
import { isTaskWorking, type TaskDetails } from "@repo/shared";
import {
isWorkspaceStarting,
isTaskWorking,
type TaskDetails,
} from "@repo/shared";

import { AgentChatHistory } from "./AgentChatHistory";
import { ErrorBanner } from "./ErrorBanner";
import { TaskDetailHeader } from "./TaskDetailHeader";
import { TaskMessageInput } from "./TaskMessageInput";
import { WorkspaceLogs } from "./WorkspaceLogs";

interface TaskDetailViewProps {
details: TaskDetails;
onBack: () => void;
}

export function TaskDetailView({ details, onBack }: TaskDetailViewProps) {
const { task, logs, logsStatus } = details;
const { task, logs } = details;

const starting = isWorkspaceStarting(task);
const isThinking = isTaskWorking(task);

return (
<div className="task-detail-view">
<TaskDetailHeader task={task} onBack={onBack} />
{task.status === "error" && <ErrorBanner task={task} />}
<AgentChatHistory
logs={logs}
logsStatus={logsStatus}
isThinking={isThinking}
/>
{starting ? (
<WorkspaceLogs task={task} />
) : (
<AgentChatHistory taskLogs={logs} isThinking={isThinking} />
)}
<TaskMessageInput task={task} />
</div>
);
Expand Down
26 changes: 26 additions & 0 deletions packages/tasks/src/components/WorkspaceLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isBuildingWorkspace, type Task } from "@repo/shared";

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

import { LogViewer, LogViewerPlaceholder } from "./LogViewer";

function LogLine({ children }: { children: string }) {
return <div className="log-entry">{children}</div>;
}

export function WorkspaceLogs({ task }: { task: Task }) {
const lines = useWorkspaceLogs();
const header = isBuildingWorkspace(task)
? "Building workspace..."
: "Running startup scripts...";

return (
<LogViewer header={header}>
{lines.length === 0 ? (
<LogViewerPlaceholder>Waiting for logs...</LogViewerPlaceholder>
) : (
lines.map((line, i) => <LogLine key={i}>{line}</LogLine>)
)}
</LogViewer>
);
}
13 changes: 3 additions & 10 deletions packages/tasks/src/hooks/useSelectedTask.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
TasksApi,
isStableTask,
type Task,
type TaskDetails,
} from "@repo/shared";
import { useIpc } from "@repo/webview-shared/react";
import { isStableTask, type Task, type TaskDetails } from "@repo/shared";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";

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

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

// Keep selected task in sync with push updates between polls
useEffect(() => {
return onNotification(TasksApi.taskUpdated, (updatedTask) => {
return api.onTaskUpdated((updatedTask) => {
if (updatedTask.id !== selectedTaskId) return;
queryClient.setQueryData<TaskDetails>(
[QUERY_KEY, selectedTaskId],
(prev) => (prev ? { ...prev, task: updatedTask } : undefined),
);
});
}, [onNotification, selectedTaskId, queryClient]);
}, [api.onTaskUpdated, selectedTaskId, queryClient]);

const deselectTask = () => {
setSelectedTaskId(null);
Expand Down
Loading