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=<chat id>` 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.
This commit is contained in:
Michael Suchacz
2026-08-11 12:49:12 +02:00
committed by GitHub
parent 5bdabc95c8
commit 2e5353bde7
12 changed files with 429 additions and 23 deletions
+11 -4
View File
@@ -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
+38
View File
@@ -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) {
+4 -1
View File
@@ -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}),