Skip to content
Open
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
84 changes: 84 additions & 0 deletions apps/portal/src/components/CliCommand.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import { CliCommand } from "./CliCommand";
import {
buildRunGet,
buildRunList,
buildRunSubmit,
buildRunBulk,
} from "@/lib/cli/buildCommand";

const meta = {
component: CliCommand,
tags: ["ai-generated", "needs-work"],
} satisfies Meta<typeof CliCommand>;
Comment on lines +6 to +17

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

/** Simple single-command modal (e.g. a run detail page). */
export const RunGet: Story = {
args: { command: buildRunGet("req_12345") },
play: async ({ canvas }) => {
const trigger = canvas.getByRole("button", { name: /show cli equivalent/i });
await expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
},
};

/** List command reflecting the active filters and sort. */
export const RunListFiltered: Story = {
args: {
command: buildRunList({
workers: ["coder-vscode-web"],
statuses: ["done"],
outcomes: ["failed"],
turns: "5",
turnsOp: "gte",
sortBy: "duration",
sortDir: "desc",
}),
},
};

/** Labeled trigger used in the Submit Run footer, with a parity note. */
export const SubmitLabeled: Story = {
args: {
label: "CLI",
title: "Submit from the CLI",
command: buildRunSubmit({
task: "Create a Snake game using React",
criteria: ["has-tests"],
worker: "coder-acp-claude-code",
model: "claude-sonnet-4.5",
occurrences: 5,
priority: 3,
}),
},
};

/** Base profile + variations: the variations go through a JSON file. */
export const SubmitProfileVariations: Story = {
args: {
label: "CLI",
title: "Submit from the CLI",
command: buildRunSubmit({
task: "Create a Snake game using React",
criteria: ["has-tests"],
baseProfileId: "prof_base@2",
profileVariations: ["prof_fast", "prof_thorough@4"],
occurrences: 3,
}),
},
};

/** Bulk delete over a multi-id selection (loop form). */
export const BulkLoop: Story = {
args: {
title: "Bulk action from the CLI",
command: buildRunBulk("delete", ["req_a", "req_b", "req_c"]),
},
};
51 changes: 51 additions & 0 deletions apps/portal/src/components/CliCommand.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";

import { ProjectProvider } from "@/contexts/ProjectContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { PROJECT_STORAGE_KEY } from "@/lib/project-scope";
import { CLI_INSTALL_COMMAND, buildRunSubmit } from "@/lib/cli/buildCommand";
import { CliCommand } from "./CliCommand";

const command = buildRunSubmit({ task: "t", worker: "coder-acp-copilot", priority: 2 });

async function openModal(withProvider: boolean) {
const ui = (
<TooltipProvider>
<CliCommand command={command} />
</TooltipProvider>
);
render(withProvider ? <ProjectProvider>{ui}</ProjectProvider> : ui);
await userEvent.click(screen.getByRole("button", { name: /show cli equivalent/i }));
return screen.getByRole("dialog").textContent ?? "";
}

describe("CliCommand", () => {
afterEach(() => {
cleanup();
localStorage.clear();
});

it("shows the public installer, project env and generated command with notes", async () => {
localStorage.setItem(PROJECT_STORAGE_KEY, "proj_42");
const text = await openModal(true);

expect(text).toContain(CLI_INSTALL_COMMAND);
expect(text).toContain("export SCOPE_PROJECT=proj_42");
expect(text).toContain("scope run submit");
expect(text).toContain("Priority (2)");
expect(text).not.toMatch(/SCOPE_TOKEN=/);
});

it("omits SCOPE_PROJECT outside a ProjectProvider", async () => {
const text = await openModal(false);

expect(text).toContain("export SCOPE_API_URL=");
expect(text).not.toContain("SCOPE_PROJECT");
});
});
220 changes: 220 additions & 0 deletions apps/portal/src/components/CliCommand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useState } from "react";
import { Terminal, Copy, Check, Info, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CLI_INSTALL_COMMAND, buildEnvCommand, type CliCommand as CliCommandValue } from "@/lib/cli/buildCommand";
import { useOptionalSelectedProjectId } from "@/contexts/ProjectContext";

export interface CliCommandProps {
/** The command to display. Recompute in the parent so it tracks live state. */
command: CliCommandValue;
/** Optional label shown next to the terminal glyph (e.g. "CLI"). */
label?: string;
/** Modal heading. */
title?: string;
/** Tooltip on the trigger. */
tooltip?: string;
className?: string;
/**
* When true, the trigger is disabled and the modal cannot be opened. Use this
* when the equivalent action (e.g. the Submit form) is not yet valid.
*/
disabled?: boolean;
/**
* Kept for backwards compatibility with the previous popover API. No longer
* used now that the affordance is a centered modal.
*/
align?: "start" | "center" | "end";
}

