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

Draft
wants to merge 3 commits into
base: kacpersaw/feat-coder-attach-cli
Choose a base branch
from
Draft
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
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
72 changes: 72 additions & 0 deletions site/src/modules/resources/AgentExternal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { chromatic } from "testHelpers/chromatic";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { AgentExternal } from "./AgentExternal";

const meta: Meta<typeof AgentExternal> = {
title: "modules/resources/AgentExternal",
component: AgentExternal,
args: {
isExternalAgent: true,
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: {
isExternalAgent: false,
agent: {
...MockWorkspaceAgent,
status: "connecting",
operating_system: "linux",
architecture: "amd64",
},
},
};
51 changes: 51 additions & 0 deletions site/src/modules/resources/AgentExternal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { API } from "api/api";
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
import isChromatic from "chromatic/isChromatic";
import { CodeExample } from "components/CodeExample/CodeExample";
import { type FC, useEffect, useState } from "react";

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

export const AgentExternal: FC<AgentExternalProps> = ({
isExternalAgent,
agent,
workspace,
}) => {
const [externalAgentToken, setExternalAgentToken] = useState<string | null>(
null,
);
const [command, setCommand] = useState<string | null>(null);

const origin = isChromatic() ? "https://example.com" : window.location.origin;
useEffect(() => {
if (
isExternalAgent &&
(agent.status === "timeout" || agent.status === "connecting")
) {
API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => {
setExternalAgentToken(res.agent_token);
setCommand(res.command);
});
}
}, [isExternalAgent, agent.status, workspace.id, agent.name]);

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={command ?? ""}
secret={false}
redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g}
redactReplacement={`CODER_AGENT_TOKEN="********"`}
showRevealButton={true}
/>
</section>
);
};
18 changes: 15 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);
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,16 @@ export const AgentRow: FC<AgentRowProps> = ({
</section>
)}

{isExternalAgent &&
(agent.status === "timeout" || agent.status === "connecting") &&
workspace_external_agent && (
<AgentExternal
isExternalAgent={isExternalAgent}
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