From 2e5353bde753be744e070e80a6704bab4602349c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:49:12 +0200 Subject: [PATCH] feat: add built-in Browser tab for agent-browser (#27910) Adds a built-in Browser tab to the Agents page right panel, alongside the built-in Terminal and Desktop tabs, when the chat's bound agent has an app with the well-known slug `agent-browser`. The tab shows only while the app is embeddable and its health is `healthy` (or `disabled`, for templates without a healthcheck), so it appears and disappears live as the daemon comes up or goes down. The iframe stays mounted across tab switches to preserve session state. To avoid duplicates, the generic Add Tab menu and persisted workspace-app tabs now exclude the `agent-browser` app. Detection uses the existing `coder_app` slug and healthcheck signals already present in the workspace data model. The workspace watch handler compares the agent app fields the chat UI consumes, so health transitions propagate without re-render churn on every heartbeat. On the backend, the chat `execute` tool now exports `AGENT_BROWSER_SESSION=` on every process it starts. agent-browser resolves its default session from that variable, so browser automation from each chat lands in its own isolated session (named by the chat id in the embedded dashboard) instead of a shared default browser. > Mux created this PR on Mike's behalf. --- coderd/x/chatd/chattool/execute.go | 15 +- coderd/x/chatd/chattool/execute_test.go | 38 +++++ coderd/x/chatd/generation_preparer.go | 5 +- site/src/modules/apps/apps.ts | 19 +++ .../pages/AgentsPage/AgentChatPage.test.ts | 72 +++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 56 +++++-- .../AgentsPage/AgentChatPageView.stories.tsx | 155 ++++++++++++++++++ .../pages/AgentsPage/AgentChatPageView.tsx | 18 +- .../RightPanelAddTabControl.stories.tsx | 28 ++++ .../RightPanel/RightPanelAddTabControl.tsx | 11 +- .../AgentsPage/utils/rightPanelTabs.test.ts | 23 +++ .../pages/AgentsPage/utils/rightPanelTabs.ts | 12 +- 12 files changed, 429 insertions(+), 23 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index f0e9b44a5a..7833d50378 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -91,6 +91,10 @@ type ExecuteResult struct { type ExecuteOptions struct { GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) DefaultTimeout time.Duration + // AgentBrowserSession, when non-empty, is exported as + // AGENT_BROWSER_SESSION so agent-browser CLI invocations land in a + // browser session scoped to this chat instead of a shared default. + AgentBrowserSession string } // ProcessToolOptions configures a process management tool @@ -126,7 +130,7 @@ func Execute(options ExecuteOptions) fantasy.AgentTool { if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } - return executeTool(ctx, conn, args, options.DefaultTimeout), nil + return executeTool(ctx, conn, args, options), nil }, ) } @@ -135,15 +139,18 @@ func executeTool( ctx context.Context, conn workspacesdk.AgentConn, args ExecuteArgs, - optTimeout time.Duration, + options ExecuteOptions, ) fantasy.ToolResponse { if args.Command == "" { return fantasy.NewTextErrorResponse("command is required") } // Build the environment map for the process request. - env := make(map[string]string, len(nonInteractiveEnvVars)+1) + env := make(map[string]string, len(nonInteractiveEnvVars)+2) env["CODER_CHAT_AGENT"] = "true" + if options.AgentBrowserSession != "" { + env["AGENT_BROWSER_SESSION"] = options.AgentBrowserSession + } for k, v := range nonInteractiveEnvVars { env[k] = v } @@ -168,7 +175,7 @@ func executeTool( if background { return executeBackground(ctx, conn, args.Command, workDir, env) } - return executeForeground(ctx, conn, args, optTimeout, workDir, env) + return executeForeground(ctx, conn, args, options.DefaultTimeout, workDir, env) } // executeBackground starts a process in the background and diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index 3e68396925..ede6c1a957 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -228,6 +228,44 @@ func TestExecuteTool(t *testing.T) { assert.Equal(t, "hello world", result.Output) assert.Empty(t, result.BackgroundProcessID) assert.Equal(t, "true", capturedReq.Env["CODER_CHAT_AGENT"]) + assert.NotContains(t, capturedReq.Env, "AGENT_BROWSER_SESSION") + }) + + t.Run("AgentBrowserSessionEnv", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + var capturedReq workspacesdk.StartProcessRequest + mockConn.EXPECT(). + StartProcess(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) { + capturedReq = req + return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil + }) + exitCode := 0 + mockConn.EXPECT(). + ProcessOutput(gomock.Any(), "proc-1", gomock.Any()). + Return(workspacesdk.ProcessOutputResponse{ + Running: false, + ExitCode: &exitCode, + }, nil) + + tool := chattool.Execute(chattool.ExecuteOptions{ + GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + AgentBrowserSession: "chat-123", + }) + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "execute", + Input: `{"command":"echo hello"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + assert.Equal(t, "chat-123", capturedReq.Env["AGENT_BROWSER_SESSION"]) }) t.Run("ModelIntentIgnoredByExecution", func(t *testing.T) { diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 957228d530..6602d11c84 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -451,7 +451,10 @@ func (server *Server) prepareGeneration( GetWorkspaceConn: workspaceCtx.getWorkspaceConn, StoreFile: storeChatAttachment, }), - chattool.Execute(chattool.ExecuteOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}), + chattool.Execute(chattool.ExecuteOptions{ + GetWorkspaceConn: workspaceCtx.getWorkspaceConn, + AgentBrowserSession: chat.ID.String(), + }), chattool.ProcessOutput(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}), chattool.ProcessList(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}), chattool.ProcessSignal(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}), diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts index 83366ef623..90c59995a7 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -186,6 +186,25 @@ export const isWorkspaceAppEmbeddable = (app: WorkspaceApp): boolean => { return !app.hidden && !isExternalApp(app) && !app.command; }; +export const AGENT_BROWSER_APP_SLUG = "agent-browser"; + +export const getAgentBrowserApp = ( + agent: WorkspaceAgent | undefined, +): WorkspaceApp | undefined => { + const app = agent?.apps.find( + (agentApp) => agentApp.slug === AGENT_BROWSER_APP_SLUG, + ); + // "disabled" means the template does not configure a health check. + if ( + app && + isWorkspaceAppEmbeddable(app) && + (app.health === "healthy" || app.health === "disabled") + ) { + return app; + } + return undefined; +}; + /** * True when an app is not an external app, or is an external app whose URL can * be parsed by the URL constructor. External apps with an unparsable URL diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index c608a91bb2..676f315e93 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -1,18 +1,29 @@ import { act, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatMessage, ChatQueuedMessage } from "#/api/typesGenerated"; +import type { + ChatMessage, + ChatQueuedMessage, + Workspace, + WorkspaceApp, +} from "#/api/typesGenerated"; import { MockChatMessage, MockChatQueuedMessage, } from "#/testHelpers/chatEntities"; import { createDeferred } from "#/testHelpers/deferred"; -import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities"; +import { + MockUserOwner, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceApp, +} from "#/testHelpers/entities"; import { buildInactiveChatQueueReconciliation, draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, + isWatchedWorkspaceViewUnchanged, reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, @@ -1393,3 +1404,60 @@ describe("sidebar tab persistence", () => { }); }); }); + +describe("isWatchedWorkspaceViewUnchanged", () => { + const cloneWithApps = (apps: WorkspaceApp[]): Workspace => ({ + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: MockWorkspace.latest_build.resources.map((resource) => ({ + ...resource, + agents: resource.agents?.map((agent) => + agent.id === MockWorkspaceAgent.id ? { ...agent, apps } : agent, + ), + })), + }, + }); + + it("is true for a fresh payload with only unwatched changes", () => { + const next: Workspace = { + ...MockWorkspace, + last_used_at: "2024-01-01T00:00:00Z", + }; + + expect( + isWatchedWorkspaceViewUnchanged( + MockWorkspace, + next, + MockWorkspaceAgent.id, + ), + ).toBe(true); + }); + + it("is false when a bound-agent app changes health", () => { + const next = cloneWithApps([{ ...MockWorkspaceApp, health: "healthy" }]); + + expect( + isWatchedWorkspaceViewUnchanged( + MockWorkspace, + next, + MockWorkspaceAgent.id, + ), + ).toBe(false); + }); + + it("is false when the bound agent gains an app", () => { + const next = cloneWithApps([ + MockWorkspaceApp, + { ...MockWorkspaceApp, id: "second-app", slug: "second-app" }, + ]); + + expect( + isWatchedWorkspaceViewUnchanged( + MockWorkspace, + next, + MockWorkspaceAgent.id, + ), + ).toBe(false); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 006f224c81..4fd0b0d09a 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -425,6 +425,50 @@ export const getWorkspaceOptionsWithLinkedWorkspace = ( return nextWorkspaceOptions; }; +// Keep this list in sync with app fields consumed by the chat UI, or live +// updates to those fields can retain stale query data. +const watchedAgentAppFields: readonly (keyof TypesGen.WorkspaceApp)[] = [ + "id", + "slug", + "health", + "hidden", + "external", + "command", + "subdomain", + "subdomain_name", + "display_name", +]; + +/** @internal Exported for testing. */ +export const isWatchedWorkspaceViewUnchanged = ( + prev: TypesGen.Workspace, + next: TypesGen.Workspace, + chatAgentId: string | undefined, +): boolean => { + const prevAgent = getWorkspaceAgent(prev, chatAgentId); + const nextAgent = getWorkspaceAgent(next, chatAgentId); + const prevApps = prevAgent?.apps ?? []; + const nextApps = nextAgent?.apps ?? []; + return ( + prev.latest_build.status === next.latest_build.status && + prev.health.healthy === next.health.healthy && + prev.name === next.name && + prev.owner_name === next.owner_name && + prevAgent?.id === nextAgent?.id && + prevAgent?.status === nextAgent?.status && + prevAgent?.name === nextAgent?.name && + prevAgent?.expanded_directory === nextAgent?.expanded_directory && + prevAgent?.lifecycle_state === nextAgent?.lifecycle_state && + prevApps.length === nextApps.length && + prevApps.every((prevApp, index) => { + const nextApp = nextApps[index]; + return watchedAgentAppFields.every( + (field) => prevApp[field] === nextApp[field], + ); + }) + ); +}; + const buildAttachmentMediaTypes = ( attachments?: readonly PendingAttachment[], ): ReadonlyMap | undefined => { @@ -969,19 +1013,9 @@ const AgentChatPage: FC = () => { // reads has changed. This prevents react-query // from notifying subscribers and avoids a full // AgentChatPage re-render on every heartbeat. - const prevAgent = getWorkspaceAgent(prev, chatAgentId); - const nextAgent = getWorkspaceAgent(next, chatAgentId); if ( prev && - prev.latest_build.status === next.latest_build.status && - prev.health.healthy === next.health.healthy && - prev.name === next.name && - prev.owner_name === next.owner_name && - prevAgent?.id === nextAgent?.id && - prevAgent?.status === nextAgent?.status && - prevAgent?.name === nextAgent?.name && - prevAgent?.expanded_directory === nextAgent?.expanded_directory && - prevAgent?.lifecycle_state === nextAgent?.lifecycle_state + isWatchedWorkspaceViewUnchanged(prev, next, chatAgentId) ) { return prev; } diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 537e6580a2..63ff87aa83 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -5,6 +5,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated"; +import { AGENT_BROWSER_APP_SLUG } from "#/modules/apps/apps"; import { MockChat } from "#/testHelpers/chatEntities"; import { MockDefaultOrganization, @@ -14,6 +15,8 @@ import { MockUserOwner, MockWorkspace, MockWorkspaceAgent, + MockWorkspaceApp, + MockWorkspaceResource, } from "#/testHelpers/entities"; import { withAuthProvider, @@ -1677,6 +1680,158 @@ export const PreservesUnavailableSidebarTab: Story = { }, }; +const mockAgentBrowserApp: TypesGen.WorkspaceApp = { + ...MockWorkspaceApp, + id: "agent-browser-app", + slug: AGENT_BROWSER_APP_SLUG, + display_name: "agent-browser", + health: "healthy", +}; + +const mockAgentWithBrowserApp: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + apps: [...MockWorkspaceAgent.apps, mockAgentBrowserApp], +}; + +export const BrowserTabForHealthyAgentBrowserApp: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const browserTab = await canvas.findByRole("tab", { name: "Browser" }); + const tabLabels = canvas.getAllByRole("tab").map((tab) => tab.textContent); + expect(tabLabels).toEqual(["Summary", "Git", "Browser", "Terminal"]); + + // The frame stays mounted while inactive to preserve app state, so + // assert visibility rather than presence. + const frame = canvas.getByTitle("agent-browser"); + expect(frame.checkVisibility()).toBe(false); + + await userEvent.click(browserTab); + + await waitFor(() => { + expect(browserTab).toHaveAttribute("aria-selected", "true"); + }); + expect(frame.checkVisibility()).toBe(true); + }, +}; + +export const BrowserTabForHealthDisabledAgentBrowserApp: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const browserTab = await canvas.findByRole("tab", { name: "Browser" }); + await userEvent.click(browserTab); + + await waitFor(() => { + expect(browserTab).toHaveAttribute("aria-selected", "true"); + }); + expect(canvas.getByTitle("agent-browser").checkVisibility()).toBe(true); + }, +}; + +export const NoBrowserTabForUnhealthyAgentBrowserApp: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByRole("tab", { name: "Summary" }); + expect(canvas.queryByRole("tab", { name: "Browser" })).toBeNull(); + }, +}; + +export const NoBrowserTabForAppOnNonBoundAgent: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByRole("tab", { name: "Summary" }); + expect(canvas.queryByRole("tab", { name: "Browser" })).toBeNull(); + }, +}; + +export const PreservesUnavailableBrowserTab: Story = { + beforeEach: () => { + localStorage.setItem(sidebarTabStorageKey, "browser"); + return () => { + localStorage.removeItem(sidebarTabStorageKey); + }; + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + const summaryTab = canvas.getByRole("tab", { name: "Summary" }); + expect(summaryTab).toHaveAttribute("aria-selected", "true"); + }); + + expect(canvas.queryByRole("tab", { name: "Browser" })).toBeNull(); + + expect(localStorage.getItem(sidebarTabStorageKey)).toBe("browser"); + }, +}; + /** * When a chat is archived, clicking a sidebar tab must not persist the * selection to localStorage. The archive flow clears the entry on diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index d6fc345683..1ae728bbef 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -19,7 +19,10 @@ import type { ChatMessagePart, } from "#/api/typesGenerated"; import { useProxy } from "#/contexts/ProxyContext"; -import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps"; +import { + getAgentBrowserApp, + isWorkspaceAppEmbeddable, +} from "#/modules/apps/apps"; import { WorkspaceAppFrame } from "#/modules/apps/WorkspaceAppFrame"; import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps"; import { cn } from "#/utils/cn"; @@ -497,6 +500,10 @@ export const AgentChatPageView: FC = ({ const availableDesktopChatId = workspace && workspaceAgent ? desktopChatId : undefined; + const availableBrowserApp = workspace + ? getAgentBrowserApp(workspaceAgent) + : undefined; + const validatedUserRightPanelTabs = validateUserRightPanelTabs( userRightPanelTabs, { workspace, workspaceAgent, wildcardHostname }, @@ -513,6 +520,7 @@ export const AgentChatPageView: FC = ({ { id: "summary", label: "Summary" }, { id: "git", label: "Git" }, ...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []), + ...(availableBrowserApp ? [{ id: "browser", label: "Browser" }] : []), ...(availableDesktopChatId ? [{ id: "desktop", label: "Desktop" }] : []), ...(hasBuiltInTerminal ? [{ id: "terminal", label: "Terminal" }] : []), ]; @@ -705,6 +713,14 @@ export const AgentChatPageView: FC = ({ chatInputRef={editing.chatInputRef} /> ); + case "browser": + return workspace && workspaceAgent && availableBrowserApp ? ( + + ) : null; case "desktop": return availableDesktopChatId ? ( { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText("Add panel")); + + const body = within(document.body); + await waitFor(() => { + expect(body.getByText("Preview")).toBeInTheDocument(); + }); + expect(body.queryByText("agent-browser")).toBeNull(); + }, +}; + export const DisconnectedWorkspace: Story = { args: { agent: { diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx index 837bfeec5c..0f29470062 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx @@ -19,7 +19,10 @@ import { DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps"; +import { + AGENT_BROWSER_APP_SLUG, + isWorkspaceAppEmbeddable, +} from "#/modules/apps/apps"; import { AppLink } from "#/modules/resources/AppLink/AppLink"; import { canShowPortForwarding, @@ -78,7 +81,11 @@ export const RightPanelAddTabControl: FC<{ onOpenPort, }) => { const [open, setOpen] = useState(false); - const userApps = agent?.apps.filter((app) => !app.hidden) ?? []; + // agent-browser already has the built-in Browser tab. + const userApps = + agent?.apps.filter( + (app) => !app.hidden && app.slug !== AGENT_BROWSER_APP_SLUG, + ) ?? []; const canCreateTerminal = workspace !== undefined && agent !== undefined && isRunning; diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts index ee449e9564..f3779120f6 100644 --- a/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceAgent, WorkspaceApp, } from "#/api/typesGenerated"; +import { AGENT_BROWSER_APP_SLUG } from "#/modules/apps/apps"; import { MockWorkspace, MockWorkspaceAgent, @@ -140,6 +141,28 @@ describe("right-panel tab validation", () => { expect(validated).toEqual([]); }); + + it("drops agent-browser app tabs in favor of the built-in Browser tab", () => { + const browserApp = buildApp("browser-app", { + slug: AGENT_BROWSER_APP_SLUG, + }); + const workspace = buildWorkspace([buildAgent("agent-1", [browserApp])]); + const appTab: UserRightPanelTab = { + id: "browser-app-tab", + kind: "workspace_app", + label: "agent-browser", + agentId: "agent-1", + appId: "browser-app", + }; + + const validated = validateUserRightPanelTabs([appTab], { + workspace, + workspaceAgent: workspace.latest_build.resources[0].agents?.[0], + wildcardHostname: "*.apps.example.com", + }); + + expect(validated).toEqual([]); + }); }); function buildWorkspace(resourceAgents: readonly WorkspaceAgent[]): Workspace { diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts index 78b6cdbab4..cd9d7ed95a 100644 --- a/site/src/pages/AgentsPage/utils/rightPanelTabs.ts +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts @@ -3,7 +3,10 @@ import type { WorkspaceAgent, WorkspaceAgentPortShareProtocol, } from "#/api/typesGenerated"; -import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps"; +import { + AGENT_BROWSER_APP_SLUG, + isWorkspaceAppEmbeddable, +} from "#/modules/apps/apps"; import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps"; import { canShowPortForwarding } from "#/modules/resources/usePortsData"; import { findWorkspaceAgent } from "#/utils/workspace"; @@ -114,7 +117,12 @@ export function validateUserRightPanelTabs( if (tab.kind === "workspace_app") { const app = findWorkspaceAppWithAgent(workspace, tab.agentId, tab.appId); - return app !== undefined && isWorkspaceAppEmbeddable(app); + // agent-browser already has the built-in Browser tab. + return ( + app !== undefined && + app.slug !== AGENT_BROWSER_APP_SLUG && + isWorkspaceAppEmbeddable(app) + ); } // Mirror the add-menu gate so a persisted port tab disappears when