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