From 7632e736080ce568e4bcf075cf7e2515a4c50aab Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:51:20 +1000 Subject: [PATCH] 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 --- .../apps/WorkspaceAppFrame.stories.tsx | 45 ++++++++ .../apps/WorkspaceAppFrame.tsx} | 43 +++++--- .../WorkspaceWildcardWarning.stories.tsx} | 11 +- .../apps/WorkspaceWildcardWarning.tsx} | 2 +- site/src/modules/apps/apps.test.ts | 48 +++++++++ site/src/modules/apps/apps.ts | 19 ++++ site/src/modules/apps/workspaceApps.test.ts | 100 ++++++++++++++++++ site/src/modules/apps/workspaceApps.ts | 37 +++++++ .../src/modules/resources/AppLink/AppLink.tsx | 8 +- site/src/modules/tasks/apps.ts | 22 ---- .../DownloadLogsDialog.tsx | 10 +- .../ChatConversation/chatHelpers.ts | 5 +- site/src/pages/TaskPage/TaskApps.tsx | 11 +- site/src/pages/TaskPage/TaskPage.tsx | 13 +-- .../pages/WorkspacesPage/WorkspacesTable.tsx | 16 +-- site/src/utils/workspace.test.ts | 76 +++++++++++++ site/src/utils/workspace.tsx | 31 ++++-- 17 files changed, 411 insertions(+), 86 deletions(-) create mode 100644 site/src/modules/apps/WorkspaceAppFrame.stories.tsx rename site/src/{pages/TaskPage/TaskAppIframe.tsx => modules/apps/WorkspaceAppFrame.tsx} (77%) rename site/src/{pages/TaskPage/TaskWildcardWarning.stories.tsx => modules/apps/WorkspaceWildcardWarning.stories.tsx} (63%) rename site/src/{pages/TaskPage/TaskWildcardWarning.tsx => modules/apps/WorkspaceWildcardWarning.tsx} (96%) create mode 100644 site/src/modules/apps/workspaceApps.test.ts create mode 100644 site/src/modules/apps/workspaceApps.ts delete mode 100644 site/src/modules/tasks/apps.ts diff --git a/site/src/modules/apps/WorkspaceAppFrame.stories.tsx b/site/src/modules/apps/WorkspaceAppFrame.stories.tsx new file mode 100644 index 0000000000..f7e56fad84 --- /dev/null +++ b/site/src/modules/apps/WorkspaceAppFrame.stories.tsx @@ -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 = { + 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; + +export const Unhealthy: Story = { + args: { + app: buildWorkspaceApp({ health: "unhealthy" }), + }, +}; + +function buildWorkspaceApp( + overrides: Partial = {}, +): WorkspaceAppWithAgent { + return { + ...MockWorkspaceApp, + agent: MockWorkspaceAgent, + health: "healthy", + ...overrides, + }; +} diff --git a/site/src/pages/TaskPage/TaskAppIframe.tsx b/site/src/modules/apps/WorkspaceAppFrame.tsx similarity index 77% rename from site/src/pages/TaskPage/TaskAppIframe.tsx rename to site/src/modules/apps/WorkspaceAppFrame.tsx index cfbca72746..4ff610f88a 100644 --- a/site/src/pages/TaskPage/TaskAppIframe.tsx +++ b/site/src/modules/apps/WorkspaceAppFrame.tsx @@ -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 = ({ +export const WorkspaceAppFrame: FC = ({ workspace, app, active, @@ -37,20 +39,24 @@ export const TaskAppIFrame: FC = ({ }); const proxy = useProxy(); const frameRef = useRef(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 (
- +
); } return (
- {app.slug === "preview" && ( + {showToolbar && (
- {/* Possibly we will put a URL bar here, but for now we cannot due to - * cross-origin restrictions in iframes. */} -
+
@@ -79,7 +83,7 @@ export const TaskAppIFrame: FC = ({ - + Open app in new tab @@ -90,7 +94,7 @@ export const TaskAppIFrame: FC = ({ )} {app.health === "healthy" || app.health === "disabled" ? ( - + ) : app.health === "unhealthy" ? (

@@ -143,11 +147,16 @@ export const TaskAppIFrame: FC = ({ ); }; -type TaskIframeProps = HTMLProps; +type WorkspaceIframeProps = ComponentProps<"iframe">; -export const TaskIframe: FC = ({ className, ...props }) => { +export const WorkspaceIframe: FC = ({ + className, + ref, + ...props +}) => { return (