feat: show build logs in chat for start_workspace and create_workspace tools (#24194)

This commit is contained in:
Danielle Maywood
2026-04-12 15:04:10 +01:00
committed by GitHub
parent 982739f3bf
commit cb0b84a2d3
17 changed files with 1941 additions and 339 deletions
+25 -1
View File
@@ -1,6 +1,11 @@
import type { QueryOptions, UseInfiniteQueryOptions } from "react-query";
import type {
QueryOptions,
UseInfiniteQueryOptions,
UseQueryOptions,
} from "react-query";
import { API } from "#/api/api";
import type {
ProvisionerJobLog,
WorkspaceBuild,
WorkspaceBuildParameter,
WorkspaceBuildsRequest,
@@ -61,6 +66,25 @@ export const infiniteWorkspaceBuilds = (
} satisfies UseInfiniteQueryOptions<WorkspaceBuild[]>;
};
function workspaceBuildLogsKey(workspaceBuildId: string) {
return ["workspaceBuilds", workspaceBuildId, "logs"] as const;
}
// Fetches build logs via REST. Completed build logs are immutable,
// so the query uses infinite staleTime to cache across re-mounts
// (e.g. collapsible expand/collapse cycles).
export function workspaceBuildLogs(workspaceBuildId: string) {
return {
queryKey: workspaceBuildLogsKey(workspaceBuildId),
queryFn: () => API.getWorkspaceBuildLogs(workspaceBuildId),
staleTime: Number.POSITIVE_INFINITY,
gcTime: 10 * 60 * 1000, // 10 minutes. Avoids holding logs in cache forever.
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
} as const satisfies UseQueryOptions<ProvisionerJobLog[]>;
}
// We use readyAgentsCount to invalidate the query when an agent connects
export const workspaceBuildTimings = (workspaceBuildId: string) => {
return {
@@ -1238,6 +1238,7 @@ const AgentChatPage: FC = () => {
isArchived={isArchived}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatBuildId={chatQuery.data?.build_id}
store={store}
editing={editing}
effectiveSelectedModel={effectiveSelectedModel}
+180 -173
View File
@@ -44,6 +44,7 @@ import { GitPanel } from "./components/GitPanel/GitPanel";
import { RightPanel } from "./components/RightPanel/RightPanel";
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
import type { ChatDetailError } from "./utils/usageLimitMessage";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -90,6 +91,7 @@ interface AgentChatPageViewProps {
isArchived: boolean;
workspaceAgent?: TypesGen.WorkspaceAgent;
workspace?: TypesGen.Workspace;
chatBuildId?: string;
// Store handle.
store: ChatStoreHandle;
@@ -180,6 +182,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isArchived,
workspaceAgent,
workspace,
chatBuildId,
store,
editing,
effectiveSelectedModel,
@@ -309,194 +312,198 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const shouldShowSidebar = showSidebarPanel;
return (
<DesktopPanelContext value={desktopPanelCtx}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<ChatWorkspaceContext
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
>
<DesktopPanelContext value={desktopPanelCtx}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
visualExpanded && "hidden",
shouldShowSidebar && "max-lg:hidden",
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
{" "}
<ChatTopBar
chatTitle={chatTitle}
parentChat={parentChat}
panel={{
showSidebarPanel,
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={
handleArchiveAndDeleteWorkspaceAction
}
{...(handleRegenerateTitle
? { onRegenerateTitle: handleRegenerateTitle }
: {})}
isRegeneratingTitle={isRegeneratingTitle}
isRegenerateTitleDisabled={isRegenerateTitleDisabled}
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
{titleElement}
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
visualExpanded && "hidden",
shouldShowSidebar && "max-lg:hidden",
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<ChatScrollContainer
key={agentId}
scrollContainerRef={scrollContainerRef}
scrollToBottomRef={effectiveScrollToBottomRef}
isFetchingMoreMessages={isFetchingMoreMessages}
hasMoreMessages={hasMoreMessages}
onFetchMoreMessages={onFetchMoreMessages}
>
<div className="px-4">
<ChatPageTimeline
chatID={agentId}
<div className="relative z-10 shrink-0 overflow-visible">
{" "}
<ChatTopBar
chatTitle={chatTitle}
parentChat={parentChat}
panel={{
showSidebarPanel,
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={
handleArchiveAndDeleteWorkspaceAction
}
{...(handleRegenerateTitle
? { onRegenerateTitle: handleRegenerateTitle }
: {})}
isRegeneratingTitle={isRegeneratingTitle}
isRegenerateTitleDisabled={isRegenerateTitleDisabled}
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<ChatScrollContainer
key={agentId}
scrollContainerRef={scrollContainerRef}
scrollToBottomRef={effectiveScrollToBottomRef}
isFetchingMoreMessages={isFetchingMoreMessages}
hasMoreMessages={hasMoreMessages}
onFetchMoreMessages={onFetchMoreMessages}
>
<div className="px-4">
<ChatPageTimeline
chatID={agentId}
store={store}
persistedError={persistedError}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
</div>
</ChatScrollContainer>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
store={store}
persistedError={persistedError}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
isModelCatalogLoading={isModelCatalogLoading}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
initialEditorState={editing.initialEditorState}
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
urlTransform={urlTransform}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
lastInjectedContext={lastInjectedContext}
attachedWorkspace={attachedWorkspace}
/>
</div>
</ChatScrollContainer>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
isModelCatalogLoading={isModelCatalogLoading}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
initialEditorState={editing.initialEditorState}
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
lastInjectedContext={lastInjectedContext}
attachedWorkspace={attachedWorkspace}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => onSetShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
activeTabId={sidebarTabId}
onActiveTabChange={setSidebarTabId}
tabs={[
{
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={handleRefresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
),
},
...(workspace && workspaceAgent
? [
{
id: "terminal",
label: "Terminal",
content: (
<TerminalPanel
chatId={agentId}
isVisible={
shouldShowSidebar && sidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
),
},
]
: []),
]}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => onSetShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
desktopChatId={
workspace && workspaceAgent ? desktopChatId : undefined
}
/>
</RightPanel>
</div>
</DesktopPanelContext>
>
<SidebarTabView
activeTabId={sidebarTabId}
onActiveTabChange={setSidebarTabId}
tabs={[
{
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={handleRefresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
),
},
...(workspace && workspaceAgent
? [
{
id: "terminal",
label: "Terminal",
content: (
<TerminalPanel
chatId={agentId}
isVisible={
shouldShowSidebar && sidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
),
},
]
: []),
]}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
desktopChatId={
workspace && workspaceAgent ? desktopChatId : undefined
}
/>
</RightPanel>
</div>
</DesktopPanelContext>
</ChatWorkspaceContext>
);
};
+7 -1
View File
@@ -575,6 +575,7 @@ const AgentsPage: FC = () => {
: c.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? c.workspace_id;
const nextBuildId = updatedChat.build_id ?? c.build_id;
const nextUpdatedAt =
c.updated_at > updatedChat.updated_at
? c.updated_at
@@ -593,6 +594,7 @@ const AgentsPage: FC = () => {
nextTitle === c.title &&
diffStatusEqual(nextDiffStatus, c.diff_status) &&
nextWorkspaceId === c.workspace_id &&
nextBuildId === c.build_id &&
nextHasUnread === c.has_unread
) {
return c;
@@ -604,6 +606,7 @@ const AgentsPage: FC = () => {
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
has_unread: nextHasUnread,
};
@@ -634,6 +637,7 @@ const AgentsPage: FC = () => {
: previousChat.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? previousChat.workspace_id;
const nextBuildId = updatedChat.build_id ?? previousChat.build_id;
const nextUpdatedAt =
previousChat.updated_at > updatedChat.updated_at
? previousChat.updated_at
@@ -643,7 +647,8 @@ const AgentsPage: FC = () => {
nextStatus === previousChat.status &&
nextTitle === previousChat.title &&
diffStatusEqual(nextDiffStatus, previousChat.diff_status) &&
nextWorkspaceId === previousChat.workspace_id
nextWorkspaceId === previousChat.workspace_id &&
nextBuildId === previousChat.build_id
) {
return previousChat;
}
@@ -653,6 +658,7 @@ const AgentsPage: FC = () => {
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
};
},
@@ -1,4 +1,9 @@
import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import {
ExternalLinkIcon,
LoaderIcon,
MonitorIcon,
TriangleAlertIcon,
} from "lucide-react";
import type React from "react";
import { Link } from "react-router";
import {
@@ -6,14 +11,16 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import { asRecord, asString, type ToolStatus } from "./utils";
import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection";
/**
* Rendering for `create_workspace` tool calls.
*
* Shows "Creating workspace…" while running, and "Created <name>" when
* complete with a link to view the workspace.
* Shows "Creating workspace…" while running with streaming build logs,
* and "Created <name>" when complete with a link to view the workspace.
* Build logs are available in a collapsible section.
*/
export const CreateWorkspaceTool: React.FC<{
workspaceName: string;
@@ -21,7 +28,17 @@ export const CreateWorkspaceTool: React.FC<{
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ workspaceName, resultJson, status, isError, errorMessage }) => {
buildId?: string;
created?: boolean;
}> = ({
workspaceName,
resultJson,
status,
isError,
errorMessage,
buildId,
created = true,
}) => {
const isRunning = status === "running";
let rec: Record<string, unknown> | null = null;
if (resultJson) {
@@ -39,38 +56,55 @@ export const CreateWorkspaceTool: React.FC<{
const label = isRunning
? "Creating workspace…"
: wsName
? `Created ${wsName}`
: "Created workspace";
: isError
? `Failed to create ${wsName || "workspace"}`
: created === false
? `Workspace ${wsName} already exists`
: wsName
? `Created ${wsName}`
: "Created workspace";
const hasBuildLogs = isRunning || Boolean(buildId);
const header = (
<>
<MonitorIcon className="h-4 w-4 shrink-0 text-content-secondary" />
<span className="text-sm text-content-secondary">{label}</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to create workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{workspaceLink && !isRunning && (
<Link
to={workspaceLink}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View workspace"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
</>
);
return (
<div className="w-full">
<div className="flex items-center gap-2">
<span className={cn("text-sm", "text-content-secondary")}>{label}</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to create workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{workspaceLink && !isRunning && (
<Link
to={workspaceLink}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View workspace"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
</div>
<ToolCollapsible
header={header}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<WorkspaceBuildLogSection status={status} buildId={buildId} />
</ToolCollapsible>
</div>
);
};
@@ -0,0 +1,73 @@
import { LoaderIcon, MonitorPlayIcon, TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import type { ToolStatus } from "./utils";
import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection";
interface StartWorkspaceToolProps {
status: ToolStatus;
buildId?: string;
workspaceName: string;
isError: boolean;
errorMessage?: string;
noBuild?: boolean;
}
export const StartWorkspaceTool: FC<StartWorkspaceToolProps> = ({
status,
buildId,
workspaceName,
isError,
errorMessage,
noBuild,
}) => {
const isRunning = status === "running";
const label = isRunning
? "Starting workspace…"
: isError
? `Failed to start ${workspaceName || "workspace"}`
: workspaceName
? `Started ${workspaceName}`
: "Started workspace";
const header = (
<>
<MonitorPlayIcon className="h-4 w-4 shrink-0 text-content-secondary" />
<span className="text-sm text-content-secondary">{label}</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to start workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
);
// Show collapsible with build logs when there's a build to show.
const hasBuildLogs = (isRunning || Boolean(buildId)) && !noBuild;
return (
<div className="w-full">
<ToolCollapsible
header={header}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<WorkspaceBuildLogSection status={status} buildId={buildId} />
</ToolCollapsible>
</div>
);
};
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
import { DesktopPanelContext } from "./DesktopPanelContext";
import { Tool } from "./Tool";
@@ -1427,6 +1428,27 @@ export const StartWorkspaceRunning: Story = {
name: "start_workspace",
status: "running",
},
decorators: [
(Story) => (
<ChatWorkspaceContext value={{ workspaceId: "test-workspace-id" }}>
<Story />
</ChatWorkspaceContext>
),
],
parameters: {
queries: [
{
key: ["workspace", "test-workspace-id"],
data: {
id: "test-workspace-id",
latest_build: {
id: "test-build-id",
status: "starting",
},
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Starting workspace…")).toBeInTheDocument();
@@ -1441,14 +1463,42 @@ export const StartWorkspaceCompleted: Story = {
started: true,
workspace_name: "my-project",
agent_status: "ready",
build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
},
parameters: {
queries: [
{
key: [
"workspaceBuilds",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"logs",
],
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Started my-project")).toBeInTheDocument();
},
};
export const StartWorkspaceLegacy: Story = {
args: {
name: "start_workspace",
status: "completed",
result: {
started: true,
workspace_name: "legacy-workspace",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Started legacy-workspace")).toBeInTheDocument();
},
};
export const StartWorkspaceError: Story = {
args: {
name: "start_workspace",
@@ -1458,4 +1508,173 @@ export const StartWorkspaceError: Story = {
error: "workspace was deleted; use create_workspace to make a new one",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Failed to start workspace")).toBeInTheDocument();
},
};
export const StartWorkspaceBuildFailed: Story = {
args: {
name: "start_workspace",
status: "completed",
result: {
error: "workspace start build failed: terraform apply failed",
build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
},
parameters: {
queries: [
{
key: [
"workspaceBuilds",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"logs",
],
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Failed to start workspace")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// create_workspace stories
// ---------------------------------------------------------------------------
export const CreateWorkspaceRunning: Story = {
args: {
name: "create_workspace",
status: "running",
},
decorators: [
(Story) => (
<ChatWorkspaceContext value={{ workspaceId: "test-workspace-id" }}>
<Story />
</ChatWorkspaceContext>
),
],
parameters: {
queries: [
{
key: ["workspace", "test-workspace-id"],
data: {
id: "test-workspace-id",
latest_build: {
id: "test-build-id",
status: "starting",
},
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Creating workspace…")).toBeInTheDocument();
},
};
export const CreateWorkspaceCompleted: Story = {
args: {
name: "create_workspace",
status: "completed",
result: {
created: true,
workspace_name: "my-project",
build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
},
parameters: {
queries: [
{
key: [
"workspaceBuilds",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"logs",
],
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Created my-project")).toBeInTheDocument();
},
};
export const CreateWorkspaceLegacy: Story = {
args: {
name: "create_workspace",
status: "completed",
result: {
created: true,
workspace_name: "legacy-workspace",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Created legacy-workspace")).toBeInTheDocument();
},
};
export const CreateWorkspaceAlreadyExists: Story = {
args: {
name: "create_workspace",
status: "completed",
result: {
created: false,
workspace_name: "my-project",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Workspace my-project already exists"),
).toBeInTheDocument();
},
};
export const CreateWorkspaceError: Story = {
args: {
name: "create_workspace",
status: "error",
isError: true,
result: {
error: "template not found",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Failed to create workspace")).toBeInTheDocument();
},
};
export const CreateWorkspaceBuildFailed: Story = {
args: {
name: "create_workspace",
status: "completed",
result: {
error: "workspace build failed: terraform apply failed",
build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
},
parameters: {
queries: [
{
key: [
"workspaceBuilds",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"logs",
],
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Failed to create workspace")).toBeInTheDocument();
},
};
@@ -25,6 +25,7 @@ import { ProposePlanTool } from "./ProposePlanTool";
import { ReadFileTool } from "./ReadFileTool";
import { ReadSkillTool } from "./ReadSkillTool";
import { ReadTemplateTool } from "./ReadTemplateTool";
import { StartWorkspaceTool } from "./StartWorkspaceTool";
import { SubagentTool } from "./SubagentTool";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
@@ -314,15 +315,20 @@ const CreateWorkspaceRenderer: FC<ToolRendererProps> = ({
}) => {
const rec = asRecord(result);
const wsName = rec ? asString(rec.workspace_name) : "";
const buildId = rec ? asString(rec.build_id) : undefined;
const resultJson = rec ? JSON.stringify(rec, null, 2) : "";
const hasErrorInResult = Boolean(rec?.error);
const created = rec?.created !== false;
return (
<CreateWorkspaceTool
workspaceName={wsName}
resultJson={resultJson}
status={status}
isError={isError}
isError={isError || hasErrorInResult}
errorMessage={rec ? asString(rec.error || rec.reason) : undefined}
buildId={buildId}
created={created}
/>
);
};
@@ -704,6 +710,29 @@ const ProcessSignalRenderer: FC<ToolRendererProps> = (props) => {
);
};
const StartWorkspaceRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const wsName = rec ? asString(rec.workspace_name) : "";
const buildId = rec ? asString(rec.build_id) : undefined;
const hasErrorInResult = Boolean(rec?.error);
const noBuild = Boolean(rec?.no_build);
return (
<StartWorkspaceTool
status={status}
buildId={buildId}
workspaceName={wsName}
isError={isError || hasErrorInResult}
errorMessage={rec ? asString(rec.error || rec.reason) : undefined}
noBuild={noBuild}
/>
);
};
// ---------------------------------------------------------------------------
// Renderer lookup map — maps tool names to their specialized renderers.
// ---------------------------------------------------------------------------
@@ -717,6 +746,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
write_file: WriteFileRenderer,
edit_files: EditFilesRenderer,
create_workspace: CreateWorkspaceRenderer,
start_workspace: StartWorkspaceRenderer,
list_templates: ListTemplatesRenderer,
read_template: ReadTemplateRenderer,
read_skill: ReadSkillRenderer,
@@ -0,0 +1,171 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import type { ProvisionerJobLog } from "#/api/typesGenerated";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection";
const TEST_BUILD_ID = "test-build-id-000";
const TEST_WORKSPACE_ID = "test-workspace-id-000";
const sampleLogs: ProvisionerJobLog[] = [
{
id: 1,
created_at: "2024-01-01T00:00:00Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Initializing Terraform...",
},
{
id: 2,
created_at: "2024-01-01T00:00:01Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Terraform has been successfully initialized!",
},
{
id: 3,
created_at: "2024-01-01T00:00:02Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Apply complete! Resources: 2 added, 0 changed, 0 destroyed.",
},
];
const meta: Meta<typeof WorkspaceBuildLogSection> = {
title: "pages/AgentsPage/ChatElements/tools/WorkspaceBuildLogSection",
component: WorkspaceBuildLogSection,
decorators: [
(Story) => (
<ChatWorkspaceContext value={{ workspaceId: TEST_WORKSPACE_ID }}>
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
</ChatWorkspaceContext>
),
],
};
export default meta;
type Story = StoryObj<typeof WorkspaceBuildLogSection>;
/** Build ID is present but the REST fetch has not resolved yet. */
export const Loading: Story = {
args: {
status: "completed",
buildId: TEST_BUILD_ID,
},
beforeEach: () => {
spyOn(API, "getWorkspaceBuildLogs").mockImplementation(
() => new Promise(() => {}),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Loading build logs\u2026")).toBeInTheDocument();
},
};
/** Completed build with logs fetched from the REST endpoint. */
export const CompletedWithLogs: Story = {
args: {
status: "completed",
buildId: TEST_BUILD_ID,
},
parameters: {
queries: [
{
key: ["workspaceBuilds", TEST_BUILD_ID, "logs"],
data: sampleLogs,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Starting workspace")).toBeInTheDocument();
});
},
};
/** REST fetch for build logs returned a server error. */
export const FetchError: Story = {
args: {
status: "completed",
buildId: TEST_BUILD_ID,
},
beforeEach: () => {
spyOn(API, "getWorkspaceBuildLogs").mockRejectedValue(
new Error("Internal Server Error"),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(
canvas.getByText("Failed to load build logs."),
).toBeInTheDocument();
});
},
};
/**
* Build completed with zero log output. The REST query succeeds but
* returns an empty array, so the component shows "No build logs
* available." instead of a perpetual spinner.
*/
export const CompletedEmptyLogs: Story = {
args: {
status: "completed",
buildId: TEST_BUILD_ID,
},
parameters: {
queries: [
{
key: ["workspaceBuilds", TEST_BUILD_ID, "logs"],
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("No build logs available.")).toBeInTheDocument();
});
},
};
/**
* Tool is running with an active build in progress. The workspace
* query returns a latest_build with status="starting", so the
* component derives an activeBuildId and shows the loading state
* while waiting for the WebSocket stream.
*/
export const Running: Story = {
args: {
status: "running",
},
parameters: {
queries: [
{
key: ["workspace", TEST_WORKSPACE_ID],
data: {
id: TEST_WORKSPACE_ID,
latest_build: {
id: TEST_BUILD_ID,
status: "starting",
},
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Loading build logs\u2026")).toBeInTheDocument();
});
},
};
@@ -0,0 +1,172 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { workspaceBuildLogs } from "#/api/queries/workspaceBuilds";
import { workspaceById } from "#/api/queries/workspaces";
import type { ProvisionerJobLog } from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { useWorkspaceBuildLogs } from "#/hooks/useWorkspaceBuildLogs";
import { WorkspaceBuildLogs } from "#/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import {
useChatBuildId,
useChatWorkspaceId,
} from "../../../context/ChatWorkspaceContext";
import type { ToolStatus } from "./utils";
interface WorkspaceBuildLogSectionProps {
status: ToolStatus;
/** Build ID from the completed tool result. */
buildId?: string;
}
// How long to wait for the first log entry before showing a
// warning. Builds can stay queued or run slow Terraform init for
// longer than this. The message is intentionally soft.
const LOG_LOAD_TIMEOUT_MS = 30_000;
/**
* Streams or fetches workspace build logs for display inside a tool
* collapsible. While the tool is running, logs stream via WebSocket
* from the build tracked by the chat binding (or the workspace's
* latest active build as a fallback). Once completed, logs
* are fetched via REST and cached by React Query so expand/collapse
* cycles don't re-fetch.
*/
export const WorkspaceBuildLogSection: FC<WorkspaceBuildLogSectionProps> = ({
status,
buildId,
}) => {
const isRunning = status === "running";
// Primary source: build ID from the chat binding, pushed via
// pubsub when create_workspace or start_workspace persists it.
// This avoids the 2s polling latency.
const chatBuildId = useChatBuildId();
// Fallback: poll the workspace to infer the build ID from
// latest_build. Only used when the binding hasn't arrived yet.
const workspaceId = useChatWorkspaceId();
const needsPoll = isRunning && !chatBuildId;
const workspaceQuery = useQuery({
...workspaceById(workspaceId ?? ""),
enabled: needsPoll && Boolean(workspaceId),
refetchInterval: needsPoll ? 2000 : false,
});
const liveBuildId = workspaceQuery.data?.latest_build?.id;
// Only use the polled build if it's actually in progress.
const latestBuildStatus = workspaceQuery.data?.latest_build?.status;
const polledActiveBuildId =
latestBuildStatus === "pending" || latestBuildStatus === "starting"
? liveBuildId
: undefined;
// While running: prefer chat binding (instant via pubsub),
// fall back to polled workspace (2s latency). When completed:
// use the build ID from the tool result.
const effectiveBuildId = isRunning
? (chatBuildId ?? polledActiveBuildId)
: buildId;
// --- Running builds: stream via WebSocket ---
const streamingLogs = useWorkspaceBuildLogs(
isRunning ? effectiveBuildId : undefined,
isRunning && Boolean(effectiveBuildId),
);
// --- Completed builds: fetch via REST (cached across mounts) ---
const completedLogsQuery = useQuery({
...workspaceBuildLogs(effectiveBuildId ?? ""),
enabled: !isRunning && Boolean(effectiveBuildId),
});
const logs: ProvisionerJobLog[] | undefined = isRunning
? streamingLogs
: // Fall back to accumulated streaming logs while the REST
// fetch is in-flight, avoiding a flash of "Loading…" on
// the running→completed transition.
(completedLogsQuery.data ?? streamingLogs);
// --- Timeout: detect if logs never arrive ---
// Derive a stable boolean so the effect only re-runs when logs
// first appear or when the build ID changes, not on every
// appended log entry.
const hasLogs = Boolean(logs && logs.length > 0);
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
setTimedOut(false);
if (!effectiveBuildId || hasLogs) {
return;
}
const timer = setTimeout(() => setTimedOut(true), LOG_LOAD_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [effectiveBuildId, hasLogs]);
const fetchFailed = !isRunning && completedLogsQuery.isError;
if (!effectiveBuildId) {
if (isRunning && workspaceId) {
return (
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
<LoaderIcon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
<span>Loading build logs…</span>
</div>
);
}
return null;
}
if (fetchFailed) {
return (
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
<TriangleAlertIcon className="h-3 w-3" />
<span>Failed to load build logs.</span>
</div>
);
}
if (timedOut && !hasLogs) {
return (
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
<TriangleAlertIcon className="h-3 w-3" />
<span>Build logs are taking longer than expected.</span>
</div>
);
}
// Query succeeded but the build produced no log output.
if (
!isRunning &&
completedLogsQuery.isSuccess &&
(!logs || logs.length === 0)
) {
return (
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
<span>No build logs available.</span>
</div>
);
}
if (!logs || logs.length === 0) {
return (
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
<LoaderIcon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
<span>Loading build logs…</span>
</div>
);
}
return (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<WorkspaceBuildLogs
logs={logs}
sticky
className="border-0 rounded-none"
/>
</ScrollArea>
);
};
@@ -0,0 +1,25 @@
import { createContext, useContext } from "react";
interface ChatWorkspaceContextValue {
workspaceId?: string;
buildId?: string;
}
const ChatWorkspaceContext = createContext<ChatWorkspaceContextValue>({});
/**
* Returns the workspace ID associated with the current chat, if any.
* Use this in tool renderers that need workspace data during execution.
*/
export const useChatWorkspaceId = () =>
useContext(ChatWorkspaceContext).workspaceId;
/**
* Returns the build ID from the chat binding, if any.
* This is set when create_workspace or start_workspace persists
* the build ID via UpdateChatWorkspaceBinding, and arrives on the
* frontend through the chat watch event without polling.
*/
export const useChatBuildId = () => useContext(ChatWorkspaceContext).buildId;
export { ChatWorkspaceContext };