refactor: refactor activity in workspace page (#17980)

Changing the activity in the workspace page. It is more boring, but more
reliable and extensible. By moving it to the bottom of the agent card,
we have more space to display longer messages and more items. It also
give us some space for interactivity controls in case we want them in
the future.

**Before:**
<img width="1512" alt="Screenshot 2025-05-21 at 19 09 41"
src="https://github.com/user-attachments/assets/c25aa848-b496-4a78-8d19-0b0efeae6115"
/>

**After:**


https://github.com/user-attachments/assets/3e88eb63-e082-4e5c-a6a3-79a6fe3d46b6
This commit is contained in:
Bruno Quaresma
2025-05-22 13:05:51 -03:00
committed by GitHub
parent 4cb35c4c65
commit 6f0defb2c9
5 changed files with 354 additions and 591 deletions
+8
View File
@@ -15,6 +15,7 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import type { Line } from "components/Logs/LogLine";
import { Stack } from "components/Stack/Stack";
import { useProxy } from "contexts/ProxyContext";
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
import {
type FC,
useCallback,
@@ -225,6 +226,13 @@ export const AgentRow: FC<AgentRowProps> = ({
</header>
<div css={styles.content}>
{workspace.latest_app_status?.agent_id === agent.id && (
<section>
<h3 className="sr-only">App statuses</h3>
<AppStatuses workspace={workspace} agent={agent} />
</section>
)}
{agent.status === "connected" && (
<section css={styles.apps}>
{shouldDisplayApps && (
@@ -12,6 +12,9 @@ import { AppStatuses } from "./AppStatuses";
const meta: Meta<typeof AppStatuses> = {
title: "pages/WorkspacePage/AppStatuses",
component: AppStatuses,
args: {
referenceDate: new Date("2024-03-26T15:15:00Z"),
},
// Add decorator for ProxyContext
decorators: [
(Story) => (
@@ -43,106 +46,95 @@ export default meta;
type Story = StoryObj<typeof AppStatuses>;
// Helper function to create timestamps easily
const createTimestamp = (
minuteOffset: number,
secondOffset: number,
): string => {
const baseDate = new Date("2024-03-26T15:00:00Z");
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
return baseDate.toISOString();
};
// Define a fixed reference date for Storybook, slightly after the last status
const storyReferenceDate = new Date("2024-03-26T15:15:00Z"); // 15 minutes after base
export const Default: Story = {
args: {
workspace: MockWorkspace,
agents: [MockWorkspaceAgent],
apps: [
{
...MockWorkspaceApp,
statuses: [
{
// This is the latest status chronologically (15:04:38)
...MockWorkspaceAppStatus,
id: "status-7",
icon: "/emojis/1f4dd.png", // 📝
message: "Creating PR with gh CLI",
created_at: createTimestamp(4, 38), // 15:04:38
uri: "https://github.com/coder/coder/pull/5678",
state: "complete" as const,
},
{
// (15:03:56)
...MockWorkspaceAppStatus,
id: "status-6",
icon: "/emojis/1f680.png", // 🚀
message: "Pushing branch to remote",
created_at: createTimestamp(3, 56), // 15:03:56
uri: "",
state: "complete" as const,
},
{
// (15:02:29)
...MockWorkspaceAppStatus,
id: "status-5",
icon: "/emojis/1f527.png", // 🔧
message: "Configuring git identity",
created_at: createTimestamp(2, 29), // 15:02:29
uri: "",
state: "complete" as const,
},
{
// (15:02:04)
...MockWorkspaceAppStatus,
id: "status-4",
icon: "/emojis/1f4be.png", // 💾
message: "Committing changes",
created_at: createTimestamp(2, 4), // 15:02:04
uri: "",
state: "complete" as const,
},
{
// (15:01:44)
...MockWorkspaceAppStatus,
id: "status-3",
icon: "/emojis/2795.png", // +
message: "Adding files to staging",
created_at: createTimestamp(1, 44), // 15:01:44
uri: "",
state: "complete" as const,
},
{
// (15:01:32)
...MockWorkspaceAppStatus,
id: "status-2",
icon: "/emojis/1f33f.png", // 🌿
message: "Creating a new branch for PR",
created_at: createTimestamp(1, 32), // 15:01:32
uri: "",
state: "complete" as const,
},
{
// (15:01:00) - Oldest
...MockWorkspaceAppStatus,
id: "status-1",
icon: "/emojis/1f680.png", // 🚀
message: "Starting to create a PR",
created_at: createTimestamp(1, 0), // 15:01:00
uri: "",
state: "complete" as const,
},
].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
), // Ensure sorted correctly for component input if needed
},
],
agent: {
...MockWorkspaceAgent,
apps: [
{
...MockWorkspaceApp,
statuses: [
{
// This is the latest status chronologically (15:04:38)
...MockWorkspaceAppStatus,
id: "status-7",
icon: "/emojis/1f4dd.png", // 📝
message: "Creating PR with gh CLI",
created_at: createTimestamp(4, 38), // 15:04:38
uri: "https://github.com/coder/coder/pull/5678",
state: "complete" as const,
},
{
// (15:03:56)
...MockWorkspaceAppStatus,
id: "status-6",
icon: "/emojis/1f680.png", // 🚀
message: "Pushing branch to remote",
created_at: createTimestamp(3, 56), // 15:03:56
uri: "",
state: "complete" as const,
},
{
// (15:02:29)
...MockWorkspaceAppStatus,
id: "status-5",
icon: "/emojis/1f527.png", // 🔧
message: "Configuring git identity",
created_at: createTimestamp(2, 29), // 15:02:29
uri: "",
state: "complete" as const,
},
{
// (15:02:04)
...MockWorkspaceAppStatus,
id: "status-4",
icon: "/emojis/1f4be.png", // 💾
message: "Committing changes",
created_at: createTimestamp(2, 4), // 15:02:04
uri: "",
state: "complete" as const,
},
{
// (15:01:44)
...MockWorkspaceAppStatus,
id: "status-3",
icon: "/emojis/2795.png", // +
message: "Adding files to staging",
created_at: createTimestamp(1, 44), // 15:01:44
uri: "",
state: "complete" as const,
},
{
// (15:01:32)
...MockWorkspaceAppStatus,
id: "status-2",
icon: "/emojis/1f33f.png", // 🌿
message: "Creating a new branch for PR",
created_at: createTimestamp(1, 32), // 15:01:32
uri: "",
state: "complete" as const,
},
{
// (15:01:00) - Oldest
...MockWorkspaceAppStatus,
id: "status-1",
icon: "/emojis/1f680.png", // 🚀
message: "Starting to create a PR",
created_at: createTimestamp(1, 0), // 15:01:00
uri: "",
state: "complete" as const,
},
].sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
), // Ensure sorted correctly for component input if needed
},
],
},
// Pass the reference date to the component for Storybook rendering
referenceDate: storyReferenceDate,
},
};
@@ -150,58 +142,67 @@ export const Default: Story = {
export const WorkingState: Story = {
args: {
workspace: MockWorkspace,
agents: [MockWorkspaceAgent],
apps: [
{
...MockWorkspaceApp,
statuses: [
{
// This is now the latest (15:05:15) and is "working"
...MockWorkspaceAppStatus,
id: "status-8",
icon: "", // Let the component handle the spinner icon
message: "Processing final checks...",
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
uri: "",
state: "working" as const,
},
{
// Previous latest (15:04:38)
...MockWorkspaceAppStatus,
id: "status-7",
icon: "/emojis/1f4dd.png", // 📝
message: "Creating PR with gh CLI",
created_at: createTimestamp(4, 38), // 15:04:38
uri: "https://github.com/coder/coder/pull/5678",
state: "complete" as const,
},
{
// (15:03:56)
...MockWorkspaceAppStatus,
id: "status-6",
icon: "/emojis/1f680.png", // 🚀
message: "Pushing branch to remote",
created_at: createTimestamp(3, 56), // 15:03:56
uri: "",
state: "complete" as const,
},
// ... include other older statuses if desired ...
{
// (15:01:00) - Oldest
...MockWorkspaceAppStatus,
id: "status-1",
icon: "/emojis/1f680.png", // 🚀
message: "Starting to create a PR",
created_at: createTimestamp(1, 0), // 15:01:00
uri: "",
state: "complete" as const,
},
].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
},
],
referenceDate: storyReferenceDate, // Use the same reference date
agent: {
...MockWorkspaceAgent,
apps: [
{
...MockWorkspaceApp,
statuses: [
{
// This is now the latest (15:05:15) and is "working"
...MockWorkspaceAppStatus,
id: "status-8",
icon: "", // Let the component handle the spinner icon
message: "Processing final checks...",
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
uri: "",
state: "working" as const,
},
{
// Previous latest (15:04:38)
...MockWorkspaceAppStatus,
id: "status-7",
icon: "/emojis/1f4dd.png", // 📝
message: "Creating PR with gh CLI",
created_at: createTimestamp(4, 38), // 15:04:38
uri: "https://github.com/coder/coder/pull/5678",
state: "complete" as const,
},
{
// (15:03:56)
...MockWorkspaceAppStatus,
id: "status-6",
icon: "/emojis/1f680.png", // 🚀
message: "Pushing branch to remote",
created_at: createTimestamp(3, 56), // 15:03:56
uri: "",
state: "complete" as const,
},
// ... include other older statuses if desired ...
{
// (15:01:00) - Oldest
...MockWorkspaceAppStatus,
id: "status-1",
icon: "/emojis/1f680.png", // 🚀
message: "Starting to create a PR",
created_at: createTimestamp(1, 0), // 15:01:00
uri: "",
state: "complete" as const,
},
].sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
),
},
],
},
},
};
function createTimestamp(minuteOffset: number, secondOffset: number) {
const baseDate = new Date("2024-03-26T15:00:00Z");
baseDate.setMinutes(baseDate.getMinutes() + minuteOffset);
baseDate.setSeconds(baseDate.getSeconds() + secondOffset);
return baseDate.toISOString();
}
+148 -296
View File
@@ -1,19 +1,24 @@
import type { Theme } from "@emotion/react";
import { useTheme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type {
WorkspaceAppStatus as APIWorkspaceAppStatus,
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { formatDistance } from "date-fns";
import {
ChevronDownIcon,
ChevronUpIcon,
CircleAlertIcon,
CircleCheckIcon,
CircleHelpIcon,
ExternalLinkIcon,
FileIcon,
HourglassIcon,
@@ -21,83 +26,45 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { FC } from "react";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
const getStatusColor = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
) => {
const getStatusColor = (state: APIWorkspaceAppStatus["state"]) => {
switch (state) {
case "complete":
return theme.palette.success.main;
return "text-content-success";
case "failure":
return theme.palette.error.main;
return "text-content-warning";
case "working":
return theme.palette.primary.main;
return "text-highlight-sky";
default:
// Assuming unknown state maps to warning/secondary visually
return theme.palette.text.secondary;
return "text-content-secondary";
}
};
const getStatusIcon = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
isLatest: boolean,
className?: string,
) => {
// Determine color: Use state color if latest, otherwise use disabled text color (grey)
const color = isLatest
? getStatusColor(theme, state)
: theme.palette.text.disabled;
const iconClassName = cn(["size-[18px]", getStatusColor(state), className]);
switch (state) {
case "complete":
return <CircleCheckIcon className="size-icon-sm" style={{ color }} />;
return <CircleCheckIcon className={iconClassName} />;
case "failure":
return <CircleAlertIcon className="size-icon-sm" style={{ color }} />;
return <CircleAlertIcon className={iconClassName} />;
case "working":
// Use Hourglass for past "working" states, spinner for the current one
return isLatest ? (
<CircularProgress size={18} sx={{ color }} />
<Spinner size="sm" loading />
) : (
<HourglassIcon className="size-icon-sm" style={{ color }} />
<HourglassIcon className={iconClassName} />
);
default:
return <TriangleAlertIcon className="size-icon-sm" style={{ color }} />;
return <TriangleAlertIcon className={iconClassName} />;
}
};
const commonStyles = {
fontSize: "12px",
lineHeight: "15px",
color: "text.disabled",
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 0.75,
py: 0.25,
borderRadius: "6px",
bgcolor: "transparent",
minWidth: 0,
maxWidth: "fit-content",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
transition: "all 0.15s ease-in-out",
"&:hover": {
textDecoration: "none",
bgcolor: "action.hover",
color: "text.secondary",
},
"& .MuiSvgIcon-root": {
// Consistent icon styling within links
fontSize: 11,
opacity: 0.7,
mt: "-1px", // Slight vertical alignment adjustment
flexShrink: 0,
},
};
const formatURI = (uri: string) => {
if (uri.startsWith("file://")) {
const path = uri.slice(7);
@@ -134,9 +101,8 @@ const formatURI = (uri: string) => {
// --- Component Implementation ---
export interface AppStatusesProps {
apps: WorkspaceApp[];
workspace: Workspace;
agents: ReadonlyArray<WorkspaceAgent>;
agent: WorkspaceAgent;
/** Optional reference date for calculating relative time. Defaults to Date.now(). Useful for Storybook. */
referenceDate?: Date;
}
@@ -148,206 +114,136 @@ interface StatusWithAppInfo extends APIWorkspaceAppStatus {
}
export const AppStatuses: FC<AppStatusesProps> = ({
apps,
workspace,
agents,
agent,
referenceDate,
}) => {
const theme = useTheme();
// 1. Flatten all statuses and include the parent app object
const allStatuses: StatusWithAppInfo[] = apps.flatMap((app) =>
app.statuses.map((status) => ({
...status,
app: app, // Store the parent app object
})),
const [displayStatuses, setDisplayStatuses] = useState(false);
const allStatuses: StatusWithAppInfo[] = agent.apps.flatMap((app) =>
app.statuses
.map((status) => ({
...status,
app,
}))
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
);
// 2. Sort statuses chronologically (newest first) - mutating the value is
// fine since it's not an outside parameter
allStatuses.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// Determine the reference point for time calculation
const comparisonDate = referenceDate ?? new Date();
if (allStatuses.length === 0) {
return null;
}
const comparisonDate = referenceDate ?? new Date();
const latestStatus = allStatuses[0];
const otherStatuses = allStatuses.slice(1);
return (
<div
css={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}
>
{allStatuses.map((status, index) => {
const isLatest = index === 0;
const isFileURI = status.uri?.startsWith("file://");
const statusTime = new Date(status.created_at);
// Use formatDistance if referenceDate is provided, otherwise formatDistanceToNow
const formattedTimestamp = referenceDate
? formatDistance(statusTime, comparisonDate, { addSuffix: true })
: formatDistanceToNow(statusTime, { addSuffix: true });
<div className="flex flex-col border border-solid border-border rounded-lg">
<div
className={`
flex items-center justify-between px-4 py-3
border-0 [&:not(:last-child)]:border-b border-solid border-border
`}
>
<div className="flex flex-col">
<span className="text-sm font-medium text-content-primary flex items-center gap-2">
{getStatusIcon(latestStatus.state, true)}
{latestStatus.message}
</span>
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
{formatDistance(new Date(latestStatus.created_at), comparisonDate, {
addSuffix: true,
})}
</span>
</div>
// Get the associated app for this status
const currentApp = status.app;
const agent = agents.find((agent) => agent.id === status.agent_id);
<div className="flex items-center gap-2">
{latestStatus.app && (
<AppLink
app={latestStatus.app}
agent={agent}
workspace={workspace}
/>
)}
// Determine if app link should be shown
const showAppLink =
isLatest ||
(index > 0 && status.app_id !== allStatuses[index - 1].app_id);
{latestStatus.uri &&
(latestStatus.uri.startsWith("file://") ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="flex items-center gap-1">
<FileIcon className="size-icon-xs" />
{formatURI(latestStatus.uri)}
</span>
</TooltipTrigger>
<TooltipContent>
This file is located in your workspace
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button asChild variant="outline" size="sm">
<a href={latestStatus.uri} target="_blank" rel="noreferrer">
<ExternalLinkIcon />
{formatURI(latestStatus.uri)}
</a>
</Button>
))}
return (
<div
key={status.id}
css={{
display: "flex",
alignItems: "flex-start", // Align icon with the first line of text
gap: 12,
backgroundColor: theme.palette.background.paper,
borderRadius: 8,
padding: 12,
opacity: isLatest ? 1 : 0.65, // Apply opacity if not the latest
transition: "opacity 0.15s ease-in-out", // Add smooth transition
"&:hover": {
opacity: 1, // Restore opacity on hover for older items
},
}}
>
{/* Icon Column */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={() => {
setDisplayStatuses((display) => !display);
}}
>
{displayStatuses ? <ChevronUpIcon /> : <ChevronDownIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>
{displayStatuses ? "Hide statuses" : "Show statuses"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{displayStatuses &&
otherStatuses.map((status) => {
const statusTime = new Date(status.created_at);
const formattedTimestamp = formatDistance(
statusTime,
comparisonDate,
{
addSuffix: true,
},
);
return (
<div
css={{
flexShrink: 0,
marginTop: 2,
display: "flex",
alignItems: "center",
}}
key={status.id}
className={`
flex items-center justify-between px-4 py-3
border-0 [&:not(:last-child)]:border-b border-solid border-border
`}
>
{getStatusIcon(theme, status.state, isLatest) || (
<CircleHelpIcon
className="size-icon-sm"
css={{ color: theme.palette.text.disabled }}
/>
)}
</div>
{/* Content Column */}
<div
css={{
display: "flex",
flexDirection: "column",
gap: 4,
minWidth: 0,
flex: 1,
}}
>
{/* Message */}
<div
css={{
fontSize: 14,
lineHeight: "20px",
color: theme.palette.text.primary,
fontWeight: 500,
}}
>
{status.message}
</div>
{/* Links Row */}
<div
css={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
marginTop: 4,
minWidth: 0,
}}
>
{/* Conditional App Link */}
{currentApp && agent && showAppLink && (
<AppLink
app={currentApp}
agent={agent}
workspace={workspace}
/>
)}
{/* Existing URI Link */}
{status.uri && (
<div css={{ display: "flex", minWidth: 0, width: "100%" }}>
{isFileURI ? (
<Tooltip title="This file is located in your workspace">
<div
css={{
...commonStyles,
"&:hover": {
bgcolor: "action.hover",
color: "text.secondary",
},
}}
>
<FileIcon
className="size-icon-xs"
style={{ marginRight: "0.5rem" }}
/>
{formatURI(status.uri)}
</div>
</Tooltip>
) : (
<Link
href={status.uri}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
"&:hover": {
...commonStyles["&:hover"],
color: "text.primary", // Keep hover color
},
}}
>
<ExternalLinkIcon
className="size-icon-xs"
style={{ marginRight: "4px" }}
/>
<div
css={{
bgcolor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flexShrink: 1, // Allow text to shrink
}}
>
{formatURI(status.uri)}
</div>
</Link>
)}
</div>
)}
</div>
{/* Timestamp */}
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
marginTop: 2,
}}
>
{formattedTimestamp}
<div className="flex items-center justify-between w-full text-content-secondary">
<span className="text-xs flex items-center gap-2">
{getStatusIcon(status.state, false, "size-icon-xs w-[18px]")}
{status.message}
</span>
<span className="text-2xs text-content-secondary first-letter:uppercase block pl-[26px]">
{formattedTimestamp}
</span>
</div>
</div>
</div>
);
})}
);
})}
</div>
);
};
@@ -360,62 +256,18 @@ type AppLinkProps = {
const AppLink: FC<AppLinkProps> = ({ app, agent, workspace }) => {
const link = useAppLink(app, { agent, workspace });
const theme = useTheme();
return (
<Tooltip title={`Open ${link.label}`} placement="top">
<Link
<Button asChild variant="outline" size="sm">
<a
href={link.href}
onClick={link.onClick}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
position: "relative",
"& .MuiSvgIcon-root": {
fontSize: 14,
opacity: 0.7,
mr: 0.5,
},
"& img": {
opacity: 0.8,
marginRight: 0.5,
},
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary, // Keep consistent hover color
"& img": {
opacity: 1,
},
"& .MuiSvgIcon-root": {
opacity: 1,
},
},
}}
rel="noreferrer"
>
{app.icon ? (
<img
src={app.icon}
alt={`${link.label} icon`}
width={14}
height={14}
style={{ borderRadius: "3px" }}
/>
) : (
<LayoutGridIcon className="size-icon-xs" />
)}
{/* Keep app name short */}
<span
css={{
lineHeight: 1,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{link.label}
</span>
</Link>
</Tooltip>
{app.icon ? <ExternalImage src={app.icon} /> : <LayoutGridIcon />}
{link.label}
</a>
</Button>
);
};
@@ -234,6 +234,10 @@ export const RunningWithAppStatuses: Story = {
available: 1,
},
},
latest_app_status: {
...Mocks.MockWorkspaceAppStatus,
agent_id: Mocks.MockWorkspaceAgent.id,
},
},
handleStart: action("start"),
handleStop: action("stop"),
+44 -146
View File
@@ -4,17 +4,15 @@ import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
import AlertTitle from "@mui/material/AlertTitle";
import type * as TypesGen from "api/typesGenerated";
import type { WorkspaceApp } from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { AgentRow } from "modules/resources/AgentRow";
import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings";
import { type FC, useMemo } from "react";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
import { AppStatuses } from "./AppStatuses";
import { HistorySidebar } from "./HistorySidebar";
import { ResourceMetadata } from "./ResourceMetadata";
import { ResourcesSidebar } from "./ResourcesSidebar";
@@ -109,14 +107,6 @@ export const Workspace: FC<WorkspaceProps> = ({
const shouldShowProvisionerAlert =
workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting;
const hasAppStatus = useMemo(() => {
return selectedResource?.agents?.some((agent) => {
return agent.apps?.some((app) => {
return app.statuses?.length > 0;
});
});
}, [selectedResource]);
return (
<div
css={{
@@ -240,150 +230,58 @@ export const Workspace: FC<WorkspaceProps> = ({
<WorkspaceBuildLogsSection logs={buildLogs} />
)}
{/* Container for Agent Rows + Activity Sidebar */}
{selectedResource && (
<div css={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
{/* Left Side: Agent Rows */}
<section
css={{
display: "flex",
flexDirection: "column",
gap: 24,
flexGrow: 1,
minWidth: 0 /* Prevent overflow */,
}}
>
{selectedResource.agents
// If an agent has a `parent_id`, that means it is
// child of another agent. We do not want these agents
// to be displayed at the top-level on this page. We
// want them to display _as children_ of their parents.
?.filter((agent) => agent.parent_id === null)
.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
template={template}
sshPrefix={sshPrefix}
showApps={permissions.updateWorkspace}
showBuiltinApps={permissions.updateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
<section
css={{
display: "flex",
flexDirection: "column",
gap: 24,
flexGrow: 1,
minWidth: 0 /* Prevent overflow */,
}}
>
{selectedResource.agents
// If an agent has a `parent_id`, that means it is
// child of another agent. We do not want these agents
// to be displayed at the top-level on this page. We
// want them to display _as children_ of their parents.
?.filter((agent) => agent.parent_id === null)
.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
template={template}
sshPrefix={sshPrefix}
showApps={permissions.updateWorkspace}
showBuiltinApps={permissions.updateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
{(!selectedResource.agents ||
selectedResource.agents?.length === 0) && (
<div
css={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
<div>
<h4 css={{ fontSize: 16, fontWeight: 500 }}>
No agents are currently assigned to this resource.
</h4>
</div>
</div>
)}
</section>
{/* Right Side: Activity Box */}
{hasAppStatus && (
{(!selectedResource.agents ||
selectedResource.agents?.length === 0) && (
<div
css={{
// Mimic AgentRow styling but with subtler border
border: `1px solid ${theme.palette.divider}`, // Use divider color
borderRadius: "8px",
boxShadow: theme.shadows[3],
width: 360,
flexShrink: 0,
backgroundColor: theme.palette.background.default, // Add background color
overflow: "hidden",
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
{/* Activity Header */}
<div
css={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: theme.palette.background.paper,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
borderBottom: `1px solid ${theme.palette.divider}`, // Add separator
}}
>
<div
css={{
fontWeight: 500,
fontSize: 14,
}}
>
Activity
</div>
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
}}
>
{
// Calculate total status count
selectedResource.agents
?.flatMap((agent) => agent.apps ?? [])
.reduce(
(count, app) => count + (app.statuses?.length ?? 0),
0,
)
}{" "}
Total
</div>
</div>
<div
css={{
maxHeight: 800,
overflowY: "auto",
// Thin scrollbar styles
"&::-webkit-scrollbar": {
width: "6px",
},
"&::-webkit-scrollbar-track": {
background: theme.palette.background.paper, // Match header background
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.divider, // Use divider color
borderRadius: "3px",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.text.secondary, // Darken on hover
},
}}
>
<AppStatuses
apps={
selectedResource.agents?.flatMap(
(agent) => agent.apps ?? [],
) as WorkspaceApp[]
}
workspace={workspace}
agents={selectedResource.agents || []}
/>
<div>
<h4 css={{ fontSize: 16, fontWeight: 500 }}>
No agents are currently assigned to this resource.
</h4>
</div>
</div>
)}
</div>
</section>
)}
<WorkspaceTimings