vscode and cursor.",
+ "description": "Info note listing the supported editor values for the connect-sandbox command. Editor names are wrapped in tags."
+ },
+ "app.gateway.connection.editorOptionsLink": {
+ "defaultMessage": "OpenShell connect documentation",
+ "description": "Link text pointing to the OpenShell sandbox connect CLI documentation."
+ },
+ "app.gateway.connection.editorOptionsLinkNewTab": {
+ "defaultMessage": "Sandbox connect reference (opens in a new tab)",
+ "description": "Accessible name for the sandbox connect docs link, including that it opens in a new tab."
+ },
+ "app.gateway.connection.editorOptionsTitle": {
+ "defaultMessage": "Editor options",
+ "description": "Title for the editor options info alert."
+ },
"app.gateway.connection.installLink": {
"defaultMessage": "Install the OpenShell CLI",
"description": "Link text pointing to the NVIDIA OpenShell installation documentation."
@@ -167,6 +195,14 @@
"defaultMessage": "Create a sandbox",
"description": "Title for the create-sandbox connection step."
},
+ "app.gateway.connection.sandboxConnect.description": {
+ "defaultMessage": "Disconnecting does not stop the sandbox process. Connect attaches to the same process instance and replays recent output.",
+ "description": "Supporting text for the connect-to-sandbox connection step."
+ },
+ "app.gateway.connection.sandboxConnect.title": {
+ "defaultMessage": "Connect to a sandbox",
+ "description": "Title for the connect-to-sandbox connection step."
+ },
"app.gateway.connection.setup.description": {
"defaultMessage": "Run these once to log in, add the Claude on Vertex AI provider, and select the model.",
"description": "Supporting text for the one-time setup connection step."
diff --git a/packages/gateway-management-ui/src/gateways/editable-command.tsx b/packages/gateway-management-ui/src/gateways/editable-command.tsx
index a8d44a15..bf1c96a7 100644
--- a/packages/gateway-management-ui/src/gateways/editable-command.tsx
+++ b/packages/gateway-management-ui/src/gateways/editable-command.tsx
@@ -105,22 +105,44 @@ function EditableField({
);
}
-/**
- * A copyable command block whose marked value slots are editable in place.
- *
- * `templateCommand` carries the edit markers and is highlighted once; `copyText`
- * is the same command with the operator's current values resolved and drives
- * both the copy button and (identically) a whole-block text selection. Editing a
- * field calls `onFieldChange(marker, value)`; a marker used twice in the command
- * (the mirrored provider name) is kept in lockstep because both slots read the
- * same entry in `values`.
- */
+function SelectField({
+ colorClassName,
+ label,
+ onChange,
+ options,
+ value,
+}: {
+ colorClassName: string;
+ label: string;
+ onChange: (value: string) => void;
+ options: readonly string[];
+ value: string;
+}) {
+ return (
+
+ );
+}
+
export function EditableCommand({
copyAriaLabel,
copyText,
labels,
markers,
onFieldChange,
+ selectOptions,
templateCommand,
values,
}: {
@@ -129,6 +151,7 @@ export function EditableCommand({
labels: Record;
markers: readonly string[];
onFieldChange: (marker: string, value: string) => void;
+ selectOptions?: Record;
templateCommand: string;
values: Record;
}) {
@@ -183,12 +206,32 @@ export function EditableCommand({
- {parts.map((part, index) =>
- part.kind === "text" ? (
-
- {part.value}
-
- ) : (
+ {parts.map((part, index) => {
+ if (part.kind === "text") {
+ return (
+
+ {part.value}
+
+ );
+ }
+
+ const options = selectOptions?.[part.marker];
+ if (options) {
+ return (
+ {
+ onFieldChange(part.marker, value);
+ }}
+ options={options}
+ value={values[part.marker] ?? ""}
+ />
+ );
+ }
+
+ return (
- ),
- )}
+ );
+ })}
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css
index 46675f7e..24bc5352 100644
--- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css
+++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css
@@ -153,6 +153,26 @@
outline-offset: 2px;
}
+.selectField {
+ appearance: auto;
+ background: transparent;
+ border: none;
+ border-radius: var(--pf-t--global--border--radius--small);
+ cursor: pointer;
+ font: inherit;
+ padding: 0;
+}
+
+.selectField:hover {
+ background-color: var(--pf-t--global--background--color--secondary--default);
+}
+
+.selectField:focus-visible {
+ background-color: var(--pf-t--global--background--color--secondary--default);
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+}
+
.prereqAlert {
margin-block: var(--pf-t--global--spacer--sm);
}
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx
index 173aa90e..66555d2b 100644
--- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx
+++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { GatewayConnectionSteps } from "./gateway-connection-steps";
import {
+ buildSandboxConnectCommand,
buildSandboxCreateCommand,
type GatewayConnection,
installDocsUrl,
@@ -32,7 +33,7 @@ function renderSteps(gateway: GatewayConnection) {
}
describe("GatewayConnectionSteps", () => {
- it("renders the setup and sandbox steps", () => {
+ it("renders the setup, sandbox create, and sandbox connect steps", () => {
renderSteps(readyGateway);
expect(
@@ -41,6 +42,9 @@ describe("GatewayConnectionSteps", () => {
expect(
screen.getByRole("heading", { name: "Create a sandbox" }),
).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "Connect to a sandbox" }),
+ ).toBeTruthy();
});
it("renders a prerequisite alert with an install docs link", () => {
@@ -62,11 +66,11 @@ describe("GatewayConnectionSteps", () => {
expect(link.textContent).toContain("Install the OpenShell CLI");
});
- it("highlights both command blocks with Shiki once they resolve", async () => {
+ it("highlights all command blocks with Shiki once they resolve", async () => {
const { container } = renderSteps(readyGateway);
await waitFor(() => {
- expect(container.querySelectorAll(".shiki").length).toBe(2);
+ expect(container.querySelectorAll(".shiki").length).toBe(3);
});
});
@@ -88,7 +92,7 @@ describe("GatewayConnectionSteps", () => {
const { container } = renderSteps(readyGateway);
await waitFor(() => {
- expect(container.querySelectorAll(".shiki").length).toBe(2);
+ expect(container.querySelectorAll(".shiki").length).toBe(3);
});
const providerFields = screen.getAllByRole("textbox", {
@@ -134,6 +138,48 @@ describe("GatewayConnectionSteps", () => {
);
});
+ it("copies the raw sandbox connect command", async () => {
+ const user = userEvent.setup();
+ renderSteps(readyGateway);
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy the connect-sandbox command",
+ }),
+ );
+
+ expect(await navigator.clipboard.readText()).toBe(
+ buildSandboxConnectCommand(),
+ );
+ });
+
+ it("mirrors an edited sandbox name into both create and connect commands", async () => {
+ const user = userEvent.setup();
+ renderSteps(readyGateway);
+
+ const createField = await screen.findByRole("textbox", {
+ name: "Sandbox name (editable)",
+ });
+ createField.textContent = "scratch";
+ fireEvent.input(createField);
+
+ await user.click(
+ screen.getByRole("button", { name: "Copy the create-sandbox command" }),
+ );
+ expect(await navigator.clipboard.readText()).toBe(
+ buildSandboxCreateCommand("scratch"),
+ );
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy the connect-sandbox command",
+ }),
+ );
+ expect(await navigator.clipboard.readText()).toBe(
+ buildSandboxConnectCommand("scratch"),
+ );
+ });
+
it("shows a pending placeholder until the gateway is ready", () => {
renderSteps({
...readyGateway,
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx
index d8908330..fd6234e3 100644
--- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx
+++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx
@@ -12,12 +12,16 @@ import { useIntl } from "react-intl";
import { messages } from "../messages";
import { EditableCommand } from "./editable-command";
import {
+ buildSandboxConnectCommand,
buildSandboxCreateCommand,
buildSetupScript,
claudeModel,
+ defaultEditor,
type GatewayConnection,
installDocsUrl,
+ sandboxConnectDocsUrl,
sandboxName as defaultSandboxName,
+ validEditors,
vertexProviderName,
} from "./gateway-connections";
import styles from "./gateway-connection-steps.module.css";
@@ -30,8 +34,10 @@ import styles from "./gateway-connection-steps.module.css";
const providerMarker = "OSPROVIDERNAMEZ";
const modelMarker = "OSMODELNAMEZ";
const sandboxMarker = "OSSANDBOXNAMEZ";
+const editorMarker = "OSEDITORNAMEZ";
const setupMarkers = [providerMarker, modelMarker];
const sandboxMarkers = [sandboxMarker, modelMarker];
+const sandboxConnectMarkers = [sandboxMarker, editorMarker];
function ConnectionStep({
children,
@@ -62,6 +68,7 @@ export function GatewayConnectionSteps({
const [providerName, setProviderName] = useState(vertexProviderName);
const [model, setModel] = useState(claudeModel);
const [sandboxName, setSandboxName] = useState(defaultSandboxName);
+ const [editor, setEditor] = useState(defaultEditor);
// Marker form drives the (stable) highlight; the resolved form drives copy and
// matches a whole-block text selection exactly.
@@ -161,6 +168,64 @@ export function GatewayConnectionSteps({
values={{ [modelMarker]: model, [sandboxMarker]: sandboxName }}
/>
+
+
+ }
+ iconPosition="end"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ {intl.formatMessage(messages.connectionEditorOptionsLink)}
+
+ }
+ className={styles.prereqAlert}
+ component="h3"
+ isInline
+ title={intl.formatMessage(messages.connectionEditorOptionsTitle)}
+ variant="info"
+ >
+ {intl.formatMessage(messages.connectionEditorOptions, {
+ code: (chunks) => {chunks},
+ })}
+
+ {
+ if (marker === sandboxMarker) {
+ setSandboxName(value);
+ } else if (marker === editorMarker) {
+ setEditor(value);
+ }
+ }}
+ selectOptions={{ [editorMarker]: validEditors }}
+ templateCommand={buildSandboxConnectCommand(
+ sandboxMarker,
+ editorMarker,
+ )}
+ values={{ [editorMarker]: editor, [sandboxMarker]: sandboxName }}
+ />
+
);
}
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts
index cefee386..fd8f1adf 100644
--- a/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts
+++ b/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts
@@ -4,6 +4,7 @@ import {
buildGatewayAddCommand,
buildInferenceSetCommand,
buildProviderCreateCommand,
+ buildSandboxConnectCommand,
buildSandboxCreateCommand,
buildSetupScript,
claudeModel,
@@ -196,6 +197,30 @@ describe("gateway connections", () => {
);
});
+ it("builds a sandbox connect command with defaults", () => {
+ expect(buildSandboxConnectCommand()).toBe(
+ `openshell sandbox connect ${sandboxName} \\\n --editor cursor`,
+ );
+ });
+
+ it("substitutes a custom name into the sandbox connect command", () => {
+ expect(buildSandboxConnectCommand("demo")).toBe(
+ "openshell sandbox connect demo \\\n --editor cursor",
+ );
+ });
+
+ it("substitutes a custom editor into the sandbox connect command", () => {
+ expect(buildSandboxConnectCommand("demo", "vscode")).toBe(
+ "openshell sandbox connect demo \\\n --editor vscode",
+ );
+ });
+
+ it("quotes shell-unsafe names in the sandbox connect command", () => {
+ expect(buildSandboxConnectCommand("my $(sandbox)")).toBe(
+ `openshell sandbox connect 'my $(sandbox)' \\\n --editor cursor`,
+ );
+ });
+
it("combines login, provider, and inference into one setup script when ready", () => {
const script = buildSetupScript(gateway);
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.ts
index 43ec8b0c..c2855571 100644
--- a/packages/gateway-management-ui/src/gateways/gateway-connections.ts
+++ b/packages/gateway-management-ui/src/gateways/gateway-connections.ts
@@ -77,6 +77,9 @@ export const vertexProviderName = "my-gcp";
export const installDocsUrl =
"https://docs.nvidia.com/openshell/about/installation";
+export const sandboxConnectDocsUrl =
+ "https://docs.nvidia.com/openshell/sandboxes/manage-sandboxes#connect-to-a-sandbox";
+
/** Default sandbox name shown in the copyable create-sandbox command. */
export const sandboxName = "mysand";
@@ -162,6 +165,19 @@ export function buildSandboxCreateCommand(
return `${variable}\n\n${command}`;
}
+export const validEditors = ["cursor", "vscode"] as const;
+export const defaultEditor = "cursor";
+
+export function buildSandboxConnectCommand(
+ name: string = sandboxName,
+ editor: string = defaultEditor,
+): string {
+ return [
+ `openshell sandbox connect ${shellArgument(name)}`,
+ `--editor ${shellArgument(editor)}`,
+ ].join(" \\\n ");
+}
+
/**
* One-time setup script that logs in to the gateway, adds the Claude on Vertex AI
* provider, and selects the model, combined into a single copyable block so
diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts
index 01ef91e4..f6a4a9fc 100644
--- a/packages/gateway-management-ui/src/messages.ts
+++ b/packages/gateway-management-ui/src/messages.ts
@@ -82,6 +82,30 @@ export const messages = defineMessages({
defaultMessage: "Region: {region}",
description: "Region context that distinguishes a managed cluster option.",
},
+ connectionEditorOptions: {
+ id: "app.gateway.connection.editorOptions",
+ defaultMessage:
+ "Supported interactive editors are vscode and cursor.",
+ description:
+ "Info note listing the supported editor values for the connect-sandbox command. Editor names are wrapped in tags.",
+ },
+ connectionEditorOptionsLink: {
+ id: "app.gateway.connection.editorOptionsLink",
+ defaultMessage: "OpenShell connect documentation",
+ description:
+ "Link text pointing to the OpenShell sandbox connect CLI documentation.",
+ },
+ connectionEditorOptionsLinkNewTab: {
+ id: "app.gateway.connection.editorOptionsLinkNewTab",
+ defaultMessage: "Sandbox connect reference (opens in a new tab)",
+ description:
+ "Accessible name for the sandbox connect docs link, including that it opens in a new tab.",
+ },
+ connectionEditorOptionsTitle: {
+ id: "app.gateway.connection.editorOptionsTitle",
+ defaultMessage: "Editor options",
+ description: "Title for the editor options info alert.",
+ },
connectionInstallLink: {
id: "app.gateway.connection.installLink",
defaultMessage: "Install the OpenShell CLI",
@@ -113,6 +137,17 @@ export const messages = defineMessages({
description:
"Shown in the login step while the gateway has not yet reached a running, ready-to-connect phase.",
},
+ connectionSandboxConnectDescription: {
+ id: "app.gateway.connection.sandboxConnect.description",
+ defaultMessage:
+ "Disconnecting does not stop the sandbox process. Connect attaches to the same process instance and replays recent output.",
+ description: "Supporting text for the connect-to-sandbox connection step.",
+ },
+ connectionSandboxConnectTitle: {
+ id: "app.gateway.connection.sandboxConnect.title",
+ defaultMessage: "Connect to a sandbox",
+ description: "Title for the connect-to-sandbox connection step.",
+ },
connectionSandboxDescription: {
id: "app.gateway.connection.sandbox.description",
defaultMessage:
@@ -177,6 +212,12 @@ export const messages = defineMessages({
defaultMessage: "Copy the create-sandbox command",
description: "Accessible label for copying the create-sandbox command.",
},
+ copySandboxConnectCommand: {
+ id: "app.gateway.connection.copySandboxConnectCommand",
+ defaultMessage: "Copy the connect-sandbox command",
+ description:
+ "Accessible label for the button that copies the sandbox connect command.",
+ },
copySetupCommand: {
id: "app.gateway.connection.copySetupCommand",
defaultMessage: "Copy the one-time setup commands",
@@ -221,6 +262,18 @@ export const messages = defineMessages({
defaultMessage: "Details",
description: "Label for the gateway detail Details tab.",
},
+ editEditor: {
+ id: "app.gateway.connection.editEditor",
+ defaultMessage: "Editor",
+ description:
+ "Accessible label for the editor selector in the connect-sandbox command.",
+ },
+ editExistingSandboxName: {
+ id: "app.gateway.connection.editExistingSandboxName",
+ defaultMessage: "Existing sandbox name (editable)",
+ description:
+ "Accessible label for the inline-editable sandbox name in the connect-sandbox command.",
+ },
editModel: {
id: "app.gateway.connection.editModel",
defaultMessage: "Model (editable)",
diff --git a/specs/web-console/architecture.spec.md b/specs/web-console/architecture.spec.md
index ca27728a..2bfb6dfa 100644
--- a/specs/web-console/architecture.spec.md
+++ b/specs/web-console/architecture.spec.md
@@ -494,7 +494,7 @@ The selected tab SHALL use the validated `tab` search parameter per `WEB-DATA-02
The `Details` tab SHALL contain the existing gateway description list (status, cluster, endpoint, CLI connection command, namespace, release identifier, and managed-database identifier), using the same values, loading, unavailable, and placement-resolution behavior required elsewhere in this specification.
-The `Connection` tab SHALL guide the user through three ordered steps using an accessible ordered structure with a visible label and description for each step:
+The `Connection` tab SHALL guide the user through four ordered steps using an accessible ordered structure with a visible label and description for each step:
1. **Log in to the gateway.** The step SHALL present the `openshell gateway add` command produced from the authorized gateway response as a read-only PatternFly Clipboard Copy value, using the same command construction, shell-argument encoding, and missing-value handling required by `WEB-UI-03`. When required connection values are absent, the step SHALL explain that login is unavailable rather than present an incomplete command.
@@ -502,6 +502,8 @@ The `Connection` tab SHALL guide the user through three ordered steps using an a
3. **Create a sandbox.** The step SHALL present an `openshell sandbox create` command that names a sandbox, specifies CPU and memory resource requests and limits via a `--driver-config-json` flag, attaches the provider from step 2, and launches the agent, as a read-only Clipboard Copy value. The default resource values SHALL be CPU requests `100m`, CPU limits `500m`, memory requests `512Mi`, and memory limits `512Mi`.
+4. **Connect to a sandbox.** The step SHALL present an `openshell sandbox connect` command that references the sandbox name from step 3 and an `--editor` flag. The sandbox name field SHALL share state with the create step so both commands stay in sync. The editor field SHALL be a constrained inline selector defaulting to `cursor`, offering only the supported values (`cursor`, `vscode`). An adjacent info alert SHALL document the supported values and link to the sandbox connect documentation on the canonical docs host.
+
The `Connection` tab SHALL also contain a secondary `Create or manage service accounts` link with its introductory content, before the walkthrough. The link SHALL set `tab=service-accounts`. It SHALL not start a create operation.
The `Service accounts` tab SHALL own the gateway-scoped automation identity workflow. It SHALL include the heading, create action, responsive collection, one-time credential handoff, command groups, and lifecycle actions defined in [`openshell-gateway-service-accounts.spec.md`](../platform/openshell-gateway-service-accounts.spec.md).
@@ -521,7 +523,7 @@ Every Clipboard Copy control SHALL have a localized accessible name and visible
- GIVEN an authenticated user opens a gateway detail page
- WHEN the page renders
- THEN the `Connection` tab SHALL be selected
-- AND the three ordered connection steps SHALL be visible
+- AND the four ordered connection steps SHALL be visible
- AND the `Service accounts` tab SHALL be available
- AND the operational description list SHALL be available under the `Details` tab