fix: make app tabs scrollable (#19881)

The solution follows the way VSCode manages its tabs. Since many other
editors use a similar approach, this feels familiar and intuitive for
our users.

**Demo:**


https://github.com/user-attachments/assets/b4cfb307-268b-4c8b-ac7f-d01dff4ce60b

Fix https://github.com/coder/coder/issues/19438
This commit is contained in:
Bruno Quaresma
2025-09-22 13:46:54 -03:00
committed by GitHub
parent 6fb4cc6b82
commit e7d648fb28
10 changed files with 253 additions and 252 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import { cn } from "utils/cn";
const buttonVariants = cva(
`
inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
border-solid rounded-md transition-colors
border-solid rounded-md transition-colors shrink-0
text-sm font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
@@ -24,7 +24,7 @@ export const ScrollArea = React.forwardRef<
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
export const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
@@ -41,6 +41,6 @@ const ScrollBar = React.forwardRef<
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
+8 -1
View File
@@ -20,10 +20,17 @@ type UseAppLinkParams = {
agent: WorkspaceAgent;
};
type AppLink = {
href: string;
onClick: (e: React.MouseEvent) => void;
label: string;
hasToken: boolean;
};
export const useAppLink = (
app: WorkspaceApp,
{ agent, workspace }: UseAppLinkParams,
) => {
): AppLink => {
const label = app.display_name ?? app.slug;
const { proxy } = useProxy();
const { data: apiKeyResponse } = useQuery({
+27 -1
View File
@@ -1,4 +1,8 @@
import type { Workspace } from "api/typesGenerated";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
export const AI_PROMPT_PARAMETER_NAME = "AI Prompt";
@@ -6,3 +10,25 @@ export type Task = {
workspace: Workspace;
prompt: string;
};
export type WorkspaceAppWithAgent = WorkspaceApp & {
agent: WorkspaceAgent;
};
export function getTaskApps(task: Task): WorkspaceAppWithAgent[] {
return (
task.workspace.latest_build.resources
.flatMap((r) => r.agents ?? [])
.flatMap((agent) =>
agent.apps.map((app) => ({
...app,
agent,
})),
)
// The Chat UI app will be displayed in the sidebar, so we don't want to
// show it as a tab.
.filter(
(app) => app.id !== task.workspace.latest_build.ai_task_sidebar_app_id,
)
);
}
+19 -33
View File
@@ -1,4 +1,3 @@
import type { WorkspaceApp } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
@@ -7,55 +6,42 @@ import {
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Spinner } from "components/Spinner/Spinner";
import { useProxy } from "contexts/ProxyContext";
import { EllipsisVertical, ExternalLinkIcon, HouseIcon } from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { Task } from "modules/tasks/tasks";
import type { Task, WorkspaceAppWithAgent } from "modules/tasks/tasks";
import { type FC, useRef } from "react";
import { Link as RouterLink } from "react-router";
import { cn } from "utils/cn";
import { TaskWildcardWarning } from "./TaskWildcardWarning";
type TaskAppIFrameProps = {
task: Task;
app: WorkspaceApp;
app: WorkspaceAppWithAgent;
active: boolean;
pathname?: string;
};
export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
task,
app,
active,
pathname,
}) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
agent: app.agent,
workspace: task.workspace,
});
const appHref = (): string => {
try {
const url = new URL(link.href, location.href);
if (pathname) {
url.pathname = pathname;
}
return url.toString();
} catch (err) {
console.warn(`Failed to parse URL ${link.href} for app ${app.id}`, err);
return link.href;
}
};
const proxy = useProxy();
const frameRef = useRef<HTMLIFrameElement>(null);
const frameSrc = appHref();
const shouldDisplayWildcardWarning =
app.subdomain && !proxy.proxy?.preferredWildcardHostname;
if (shouldDisplayWildcardWarning) {
return (
<div className="h-full flex items-center justify-center pb-4">
<TaskWildcardWarning />
</div>
);
}
return (
<div className={cn([active ? "flex" : "hidden", "w-full h-full flex-col"])}>
@@ -67,7 +53,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
onClick={(e) => {
e.preventDefault();
if (frameRef.current?.contentWindow) {
frameRef.current.contentWindow.location.href = appHref();
frameRef.current.contentWindow.location.href = link.href;
}
}}
>
@@ -88,7 +74,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<RouterLink to={frameSrc} target="_blank">
<RouterLink to={link.href} target="_blank">
<ExternalLinkIcon />
Open app in new tab
</RouterLink>
@@ -103,7 +89,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
app.health === "unhealthy" ? (
<iframe
ref={frameRef}
src={frameSrc}
src={link.href}
title={link.label}
loading="eager"
className={"w-full h-full border-0"}
+89 -93
View File
@@ -1,14 +1,25 @@
import {
MockPrimaryWorkspaceProxy,
MockTasks,
MockUserOwner,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockWorkspaceProxies,
} from "testHelpers/entities";
import { withProxyProvider } from "testHelpers/storybook";
import { withAuthProvider, withProxyProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceApp } from "api/typesGenerated";
import { getPreferredProxy } from "contexts/ProxyContext";
import kebabCase from "lodash/kebabCase";
import type { Task } from "modules/tasks/tasks";
import { TaskApps } from "./TaskApps";
const mockExternalApp: WorkspaceApp = {
...MockWorkspaceApp,
external: true,
};
const meta: Meta<typeof TaskApps> = {
title: "pages/TaskPage/TaskApps",
component: TaskApps,
@@ -21,114 +32,99 @@ const meta: Meta<typeof TaskApps> = {
export default meta;
type Story = StoryObj<typeof TaskApps>;
const mockAgentNoApps = {
...MockWorkspaceAgent,
apps: [],
};
const mockExternalApp: WorkspaceApp = {
...MockWorkspaceApp,
external: true,
};
const mockEmbeddedApp: WorkspaceApp = {
...MockWorkspaceApp,
external: false,
};
const taskWithNoApps = {
...MockTasks[0],
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [mockAgentNoApps],
},
],
},
},
};
export const NoEmbeddedApps: Story = {
args: {
task: taskWithNoApps,
task: mockTask([]),
},
};
export const WithExternalAppsOnly: Story = {
args: {
task: {
...MockTasks[0],
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [
{
...MockWorkspaceAgent,
apps: [mockExternalApp],
},
],
},
],
},
},
},
task: mockTask([mockExternalApp]),
},
};
export const WithEmbeddedApps: Story = {
args: {
task: {
...MockTasks[0],
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [
{
...MockWorkspaceAgent,
apps: [mockEmbeddedApp],
},
],
},
],
},
},
},
task: mockTask([mockEmbeddedApp()]),
},
};
export const WithMixedApps: Story = {
args: {
task: {
...MockTasks[0],
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [
{
...MockWorkspaceAgent,
apps: [mockEmbeddedApp, mockExternalApp],
},
],
},
],
},
},
},
task: mockTask([mockEmbeddedApp(), mockExternalApp]),
},
};
export const WithWildcardWarning: Story = {
decorators: [
withAuthProvider,
withProxyProvider({
proxy: {
...getPreferredProxy(MockWorkspaceProxies, MockPrimaryWorkspaceProxy),
preferredWildcardHostname: "",
},
}),
],
parameters: {
user: MockUserOwner,
},
args: {
task: mockTask([
{
...mockEmbeddedApp(),
subdomain: true,
},
]),
},
};
export const WithManyEmbeddedApps: Story = {
args: {
task: mockTask([
mockEmbeddedApp("Code Server"),
mockEmbeddedApp("Jupyter Notebook"),
mockEmbeddedApp("Web Terminal"),
mockEmbeddedApp("Database Client"),
mockEmbeddedApp("API Documentation"),
mockEmbeddedApp("Monitoring Dashboard"),
mockEmbeddedApp("Task Manager"),
mockEmbeddedApp("File Manager"),
mockEmbeddedApp("Test Runner"),
mockEmbeddedApp("Build Pipeline"),
]),
},
};
function mockEmbeddedApp(name = MockWorkspaceApp.display_name): WorkspaceApp {
return {
...MockWorkspaceApp,
id: crypto.randomUUID(),
slug: kebabCase(name),
display_name: name,
external: false,
};
}
function mockTask(apps: WorkspaceApp[]): Task {
return {
...MockTasks[0],
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [
{
...MockWorkspaceAgent,
apps,
},
],
},
],
},
},
};
}
+66 -101
View File
@@ -1,4 +1,3 @@
import type { WorkspaceAgent, WorkspaceApp } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
@@ -9,104 +8,67 @@ import {
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
import { Link } from "components/Link/Link";
import { useProxy } from "contexts/ProxyContext";
import { ScrollArea, ScrollBar } from "components/ScrollArea/ScrollArea";
import { ChevronDownIcon, LayoutGridIcon } from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { Task } from "modules/tasks/tasks";
import {
getTaskApps,
type Task,
type WorkspaceAppWithAgent,
} from "modules/tasks/tasks";
import type React from "react";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import { TaskAppIFrame } from "./TaskAppIframe";
import { TaskWildcardWarning } from "./TaskWildcardWarning";
type TaskAppsProps = {
task: Task;
};
type AppWithAgent = {
app: WorkspaceApp;
agent: WorkspaceAgent;
};
export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
const { proxy } = useProxy();
const agents = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a);
// The Chat UI app will be displayed in the sidebar, so we don't want to show
// it here
const apps = agents
.flatMap((agent) =>
agent.apps.map((app) => ({
app,
agent,
})),
)
.filter(
({ app }) =>
!!app && app.id !== task.workspace.latest_build.ai_task_sidebar_app_id,
);
const embeddedApps = apps.filter(({ app }) => !app.external);
const externalApps = apps.filter(({ app }) => app.external);
const [activeAppId, setActiveAppId] = useState<string | undefined>(
embeddedApps[0]?.app.id,
);
const activeApp = embeddedApps.find(({ app }) => app.id === activeAppId)?.app;
const shouldDisplayWildcardWarning =
activeApp?.subdomain && !proxy.proxy?.wildcard_hostname;
const apps = getTaskApps(task);
const [embeddedApps, externalApps] = splitEmbeddedAndExternalApps(apps);
const [activeAppId, setActiveAppId] = useState(embeddedApps.at(0)?.id);
return (
<main className="flex flex-col">
<main className="flex flex-col h-full">
<div className="w-full flex items-center border-0 border-b border-border border-solid">
<div className="p-2 pb-0 flex gap-2 items-center">
{embeddedApps.map(({ app, agent }) => (
<TaskAppTab
key={app.id}
task={task}
app={app}
agent={agent}
active={app.id === activeAppId}
onClick={(e) => {
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
</div>
<ScrollArea className="max-w-full">
<div className="flex w-max gap-2 items-center p-2 pb-0">
{embeddedApps.map((app) => (
<TaskAppTab
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
</div>
<ScrollBar orientation="horizontal" className="h-2" />
</ScrollArea>
{externalApps.length > 0 && (
<TaskExternalAppsDropdown
task={task}
agents={agents}
externalApps={externalApps}
/>
<ExternalAppsDropdown task={task} externalApps={externalApps} />
)}
</div>
{embeddedApps.length > 0 ? (
shouldDisplayWildcardWarning ? (
<div className="flex-1 flex flex-col items-center justify-center pb-4">
<TaskWildcardWarning className="max-w-xl" />
</div>
) : (
<div className="flex-1">
{embeddedApps.map(({ app }) => (
<TaskAppIFrame
key={app.id}
active={activeAppId === app.id}
app={app}
task={task}
/>
))}
</div>
)
<div className="flex-1">
{embeddedApps.map((app) => (
<TaskAppIFrame
key={app.id}
active={activeAppId === app.id}
app={app}
task={task}
/>
))}
</div>
) : (
<div className="mx-auto my-auto flex flex-col items-center">
<h3 className="font-medium text-content-primary text-base">
@@ -129,13 +91,12 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
);
};
type TaskExternalAppsDropdownProps = {
type ExternalAppsDropdownProps = {
task: Task;
agents: WorkspaceAgent[];
externalApps: AppWithAgent[];
externalApps: WorkspaceAppWithAgent[];
};
const TaskExternalAppsDropdown: FC<TaskExternalAppsDropdownProps> = ({
const ExternalAppsDropdown: FC<ExternalAppsDropdownProps> = ({
task,
externalApps,
}) => {
@@ -149,13 +110,8 @@ const TaskExternalAppsDropdown: FC<TaskExternalAppsDropdownProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{externalApps.map(({ app, agent }) => (
<ExternalAppMenuItem
key={app.id}
app={app}
agent={agent}
task={task}
/>
{externalApps.map((app) => (
<ExternalAppMenuItem key={app.id} app={app} task={task} />
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -164,12 +120,11 @@ const TaskExternalAppsDropdown: FC<TaskExternalAppsDropdownProps> = ({
};
const ExternalAppMenuItem: FC<{
app: WorkspaceApp;
agent: WorkspaceAgent;
app: WorkspaceAppWithAgent;
task: Task;
}> = ({ app, agent, task }) => {
}> = ({ app, task }) => {
const link = useAppLink(app, {
agent,
agent: app.agent,
workspace: task.workspace,
});
@@ -185,21 +140,14 @@ const ExternalAppMenuItem: FC<{
type TaskAppTabProps = {
task: Task;
app: WorkspaceApp;
agent: WorkspaceAgent;
app: WorkspaceAppWithAgent;
active: boolean;
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
const TaskAppTab: FC<TaskAppTabProps> = ({
task,
app,
agent,
active,
onClick,
}) => {
const TaskAppTab: FC<TaskAppTabProps> = ({ task, app, active, onClick }) => {
const link = useAppLink(app, {
agent,
agent: app.agent,
workspace: task.workspace,
});
@@ -232,3 +180,20 @@ const TaskAppTab: FC<TaskAppTabProps> = ({
</Button>
);
};
function splitEmbeddedAndExternalApps(
apps: WorkspaceAppWithAgent[],
): [WorkspaceAppWithAgent[], WorkspaceAppWithAgent[]] {
const embeddedApps = [];
const externalApps = [];
for (const app of apps) {
if (app.external) {
externalApps.push(app);
} else {
embeddedApps.push(app);
}
}
return [embeddedApps, externalApps];
}
+10 -9
View File
@@ -1,7 +1,10 @@
import type { WorkspaceApp } from "api/typesGenerated";
import { Spinner } from "components/Spinner/Spinner";
import { useProxy } from "contexts/ProxyContext";
import type { Task } from "modules/tasks/tasks";
import {
getTaskApps,
type Task,
type WorkspaceAppWithAgent,
} from "modules/tasks/tasks";
import type { FC } from "react";
import { TaskAppIFrame } from "./TaskAppIframe";
import { TaskWildcardWarning } from "./TaskWildcardWarning";
@@ -12,7 +15,9 @@ type TaskSidebarProps = {
type SidebarAppStatus = "error" | "loading" | "healthy";
const getSidebarApp = (task: Task): [WorkspaceApp | null, SidebarAppStatus] => {
const getSidebarApp = (
task: Task,
): [WorkspaceAppWithAgent | null, SidebarAppStatus] => {
const sidebarAppId = task.workspace.latest_build.ai_task_sidebar_app_id;
// a task workspace with a finished build must have a sidebar app id
if (!sidebarAppId && task.workspace.latest_build.job.completed_at) {
@@ -23,10 +28,7 @@ const getSidebarApp = (task: Task): [WorkspaceApp | null, SidebarAppStatus] => {
return [null, "error"];
}
const sidebarApp = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.find((a) => a?.id === sidebarAppId);
const sidebarApp = getTaskApps(task).find((a) => a.id === sidebarAppId);
if (!task.workspace.latest_build.job.completed_at) {
// while the workspace build is running, we don't have a sidebar app yet
@@ -67,7 +69,6 @@ const getSidebarApp = (task: Task): [WorkspaceApp | null, SidebarAppStatus] => {
export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
const proxy = useProxy();
const [sidebarApp, sidebarAppStatus] = getSidebarApp(task);
const shouldDisplayWildcardWarning =
sidebarApp?.subdomain && proxy.proxy?.preferredWildcardHostname === "";
@@ -80,7 +81,7 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
</div>
) : shouldDisplayWildcardWarning ? (
<div className="flex-1 flex flex-col items-center justify-center pb-4">
<TaskWildcardWarning className="max-w-xl" />
<TaskWildcardWarning />
</div>
) : sidebarAppStatus === "healthy" && sidebarApp ? (
<TaskAppIFrame
@@ -0,0 +1,28 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { TaskApps } from "./TaskApps";
import { TaskWildcardWarning } from "./TaskWildcardWarning";
const meta: Meta<typeof TaskWildcardWarning> = {
title: "pages/TaskPage/TaskWildcardWarning",
component: TaskWildcardWarning,
decorators: [withAuthProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
},
};
export default meta;
type Story = StoryObj<typeof TaskApps>;
export const WithoutEditPermission: Story = {};
export const WithEditPermission: Story = {
parameters: {
permissions: {
editDeploymentConfig: true,
},
},
};
@@ -2,21 +2,13 @@ import { Button } from "components/Button/Button";
import { useAuthenticated } from "hooks/useAuthenticated";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import { Link as RouterLink } from "react-router";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
type TaskWildcardWarningProps = {
className?: string;
};
export const TaskWildcardWarning = ({
className,
}: TaskWildcardWarningProps) => {
export const TaskWildcardWarning = () => {
const { permissions } = useAuthenticated();
const canEditDeploymentConfig = Boolean(permissions.editDeploymentConfig);
return (
<div className={cn("text-center", className)}>
<div className="text-center max-w-md">
<h3 className="font-medium text-content-primary text-base mb-3">Error</h3>
<div className="text-content-secondary text-sm flex flex-col gap-3 items-center">
<div className="px-4">
@@ -24,7 +16,7 @@ export const TaskWildcardWarning = ({
<code className="py-px px-1 bg-surface-tertiary rounded-sm text-content-primary">
subdomain = true
</code>
{canEditDeploymentConfig ? (
{permissions.editDeploymentConfig ? (
<>
, but subdomain applications are not configured. This application
won't be accessible until you configure the{" "}