mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show build logs in chat for start_workspace and create_workspace tools (#24194)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+171
@@ -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 };
|
||||
Reference in New Issue
Block a user