Skip to content

Commit d21133d

Browse files
committed
Add task detail view with navigation and log streaming
Adds the task detail view layer including: Components: - TaskDetailView as the main detail view container - TaskDetailHeader with back button, status, and action menu - AgentChatHistory for displaying task log entries with scroll tracking - TaskInput with pause button and state-aware placeholder - ErrorBanner for displaying task errors with link to logs App.tsx enhancements: - Navigation between task list and detail view (inline in Task History) - Selected task state persistence and validation - Adaptive polling intervals based on task state (active vs idle) - Real-time log streaming via logsAppend push messages - refs to avoid stale closures in message handlers - Transition animation when switching views Config additions: - TASK_ACTIVE_INTERVAL_MS for faster updates when task is working - TASK_IDLE_INTERVAL_MS for slower updates when task is idle/complete Also adds codicons CSS import for icon rendering.
1 parent 6ff253d commit d21133d

File tree

12 files changed

+456
-20
lines changed

12 files changed

+456
-20
lines changed

packages/tasks/src/App.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import {
1212
ErrorState,
1313
NoTemplateState,
1414
NotSupportedState,
15+
TaskDetailView,
1516
TaskList,
1617
} from "./components";
1718
import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle";
1819
import { useScrollableHeight } from "./hooks/useScrollableHeight";
20+
import { useSelectedTask } from "./hooks/useSelectedTask";
1921
import { useTasksData } from "./hooks/useTasksData";
2022

2123
type CollapsibleElement = React.ComponentRef<typeof VscodeCollapsible>;
@@ -34,6 +36,9 @@ export default function App() {
3436
persistUiState,
3537
} = useTasksData();
3638

39+
const { selectedTask, isLoadingDetails, selectTask, deselectTask } =
40+
useSelectedTask(tasks);
41+
3742
const [createRef, createOpen, setCreateOpen] =
3843
useCollapsibleToggle<CollapsibleElement>(initialCreateExpanded);
3944
const [historyRef, historyOpen, _setHistoryOpen] =
@@ -46,8 +51,11 @@ export default function App() {
4651

4752
const { onNotification } = useIpc();
4853
useEffect(() => {
49-
return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true));
50-
}, [onNotification, setCreateOpen]);
54+
return onNotification(TasksApi.showCreateForm, () => {
55+
deselectTask();
56+
setCreateOpen(true);
57+
});
58+
}, [onNotification, setCreateOpen, deselectTask]);
5159

