mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site/src): extract shared workspace-app frame module (#26088)
Extracts the workspace app iframe, wildcard warning, and workspace-app helper functions out of TaskPage into shared `site/src/modules/apps` modules. Existing agent and app lookups in the task chat helpers, download-logs dialog, and workspaces table now route through the shared `workspaceApps` helpers instead of duplicating resource-flattening logic. The extracted frame preserves the existing preview-only toolbar behavior, and its open-in-new-tab link gains `rel="noreferrer"` to harden against tabnabbing. Relates to CODAGT-346
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
MockUserOwner,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
} from "#/testHelpers/entities";
|
||||
import { withAuthProvider, withProxyProvider } from "#/testHelpers/storybook";
|
||||
import { WorkspaceAppFrame } from "./WorkspaceAppFrame";
|
||||
import type { WorkspaceAppWithAgent } from "./workspaceApps";
|
||||
|
||||
const meta: Meta<typeof WorkspaceAppFrame> = {
|
||||
title: "modules/apps/WorkspaceAppFrame",
|
||||
component: WorkspaceAppFrame,
|
||||
decorators: [withAuthProvider, withProxyProvider()],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
user: MockUserOwner,
|
||||
},
|
||||
args: {
|
||||
workspace: MockWorkspace,
|
||||
app: buildWorkspaceApp(),
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Unhealthy: Story = {
|
||||
args: {
|
||||
app: buildWorkspaceApp({ health: "unhealthy" }),
|
||||
},
|
||||
};
|
||||
|
||||
function buildWorkspaceApp(
|
||||
overrides: Partial<WorkspaceAppWithAgent> = {},
|
||||
): WorkspaceAppWithAgent {
|
||||
return {
|
||||
...MockWorkspaceApp,
|
||||
agent: MockWorkspaceAgent,
|
||||
health: "healthy",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+26
-17
@@ -3,7 +3,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
HouseIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, type HTMLProps, useRef } from "react";
|
||||
import { type ComponentProps, type FC, useRef } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import type { Workspace } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -15,18 +15,20 @@ import {
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useAppLink } from "#/modules/apps/useAppLink";
|
||||
import type { WorkspaceAppWithAgent } from "#/modules/tasks/apps";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { TaskWildcardWarning } from "./TaskWildcardWarning";
|
||||
import { isAppBlockedByMissingWildcard } from "./apps";
|
||||
import { useAppLink } from "./useAppLink";
|
||||
import { WorkspaceWildcardWarning } from "./WorkspaceWildcardWarning";
|
||||
import type { WorkspaceAppWithAgent } from "./workspaceApps";
|
||||
|
||||
type TaskAppIFrameProps = {
|
||||
type WorkspaceAppFrameProps = {
|
||||
workspace: Workspace;
|
||||
app: WorkspaceAppWithAgent;
|
||||
// Keep the iframe mounted while hidden so callers can preserve app state.
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
export const WorkspaceAppFrame: FC<WorkspaceAppFrameProps> = ({
|
||||
workspace,
|
||||
app,
|
||||
active,
|
||||
@@ -37,20 +39,24 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
});
|
||||
const proxy = useProxy();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const shouldDisplayWildcardWarning =
|
||||
app.subdomain && !proxy.proxy?.preferredWildcardHostname;
|
||||
const shouldDisplayWildcardWarning = isAppBlockedByMissingWildcard(
|
||||
app,
|
||||
proxy.proxy?.preferredWildcardHostname,
|
||||
);
|
||||
// The "preview" app renders a navigation toolbar above its iframe.
|
||||
const showToolbar = app.slug === "preview";
|
||||
|
||||
if (shouldDisplayWildcardWarning) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center pb-4">
|
||||
<TaskWildcardWarning />
|
||||
<WorkspaceWildcardWarning />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn([active ? "flex" : "hidden", "w-full h-full flex-col"])}>
|
||||
{app.slug === "preview" && (
|
||||
{showToolbar && (
|
||||
<div className="bg-surface-tertiary flex items-center p-2 py-1 gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -66,9 +72,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
<span className="sr-only">Home</span>
|
||||
</Button>
|
||||
|
||||
{/* Possibly we will put a URL bar here, but for now we cannot due to
|
||||
* cross-origin restrictions in iframes. */}
|
||||
<div className="w-full"></div>
|
||||
<div className="w-full" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -79,7 +83,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<RouterLink to={link.href} target="_blank">
|
||||
<RouterLink to={link.href} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon />
|
||||
Open app in new tab
|
||||
</RouterLink>
|
||||
@@ -90,7 +94,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
)}
|
||||
|
||||
{app.health === "healthy" || app.health === "disabled" ? (
|
||||
<TaskIframe ref={frameRef} src={link.href} title={link.label} />
|
||||
<WorkspaceIframe ref={frameRef} src={link.href} title={link.label} />
|
||||
) : app.health === "unhealthy" ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-4">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base text-center">
|
||||
@@ -143,11 +147,16 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type TaskIframeProps = HTMLProps<HTMLIFrameElement>;
|
||||
type WorkspaceIframeProps = ComponentProps<"iframe">;
|
||||
|
||||
export const TaskIframe: FC<TaskIframeProps> = ({ className, ...props }) => {
|
||||
export const WorkspaceIframe: FC<WorkspaceIframeProps> = ({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<iframe
|
||||
ref={ref}
|
||||
loading="eager"
|
||||
className={cn("w-full h-full border-0", className)}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
+5
-6
@@ -1,12 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MockUserOwner } from "#/testHelpers/entities";
|
||||
import { withAuthProvider } from "#/testHelpers/storybook";
|
||||
import type { TaskApps } from "./TaskApps";
|
||||
import { TaskWildcardWarning } from "./TaskWildcardWarning";
|
||||
import { WorkspaceWildcardWarning } from "./WorkspaceWildcardWarning";
|
||||
|
||||
const meta: Meta<typeof TaskWildcardWarning> = {
|
||||
title: "pages/TaskPage/TaskWildcardWarning",
|
||||
component: TaskWildcardWarning,
|
||||
const meta: Meta<typeof WorkspaceWildcardWarning> = {
|
||||
title: "modules/apps/WorkspaceWildcardWarning",
|
||||
component: WorkspaceWildcardWarning,
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
@@ -15,7 +14,7 @@ const meta: Meta<typeof TaskWildcardWarning> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TaskApps>;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithoutEditPermission: Story = {};
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { Button } from "#/components/Button/Button";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { docs } from "#/utils/docs";
|
||||
|
||||
export const TaskWildcardWarning = () => {
|
||||
export const WorkspaceWildcardWarning = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceApp } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
@@ -6,10 +7,20 @@ import {
|
||||
import {
|
||||
getAppHref,
|
||||
getVSCodeHref,
|
||||
isAppBlockedByMissingWildcard,
|
||||
isWorkspaceAppEmbeddable,
|
||||
openAppInNewWindow,
|
||||
SESSION_TOKEN_PLACEHOLDER,
|
||||
} from "./apps";
|
||||
|
||||
function buildApp(overrides: Partial<WorkspaceApp> = {}): WorkspaceApp {
|
||||
return {
|
||||
...MockWorkspaceApp,
|
||||
health: "healthy",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getVSCodeHref", () => {
|
||||
it("includes the chat ID when provided", () => {
|
||||
const folder = "/workspace/test";
|
||||
@@ -219,3 +230,40 @@ describe("openAppInNewWindow", () => {
|
||||
expect(popup.location.href).toBe("https://app.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkspaceAppEmbeddable", () => {
|
||||
it("returns true for visible path-based apps", () => {
|
||||
expect(isWorkspaceAppEmbeddable(buildApp())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for command apps, hidden apps, and external apps", () => {
|
||||
expect(isWorkspaceAppEmbeddable(buildApp({ command: "run-preview" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isWorkspaceAppEmbeddable(buildApp({ hidden: true }))).toBe(false);
|
||||
expect(
|
||||
isWorkspaceAppEmbeddable(
|
||||
buildApp({ external: true, url: "https://example.com" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAppBlockedByMissingWildcard", () => {
|
||||
it("blocks subdomain apps when no wildcard host is configured", () => {
|
||||
const subdomainApp = buildApp({ subdomain: true });
|
||||
|
||||
expect(isAppBlockedByMissingWildcard(subdomainApp, "")).toBe(true);
|
||||
expect(isAppBlockedByMissingWildcard(subdomainApp, undefined)).toBe(true);
|
||||
expect(
|
||||
isAppBlockedByMissingWildcard(subdomainApp, "*.apps.example.com"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never blocks path-based apps", () => {
|
||||
const pathApp = buildApp({ subdomain: false });
|
||||
|
||||
expect(isAppBlockedByMissingWildcard(pathApp, "")).toBe(false);
|
||||
expect(isAppBlockedByMissingWildcard(pathApp, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,3 +170,22 @@ export const needsSessionToken = (app: ExternalWorkspaceApp) => {
|
||||
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
|
||||
return requiresSessionToken && !isHttp;
|
||||
};
|
||||
|
||||
/**
|
||||
* True for apps that can be rendered inside a dashboard iframe. Command apps
|
||||
* open in terminal tabs instead.
|
||||
*/
|
||||
export const isWorkspaceAppEmbeddable = (app: WorkspaceApp): boolean => {
|
||||
return !app.hidden && !isExternalApp(app) && !app.command;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when an app requires subdomain access but the deployment has no wildcard
|
||||
* access URL configured, so the app cannot be launched or embedded.
|
||||
*/
|
||||
export const isAppBlockedByMissingWildcard = (
|
||||
app: WorkspaceApp,
|
||||
wildcardHostname: string | undefined,
|
||||
): boolean => {
|
||||
return app.subdomain && !wildcardHostname;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
findWorkspaceAppWithAgent,
|
||||
getAllAppsWithAgent,
|
||||
} from "./workspaceApps";
|
||||
|
||||
describe("workspaceApps", () => {
|
||||
describe("getAllAppsWithAgent", () => {
|
||||
it("flattens workspace apps with their owning agent", () => {
|
||||
const workspace = buildWorkspace([
|
||||
[buildAgent("agent-1", [buildApp("app-1")])],
|
||||
[buildAgent("agent-2", [buildApp("app-2")])],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAllAppsWithAgent(workspace).map((app) => ({
|
||||
appId: app.id,
|
||||
agentId: app.agent.id,
|
||||
})),
|
||||
).toEqual([
|
||||
{ appId: "app-1", agentId: "agent-1" },
|
||||
{ appId: "app-2", agentId: "agent-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list when the workspace has no agents", () => {
|
||||
const workspace = buildWorkspace([]);
|
||||
|
||||
expect(getAllAppsWithAgent(workspace)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorkspaceAppWithAgent", () => {
|
||||
it("returns the matching app with its owning agent", () => {
|
||||
const workspace = buildWorkspace([
|
||||
[buildAgent("agent-1", [buildApp("app-1")])],
|
||||
[buildAgent("agent-2", [buildApp("app-2")])],
|
||||
]);
|
||||
|
||||
expect(
|
||||
findWorkspaceAppWithAgent(workspace, "agent-2", "app-2"),
|
||||
).toMatchObject({
|
||||
id: "app-2",
|
||||
agent: { id: "agent-2" },
|
||||
});
|
||||
expect(
|
||||
findWorkspaceAppWithAgent(workspace, "agent-1", "app-2"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildWorkspace(
|
||||
resourceAgents: readonly WorkspaceAgent[][],
|
||||
): Workspace {
|
||||
const resourceTemplate = MockWorkspace.latest_build.resources[0];
|
||||
return {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
resources: resourceAgents.map((agents) => ({
|
||||
...resourceTemplate,
|
||||
agents,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgent(id: string, apps: WorkspaceApp[]): WorkspaceAgent {
|
||||
return {
|
||||
...MockWorkspaceAgent,
|
||||
id,
|
||||
name: id,
|
||||
apps,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
id: string,
|
||||
overrides: Partial<WorkspaceApp> = {},
|
||||
): WorkspaceApp {
|
||||
return {
|
||||
...MockWorkspaceApp,
|
||||
id,
|
||||
slug: id,
|
||||
display_name: id,
|
||||
health: "healthy",
|
||||
statuses: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "#/api/typesGenerated";
|
||||
import { findWorkspaceAgent, getWorkspaceAgents } from "#/utils/workspace";
|
||||
|
||||
export type WorkspaceAppWithAgent = WorkspaceApp & {
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
export function getAllAppsWithAgent(
|
||||
workspace: Workspace,
|
||||
): WorkspaceAppWithAgent[] {
|
||||
return getWorkspaceAgents(workspace).flatMap((agent) =>
|
||||
agent.apps.map((app) => ({
|
||||
...app,
|
||||
agent,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function findWorkspaceAppWithAgent(
|
||||
workspace: Workspace,
|
||||
agentId: string,
|
||||
appId: string,
|
||||
): WorkspaceAppWithAgent | undefined {
|
||||
const agent = findWorkspaceAgent(workspace, agentId);
|
||||
if (!agent) {
|
||||
return undefined;
|
||||
}
|
||||
const app = agent.apps.find((workspaceApp) => workspaceApp.id === appId);
|
||||
if (!app) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...app, agent };
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { isExternalApp, needsSessionToken } from "#/modules/apps/apps";
|
||||
import {
|
||||
isAppBlockedByMissingWildcard,
|
||||
isExternalApp,
|
||||
needsSessionToken,
|
||||
} from "#/modules/apps/apps";
|
||||
import { useAppLink } from "#/modules/apps/useAppLink";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { AgentButton } from "../AgentButton";
|
||||
@@ -76,7 +80,7 @@ export const AppLink: FC<AppLinkProps> = ({
|
||||
primaryTooltip = "Unhealthy";
|
||||
}
|
||||
|
||||
if (!host && app.subdomain) {
|
||||
if (isAppBlockedByMissingWildcard(app, host)) {
|
||||
canClick = false;
|
||||
icon = (
|
||||
<CircleAlertIcon
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "#/api/typesGenerated";
|
||||
|
||||
export type WorkspaceAppWithAgent = WorkspaceApp & {
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
export function getAllAppsWithAgent(
|
||||
workspace: Workspace,
|
||||
): WorkspaceAppWithAgent[] {
|
||||
return workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents ?? [])
|
||||
.flatMap((agent) =>
|
||||
agent.apps.map((app) => ({
|
||||
...app,
|
||||
agent,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getWorkspaceAgents } from "#/utils/workspace";
|
||||
|
||||
type DownloadLogsDialogProps = Pick<
|
||||
ConfirmDialogProps,
|
||||
@@ -39,15 +40,12 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
|
||||
});
|
||||
|
||||
const allUniqueAgents = useMemo<readonly WorkspaceAgent[]>(() => {
|
||||
const allAgents = workspace.latest_build.resources.flatMap(
|
||||
(resource) => resource.agents ?? [],
|
||||
);
|
||||
const allAgents = getWorkspaceAgents(workspace);
|
||||
|
||||
// Can't use the "new Set()" trick because we're not dealing with primitives
|
||||
const uniqueAgents = new Map(allAgents.map((agent) => [agent.id, agent]));
|
||||
const iterable = [...uniqueAgents.values()];
|
||||
return iterable;
|
||||
}, [workspace.latest_build.resources]);
|
||||
return [...uniqueAgents.values()];
|
||||
}, [workspace]);
|
||||
|
||||
const agentLogQueries = useQueries({
|
||||
queries: allUniqueAgents.map((agent) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { getWorkspaceAgents } from "#/utils/workspace";
|
||||
import type { AgentContextUsage } from "../AgentChatInput";
|
||||
import type { ModelSelectorOption } from "../ChatElements";
|
||||
import { asString } from "../ChatElements/runtimeTypeUtils";
|
||||
@@ -114,9 +115,7 @@ export const getWorkspaceAgent = (
|
||||
if (!workspace) {
|
||||
return undefined;
|
||||
}
|
||||
const agents = workspace.latest_build.resources.flatMap(
|
||||
(resource) => resource.agents ?? [],
|
||||
);
|
||||
const agents = getWorkspaceAgents(workspace);
|
||||
if (agents.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@ import { Link } from "#/components/Link/Link";
|
||||
import { ScrollArea, ScrollBar } from "#/components/ScrollArea/ScrollArea";
|
||||
import { getTerminalHref } from "#/modules/apps/apps";
|
||||
import { useAppLink } from "#/modules/apps/useAppLink";
|
||||
import {
|
||||
WorkspaceAppFrame,
|
||||
WorkspaceIframe,
|
||||
} from "#/modules/apps/WorkspaceAppFrame";
|
||||
import {
|
||||
getAllAppsWithAgent,
|
||||
type WorkspaceAppWithAgent,
|
||||
} from "#/modules/tasks/apps";
|
||||
} from "#/modules/apps/workspaceApps";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { TaskAppIFrame, TaskIframe } from "./TaskAppIframe";
|
||||
|
||||
type TaskAppsProps = {
|
||||
task: Task;
|
||||
@@ -94,7 +97,7 @@ export const TaskApps: FC<TaskAppsProps> = ({ task, workspace }) => {
|
||||
{embeddedApps.length > 0 ? (
|
||||
<div className="flex-1">
|
||||
{embeddedApps.map((app) => (
|
||||
<TaskAppIFrame
|
||||
<WorkspaceAppFrame
|
||||
key={app.id}
|
||||
active={activeAppId === app.id}
|
||||
app={app}
|
||||
@@ -102,7 +105,7 @@ export const TaskApps: FC<TaskAppsProps> = ({ task, workspace }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
<TaskIframe
|
||||
<WorkspaceIframe
|
||||
src={terminalHref}
|
||||
title="Terminal"
|
||||
className={cn({
|
||||
|
||||
@@ -43,9 +43,10 @@ import { Margins } from "#/components/Margins/Margins";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { useWorkspaceBuildLogs } from "#/hooks/useWorkspaceBuildLogs";
|
||||
import { WorkspaceAppFrame } from "#/modules/apps/WorkspaceAppFrame";
|
||||
import { getAllAppsWithAgent } from "#/modules/apps/workspaceApps";
|
||||
import { AgentLogs } from "#/modules/resources/AgentLogs/AgentLogs";
|
||||
import { useAgentLogs } from "#/modules/resources/useAgentLogs";
|
||||
import { getAllAppsWithAgent } from "#/modules/tasks/apps";
|
||||
import { TasksSidebar } from "#/modules/tasks/TasksSidebar/TasksSidebar";
|
||||
import { isPauseDisabled } from "#/modules/tasks/taskActions";
|
||||
import { WorkspaceErrorDialog } from "#/modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||
@@ -54,13 +55,13 @@ import { WorkspaceOutdatedTooltip } from "#/modules/workspaces/WorkspaceOutdated
|
||||
import { cn } from "#/utils/cn";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { relativeTime } from "#/utils/time";
|
||||
import { getWorkspaceAgents } from "#/utils/workspace";
|
||||
import {
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "../WorkspacePage/WorkspaceBuildProgress";
|
||||
import { FollowUpDialog } from "./FollowUpDialog";
|
||||
import { ModifyPromptDialog } from "./ModifyPromptDialog";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskTopbar } from "./TaskTopbar";
|
||||
|
||||
@@ -334,7 +335,7 @@ const TaskPage = () => {
|
||||
<PanelGroup autoSaveId="task" direction="horizontal">
|
||||
<Panel defaultSize={25} minSize={20}>
|
||||
{chatApp ? (
|
||||
<TaskAppIFrame active workspace={workspace} app={chatApp} />
|
||||
<WorkspaceAppFrame active workspace={workspace} app={chatApp} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-6 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -898,9 +899,5 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ task, agent }) => {
|
||||
};
|
||||
|
||||
function selectAgent(workspace: Workspace) {
|
||||
const agents = workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.filter(Boolean);
|
||||
|
||||
return agents.at(0);
|
||||
return getWorkspaceAgents(workspace).at(0);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
openAppInNewWindow,
|
||||
} from "#/modules/apps/apps";
|
||||
import { useAppLink } from "#/modules/apps/useAppLink";
|
||||
import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { abilitiesByWorkspaceStatus } from "#/modules/workspaces/actions";
|
||||
import { WorkspaceBuildCancelDialog } from "#/modules/workspaces/WorkspaceBuildCancelDialog/WorkspaceBuildCancelDialog";
|
||||
@@ -759,15 +760,18 @@ 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);
|
||||
const appWithAgent = status
|
||||
? findWorkspaceAppWithAgent(workspace, status.agent_id, status.app_id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{agent && app && (
|
||||
<IconAppLink app={app} workspace={workspace} agent={agent} />
|
||||
{appWithAgent && (
|
||||
<IconAppLink
|
||||
app={appWithAgent}
|
||||
workspace={workspace}
|
||||
agent={appWithAgent.agent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status?.uri && status?.uri !== "n/a" && (
|
||||
|
||||
@@ -4,12 +4,39 @@ import * as Mocks from "#/testHelpers/entities";
|
||||
import {
|
||||
agentVersionStatus,
|
||||
defaultWorkspaceExtension,
|
||||
findWorkspaceAgent,
|
||||
getDisplayVersionStatus,
|
||||
getDisplayWorkspaceBuildInitiatedBy,
|
||||
getDisplayWorkspaceTemplateName,
|
||||
getMatchingAgentOrFirst,
|
||||
getWorkspaceAgents,
|
||||
isWorkspaceOn,
|
||||
} from "./workspace";
|
||||
|
||||
function buildWorkspace(
|
||||
resourceAgents: readonly TypesGen.WorkspaceAgent[][],
|
||||
): TypesGen.Workspace {
|
||||
const resourceTemplate = Mocks.MockWorkspace.latest_build.resources[0];
|
||||
return {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspace.latest_build,
|
||||
resources: resourceAgents.map((agents) => ({
|
||||
...resourceTemplate,
|
||||
agents,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgent(id: string): TypesGen.WorkspaceAgent {
|
||||
return {
|
||||
...Mocks.MockWorkspaceAgent,
|
||||
id,
|
||||
name: id,
|
||||
};
|
||||
}
|
||||
|
||||
describe("util > workspace", () => {
|
||||
describe("isWorkspaceOn", () => {
|
||||
it.each<
|
||||
@@ -148,4 +175,53 @@ describe("util > workspace", () => {
|
||||
expect(displayed).toEqual(workspace.template_display_name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceAgents", () => {
|
||||
it("flattens agents across workspace resources", () => {
|
||||
const workspace = buildWorkspace([
|
||||
[buildAgent("agent-1")],
|
||||
[buildAgent("agent-2")],
|
||||
]);
|
||||
|
||||
expect(getWorkspaceAgents(workspace).map((agent) => agent.id)).toEqual([
|
||||
"agent-1",
|
||||
"agent-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorkspaceAgent", () => {
|
||||
it("returns the matching agent by ID", () => {
|
||||
const workspace = buildWorkspace([[buildAgent("agent-1")]]);
|
||||
|
||||
expect(findWorkspaceAgent(workspace, "agent-1")?.name).toBe("agent-1");
|
||||
expect(findWorkspaceAgent(workspace, "missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMatchingAgentOrFirst", () => {
|
||||
it("returns the agent matching by name across resources", () => {
|
||||
const workspace = buildWorkspace([
|
||||
[buildAgent("agent-1")],
|
||||
[buildAgent("agent-2")],
|
||||
]);
|
||||
|
||||
expect(getMatchingAgentOrFirst(workspace, "agent-2")?.id).toBe("agent-2");
|
||||
});
|
||||
|
||||
it("returns the first agent when no name is given", () => {
|
||||
const workspace = buildWorkspace([
|
||||
[buildAgent("agent-1")],
|
||||
[buildAgent("agent-2")],
|
||||
]);
|
||||
|
||||
expect(getMatchingAgentOrFirst(workspace, undefined)?.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("returns undefined when no agent matches the name", () => {
|
||||
const workspace = buildWorkspace([[buildAgent("agent-1")]]);
|
||||
|
||||
expect(getMatchingAgentOrFirst(workspace, "missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,21 +239,30 @@ export const getDisplayWorkspaceStatus = (
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorkspaceAgents = (
|
||||
workspace: TypesGen.Workspace,
|
||||
): TypesGen.WorkspaceAgent[] => {
|
||||
return workspace.latest_build.resources.flatMap(
|
||||
(resource) => resource.agents ?? [],
|
||||
);
|
||||
};
|
||||
|
||||
export const findWorkspaceAgent = (
|
||||
workspace: TypesGen.Workspace,
|
||||
agentId: string,
|
||||
): TypesGen.WorkspaceAgent | undefined => {
|
||||
return getWorkspaceAgents(workspace).find((agent) => agent.id === agentId);
|
||||
};
|
||||
|
||||
export const getMatchingAgentOrFirst = (
|
||||
workspace: TypesGen.Workspace,
|
||||
agentName: string | undefined,
|
||||
): TypesGen.WorkspaceAgent | undefined => {
|
||||
return workspace.latest_build.resources
|
||||
.map((resource) => {
|
||||
if (!resource.agents || resource.agents.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!agentName) {
|
||||
return resource.agents[0];
|
||||
}
|
||||
return resource.agents.find((agent) => agent.name === agentName);
|
||||
})
|
||||
.filter((a) => a)[0];
|
||||
const agents = getWorkspaceAgents(workspace);
|
||||
if (!agentName) {
|
||||
return agents[0];
|
||||
}
|
||||
return agents.find((agent) => agent.name === agentName);
|
||||
};
|
||||
|
||||
export const mustUpdateWorkspace = (
|
||||
|
||||
Reference in New Issue
Block a user