From e1ac7a9b09ca3cc8f88e7444dafb193d36ceac40 Mon Sep 17 00:00:00 2001 From: Wassim Chegham Date: Mon, 1 Jun 2026 17:01:42 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(portal):=20add=20"Copy=20as=20CLI"=20a?= =?UTF-8?q?ffordance=20(Portal=20=E2=86=92=20CLI=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the exact `scope` command equivalent of the current Portal view via a terminal-glyph button that opens a GitHub-style modal with step-by-step instructions (install, point at API, run), each with its own copy button plus parity caveats. - Component: apps/portal/src/components/CliCommand.tsx (Dialog-based modal) - Builders: apps/portal/src/lib/cli/buildCommand.ts (pure, flag-accurate; unsupported Portal state surfaced as notes, never dropped; secrets never embedded) + unit tests. - Mount points: RunDetail header, RunPreviewPanel, RunsList header + bulk bar, SubmitRun footer. - Docs: restores the "Copy as CLI" section in cli-distribution.md. Extracted from the ux-v2 PR (#945) into this focused follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/CliCommand.stories.tsx | 65 ++++++ apps/portal/src/components/CliCommand.tsx | 215 +++++++++++++++++ apps/portal/src/lib/cli/buildCommand.test.ts | 168 ++++++++++++++ apps/portal/src/lib/cli/buildCommand.ts | 219 ++++++++++++++++++ apps/portal/src/pages/RunDetail.tsx | 3 + apps/portal/src/pages/RunPreviewPanel.tsx | 3 + apps/portal/src/pages/RunsList.tsx | 48 ++++ apps/portal/src/pages/SubmitRun.tsx | 32 +++ docs/architecture/cli-distribution.md | 29 +++ 9 files changed, 782 insertions(+) create mode 100644 apps/portal/src/components/CliCommand.stories.tsx create mode 100644 apps/portal/src/components/CliCommand.tsx create mode 100644 apps/portal/src/lib/cli/buildCommand.test.ts create mode 100644 apps/portal/src/lib/cli/buildCommand.ts diff --git a/apps/portal/src/components/CliCommand.stories.tsx b/apps/portal/src/components/CliCommand.stories.tsx new file mode 100644 index 000000000..4ac954548 --- /dev/null +++ b/apps/portal/src/components/CliCommand.stories.tsx @@ -0,0 +1,65 @@ +// 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; + +export default meta; +type Story = StoryObj; + +/** 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 active filters, with a parity note. */ +export const RunListWithNote: Story = { + args: { + command: buildRunList({ + worker: "coder-vscode-web", + turns: "5", + turnsOp: "gte", + unsupportedFilters: ["status", "outcome", "model"], + }), + }, +}; + +/** Labeled trigger used in the Submit Run footer. */ +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, + }), + }, +}; + +/** Bulk action 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"]), + }, +}; diff --git a/apps/portal/src/components/CliCommand.tsx b/apps/portal/src/components/CliCommand.tsx new file mode 100644 index 000000000..dd65ccac8 --- /dev/null +++ b/apps/portal/src/components/CliCommand.tsx @@ -0,0 +1,215 @@ +// 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 type { CliCommand as CliCommandValue } from "@/lib/cli/buildCommand"; + +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"; +} + +/** The CLI installer one-liner (see docs/architecture/cli-distribution.md). */ +const INSTALL_COMMAND = + 'gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H "Accept: application/vnd.github.raw" | bash'; + +/** Public documentation home for the Scope CLI. */ +const DOCS_URL = "https://aka.ms/projectscope/doc"; + +/** + * 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 ( +
+
+        {display ?? copyValue}
+      
+ +
+ ); +} + +/** 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 ( +
+

+ Step {n} {label} +

+ {children} +
+ ); +} + +/** + * "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 trigger = label ? ( + + ) : ( + + ); + + return ( + + + + {trigger} + + {tooltip} + + + + {title} + + Reproduce your current selection from the terminal or wire it into CI. Everything below maps to the state + you have configured in the Portal. + + + +
+ + +

+ Requires Node.js 20+ and an authenticated gh CLI. +

+
+ + + + + + + + {command.notes.length > 0 && ( +
    + {command.notes.map((note, i) => ( +
  • + + {note} +
  • + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/lib/cli/buildCommand.test.ts b/apps/portal/src/lib/cli/buildCommand.test.ts new file mode 100644 index 000000000..ce1c1d070 --- /dev/null +++ b/apps/portal/src/lib/cli/buildCommand.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { + shellQuote, + buildRunSubmit, + buildRunList, + buildRunGet, + buildRunAction, + buildRunBulk, +} from "./buildCommand"; + +describe("shellQuote", () => { + it("leaves safe bare words untouched", () => { + expect(shellQuote("coder-acp-copilot")).toBe("coder-acp-copilot"); + expect(shellQuote("req_123")).toBe("req_123"); + expect(shellQuote("vercel-labs/agent-skills/my-skill")).toBe("vercel-labs/agent-skills/my-skill"); + }); + + it("single-quotes values with spaces", () => { + expect(shellQuote("hello world")).toBe("'hello world'"); + }); + + it("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe(`'it'\\''s'`); + }); + + it("quotes empty strings explicitly", () => { + expect(shellQuote("")).toBe("''"); + }); +}); + +describe("buildRunSubmit", () => { + it("omits defaults (worker, max-iterations) and empty fields", () => { + const { command, notes } = buildRunSubmit({ + task: "build a snake game", + worker: "coder-acp-copilot", + maxIterations: 10, + }); + expect(command).toBe("scope run submit -m 'build a snake game'"); + expect(notes).toEqual([]); + }); + + it("emits non-default worker, model, criteria and tool selections", () => { + const { command } = buildRunSubmit({ + task: "task", + worker: "coder-acp-claude-code", + model: "claude-sonnet-4.5", + reasoningEffort: "high", + maxIterations: 20, + criteria: ["has-tests", "compiles"], + mcpServers: ["github"], + skills: ["a/b/c"], + extensions: ["ms-python.python"], + agentVersion: "copilot-0.0.415", + baseProfileId: "prof_1", + }); + expect(command).toBe( + "scope run submit -m task -c has-tests compiles -w coder-acp-claude-code " + + "--model claude-sonnet-4.5 --reasoning-effort high --max-iterations 20 " + + "--mcp-servers github --skills a/b/c --extensions ms-python.python " + + "--agent-version copilot-0.0.415 --profile prof_1", + ); + }); + + it("surfaces Portal-only knobs as notes without dropping the command", () => { + const { command, notes } = buildRunSubmit({ + task: "t", + occurrences: 5, + priority: 3, + variationMode: true, + }); + expect(command).toBe("scope run submit -m t"); + expect(notes).toHaveLength(3); + expect(notes[0]).toContain("Occurrences (5)"); + expect(notes[1]).toContain("Priority (3)"); + expect(notes[2]).toContain("profile-variations-file"); + }); + + it("includes the profile version in --profile when selected", () => { + const { command } = buildRunSubmit({ task: "t", baseProfileId: "prof_1@3" }); + expect(command).toBe("scope run submit -m t --profile prof_1@3"); + }); + + it("suppresses per-run worker/model/tool fields in variation mode", () => { + const { command } = buildRunSubmit({ + task: "t", + worker: "coder-acp-claude-code", + model: "claude-sonnet-4.5", + reasoningEffort: "high", + mcpServers: ["github"], + skills: ["a/b/c"], + extensions: ["ms-python.python"], + agentVersion: "copilot-0.0.415", + baseProfileId: "prof_1@2", + variationMode: true, + }); + // Only task, base profile remain; the profiles supply the rest. + expect(command).toBe("scope run submit -m t --profile prof_1@2"); + }); +}); + +describe("buildRunList", () => { + it("is a bare command with no filters", () => { + expect(buildRunList({}).command).toBe("scope run list"); + }); + + it("maps supported filters with operator symbols", () => { + const { command } = buildRunList({ + worker: "coder-vscode-web", + submissionId: "sub_9", + turns: "5", + turnsOp: "gte", + maxIter: "10", + maxIterOp: "lte", + includeDeleted: true, + }); + expect(command).toBe( + "scope run list -w coder-vscode-web --submission-id sub_9 --turns '>=5' --max-iterations '<=10' --include-deleted", + ); + }); + + it("notes Portal-only filters that the CLI cannot express", () => { + const { command, notes } = buildRunList({ + worker: "coder-acp-copilot", + unsupportedFilters: ["status", "outcome", "task"], + }); + expect(command).toBe("scope run list -w coder-acp-copilot"); + expect(notes[0]).toContain("status, outcome, task"); + }); +}); + +describe("buildRunGet / buildRunAction", () => { + it("builds get", () => { + expect(buildRunGet("req_1").command).toBe("scope run get -i req_1"); + }); + + it("builds retry with force", () => { + expect(buildRunAction("retry", "req_1", { force: true }).command).toBe("scope run retry -i req_1 -f"); + }); + + it("builds delete without force flag", () => { + expect(buildRunAction("delete", "req_1", { force: true }).command).toBe("scope run delete -i req_1"); + }); +}); + +describe("buildRunBulk", () => { + it("uses a single variadic command for cancel", () => { + expect(buildRunBulk("cancel", ["a", "b", "c"]).command).toBe("scope run cancel -i a b c"); + }); + + it("collapses a single id to the plain action command", () => { + expect(buildRunBulk("delete", ["a"]).command).toBe("scope run delete -i a"); + }); + + it("emits a shell loop for multi-id delete (single-id CLI verb)", () => { + expect(buildRunBulk("delete", ["a", "b"]).command).toBe( + 'for id in a b; do scope run delete -i "$id"; done', + ); + }); + + it("carries force into the retry loop", () => { + expect(buildRunBulk("retry", ["a", "b"], { force: true }).command).toBe( + 'for id in a b; do scope run retry -i "$id" -f; done', + ); + }); +}); diff --git a/apps/portal/src/lib/cli/buildCommand.ts b/apps/portal/src/lib/cli/buildCommand.ts new file mode 100644 index 000000000..86efcbfa3 --- /dev/null +++ b/apps/portal/src/lib/cli/buildCommand.ts @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Builders that translate the Portal's current state into the equivalent + * `scope` CLI command. These power the "Copy as CLI" affordance so a user who + * configures something visually can switch to the CLI (or wire it into CI) + * without hunting for the right flags. + * + * Design goals: + * - Accuracy over completeness: only emit flags the CLI actually supports. + * - Honesty: when the Portal can do something the CLI can't express, surface a + * `note` instead of silently dropping it (see issue #1004 parity tracking). + * - No secrets: token/account values are never embedded. + */ + +export interface CliCommand { + /** Single-line command, suitable for copying / pasting into CI. */ + command: string; + /** Multi-line, backslash-continued rendition for readable display. */ + display: string; + /** Caveats: Portal-only state that the CLI cannot currently express. */ + notes: string[]; +} + +const BINARY = "scope"; + +/** + * Quote a token for POSIX shells. Bare words that only contain safe characters + * are left untouched; everything else is wrapped in single quotes with embedded + * single quotes escaped using the `'\''` idiom. + */ +export function shellQuote(value: string): string { + if (value === "") return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** A single-valued flag. Returns [] when the value is empty/nullish. */ +function flag(name: string, value: string | number | null | undefined): string[] { + if (value === null || value === undefined) return []; + const str = String(value); + if (str === "") return []; + return [name, shellQuote(str)]; +} + +/** A variadic flag (`--criteria a b c`). Returns [] for empty lists. */ +function variadicFlag(name: string, values: readonly string[] | undefined): string[] { + if (!values || values.length === 0) return []; + return [name, ...values.map(shellQuote)]; +} + +/** A boolean flag, emitted only when `on` is true. */ +function boolFlag(name: string, on: boolean | undefined): string[] { + return on ? [name] : []; +} + +/** Assemble tokens into a {command, display, notes} triple. */ +function assemble(tokens: string[], notes: string[] = []): CliCommand { + const command = tokens.join(" "); + // For display, break before every top-level flag (tokens starting with `-`) + // so long commands wrap readably with trailing backslashes. + const lines: string[] = []; + for (const tok of tokens) { + if (tok.startsWith("-") && lines.length > 0) { + lines.push(tok); + } else if (lines.length === 0) { + lines.push(tok); + } else { + lines[lines.length - 1] += ` ${tok}`; + } + } + const display = lines.join(" \\\n "); + return { command, display, notes }; +} + +// --------------------------------------------------------------------------- +// Runs +// --------------------------------------------------------------------------- + +export interface RunSubmitState { + task?: string; + criteria?: readonly string[]; + worker?: string; + model?: string; + reasoningEffort?: string; + maxIterations?: number; + mcpServers?: readonly string[]; + skills?: readonly string[]; + extensions?: readonly string[]; + agentVersion?: string; + baseProfileId?: string | null; + /** Portal-only knobs that the CLI submit command cannot express directly. */ + occurrences?: number; + priority?: number; + /** + * True when a base profile + at least one variation is selected. In this mode + * the Portal (and API) ignore per-run worker/model/tool fields in favour of + * the profiles, so we suppress them here too and point at the variations file. + */ + variationMode?: boolean; +} + +const DEFAULT_WORKER = "coder-acp-copilot"; + +export function buildRunSubmit(state: RunSubmitState): CliCommand { + const variationMode = state.variationMode ?? false; + const tokens = [BINARY, "run", "submit"]; + tokens.push(...flag("-m", state.task)); + tokens.push(...variadicFlag("-c", state.criteria)); + // In variation mode the Portal omits per-run worker/model/tool fields; the + // profiles supply them, so the CLI command must omit them as well. + if (!variationMode) { + // The CLI defaults worker to coder-acp-copilot; only emit when it differs. + if (state.worker && state.worker !== DEFAULT_WORKER) { + tokens.push(...flag("-w", state.worker)); + } + tokens.push(...flag("--model", state.model)); + tokens.push(...flag("--reasoning-effort", state.reasoningEffort)); + } + // The CLI defaults max-iterations to 10; only emit when it differs. + if (state.maxIterations !== undefined && state.maxIterations !== 10) { + tokens.push(...flag("--max-iterations", state.maxIterations)); + } + if (!variationMode) { + tokens.push(...variadicFlag("--mcp-servers", state.mcpServers)); + tokens.push(...variadicFlag("--skills", state.skills)); + tokens.push(...variadicFlag("--extensions", state.extensions)); + tokens.push(...flag("--agent-version", state.agentVersion)); + } + tokens.push(...flag("--profile", state.baseProfileId ?? undefined)); + + const notes: string[] = []; + if (state.occurrences !== undefined && state.occurrences > 1) { + notes.push( + `Occurrences (${state.occurrences}) submit one run per CLI invocation — run the command ${state.occurrences}× or use a loop.`, + ); + } + if (state.priority !== undefined && state.priority !== 0) { + notes.push(`Priority (${state.priority}) is set in the Portal only; \`run submit\` has no --priority flag.`); + } + if (variationMode) { + notes.push("Profile variations require --profile-variations-file (export the variations to JSON first)."); + } + return assemble(tokens, notes); +} + +export type IterationOp = "eq" | "gte" | "lte"; + +const OP_SYMBOL: Record = { eq: "=", gte: ">=", lte: "<=" }; + +export interface RunListState { + worker?: string | null; + submissionId?: string | null; + turns?: string | null; + turnsOp?: IterationOp | null; + maxIter?: string | null; + maxIterOp?: IterationOp | null; + includeDeleted?: boolean; + /** Active UI filters the CLI `run list` cannot express, for honest notes. */ + unsupportedFilters?: readonly string[]; +} + +export function buildRunList(state: RunListState): CliCommand { + const tokens = [BINARY, "run", "list"]; + tokens.push(...flag("-w", state.worker ?? undefined)); + tokens.push(...flag("--submission-id", state.submissionId ?? undefined)); + if (state.turns) { + const op = OP_SYMBOL[state.turnsOp ?? "gte"]; + tokens.push(...flag("--turns", `${op}${state.turns}`)); + } + if (state.maxIter) { + const op = OP_SYMBOL[state.maxIterOp ?? "gte"]; + tokens.push(...flag("--max-iterations", `${op}${state.maxIter}`)); + } + tokens.push(...boolFlag("--include-deleted", state.includeDeleted)); + + const notes: string[] = []; + if (state.unsupportedFilters && state.unsupportedFilters.length > 0) { + notes.push( + `Filtered in the Portal only (no \`run list\` flag): ${state.unsupportedFilters.join(", ")}.`, + ); + } + return assemble(tokens, notes); +} + +export function buildRunGet(id: string): CliCommand { + return assemble([BINARY, "run", "get", "-i", shellQuote(id)]); +} + +export type RunAction = "retry" | "cancel" | "delete" | "download"; + +export function buildRunAction(action: RunAction, id: string, opts?: { force?: boolean }): CliCommand { + const tokens = [BINARY, "run", action, "-i", shellQuote(id)]; + if (action === "retry" && opts?.force) tokens.push("-f"); + return assemble(tokens); +} + +/** + * Build a command for a bulk action over a selection of run IDs. + * `cancel` is variadic in the CLI, so all ids go on one command; `delete`, + * `retry` and `download` take a single id, so >1 selection becomes a loop. + */ +export function buildRunBulk(action: RunAction, ids: readonly string[], opts?: { force?: boolean }): CliCommand { + const quoted = ids.map(shellQuote); + if (ids.length === 0) { + return assemble([BINARY, "run", action, "-i", "RUN_ID"]); + } + if (action === "cancel") { + return assemble([BINARY, "run", "cancel", "-i", ...quoted]); + } + if (ids.length === 1) { + return buildRunAction(action, ids[0], opts); + } + const force = action === "retry" && opts?.force ? " -f" : ""; + const list = quoted.join(" "); + const command = `for id in ${list}; do ${BINARY} run ${action} -i "$id"${force}; done`; + return { command, display: command, notes: [] }; +} diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 77ac6b0fa..c68112849 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -48,6 +48,8 @@ import type { RunState, LogEvent, ResourceBinding, ResourceRunOutcome } from "@/ import { useShiftModifier } from "@/hooks/useShiftModifier"; import { getRetryButtonState } from "@/components/RetryButton"; import { getRunSkillReferences } from "@/lib/skill-spec"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunGet } from "@/lib/cli/buildCommand"; /** A compact labeled stat: a micro uppercase label above its value. */ function MetaItem({ label, value, title }: { label: string; value: ReactNode; title?: string }) { @@ -628,6 +630,7 @@ export function RunDetail() { {copied ? : } Copy run ID + {/* Tier 1 — semantic status pills */} diff --git a/apps/portal/src/pages/RunPreviewPanel.tsx b/apps/portal/src/pages/RunPreviewPanel.tsx index 1a6bacee2..e2f66dac0 100644 --- a/apps/portal/src/pages/RunPreviewPanel.tsx +++ b/apps/portal/src/pages/RunPreviewPanel.tsx @@ -13,6 +13,8 @@ import { StatusBadge, OutcomeBadge } from "@/components/StatusBadge"; import { TaskPromptBadge } from "@/components/TaskPromptBadge"; import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge"; import { GATE_METADATA } from "@/lib/gates"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunGet } from "@/lib/cli/buildCommand"; import { formatDate, formatId, formatDuration, truncate } from "@/lib/utils"; export function RunPreviewPanel() { @@ -79,6 +81,7 @@ export function RunPreviewPanel() { onClose={closePanel} headerActions={
+
} filterRail={ @@ -2192,6 +2239,7 @@ export function RunsList() { > + {groupBy === "none" ? ( diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx index 54beb26ed..6bffe3397 100644 --- a/apps/portal/src/pages/SubmitRun.tsx +++ b/apps/portal/src/pages/SubmitRun.tsx @@ -52,6 +52,8 @@ import { type GateConfig, type GateId, } from "@/lib/gates"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunSubmit } from "@/lib/cli/buildCommand"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, @@ -746,6 +748,35 @@ export function SubmitRun() { const submitRunCount = expandedRunCount; const lastVariationDraft = variationDrafts.length > 0 ? variationDrafts[variationDrafts.length - 1] : null; + // Equivalent `scope run submit` command for the "Copy as CLI" affordance. + const submitCli = useMemo(() => { + const inVariationMode = !!selectedProfileId && selectedVariationCount > 0; + const baseProfileSpec = selectedProfileId + ? selectedProfileVersion + ? `${selectedProfileId}@${selectedProfileVersion}` + : selectedProfileId + : null; + return buildRunSubmit({ + task, + criteria: pickedCriteria, + worker, + model, + reasoningEffort, + maxIterations, + mcpServers: selectedMcpServers, + skills: selectedSkills, + extensions: selectedExtensions, + agentVersion: selectedAgentVersion || undefined, + baseProfileId: baseProfileSpec, + occurrences, + priority, + variationMode: inVariationMode, + }); + }, [ + task, pickedCriteria, worker, model, reasoningEffort, maxIterations, + selectedMcpServers, selectedSkills, selectedExtensions, selectedAgentVersion, + selectedProfileId, selectedProfileVersion, occurrences, priority, selectedVariationCount, + ]); const lastVariationName = lastVariationDraft ? profileList.find((p) => p._id === lastVariationDraft.profileId)?.name : null; @@ -1990,6 +2021,7 @@ export function SubmitRun() { )} +