mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show workspace badge in agent chat top bar (#23964)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user