Skip to content

feat(site): add support for external agents in the UI and extend CodeExample #19288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,16 @@ class ApiMethods {
return response.data;
};

getWorkspaceAgentCredentials = async (
workspaceID: string,
agentName: string,
): Promise<TypesGen.ExternalAgentCredentials> => {
const response = await this.axios.get(
`/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`,
);
return response.data;
};

upsertWorkspaceAgentSharedPort = async (
workspaceID: string,
req: TypesGen.UpsertWorkspaceAgentPortShareRequest,
Expand Down
10 changes: 10 additions & 0 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,13 @@ export const updateWorkspaceACL = (workspaceId: string) => {
},
};
};

export const workspaceAgentCredentials = (
workspaceId: string,
agentName: string,
) => {
return {
queryKey: ["workspaces", workspaceId, "agents", agentName, "credentials"],
queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName),
};
};
9 changes: 9 additions & 0 deletions site/src/components/CodeExample/CodeExample.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ export const LongCode: Story = {
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
},
};

export const Redact: Story = {
args: {
secret: false,
redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g,
redactReplacement: `CODER_AGENT_TOKEN="********"`,
showRevealButton: true,
},
};
65 changes: 58 additions & 7 deletions site/src/components/CodeExample/CodeExample.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type FC, useState } from "react";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { CopyButton } from "../CopyButton/CopyButton";

interface CodeExampleProps {
code: string;
/** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */
secret?: boolean;
/** Redact parts of the code if the user doesn't want to obfuscate the whole code */
redactPattern?: RegExp;
/** Replacement text for redacted content */
redactReplacement?: string;
/** Show a button to reveal the redacted parts of the code */
showRevealButton?: boolean;
className?: string;
}

Expand All @@ -15,11 +30,28 @@ interface CodeExampleProps {
export const CodeExample: FC<CodeExampleProps> = ({
code,
className,

// Defaulting to true to be on the safe side; you should have to opt out of
// the secure option, not remember to opt in
secret = true,
redactPattern,
redactReplacement = "********",
showRevealButton,
}) => {
const [showFullValue, setShowFullValue] = useState(false);

const displayValue = secret
? obfuscateText(code)
: redactPattern && !showFullValue
? code.replace(redactPattern, redactReplacement)
: code;

const showButtonLabel = showFullValue
? "Hide sensitive data"
: "Show sensitive data";
const icon = showFullValue ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
);

return (
<div css={styles.container} className={className}>
<code css={[styles.code, secret && styles.secret]}>
Expand All @@ -33,17 +65,36 @@ export const CodeExample: FC<CodeExampleProps> = ({
* 2. Even with it turned on and supported, the plaintext is still
* readily available in the HTML itself
*/}
<span aria-hidden>{obfuscateText(code)}</span>
<span aria-hidden>{displayValue}</span>
<span className="sr-only">
Encrypted text. Please access via the copy button.
</span>
</>
) : (
code
displayValue
)}
</code>

<CopyButton text={code} label="Copy code" />
<div className="flex items-center gap-1">
{showRevealButton && redactPattern && !secret && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={() => setShowFullValue(!showFullValue)}
>
{icon}
<span className="sr-only">{showButtonLabel}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{showButtonLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<CopyButton text={code} label="Copy code" />
</div>
</div>
);
};
Expand Down
70 changes: 70 additions & 0 deletions site/src/modules/resources/AgentExternal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { chromatic } from "testHelpers/chromatic";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AgentExternal } from "./AgentExternal";

const meta: Meta<typeof AgentExternal> = {
title: "modules/resources/AgentExternal",
component: AgentExternal,
args: {
agent: {
...MockWorkspaceAgent,
status: "connecting",
operating_system: "linux",
architecture: "amd64",
},
workspace: MockWorkspace,
},
decorators: [withDashboardProvider],
parameters: {
chromatic,
},
};