5260
useEffect(() => {
5361
persistUiState({
@@ -96,12 +104,15 @@ export default function App() {
96104
open={historyOpen}
97105
>
98106
<VscodeScrollable ref={historyScrollRef}>
99-
<TaskList
100-
tasks={tasks}
101-
onSelectTask={(_taskId: string) => {
102-
// Task detail view will be added in next PR
103-
}}
104-
/>
107+
{selectedTask ? (
108+
<TaskDetailView details={selectedTask} onBack={deselectTask} />
109+
) : isLoadingDetails ? (
110+
<div className="loading-container">
111+
<VscodeProgressRing />
112+
</div>
113+
) : (
114+
<TaskList tasks={tasks} onSelectTask={selectTask} />
115+
)}
105116
</VscodeScrollable>
106117
</VscodeCollapsible>
107118
</div>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useEffect, useRef, useCallback } from "react";
2+
3+
import type { LogsStatus, TaskLogEntry } from "@repo/shared";
4+
5+
interface AgentChatHistoryProps {
6+
logs: TaskLogEntry[];
7+
logsStatus: LogsStatus;
8+
isThinking: boolean;
9+
}
10+
11+
function getEmptyMessage(logsStatus: LogsStatus): string {
12+
switch (logsStatus) {
13+
case "not_available":
14+
return "Logs not available in current task state";
15+
case "error":
16+
return "Failed to load logs";
17+
default:
18+
return "No messages yet";
19+
}
20+
}
21+
22+
export function AgentChatHistory({
23+
logs,
24+
logsStatus,
25+
isThinking,
26+
}: AgentChatHistoryProps) {
27+
const containerRef = useRef<HTMLDivElement>(null);
28+
const isAtBottomRef = useRef(true);
29+
const isInitialMountRef = useRef(true);
30+
31+
const checkIfAtBottom = useCallback(() => {
32+
const container = containerRef.current;
33+
if (!container) return true;
34+
const distanceFromBottom =
35+
container.scrollHeight - container.scrollTop - container.clientHeight;
36+
return distanceFromBottom <= 50;
37+
}, []);
38+
39+
const handleScroll = useCallback(() => {
40+
isAtBottomRef.current = checkIfAtBottom();
41+
}, [checkIfAtBottom]);
42+
43+
useEffect(() => {
44+
if (isInitialMountRef.current && containerRef.current) {
45+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
46+
isInitialMountRef.current = false;
47+
}
48+
}, []);
49+
50+
useEffect(() => {
51+
if (containerRef.current && isAtBottomRef.current) {
52+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
53+
}
54+
}, [logs]);
55+
56+
const emptyMessage = getEmptyMessage(logsStatus);
57+
58+
return (
59+
<div className="agent-chat-history">
60+
<div className="chat-history-header">Agent chat history</div>
61+
<div
62+
className="chat-history-content"
63+
ref={containerRef}
64+
onScroll={handleScroll}
65+
>
66+
{logs.length === 0 ? (
67+
<div
68+
className={`chat-history-empty ${logsStatus === "error" ? "chat-history-error" : ""}`}
69+
>
70+
{emptyMessage}
71+
</div>
72+
) : (
73+
logs.map((log) => (
74+
<div key={log.id} className={`log-entry log-entry-${log.type}`}>
75+
{log.content}
76+
</div>
77+
))
78+
)}
79+
{isThinking && (
80+
<div className="log-entry log-entry-thinking">
81+
<span className="log-content">*Thinking...</span>
82+
</div>
83+
)}
84+
</div>
85+
</div>
86+
);
87+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { VscodeIcon } from "@vscode-elements/react-elements";
2+
import { useCallback } from "react";
3+
4+
import { useTasksApi } from "../hooks/useTasksApi";
5+
6+
import type { Task } from "@repo/shared";
7+
8+
interface ErrorBannerProps {
9+
task: Task;
10+
}
11+
12+
export function ErrorBanner({ task }: ErrorBannerProps) {
13+
const api = useTasksApi();
14+
const message = task.current_state?.message || "Build failed";
15+
16+
const handleViewLogs = useCallback(() => {
17+
void api.viewLogs(task.id);
18+
}, [api, task.id]);
19+
20+
return (
21+
<div className="error-banner">
22+
<VscodeIcon name="warning" />
23+
<span className="error-banner-message">{message}.</span>
24+
<button type="button" className="text-link" onClick={handleViewLogs}>
25+
View logs <VscodeIcon name="link-external" />
26+
</button>
27+
</div>
28+
);
29+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { VscodeIcon } from "@vscode-elements/react-elements";
2+
3+
import { ActionMenu, type ActionMenuItem } from "./ActionMenu";
4+
import { StatusIndicator } from "./StatusIndicator";
5+
import { getDisplayName } from "./utils";
6+
7+
import type { Task } from "@repo/shared";
8+
9+
interface TaskDetailHeaderProps {
10+
task: Task;
11+
menuItems: ActionMenuItem[];
12+
onBack: () => void;
13+
loadingAction?: string | null;
14+
}
15+
16+
export function TaskDetailHeader({
17+
task,
18+
menuItems,
19+
onBack,
20+
loadingAction,
21+
}: TaskDetailHeaderProps) {
22+
const displayName = getDisplayName(task);
23+
24+
return (
25+
<div className="task-detail-header">
26+
<VscodeIcon
27+
actionIcon
28+
name="arrow-left"
29+
label="Back to task list"
30+
onClick={onBack}
31+
/>
32+
<StatusIndicator task={task} />
33+
<span className="task-detail-title" title={displayName}>
34+
{displayName}
35+
{loadingAction && (
36+
<span className="task-action-label">{loadingAction}</span>
37+
)}
38+
</span>
39+
<ActionMenu items={menuItems} />
40+
</div>
41+
);
42+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getTaskActions, type TaskDetails } from "@repo/shared";
2+
3+
import { AgentChatHistory } from "./AgentChatHistory";
4+
import { ErrorBanner } from "./ErrorBanner";
5+
import { TaskDetailHeader } from "./TaskDetailHeader";
6+
import { TaskInput } from "./TaskInput";
7+
import { useTaskMenuItems } from "./useTaskMenuItems";
8+
import { getActionLabel } from "./utils";
9+
10+
interface TaskDetailViewProps {
11+
details: TaskDetails;
12+
onBack: () => void;
13+
}
14+
15+
export function TaskDetailView({ details, onBack }: TaskDetailViewProps) {
16+
const { task, logs, logsStatus } = details;
17+
const { canPause } = getTaskActions(task);
18+
19+
const isWorking =
20+
task.status === "active" &&
21+
task.current_state?.state === "working" &&
22+
task.workspace_agent_lifecycle === "ready";
23+
24+
const { menuItems, action } = useTaskMenuItems({ task });
25+
26+
return (
27+
<div className="task-detail-view">
28+
<TaskDetailHeader
29+
task={task}
30+
menuItems={menuItems}
31+
onBack={onBack}
32+
loadingAction={getActionLabel(action)}
33+
/>
34+
{task.status === "error" && <ErrorBanner task={task} />}
35+
<AgentChatHistory
36+
logs={logs}
37+
logsStatus={logsStatus}
38+
isThinking={isWorking}
39+
/>
40+
<TaskInput taskId={task.id} task={task} canPause={canPause} />
41+
</div>
42+
);
43+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {
2+
VscodeIcon,
3+
VscodeProgressRing,
4+
} from "@vscode-elements/react-elements";
5+
import { useState } from "react";
6+
7+
import { useTasksApi } from "../hooks/useTasksApi";
8+
9+
import type { Task } from "@repo/shared";
10+
11+
interface TaskInputProps {
12+
taskId: string;
13+
task: Task;
14+
canPause: boolean;
15+
}
16+
17+
function getPlaceholder(task: Task): string {
18+
if (task.status === "error" || task.current_state?.state === "failed") {
19+
return task.current_state?.message || "Error occurred...";
20+
}
21+
if (task.status === "paused") {
22+
return "Task paused";
23+
}
24+
if (task.status === "pending" || task.status === "initializing") {
25+
return "Initializing...";
26+
}
27+
if (task.current_state?.state === "working") {
28+
return "Agent is working...";
29+
}
30+
if (task.current_state?.state === "complete") {
31+
return "Task completed";
32+
}
33+
return "Type a message to the agent...";
34+
}
35+
36+
function isInputEnabled(task: Task): boolean {
37+
const state = task.current_state?.state;
38+
return state === "idle" || state === "complete" || task.status === "paused";
39+
}
40+
41+
export function TaskInput({ taskId, task, canPause }: TaskInputProps) {
42+
const api = useTasksApi();
43+
const [message, setMessage] = useState("");
44+
const [isPausing, setIsPausing] = useState(false);
45+
const [isSending, setIsSending] = useState(false);
46+
47+
const inputEnabled = isInputEnabled(task);
48+
const showPauseButton = task.current_state?.state === "working" && canPause;
49+
const placeholder = getPlaceholder(task);
50+
51+
const handleSend = () => {
52+
if (!message.trim() || !inputEnabled || isSending) return;
53+
setIsSending(true);
54+
api.sendTaskMessage(taskId, message.trim());
55+
setMessage("");
56+
setTimeout(() => setIsSending(false), 500);
57+
};
58+
59+
const handlePause = async () => {
60+
if (isPausing) return;
61+
setIsPausing(true);
62+
try {
63+
await api.pauseTask(taskId);
64+
} catch {
65+
// Extension shows error notification
66+
} finally {
67+
setIsPausing(false);
68+
}
69+
};
70+
71+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
72+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && inputEnabled) {
73+
e.preventDefault();
74+
void handleSend();
75+
}
76+
};
77+
78+
return (
79+
<div className="task-input-container">
80+
<textarea
81+
className="task-input"
82+
placeholder={placeholder}
83+
value={message}
84+
onChange={(e) => setMessage(e.target.value)}
85+
onKeyDown={handleKeyDown}
86+
disabled={!inputEnabled}
87+
/>
88+
<div className="task-input-button">
89+
{showPauseButton ? (
90+
isPausing ? (
91+
<VscodeProgressRing className="task-input-spinner" />
92+
) : (
93+
<VscodeIcon
94+
actionIcon
95+
name="debug-pause"
96+
label="Pause task"
97+
onClick={() => void handlePause()}
98+
/>
99+
)
100+
) : isSending ? (
101+
<VscodeProgressRing className="task-input-spinner" />
102+
) : (
103+
<VscodeIcon
104+
actionIcon
105+
name="send"
106+
label="Send message"
107+
onClick={
108+
inputEnabled && message.trim()
109+
? () => void handleSend()
110+
: undefined
111+
}
112+
className={!inputEnabled || !message.trim() ? "disabled" : ""}
113+
/>
114+
)}
115+
</div>
116+
</div>
117+
);
118+
}

0 commit comments

Comments
 (0)