refactor: minor task page design adjustments (#18187)

**Before:**
<img width="1624" alt="Screenshot 2025-06-02 at 13 38 45"
src="https://github.com/user-attachments/assets/545400e9-18d6-44a3-9f21-a51ceceedc8e"
/>

**After:**
<img width="1624" alt="Screenshot 2025-06-02 at 13 40 18"
src="https://github.com/user-attachments/assets/1b0b89f0-8cd6-4afd-8800-786e43ae4b52"
/>

- Show output links
- Move workspace app bar to top of screen
- Remove redundant task status indicators
- Update "building your task" to "Building your workspace" to be more
transparent
- Change “Open IDE” language to “Open Locally”

Closes:
- https://github.com/coder/coder/issues/18181
- https://github.com/coder/coder/issues/18162
This commit is contained in:
Bruno Quaresma
2025-06-02 14:52:55 -03:00
committed by GitHub
parent 87c4948c1d
commit 45d46c8c0a
6 changed files with 261 additions and 157 deletions
@@ -1,6 +1,7 @@
import type { WorkspaceAppStatus } from "api/typesGenerated";
import type { WorkspaceAppStatusState } from "api/typesGenerated";
import { Spinner } from "components/Spinner/Spinner";
import {
BanIcon,
CircleAlertIcon,
CircleCheckIcon,
HourglassIcon,
@@ -9,20 +10,22 @@ import {
import type { FC } from "react";
import { cn } from "utils/cn";
type AppStatusIconProps = {
status: WorkspaceAppStatus;
type AppStatusStateIconProps = {
state: WorkspaceAppStatusState;
latest: boolean;
disabled?: boolean;
className?: string;
};
export const AppStatusIcon: FC<AppStatusIconProps> = ({
status,
export const AppStatusStateIcon: FC<AppStatusStateIconProps> = ({
state,
disabled,
latest,
className: customClassName,
}) => {
const className = cn(["size-4 shrink-0", customClassName]);
switch (status.state) {
switch (state) {
case "complete":
return (
<CircleCheckIcon className={cn(["text-content-success", className])} />
@@ -32,10 +35,12 @@ export const AppStatusIcon: FC<AppStatusIconProps> = ({
<CircleAlertIcon className={cn(["text-content-warning", className])} />
);
case "working":
return latest ? (
return disabled ? (
<BanIcon className={cn(["text-content-disabled", className])} />
) : latest ? (
<Spinner size="sm" className="shrink-0" loading />
) : (
<HourglassIcon className={cn(["text-highlight-sky", className])} />
<HourglassIcon className={cn(["text-content-secondary", className])} />
);
default:
return (
@@ -5,7 +5,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
import { cn } from "utils/cn";
type WorkspaceAppStatusProps = {
@@ -31,9 +31,10 @@ export const WorkspaceAppStatus = ({
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<AppStatusIcon
status={status}
<AppStatusStateIcon
latest
disabled={disabled}
state={status.state}
className={cn({
"text-content-disabled": disabled,
})}
+201 -104
View File
@@ -1,3 +1,4 @@
import GitHub from "@mui/icons-material/GitHub";
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type { WorkspaceApp, WorkspaceStatus } from "api/typesGenerated";
@@ -21,14 +22,17 @@ import {
} from "components/Tooltip/Tooltip";
import {
ArrowLeftIcon,
BugIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ExternalLinkIcon,
GitPullRequestArrowIcon,
LayoutGridIcon,
RotateCcwIcon,
} from "lucide-react";
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
import { useAppLink } from "modules/apps/useAppLink";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import type React from "react";
import { type FC, type ReactNode, useState } from "react";
import { Helmet } from "react-helmet-async";
@@ -38,6 +42,7 @@ import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { timeFrom } from "utils/time";
import { truncateURI } from "utils/uri";
const TaskPage = () => {
const { workspace: workspaceName, username } = useParams() as {
@@ -115,10 +120,10 @@ const TaskPage = () => {
<div className="flex flex-col items-center">
<Spinner loading className="mb-4" />
<h3 className="m-0 font-medium text-content-primary text-base">
Building your task
Building the workspace
</h3>
<span className="text-content-secondary text-sm">
Your task is being built and will be ready soon
Your task will run as soon as the workspace is ready
</span>
</div>
</div>
@@ -146,21 +151,21 @@ const TaskPage = () => {
} else if (terminatedStatuses.includes(task.workspace.latest_build.status)) {
content = (
<Margins>
<div className="py-6 flex flex-col gap-3">
{task.workspace.latest_app_status && (
<div className="p-3 border border-border border-solid rounded-lg">
<WorkspaceAppStatus status={task.workspace.latest_app_status} />
</div>
)}
<div className="border border-border border-solid rounded-lg w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build terminated
</h3>
<span className="text-content-secondary text-sm">
So apps and previous statuses are not available
</span>
</div>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Workspace is not running
</h3>
<span className="text-content-secondary text-sm">
Apps and previous statuses are not available
</span>
<Button size="sm" className="mt-4" asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</Button>
</div>
</div>
</Margins>
@@ -180,53 +185,7 @@ const TaskPage = () => {
</div>
);
} else {
const statuses = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.flatMap((a) => a?.statuses)
.filter((s) => !!s)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
content = (
<div className="flex-1 flex justify-stretch overflow-hidden">
<aside className="w-full max-w-xs border-0 border-r border-border border-solid">
<ScrollArea className="h-full py-3">
{statuses.map((status, index) => {
return (
<article
className={cn(["px-4 py-3 flex gap-3"], {
"opacity-75 hover:opacity-100": index !== 0,
})}
key={status.id}
>
<AppStatusIcon
status={status}
latest={index === 0}
className="size-4 mt-1"
/>
<div className="flex flex-col gap-1">
<h3 className="m-0 font-medium text-sm">
{status.message}
</h3>
<time
dateTime={status.created_at}
className="font-medium text-xs text-content-secondary"
>
{timeFrom(new Date(status.created_at))}
</time>
</div>
</article>
);
})}
</ScrollArea>
</aside>
<TaskApps task={task} />
</div>
);
content = <TaskApps task={task} />;
}
return (
@@ -235,50 +194,151 @@ const TaskPage = () => {
<title>{pageTitle(task.prompt)}</title>
</Helmet>
<section className="h-full flex flex-col">
<header className="h-20 border-0 border-b border-solid border-border px-4 flex items-center shrink-0 justify-between">
<div className="flex items-center gap-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-lg" variant="outline" asChild>
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex flex-col">
<h1 className="m-0 text-sm font-medium">{task.prompt}</h1>
<span className="text-xs text-content-secondary">
Created by{" "}
{task.workspace.owner_name ?? task.workspace.owner_name}{" "}
{timeFrom(new Date(task.workspace.created_at))}
</span>
</div>
</div>
<Button variant="outline" asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</Button>
</header>
<div className="h-full flex justify-stretch">
<TaskSidebar task={task} />
{content}
</section>
</div>
</>
);
};
export default TaskPage;
type TaskSidebarProps = {
task: Task;
};
const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
let statuses = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.flatMap((a) => a?.statuses)
.filter((s) => !!s)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// This happens when the workspace is not running so it has no resources to
// get the statuses so we can fallback to the latest status received from the
// workspace.
if (statuses.length === 0 && task.workspace.latest_app_status) {
statuses = [task.workspace.latest_app_status];
}
return (
<aside className="flex flex-col h-full border-0 border-r border-solid border-border w-[320px] shrink-0">
<header className="border-0 border-b border-solid border-border p-4 pt-0">
<div className="flex items-center justify-between py-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="subtle" asChild className="-ml-2">
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="subtle" className="-mr-2">
<EllipsisVerticalIcon />
<span className="sr-only">Settings</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h1 className="m-0 mt-1 text-base font-medium">{task.prompt}</h1>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
</div>
)}
</header>
{statuses ? (
<ScrollArea className="h-full">
{statuses.length === 0 && (
<article className="px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4">
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
Running your task
</h3>
<time
dateTime={task.workspace.latest_build.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(task.workspace.latest_build.created_at))}
</time>
</div>
<AppStatusStateIcon state="working" latest className="size-5" />
</article>
)}
{statuses.map((status, index) => {
return (
<article
className={cn(
["px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4"],
{
"opacity-50 hover:opacity-100": index !== 0,
},
)}
key={status.id}
>
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
{status.message}
</h3>
<time
dateTime={status.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(status.created_at))}
</time>
</div>
<AppStatusStateIcon
state={status.state}
latest={index === 0}
disabled={task.workspace.latest_build.status !== "running"}
className={cn(["size-5", { "opacity-0": index !== 0 }])}
/>
</article>
);
})}
</ScrollArea>
) : (
<Spinner loading />
)}
</aside>
);
};
type TaskAppsProps = {
task: Task;
};
@@ -340,7 +400,7 @@ const TaskApps: FC<TaskAppsProps> = ({ task }) => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="subtle">
Open in IDE
Open locally
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
@@ -465,6 +525,43 @@ const TaskAppIFrame: FC<TaskAppIFrameProps> = ({ task, app, active }) => {
);
};
type TaskStatusLinkProps = {
uri: string;
};
const TaskStatusLink: FC<TaskStatusLinkProps> = ({ uri }) => {
let icon = <ExternalLinkIcon />;
let label = truncateURI(uri);
if (uri.startsWith("https://github.com")) {
const issueNumber = uri.split("/").pop();
const [org, repo] = uri.split("/").slice(3, 5);
const prefix = `${org}/${repo}`;
if (uri.includes("pull/")) {
icon = <GitPullRequestArrowIcon />;
label = issueNumber
? `${prefix}#${issueNumber}`
: `${prefix} Pull Request`;
} else if (uri.includes("issues/")) {
icon = <BugIcon />;
label = issueNumber ? `${prefix}#${issueNumber}` : `${prefix} Issue`;
} else {
icon = <GitHub />;
label = `${org}/${repo}`;
}
}
return (
<Button asChild variant="outline" size="sm" className="min-w-0">
<a href={uri} target="_blank" rel="noreferrer">
{icon}
{label}
</a>
</Button>
);
};
export const data = {
fetchTask: async (workspaceOwnerUsername: string, workspaceName: string) => {
const workspace = await API.getWorkspaceByOwnerAndName(
+4 -1
View File
@@ -363,7 +363,10 @@ const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
/>
</TableCell>
<TableCell>
<WorkspaceAppStatus status={workspace.latest_app_status} />
<WorkspaceAppStatus
disabled={workspace.latest_build.status !== "running"}
status={workspace.latest_app_status}
/>
</TableCell>
<TableCell>
<AvatarData
+7 -41
View File
@@ -21,44 +21,10 @@ import {
FileIcon,
LayoutGridIcon,
} from "lucide-react";
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
import { useAppLink } from "modules/apps/useAppLink";
import { type FC, useState } from "react";
const formatURI = (uri: string) => {
if (uri.startsWith("file://")) {
const path = uri.slice(7);
// Slightly shorter truncation for this context if needed
if (path.length > 35) {
const start = path.slice(0, 15);
const end = path.slice(-15);
return `${start}...${end}`;
}
return path;
}
try {
const url = new URL(uri);
const fullUrl = url.toString();
// Slightly shorter truncation
if (fullUrl.length > 40) {
const start = fullUrl.slice(0, 20);
const end = fullUrl.slice(-20);
return `${start}...${end}`;
}
return fullUrl;
} catch {
// Slightly shorter truncation
if (uri.length > 35) {
const start = uri.slice(0, 15);
const end = uri.slice(-15);
return `${start}...${end}`;
}
return uri;
}
};
// --- Component Implementation ---
import { truncateURI } from "utils/uri";
interface AppStatusesProps {
workspace: Workspace;
@@ -109,7 +75,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
>
<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 />
<AppStatusStateIcon state={latestStatus.state} latest />
<span className="block flex-1 whitespace-nowrap overflow-hidden text-ellipsis">
{latestStatus.message}
</span>
@@ -135,7 +101,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
<TooltipTrigger>
<span className="flex items-center gap-1">
<FileIcon className="size-icon-xs" />
{formatURI(latestStatus.uri)}
{truncateURI(latestStatus.uri)}
</span>
</TooltipTrigger>
<TooltipContent>
@@ -147,7 +113,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
<Button asChild variant="outline" size="sm">
<a href={latestStatus.uri} target="_blank" rel="noreferrer">
<ExternalLinkIcon />
{formatURI(latestStatus.uri)}
{truncateURI(latestStatus.uri)}
</a>
</Button>
))}
@@ -189,8 +155,8 @@ export const AppStatuses: FC<AppStatusesProps> = ({
>
<div className="flex items-center justify-between w-full text-content-secondary">
<span className="text-xs flex items-center gap-2">
<AppStatusIcon
status={latestStatus}
<AppStatusStateIcon
state={latestStatus.state}
latest={false}
className="size-icon-xs w-[18px]"
/>
+32
View File
@@ -0,0 +1,32 @@
export const truncateURI = (uri: string) => {
if (uri.startsWith("file://")) {
const path = uri.slice(7);
// Slightly shorter truncation for this context if needed
if (path.length > 35) {
const start = path.slice(0, 15);
const end = path.slice(-15);
return `${start}...${end}`;
}
return path;
}
try {
const url = new URL(uri);
const fullUrl = url.toString();
// Slightly shorter truncation
if (fullUrl.length > 30) {
const start = fullUrl.slice(0, 15);
const end = fullUrl.slice(-15);
return `${start}...${end}`;
}
return fullUrl;
} catch {
// Slightly shorter truncation
if (uri.length > 20) {
const start = uri.slice(0, 10);
const end = uri.slice(-10);
return `${start}...${end}`;
}
return uri;
}
};