refactor: refactor activity column in the workspaces table (#17976)

The goal is to better integrate the activity column data with the
existent data:
- Make the message one line, the full message is in the tooltip, and
display the state at the bottom. This way, it is visually consistent
with the other columns like status, name and template.
- Moved the app, and uri, to the actions column, instead of showing them
together with the message in the activity column.

**Previous:**
<img width="1512" alt="Screenshot 2025-05-21 at 17 28 46"
src="https://github.com/user-attachments/assets/ea9188a5-d82e-416c-b961-edf0104f66c6"
/>

**After:**
<img width="1512" alt="Screenshot 2025-05-21 at 17 28 57"
src="https://github.com/user-attachments/assets/f50dbe82-cd3e-4448-9fa2-bde9193166d6"
/>
This commit is contained in:
Bruno Quaresma
2025-05-22 20:03:13 -04:00
committed by GitHub
parent c777740801
commit b4daf36d0b
3 changed files with 106 additions and 340 deletions
@@ -2,9 +2,6 @@ import type { Meta, StoryObj } from "@storybook/react";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import {
MockProxyLatencies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockWorkspaceAppStatus,
} from "testHelpers/entities";
import { WorkspaceAppStatus } from "./WorkspaceAppStatus";
@@ -68,24 +65,6 @@ export const Working: Story = {
},
};
export const LongURI: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
uri: "https://www.google.com/search?q=hello+world+plus+a+lot+of+other+words",
},
},
};
export const FileURI: Story = {
args: {
status: {
...MockWorkspaceAppStatus,
uri: "file:///Users/jason/Desktop/test.txt",
},
},
};
export const LongMessage: Story = {
args: {
status: {
@@ -95,14 +74,3 @@ export const LongMessage: Story = {
},
},
};
export const WithApp: Story = {
args: {
status: MockWorkspaceAppStatus,
app: {
...MockWorkspaceApp,
},
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
},
};
@@ -1,305 +1,54 @@
import type { Theme } from "@emotion/react";
import { useTheme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import type {
WorkspaceAppStatus as APIWorkspaceAppStatus,
Workspace,
WorkspaceAgent,
WorkspaceApp,
WorkspaceAppStatusState,
} from "api/typesGenerated";
import { Spinner } from "components/Spinner/Spinner";
import {
CircleAlertIcon,
CircleCheckIcon,
ExternalLinkIcon,
FileIcon,
LayoutGridIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { FC } from "react";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import type { ReactNode } from "react";
const formatURI = (uri: string) => {
try {
const url = new URL(uri);
return url.hostname + url.pathname;
} catch {
return uri;
}
};
const getStatusColor = (
theme: Theme,
state: APIWorkspaceAppStatus["state"],
) => {
switch (state) {
case "complete":
return theme.palette.success.main;
case "failure":
return theme.palette.error.main;
case "working":
return theme.palette.primary.main;
default:
// Assuming unknown state maps to warning/secondary visually
return theme.palette.text.secondary;
}
};
const getStatusIcon = (theme: Theme, state: APIWorkspaceAppStatus["state"]) => {
const color = getStatusColor(theme, state);
switch (state) {
case "complete":
return <CircleCheckIcon className="size-icon-xs" style={{ color }} />;
case "failure":
return <CircleAlertIcon className="size-icon-xs" style={{ color }} />;
case "working":
return <CircularProgress size={16} sx={{ color }} />;
default:
return <TriangleAlertIcon className="size-icon-xs" style={{ color }} />;
}
const iconByState: Record<WorkspaceAppStatusState, ReactNode> = {
complete: (
<CircleCheckIcon className="size-4 shrink-0 text-content-success" />
),
failure: <CircleAlertIcon className="size-4 shrink-0 text-content-warning" />,
working: <Spinner size="sm" className="shrink-0" loading />,
};
export const WorkspaceAppStatus = ({
workspace,
status,
agent,
app,
}: {
workspace: Workspace;
status?: APIWorkspaceAppStatus | null;
app?: WorkspaceApp;
agent?: WorkspaceAgent;
status: APIWorkspaceAppStatus | null;
}) => {
const theme = useTheme();
const commonStyles = useCommonStyles();
if (!status) {
return (
<div
css={{
display: "flex",
alignItems: "center",
gap: 12,
minWidth: 0,
paddingRight: 16,
}}
>
<div
css={{
fontSize: "14px",
color: theme.palette.text.disabled,
flexShrink: 1,
minWidth: 0,
}}
>
―
</div>
</div>
<span className="text-content-disabled text-sm">
-<span className="sr-only">No activity</span>
</span>
);
}
const isFileURI = status.uri?.startsWith("file://");
return (
<div
css={{
display: "flex",
alignItems: "flex-start",
gap: 8,
minWidth: 0,
paddingRight: 16,
}}
>
<div
css={{
display: "flex",
alignItems: "center",
flexShrink: 0,
marginTop: 2,
}}
>
{getStatusIcon(theme, status.state)}
</div>
<div
css={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
flex: 1,
}}
>
<div
css={{
fontSize: "14px",
lineHeight: "20px",
color: "text.primary",
margin: 0,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{status.message}
</div>
<div
css={{
display: "flex",
alignItems: "center",
}}
>
{app && agent && (
<AppLink app={app} workspace={workspace} agent={agent} />
)}
{status.uri && (
<div
css={{
display: "flex",
minWidth: 0,
}}
>
{isFileURI ? (
<div
css={{
...commonStyles,
}}
>
<FileIcon
className="size-icon-xs"
css={{
opacity: 0.5,
marginRight: "0.25rem",
}}
/>
<span>{formatURI(status.uri)}</span>
</div>
) : (
<a
href={status.uri}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
},
}}
>
<ExternalLinkIcon
className="size-icon-xs"
css={{
opacity: 0.7,
flexShrink: 0,
marginRight: 2,
}}
/>
<span
css={{
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatURI(status.uri)}
</span>
</a>
)}
<div className="flex flex-col">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
{iconByState[status.state]}
<span className="whitespace-nowrap max-w-72 overflow-hidden text-ellipsis text-sm text-content-primary font-medium">
{status.message}
</span>
</div>
)}
</div>
</div>
</TooltipTrigger>
<TooltipContent>{status.message}</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="first-letter:uppercase block pl-6">{status.state}</span>
</div>
);
};
type AppLinkProps = {
app: WorkspaceApp;
workspace: Workspace;
agent: WorkspaceAgent;
};
const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const theme = useTheme();
const commonStyles = useCommonStyles();
const link = useAppLink(app, { agent, workspace });
return (
<a
href={link.href}
onClick={link.onClick}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
marginRight: 8,
position: "relative",
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
"& img": {
opacity: 1,
},
},
}}
>
{app.icon ? (
<img
src={app.icon}
alt={`${app.display_name} icon`}
width={14}
height={14}
css={{
borderRadius: "3px",
opacity: 0.8,
marginRight: 4,
}}
/>
) : (
<LayoutGridIcon
className="size-icon-xs"
css={{
opacity: 0.7,
}}
/>
)}
<span>{app.display_name}</span>
</a>
);
};
const useCommonStyles = () => {
const theme = useTheme();
return {
fontSize: "12px",
lineHeight: "15px",
color: theme.palette.text.disabled,
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 6px",
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",
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.secondary,
},
};
};
@@ -43,7 +43,7 @@ import {
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { StarIcon } from "lucide-react";
import { ExternalLinkIcon, FileIcon, StarIcon } from "lucide-react";
import { EllipsisVertical } from "lucide-react";
import {
BanIcon,
@@ -138,16 +138,22 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
) || {}
);
}, [workspaces]);
const hasAppStatus = useMemo(
const hasActivity = useMemo(
() => Object.keys(workspaceIDToAppByStatus).length > 0,
[workspaceIDToAppByStatus],
);
const tableColumnSize = {
name: "w-2/6",
template: hasActivity ? "w-1/6" : "w-2/6",
status: hasActivity ? "w-1/6" : "w-2/6",
activity: "w-2/6",
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className={hasAppStatus ? "w-1/6" : "w-2/6"}>
<TableHead className={tableColumnSize.name}>
<div className="flex items-center gap-2">
{canCheckWorkspaces && (
<Checkbox
@@ -171,10 +177,14 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
Name
</div>
</TableHead>
{hasAppStatus && <TableHead className="w-2/6">Activity</TableHead>}
<TableHead className="w-2/6">Template</TableHead>
<TableHead className="w-2/6">Status</TableHead>
<TableHead className="w-0" />
<TableHead className={tableColumnSize.template}>Template</TableHead>
<TableHead className={tableColumnSize.status}>Status</TableHead>
{hasActivity && (
<TableHead className={tableColumnSize.activity}>Activity</TableHead>
)}
<TableHead className="w-0">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_td]:h-[72px]">
@@ -229,7 +239,9 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
<AvatarData
title={
<Stack direction="row" spacing={0.5} alignItems="center">
{workspace.name}
<span className="whitespace-nowrap">
{workspace.name}
</span>
{workspace.favorite && (
<StarIcon className="size-icon-xs" />
)}
@@ -255,20 +267,13 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
</div>
</TableCell>
{hasAppStatus && (
<TableCell>
<WorkspaceAppStatus
workspace={workspace}
agent={workspaceIDToAppByStatus[workspace.id]?.agent}
app={workspaceIDToAppByStatus[workspace.id]?.app}
status={workspace.latest_app_status}
/>
</TableCell>
)}
<TableCell>
<AvatarData
title={getDisplayWorkspaceTemplateName(workspace)}
title={
<span className="whitespace-nowrap block max-w-52 text-ellipsis overflow-hidden">
{getDisplayWorkspaceTemplateName(workspace)}
</span>
}
subtitle={
dashboard.showOrganizations && (
<>
@@ -290,6 +295,12 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
<WorkspaceStatusCell workspace={workspace} />
{hasActivity && (
<TableCell>
<WorkspaceAppStatus status={workspace.latest_app_status} />
</TableCell>
)}
<WorkspaceActionsCell
workspace={workspace}
onActionSuccess={onActionSuccess}
@@ -399,7 +410,7 @@ const WorkspaceStatusCell: FC<WorkspaceStatusCellProps> = ({ workspace }) => {
<WorkspaceDormantBadge workspace={workspace} />
)}
</WorkspaceStatusIndicator>
<span className="text-xs font-medium text-content-secondary ml-6">
<span className="text-xs font-medium text-content-secondary ml-6 whitespace-nowrap">
{lastUsedMessage(workspace.last_used_at)}
</span>
</div>
@@ -504,9 +515,12 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
}}
>
<div className="flex gap-1 justify-end">
{workspace.latest_build.status === "running" && (
<WorkspaceApps workspace={workspace} />
)}
{workspace.latest_build.status === "running" &&
(workspace.latest_app_status ? (
<WorkspaceAppStatusLinks workspace={workspace} />
) : (
<WorkspaceApps workspace={workspace} />
))}
{abilities.actions.includes("start") && (
<PrimaryAction
@@ -701,6 +715,38 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
return buttons;
};
type WorkspaceAppStatusLinksProps = {
workspace: Workspace;
};
const WorkspaceAppStatusLinks: FC<WorkspaceAppStatusLinksProps> = ({
workspace,
}) => {
const status = workspace.latest_app_status;
const agent = workspace.latest_build.resources
.flatMap((r) => r.agents)
.find((a) => a?.id === status?.agent_id);
const app = agent?.apps.find((a) => a.id === status?.app_id);
return (
<>
{agent && app && (
<IconAppLink app={app} workspace={workspace} agent={agent} />
)}
{status?.uri && status?.uri !== "n/a" && (
<BaseIconLink label={status.uri} href={status.uri} target="_blank">
{status.uri.startsWith("file://") ? (
<FileIcon />
) : (
<ExternalLinkIcon />
)}
</BaseIconLink>
)}
</>
);
};
type IconAppLinkProps = {
app: WorkspaceApp;
workspace: Workspace;
@@ -730,6 +776,7 @@ type BaseIconLinkProps = PropsWithChildren<{
href: string;
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
target?: string;
}>;
const BaseIconLink: FC<BaseIconLinkProps> = ({
@@ -737,6 +784,7 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
isLoading,
label,
children,
target,
onClick,
}) => {
return (
@@ -745,6 +793,7 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
<TooltipTrigger asChild>
<Button variant="outline" size="icon-lg" asChild>
<a
target={target}
className={isLoading ? "animate-pulse" : ""}
href={href}
onClick={(e) => {