/** Public documentation for installing and using the Scope CLI. */
const DOCS_URL = "https://microsoft.github.io/scope/getting-started/install-cli/";

/**
* The API base URL to suggest for `SCOPE_API_URL`.
*
* Every supported deployment fronts the API on the same origin as the Portal:
* nginx proxies `/api/*` in production, and the Vite dev server proxies `/api`
* in local dev (`src/lib/api.ts` calls it via the relative `/api/v1` base).
* Because the CLI requests `${SCOPE_API_URL}/api/v1/...`, the current browser
* origin is the correct, environment-specific value in every case — no
* hardcoding or per-environment branching needed.
*/
function apiUrl(): string {
if (typeof window === "undefined" || !window.location?.origin) {
return "http://localhost:5106";
}
return window.location.origin;
}

/** A single copyable command block with its own copy button (GitHub-style). */
function CommandBlock({
copyValue,
display,
toastLabel,
}: {
/** Exact string written to the clipboard (single line). */
copyValue: string;
/** Optional multi-line rendition for display; defaults to copyValue. */
display?: string;
toastLabel: string;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(copyValue);
setCopied(true);
toast.success(toastLabel);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Couldn't copy to clipboard");
}
};
return (
<div className="flex items-start gap-2">
<pre className="min-w-0 flex-1 overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 text-xs leading-relaxed">
<code className="font-mono text-foreground">{display ?? copyValue}</code>
</pre>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
aria-label="Copy command"
onClick={copy}
>
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
);
}

/** A numbered step: bold "Step N" lead-in + description, then its content. */
function Step({ n, label, children }: { n: number; label: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<p className="text-sm text-foreground">
<span className="font-semibold">Step {n}</span> {label}
</p>
{children}
</div>
);
}

/**
* "Copy as CLI" affordance: a terminal-glyph button that opens a GitHub-style
* modal with step-by-step instructions to reproduce the user's current Portal
* state from the `scope` CLI. See lib/cli/buildCommand.ts for the builders.
*/
export function CliCommand({
command,
label,
title = "Run this from the CLI",
tooltip = "Show CLI equivalent",
className,
disabled = false,
}: CliCommandProps) {
const projectId = useOptionalSelectedProjectId();
const trigger = label ? (
<Button
type="button"
variant="outline"
size="sm"
className={cn("gap-1.5", className)}
aria-label={tooltip}
disabled={disabled}
>
<Terminal className="h-4 w-4" />
{label}
</Button>
) : (
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8 text-muted-foreground", className)}
aria-label={tooltip}
disabled={disabled}
>
<Terminal className="h-4 w-4" />
</Button>
);

return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>{trigger}</DialogTrigger>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Reproduce your current selection from the terminal or wire it into CI. Everything below maps to the state
you have configured in the Portal.
</DialogDescription>
</DialogHeader>

<div className="min-w-0 space-y-5 py-1">
<Step n={1} label="Install the Scope CLI (one-time).">
<CommandBlock copyValue={CLI_INSTALL_COMMAND} toastLabel="Install command copied" />
<p className="text-xs text-muted-foreground">
Requires Node.js 20+ and <code className="font-mono">curl</code>.
</p>
</Step>

<Step n={2} label={projectId ? "Point the CLI at this API and project." : "Point the CLI at this API."}>
<CommandBlock
copyValue={buildEnvCommand(apiUrl(), projectId)}
toastLabel="Environment variables copied"
/>
<p className="text-xs text-muted-foreground">
If this deployment requires sign-in, also export <code className="font-mono">SCOPE_TOKEN</code> with an
access token for the API. Credentials are never included here.
</p>
</Step>

<Step n={3} label="Run the command.">
<CommandBlock copyValue={command.command} display={command.display} toastLabel="CLI command copied" />
{command.notes.length > 0 && (
<ul className="space-y-1.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5">
{command.notes.map((note, i) => (
<li key={i} className="flex gap-2 text-xs leading-snug text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{note}</span>
</li>
))}
</ul>
)}
</Step>

<div className="border-t pt-3">
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
Read the CLI documentation
</a>
</div>
</div>
</DialogContent>
</Dialog>
);
}
5 changes: 5 additions & 0 deletions apps/portal/src/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,8 @@ export function useProjectContext(): ProjectContextValue {
export function useSelectedProjectId(): string | undefined {
return useProjectContext().selectedProjectId;
}

/** Like {@link useSelectedProjectId}, but returns undefined outside a ProjectProvider. */
export function useOptionalSelectedProjectId(): string | undefined {
return useContext(ProjectContext)?.selectedProjectId;
}
Loading
Loading