refactor: improve app status and statuses (#18121)

#### 1. Gray out status icons when the workspace is not running. 

**Before:**
<img width="1624" alt="Screenshot 2025-05-29 at 21 33 45"
src="https://github.com/user-attachments/assets/7916e707-e5ae-4226-8234-39c42f0ec8c4"
/>

**After:**
<img width="1624" alt="Screenshot 2025-05-29 at 21 35 07"
src="https://github.com/user-attachments/assets/52fd8553-414d-4c49-a44e-7a530f0d522d"
/>

#### 2. Truncate long messages

**Before**
<img width="1213" alt="Screenshot 2025-05-29 at 21 28 50"
src="https://github.com/user-attachments/assets/b76b7a4d-7ef0-41a0-822c-c32c98f997fc"
/>

**After**
<img width="1206" alt="Screenshot 2025-05-29 at 21 25 42"
src="https://github.com/user-attachments/assets/fb3f3916-a4a4-4697-b7d1-0b4873b6e528"
/>

#### 3. Disable "show more" button if there is one single status
This commit is contained in:
Bruno Quaresma
2025-05-29 22:39:18 -03:00
committed by GitHub
parent e5c254888a
commit a19539ce83
7 changed files with 182 additions and 179 deletions
+4 -4
View File
@@ -25,22 +25,22 @@ export const AppStatusIcon: FC<AppStatusIconProps> = ({
switch (status.state) {
case "complete":
return (
<CircleCheckIcon className={cn([className, "text-content-success"])} />
<CircleCheckIcon className={cn(["text-content-success", className])} />
);
case "failure":
return (
<CircleAlertIcon className={cn([className, "text-content-warning"])} />
<CircleAlertIcon className={cn(["text-content-warning", className])} />
);
case "working":
return latest ? (
<Spinner size="sm" className="shrink-0" loading />
) : (
<HourglassIcon className={cn([className, "text-highlight-sky"])} />
<HourglassIcon className={cn(["text-highlight-sky", className])} />
);
default:
return (
<TriangleAlertIcon
className={cn([className, "text-content-secondary"])}
className={cn(["text-content-secondary", className])}
/>
);
}
@@ -48,3 +48,10 @@ export const LongMessage: Story = {
},
},
};
export const Disabled: Story = {
args: {
status: MockWorkspaceAppStatus,
disabled: true,
},
};
@@ -1,30 +1,22 @@
import type {
WorkspaceAppStatus as APIWorkspaceAppStatus,
WorkspaceAppStatusState,
} from "api/typesGenerated";
import { Spinner } from "components/Spinner/Spinner";
import type { WorkspaceAppStatus as APIWorkspaceAppStatus } from "api/typesGenerated";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
import { cn } from "utils/cn";
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 />,
type WorkspaceAppStatusProps = {
status: APIWorkspaceAppStatus | null;
disabled?: boolean;
};
export const WorkspaceAppStatus = ({
status,
}: {
status: APIWorkspaceAppStatus | null;
}) => {
disabled,
}: WorkspaceAppStatusProps) => {
if (!status) {
return (
<span className="text-content-disabled text-sm">
@@ -39,7 +31,13 @@ export const WorkspaceAppStatus = ({
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
{iconByState[status.state]}
<AppStatusIcon
status={status}
latest
className={cn({
"text-content-disabled": disabled,
})}
/>
<span className="whitespace-nowrap max-w-72 overflow-hidden text-ellipsis text-sm text-content-primary font-medium">
{status.message}
</span>
@@ -1,9 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { WorkspaceAppStatus } from "api/typesGenerated";
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockWorkspaceAppStatus,
MockWorkspaceAppStatuses,
createTimestamp,
} from "testHelpers/entities";
import { withProxyProvider } from "testHelpers/storybook";
import { AppStatuses } from "./AppStatuses";
@@ -13,6 +16,8 @@ const meta: Meta<typeof AppStatuses> = {
component: AppStatuses,
args: {
referenceDate: new Date("2024-03-26T15:15:00Z"),
agent: mockAgent(MockWorkspaceAppStatuses),
workspace: MockWorkspace,
},
decorators: [withProxyProvider()],
};
@@ -21,163 +26,70 @@ export default meta;
type Story = StoryObj<typeof AppStatuses>;
export const Default: Story = {
args: {
workspace: MockWorkspace,
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
},
};
export const Default: Story = {};
// Add a story with a "Working" status as the latest
export const WorkingState: Story = {
args: {
workspace: MockWorkspace,
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(),
),
},
],
},
agent: mockAgent([
{
// 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,
},
...MockWorkspaceAppStatuses,
]),
},
};
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();
export const LongStatusText: Story = {
args: {
agent: mockAgent([
{
// 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 with a very long message that exceeds the usual length to test how the component handles overflow and truncation in the UI. This should be long enough to ensure it wraps correctly and doesn't break the layout.",
created_at: createTimestamp(5, 15), // 15:05:15 (after referenceDate)
uri: "",
state: "complete" as const,
},
...MockWorkspaceAppStatuses,
]),
},
};
export const SingleStatus: Story = {
args: {
agent: mockAgent([
{
...MockWorkspaceAppStatus,
id: "status-1",
icon: "",
message: "Initial setup complete.",
created_at: createTimestamp(5, 10), // 15:05:10 (after referenceDate)
uri: "",
state: "complete" as const,
},
]),
},
};
function mockAgent(statuses: WorkspaceAppStatus[]) {
return {
...MockWorkspaceAgent,
apps: [
{
...MockWorkspaceApp,
statuses,
},
],
};
}
+8 -5
View File
@@ -103,15 +103,17 @@ export const AppStatuses: FC<AppStatusesProps> = ({
<div className="flex flex-col border border-solid border-border rounded-lg">
<div
className={`
flex items-center justify-between px-4 py-3
flex items-center justify-between px-4 py-3 gap-6
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">
<div className="flex flex-col overflow-hidden">
<div className="text-sm font-medium text-content-primary flex items-center gap-2 ">
<AppStatusIcon status={latestStatus} latest />
{latestStatus.message}
</span>
<span className="block flex-1 whitespace-nowrap overflow-hidden text-ellipsis">
{latestStatus.message}
</span>
</div>
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
{timeFrom(new Date(latestStatus.created_at), comparisonDate)}
</span>
@@ -154,6 +156,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={otherStatuses.length === 0}
size="icon"
variant="subtle"
onClick={() => {
@@ -297,7 +297,10 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
{hasActivity && (
<TableCell>
<WorkspaceAppStatus status={workspace.latest_app_status} />
<WorkspaceAppStatus
status={workspace.latest_app_status}
disabled={workspace.latest_build.status !== "running"}
/>
</TableCell>
)}
+80
View File
@@ -4392,3 +4392,83 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = {
},
devcontainer_dirty: false,
};
export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [
{
// 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,
},
];
export 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();
}