mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user