feat: show workspace badge in agent chat top bar (#23964)

This commit is contained in:
Danielle Maywood
2026-04-02 17:49:56 +01:00
committed by GitHub
parent 796e8e4e18
commit d5ed51a190
5 changed files with 148 additions and 5 deletions
@@ -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}
@@ -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<AgentChatPageViewProps> = ({
// 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<DisplayWorkspaceStatusType, React.ReactNode> = {
success: <MonitorIcon className={iconCls} />,
active: <MonitorDotIcon className={iconCls} />,
inactive: <MonitorPauseIcon className={iconCls} />,
error: <MonitorXIcon className={iconCls} />,
danger: <MonitorXIcon className={iconCls} />,
warning: <MonitorXIcon className={iconCls} />,
};
return {
name: workspace.name,
route: workspaceRoute,
statusIcon: statusIconMap[effectiveType],
statusLabel,
};
})();
const titleElement = (
<title>
{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>
@@ -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",
},
},
};
@@ -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}
@@ -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}
/>
);
};