diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 6485388c5d..881ad34262 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -537,6 +537,7 @@ const AgentChatPage: FC = () => { if ( prev && prev.latest_build.status === next.latest_build.status && + prev.health.healthy === next.health.healthy && prev.name === next.name && prev.owner_name === next.owner_name && prevAgent?.id === nextAgent?.id && @@ -1108,6 +1109,7 @@ const AgentChatPage: FC = () => { persistedError={persistedError} isArchived={isArchived} hasWorkspace={Boolean(workspaceId)} + workspace={workspace} store={store} editing={editing} pendingEditMessageId={pendingEditMessageId} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 523234dea2..5f85836e1f 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -1,4 +1,11 @@ -import { ArchiveIcon } from "lucide-react"; +import { + ArchiveIcon, + MonitorDotIcon, + MonitorIcon, + MonitorPauseIcon, + MonitorXIcon, +} from "lucide-react"; + import { type FC, type RefObject, useRef, useState } from "react"; import { useQueryClient } from "react-query"; import type { UrlTransform } from "streamdown"; @@ -7,6 +14,11 @@ import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; +import { + type DisplayWorkspaceStatusType, + getDisplayWorkspaceStatus, +} from "#/utils/workspace"; + import { AgentChatInput, type ChatMessageInputRef, @@ -249,6 +261,37 @@ export const AgentChatPageView: FC = ({ // Compute local diff stats from git watcher unified diffs. + const workspaceRoute = workspace + ? `/@${workspace.owner_name}/${workspace.name}` + : undefined; + + const attachedWorkspace = (() => { + if (!workspace || !workspaceRoute) return undefined; + const { type, text } = getDisplayWorkspaceStatus( + workspace.latest_build.status, + workspace.latest_build.job, + ); + const effectiveType = workspace.health.healthy ? type : "warning"; + const statusLabel = workspace.health.healthy + ? `Workspace ${text.toLowerCase()}` + : `Workspace ${text.toLowerCase()} (unhealthy)`; + const iconCls = "size-3"; + const statusIconMap: Record = { + success: , + active: , + inactive: , + error: , + danger: , + warning: , + }; + return { + name: workspace.name, + route: workspaceRoute, + statusIcon: statusIconMap[effectiveType], + statusLabel, + }; + })(); + const titleElement = ( {chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")} @@ -378,6 +421,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} lastInjectedContext={lastInjectedContext} + attachedWorkspace={attachedWorkspace} /> </div> </div> diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 98244feed4..70ebde7ddc 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -1,4 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + MonitorDotIcon, + MonitorIcon, + MonitorPauseIcon, + MonitorXIcon, +} from "lucide-react"; import { useEffect, useRef } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; @@ -728,3 +734,49 @@ export const ContextNearLimit: Story = { }, }, }; + +const iconCls = "size-3"; + +export const AttachedWorkspaceRunning: Story = { + args: { + attachedWorkspace: { + name: "my-workspace", + route: "/@admin/my-workspace", + statusIcon: <MonitorIcon className={iconCls} />, + statusLabel: "Workspace running", + }, + }, +}; + +export const AttachedWorkspaceStopped: Story = { + args: { + attachedWorkspace: { + name: "my-workspace", + route: "/@admin/my-workspace", + statusIcon: <MonitorPauseIcon className={iconCls} />, + statusLabel: "Workspace stopped", + }, + }, +}; + +export const AttachedWorkspaceStarting: Story = { + args: { + attachedWorkspace: { + name: "my-workspace", + route: "/@admin/my-workspace", + statusIcon: <MonitorDotIcon className={iconCls} />, + statusLabel: "Workspace starting", + }, + }, +}; + +export const AttachedWorkspaceError: Story = { + args: { + attachedWorkspace: { + name: "my-workspace", + route: "/@admin/my-workspace", + statusIcon: <MonitorXIcon className={iconCls} />, + statusLabel: "Workspace failed", + }, + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 61be8c76d5..681dab3785 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -20,6 +20,7 @@ import { useRef, useState, } from "react"; +import { Link } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; @@ -42,6 +43,11 @@ import { Separator } from "#/components/Separator/Separator"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { Spinner } from "#/components/Spinner/Spinner"; import { Switch } from "#/components/Switch/Switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { countInvisibleCharacters } from "#/utils/invisibleUnicode"; import { isMobileViewport } from "#/utils/mobile"; @@ -141,9 +147,18 @@ interface AgentChatInputProps { selectedMCPServerIds?: readonly string[]; onMCPSelectionChange?: (ids: string[]) => void; onMCPAuthComplete?: (serverId: string) => void; + attachedWorkspace?: AttachedWorkspaceInfo; +} + +export interface AttachedWorkspaceInfo { + name: string; + route: string; + statusIcon: React.ReactNode; + statusLabel: string; } type ToolBadgeData = | { kind: "workspace"; name: string } + | ({ kind: "attached-workspace" } & AttachedWorkspaceInfo) | { kind: "mcp"; server: TypesGen.MCPServerConfig }; const ToolBadge: FC<{ @@ -157,6 +172,28 @@ const ToolBadge: FC<{ className, ); + if (badge.kind === "attached-workspace") { + return ( + <Tooltip> + <TooltipTrigger asChild> + <Link + to={badge.route} + target="_blank" + rel="noreferrer" + className={cn( + badgeCls, + "no-underline transition-colors hover:bg-surface-tertiary hover:text-content-primary", + )} + > + {badge.statusIcon} + {badge.name} + </Link> + </TooltipTrigger> + <TooltipContent>{badge.statusLabel}</TooltipContent> + </Tooltip> + ); + } + if (badge.kind === "workspace") { return ( <span className={badgeCls}> @@ -247,6 +284,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ selectedMCPServerIds, onMCPSelectionChange, onMCPAuthComplete, + attachedWorkspace, }) => { const internalRef = useRef<ChatMessageInputRef>(null); const [previewImage, setPreviewImage] = useState<string | null>(null); @@ -355,6 +393,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ // Ordered list of active tool badge data so we can determine // which ones ended up in the overflow popover. const allBadges: ToolBadgeData[] = []; + if (attachedWorkspace) { + allBadges.push({ kind: "attached-workspace", ...attachedWorkspace }); + } if (selectedWorkspace && onWorkspaceChange) { allBadges.push({ kind: "workspace", name: selectedWorkspace.name }); } @@ -883,7 +924,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ const isOverflow = overflowCount > 0 && i >= visibleCount; return ( <ToolBadge - key={badge.kind === "workspace" ? "ws" : badge.server.id} + key={badge.kind === "mcp" ? badge.server.id : badge.kind} badge={badge} onRemoveWorkspace={handleRemoveWorkspace} onRemoveMcp={handleRemoveMcp} @@ -920,9 +961,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ {overflowBadges.map((badge) => ( <ToolBadge key={ - badge.kind === "workspace" - ? "ws-overflow" - : badge.server.id + badge.kind === "mcp" + ? badge.server.id + : `${badge.kind}-overflow` } badge={badge} onRemoveWorkspace={handleRemoveWorkspace} diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 0a9511cf55..a6d13e69c7 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -7,6 +7,7 @@ import { useFileAttachments } from "../hooks/useFileAttachments"; import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, + type AttachedWorkspaceInfo, type ChatMessageInputRef, type UploadState, } from "./AgentChatInput"; @@ -162,6 +163,7 @@ interface ChatPageInputProps { onMCPSelectionChange?: (ids: string[]) => void; onMCPAuthComplete?: (serverId: string) => void; lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; + attachedWorkspace?: AttachedWorkspaceInfo; } export const ChatPageInput: FC<ChatPageInputProps> = ({ @@ -197,6 +199,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ onMCPSelectionChange, onMCPAuthComplete, lastInjectedContext, + attachedWorkspace, }) => { const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); @@ -357,6 +360,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ selectedMCPServerIds={selectedMCPServerIds} onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} + attachedWorkspace={attachedWorkspace} /> ); };