export default meta;
type Story = StoryObj<typeof AgentExternal>;

export const Connecting: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connecting",
operating_system: "linux",
architecture: "amd64",
},
},
};

export const Timeout: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "timeout",
operating_system: "linux",
architecture: "amd64",
},
},
};

export const DifferentOS: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connecting",
operating_system: "darwin",
architecture: "arm64",
},
},
};

export const NotExternalAgent: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connecting",
operating_system: "linux",
architecture: "amd64",
},
},
};
45 changes: 45 additions & 0 deletions site/src/modules/resources/AgentExternal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { workspaceAgentCredentials } from "api/queries/workspaces";
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { CodeExample } from "components/CodeExample/CodeExample";
import { Loader } from "components/Loader/Loader";
import type { FC } from "react";
import { useQuery } from "react-query";

interface AgentExternalProps {
agent: WorkspaceAgent;
workspace: Workspace;
}

export const AgentExternal: FC<AgentExternalProps> = ({ agent, workspace }) => {
const {
data: credentials,
error,
isLoading,
isError,
} = useQuery(workspaceAgentCredentials(workspace.id, agent.name));

if (isLoading) {
return <Loader />;
}

if (isError) {
return <ErrorAlert error={error} />;
}

return (
<section className="text-base text-muted-foreground pb-2 leading-relaxed">
<p>
Please run the following command to attach an agent to the{" "}
{workspace.name} workspace:
</p>
<CodeExample
code={credentials?.command ?? ""}
secret={false}
redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g}
redactReplacement={`CODER_AGENT_TOKEN="********"`}
showRevealButton={true}
/>
</section>
);
};
14 changes: 11 additions & 3 deletions site/src/modules/resources/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import AutoSizer from "react-virtualized-auto-sizer";
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
import { AgentExternal } from "./AgentExternal";
import { AgentLatency } from "./AgentLatency";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
import { AgentLogs } from "./AgentLogs/AgentLogs";
Expand Down Expand Up @@ -58,13 +59,14 @@ export const AgentRow: FC<AgentRowProps> = ({
onUpdateAgent,
initialMetadata,
}) => {
const { browser_only } = useFeatureVisibility();
const { browser_only, workspace_external_agent } = useFeatureVisibility();
const appSections = organizeAgentApps(agent.apps);
const hasAppsToDisplay =
!browser_only || appSections.some((it) => it.apps.length > 0);
const isExternalAgent = workspace.latest_build.has_external_agent;
const shouldDisplayAgentApps =
(agent.status === "connected" && hasAppsToDisplay) ||
agent.status === "connecting";
(agent.status === "connecting" && !isExternalAgent);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we only hide them while connecting?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hid the apps when connecting to have a clear view with external agent credentials. The apps would only be installed when the agent is connected, so having them visible doesn’t make sense imo.

image image

const hasVSCodeApp =
agent.display_apps.includes("vscode") ||
agent.display_apps.includes("vscode_insiders");
Expand Down Expand Up @@ -258,7 +260,7 @@ export const AgentRow: FC<AgentRowProps> = ({
</section>
)}

{agent.status === "connecting" && (
{agent.status === "connecting" && !isExternalAgent && (
<section css={styles.apps}>
<Skeleton
width={80}
Expand Down Expand Up @@ -293,6 +295,12 @@ export const AgentRow: FC<AgentRowProps> = ({
</section>
)}

{isExternalAgent &&
(agent.status === "timeout" || agent.status === "connecting") &&
workspace_external_agent && (
<AgentExternal agent={agent} workspace={workspace} />
)}

<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
</div>

Expand Down
8 changes: 8 additions & 0 deletions site/src/modules/workspaces/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export const abilitiesByWorkspaceStatus = (
};
}

if (workspace.latest_build.has_external_agent) {
return {
actions: [],
canCancel: false,
canAcceptJobs: true,
};
}

const status = workspace.latest_build.status;

switch (status) {
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy