feat(web): add "(use step default)" reset to watcher profile selects (#1124)
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
DEFAULT_ISSUE_WATCH_PROMPT,
|
||||
} from "@/components/github/issue-watch-placeholders";
|
||||
import { RepoFilterSelector } from "@/components/github/repo-filter-selector";
|
||||
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
|
||||
import type {
|
||||
RepoFilter,
|
||||
IssueWatch,
|
||||
@@ -300,23 +301,29 @@ function IssueAutomationFields({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
description="The agent configuration for the task."
|
||||
value={form.agentProfileId}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: v }))}
|
||||
placeholder="Select agent profile"
|
||||
items={agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
}))}
|
||||
description="Optional — falls back to step default."
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor Profile"
|
||||
description="The executor environment for the agent."
|
||||
value={form.executorProfileId}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, executorProfileId: v }))}
|
||||
placeholder="Select executor profile"
|
||||
items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))}
|
||||
description="Optional — falls back to step default."
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, executorProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...allExecutorProfiles.map((p) => ({ id: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { DEFAULT_REVIEW_WATCH_PROMPT } from "@/components/github/review-watch-placeholders";
|
||||
import { ReviewWatchPromptField } from "@/components/github/review-watch-prompt-field";
|
||||
import { RepoFilterSelector } from "@/components/github/repo-filter-selector";
|
||||
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
|
||||
import type {
|
||||
RepoFilter,
|
||||
ReviewWatch,
|
||||
@@ -441,15 +442,18 @@ function ProfileFields({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
description="The agent configuration used to review the PR."
|
||||
value={form.agentProfileId}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: v }))}
|
||||
placeholder="Select agent profile"
|
||||
items={agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
}))}
|
||||
description="Optional — falls back to step default."
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -457,16 +461,19 @@ function ProfileFields({
|
||||
<HelpTip text="The repository will be automatically cloned to ~/.kandev/repos/<owner>/<repo> if it is not already present in the workspace." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The executor environment where the agent will run.
|
||||
Optional — falls back to step default. The executor environment where the agent will run.
|
||||
</p>
|
||||
<Select
|
||||
value={form.executorProfileId || undefined}
|
||||
onValueChange={(v) => setForm((prev) => ({ ...prev, executorProfileId: v }))}
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onValueChange={(v) =>
|
||||
setForm((prev) => ({ ...prev, executorProfileId: resolveProfileId(v) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select executor profile" />
|
||||
<SelectValue placeholder={STEP_DEFAULT_LABEL} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={STEP_DEFAULT}>{STEP_DEFAULT_LABEL}</SelectItem>
|
||||
{executorProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
JIRA_ISSUE_WATCH_PLACEHOLDERS,
|
||||
DEFAULT_JIRA_ISSUE_WATCH_PROMPT,
|
||||
} from "@/components/jira/jira-issue-watch-placeholders";
|
||||
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
|
||||
import type {
|
||||
CreateJiraIssueWatchInput,
|
||||
JiraIssueWatch,
|
||||
@@ -326,22 +327,28 @@ function AutomationFields({
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
description="Optional — falls back to step default."
|
||||
value={form.agentProfileId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: v }))}
|
||||
placeholder="(use step default)"
|
||||
items={agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
}))}
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor Profile"
|
||||
description="Optional — falls back to step default."
|
||||
value={form.executorProfileId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: v }))}
|
||||
placeholder="(use step default)"
|
||||
items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))}
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...allExecutorProfiles.map((p) => ({ id: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useTeamsAndStates,
|
||||
} from "./linear-issue-watch-fields";
|
||||
import { LINEAR_ISSUE_WATCH_PLACEHOLDERS } from "./linear-issue-watch-placeholders";
|
||||
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
|
||||
import {
|
||||
ASSIGNED_ANY,
|
||||
CREATOR_ANY,
|
||||
@@ -431,22 +432,28 @@ function AutomationFields({
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
description="Optional — falls back to step default."
|
||||
value={form.agentProfileId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: v }))}
|
||||
placeholder="(use step default)"
|
||||
items={agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
}))}
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...agentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: p.cli_passthrough ? <CliModeIcon /> : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor Profile"
|
||||
description="Optional — falls back to step default."
|
||||
value={form.executorProfileId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: v }))}
|
||||
placeholder="(use step default)"
|
||||
items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))}
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
items={[
|
||||
{ id: STEP_DEFAULT, label: STEP_DEFAULT_LABEL },
|
||||
...allExecutorProfiles.map((p) => ({ id: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { LinearSettingsPage } from "../../pages/linear-settings-page";
|
||||
import { assertWatcherAgentProfileResetsToStepDefault } from "./watcher-profile-default-flow";
|
||||
|
||||
test.describe("Linear settings", () => {
|
||||
test("empty workspace shows form with disabled save/test until secret is filled", async ({
|
||||
@@ -135,4 +136,8 @@ test.describe("Linear settings", () => {
|
||||
// the "<agent> • <profile>" label format.
|
||||
await expect(testPage.getByRole("option", { name: new RegExp(passthroughName) })).toBeVisible();
|
||||
});
|
||||
|
||||
test("watcher dialog resets the agent profile back to the step default", async ({ testPage }) => {
|
||||
await assertWatcherAgentProfileResetsToStepDefault(testPage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { assertWatcherAgentProfileResetsToStepDefault } from "./watcher-profile-default-flow";
|
||||
|
||||
test.describe("Linear watcher dialog (mobile)", () => {
|
||||
test("resets the agent profile back to the step default", async ({ testPage }) => {
|
||||
await assertWatcherAgentProfileResetsToStepDefault(testPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type Page, expect } from "@playwright/test";
|
||||
import { LinearSettingsPage } from "../../pages/linear-settings-page";
|
||||
|
||||
// Drives the Linear watcher create dialog: picks a real agent profile, then
|
||||
// re-selects "(use step default)" and asserts the field clears back. Shared by
|
||||
// the desktop (linear-settings.spec.ts) and mobile (mobile-*.spec.ts) suites so
|
||||
// both viewports exercise the same sentinel-reset behavior.
|
||||
export async function assertWatcherAgentProfileResetsToStepDefault(testPage: Page) {
|
||||
const settings = new LinearSettingsPage(testPage);
|
||||
await settings.goto();
|
||||
|
||||
await testPage.getByRole("button", { name: /new watcher/i }).click();
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Scope to the Agent Profile field; the label text is unique within the form.
|
||||
const agentTrigger = dialog
|
||||
.locator("div.space-y-1\\.5")
|
||||
.filter({ hasText: "Agent Profile" })
|
||||
.getByRole("combobox");
|
||||
|
||||
// The select defaults to the sentinel option.
|
||||
await expect(agentTrigger).toContainText("(use step default)");
|
||||
|
||||
// Pick the first real profile — option 0 is the "(use step default)" sentinel.
|
||||
// Radix renders the open list in a portal; scope to its listbox so the option
|
||||
// count can't drift across other (closed) selects in the dialog.
|
||||
await agentTrigger.click();
|
||||
const options = testPage.getByRole("listbox").getByRole("option");
|
||||
// Sentinel + at least one real profile. A bare sentinel means the settings
|
||||
// fixture seeded no agent profiles — fail with a clear count, not a vague
|
||||
// "element not found" timeout on nth(1) below.
|
||||
await expect.poll(() => options.count()).toBeGreaterThan(1);
|
||||
const profileOption = options.nth(1);
|
||||
await expect(profileOption).toBeVisible();
|
||||
const profileLabel = ((await profileOption.textContent()) ?? "").trim();
|
||||
expect(profileLabel.length).toBeGreaterThan(0);
|
||||
await profileOption.click();
|
||||
await expect(agentTrigger).toContainText(profileLabel);
|
||||
|
||||
// Re-select "(use step default)" and confirm the field reverts.
|
||||
await agentTrigger.click();
|
||||
await testPage.getByRole("listbox").getByRole("option", { name: "(use step default)" }).click();
|
||||
await expect(agentTrigger).toContainText("(use step default)");
|
||||
await expect(agentTrigger).not.toContainText(profileLabel);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { STEP_DEFAULT, resolveProfileId } from "./watcher-profile-default";
|
||||
|
||||
describe("resolveProfileId", () => {
|
||||
it("collapses the sentinel to an empty string so the payload signals 'use step default'", () => {
|
||||
expect(resolveProfileId(STEP_DEFAULT)).toBe("");
|
||||
});
|
||||
|
||||
it("passes a real profile id through unchanged", () => {
|
||||
expect(resolveProfileId("agent-profile-123")).toBe("agent-profile-123");
|
||||
expect(resolveProfileId("exec-profile-abc")).toBe("exec-profile-abc");
|
||||
});
|
||||
|
||||
it("leaves an already-empty value empty (initial/reset state)", () => {
|
||||
expect(resolveProfileId("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Shared sentinel for the "(use step default)" option in the Agent/Executor
|
||||
// Profile selects across the four watcher dialogs (Linear, Jira, GitHub issue,
|
||||
// GitHub review). The form stores "" to mean "fall back to the workflow step's
|
||||
// default profile", and the create/update payloads pass "" through. Radix
|
||||
// disallows <SelectItem value="">, so the dropdown item carries this sentinel
|
||||
// value and maps back to "" on change.
|
||||
export const STEP_DEFAULT = "__step_default__";
|
||||
export const STEP_DEFAULT_LABEL = "(use step default)";
|
||||
|
||||
// Map a select value back to the stored profile id, collapsing the sentinel to
|
||||
// "" so the payload keeps signalling "use step default".
|
||||
export function resolveProfileId(value: string): string {
|
||||
return value === STEP_DEFAULT ? "" : value;
|
||||
}
|
||||
Reference in New Issue
Block a user