feat(web): add "(use step default)" reset to watcher profile selects (#1124)

This commit is contained in:
Nicolas Lenepveu
2026-05-31 19:58:48 +02:00
committed by GitHub
parent 83c800879a
commit 1931619781
9 changed files with 169 additions and 51 deletions
@@ -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("");
});
});
+14
View File
@@ -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;
}