From 6f0defb2c97e40fad25015a1a4d30424cd18e362 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 22 May 2025 13:05:51 -0300 Subject: [PATCH] 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:** Screenshot 2025-05-21 at 19 09 41 **After:** https://github.com/user-attachments/assets/3e88eb63-e082-4e5c-a6a3-79a6fe3d46b6 --- site/src/modules/resources/AgentRow.tsx | 8 + .../WorkspacePage/AppStatuses.stories.tsx | 299 ++++++------ site/src/pages/WorkspacePage/AppStatuses.tsx | 444 ++++++------------ .../pages/WorkspacePage/Workspace.stories.tsx | 4 + site/src/pages/WorkspacePage/Workspace.tsx | 190 ++------ 5 files changed, 354 insertions(+), 591 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 4e53c2cf2b..f97c91e89a 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -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 = ({
+ {workspace.latest_app_status?.agent_id === agent.id && ( +
+

App statuses

+ +
+ )} + {agent.status === "connected" && (
{shouldDisplayApps && ( diff --git a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx index 86e6f345b5..a339c09e18 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.stories.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.stories.tsx @@ -12,6 +12,9 @@ import { AppStatuses } from "./AppStatuses"; const meta: Meta = { 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; -// 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(); +} diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 1b86335087..5eaffe8e02 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -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 ; + return ; case "failure": - return ; + return ; case "working": - // Use Hourglass for past "working" states, spinner for the current one return isLatest ? ( - + ) : ( - + ); default: - return ; + return ; } }; -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; + 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 = ({ - 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 ( -
- {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 }); +
+
+
+ + {getStatusIcon(latestStatus.state, true)} + {latestStatus.message} + + + {formatDistance(new Date(latestStatus.created_at), comparisonDate, { + addSuffix: true, + })} + +
- // Get the associated app for this status - const currentApp = status.app; - const agent = agents.find((agent) => agent.id === status.agent_id); +
+ {latestStatus.app && ( + + )} - // 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://") ? ( + + + + + + {formatURI(latestStatus.uri)} + + + + This file is located in your workspace + + + + ) : ( + + ))} - return ( -
- {/* Icon Column */} + + + + + + + {displayStatuses ? "Hide statuses" : "Show statuses"} + + + +
+
+ + {displayStatuses && + otherStatuses.map((status) => { + const statusTime = new Date(status.created_at); + const formattedTimestamp = formatDistance( + statusTime, + comparisonDate, + { + addSuffix: true, + }, + ); + + return (
- {getStatusIcon(theme, status.state, isLatest) || ( - - )} -
- - {/* Content Column */} -
- {/* Message */} -
- {status.message} -
- - {/* Links Row */} -
- {/* Conditional App Link */} - {currentApp && agent && showAppLink && ( - - )} - - {/* Existing URI Link */} - {status.uri && ( -
- {isFileURI ? ( - -
- - {formatURI(status.uri)} -
-
- ) : ( - - -
- {formatURI(status.uri)} -
- - )} -
- )} -
- - {/* Timestamp */} -
- {formattedTimestamp} +
+ + {getStatusIcon(status.state, false, "size-icon-xs w-[18px]")} + {status.message} + + + {formattedTimestamp} +
-
- ); - })} + ); + })}
); }; @@ -360,62 +256,18 @@ type AppLinkProps = { const AppLink: FC = ({ app, agent, workspace }) => { const link = useAppLink(app, { agent, workspace }); - const theme = useTheme(); return ( - - + - {app.icon ? ( - {`${link.label} - ) : ( - - )} - {/* Keep app name short */} - - {link.label} - - - + {app.icon ? : } + {link.label} + + ); }; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index a59e2f78bc..7426158ffb 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -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"), diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 8c874e71be..6016ca8c42 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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 = ({ 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 (
= ({ )} - {/* Container for Agent Rows + Activity Sidebar */} {selectedResource && ( -
- {/* Left Side: Agent Rows */} -
- {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) => ( - - ))} +
+ {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) => ( + + ))} - {(!selectedResource.agents || - selectedResource.agents?.length === 0) && ( -
-
-

- No agents are currently assigned to this resource. -

-
-
- )} -
- - {/* Right Side: Activity Box */} - {hasAppStatus && ( + {(!selectedResource.agents || + selectedResource.agents?.length === 0) && (
- {/* Activity Header */} -
-
- Activity -
-
- { - // Calculate total status count - selectedResource.agents - ?.flatMap((agent) => agent.apps ?? []) - .reduce( - (count, app) => count + (app.statuses?.length ?? 0), - 0, - ) - }{" "} - Total -
-
- -
- agent.apps ?? [], - ) as WorkspaceApp[] - } - workspace={workspace} - agents={selectedResource.agents || []} - /> +
+

+ No agents are currently assigned to this resource. +

)} -
+
)}