diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index f0e9b44a5ac56..7833d503785e7 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 3e6839692590b..ede6c1a957e70 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 9acf7f382375a..ae9c01ec63539 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -412,7 +412,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 f987a992cda95..a7388170f4be1 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -179,6 +179,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 requires subdomain access but the deployment has no wildcard * access URL configured, so the app cannot be launched or embedded. diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index c608a91bb2cbb..676f315e9375e 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 8242f78d5ae8e..7cc70688d94e6 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -424,6 +424,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 => { @@ -967,19 +1011,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 537e6580a2858..63ff87aa83144 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 bb772d3dba337..fb238df6798c3 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"; @@ -500,6 +503,10 @@ export const AgentChatPageView: FC = ({ const availableDesktopChatId = workspace && workspaceAgent ? desktopChatId : undefined; + const availableBrowserApp = workspace + ? getAgentBrowserApp(workspaceAgent) + : undefined; + const validatedUserRightPanelTabs = validateUserRightPanelTabs( userRightPanelTabs, { workspace, workspaceAgent, wildcardHostname }, @@ -516,6 +523,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" }] : []), ]; @@ -708,6 +716,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 837bfeec5c828..0f294700626a1 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 ee449e9564bce..f3779120f6467 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 78b6cdbab416e..cd9d7ed95a50e 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