feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -16,7 +16,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { AnnouncementsPreview } from "@/features/announcements";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
@ -287,25 +287,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
<SmallDropdown
|
||||
id="agent-template-select"
|
||||
aria-label="agent template"
|
||||
value={newTemplateId}
|
||||
onChange={(e) => setNewTemplateId(e.target.value)}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
onChange={setNewTemplateId}
|
||||
className="w-full"
|
||||
disabled={vm.busy}
|
||||
>
|
||||
<option value="">(none / from scratch)</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
size="md"
|
||||
options={[
|
||||
{ value: "", label: "(none / from scratch)" },
|
||||
...templates.map((t) => ({ value: t.id, label: t.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profile selector — hidden when a template is chosen */}
|
||||
@ -321,24 +315,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
)}
|
||||
</label>
|
||||
{vm.profiles.length > 0 ? (
|
||||
<select
|
||||
<SmallDropdown
|
||||
id="agent-profile-select"
|
||||
aria-label="agent profile"
|
||||
value={newProfileId}
|
||||
onChange={(e) => setNewProfileId(e.target.value)}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="">— select profile —</option>
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{profileLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setNewProfileId}
|
||||
className="w-full"
|
||||
size="md"
|
||||
options={[
|
||||
{ value: "", label: "— select profile —" },
|
||||
...vm.profiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: profileLabel(p),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="agent-profile-select"
|
||||
@ -469,31 +460,25 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||
engine abandons the conversation history → confirmation. */}
|
||||
{vm.profiles.length > 0 && (
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label={`profile for ${a.name}`}
|
||||
value={a.profileId}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => {
|
||||
const profileId = e.target.value;
|
||||
onChange={(profileId) => {
|
||||
if (profileId && profileId !== a.profileId) {
|
||||
setPendingProfileChange({ agentId: a.id, profileId });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{vm.profiles.every((p) => p.id !== a.profileId) && (
|
||||
<option value={a.profileId}>{a.profileId}</option>
|
||||
)}
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{profileLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
...(vm.profiles.every((p) => p.id !== a.profileId)
|
||||
? [{ value: a.profileId, label: a.profileId }]
|
||||
: []),
|
||||
...vm.profiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: profileLabel(p),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{agentDrift && (
|
||||
<Button
|
||||
@ -612,29 +597,26 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
|
||||
{/* Assign selector */}
|
||||
<div className="flex items-end gap-2">
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label="skill to assign"
|
||||
value={skillToAssign}
|
||||
onChange={(e) => setSkillToAssign(e.target.value)}
|
||||
onChange={setSkillToAssign}
|
||||
disabled={vm.busy}
|
||||
className={cn(
|
||||
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="">— assign a skill —</option>
|
||||
{skills
|
||||
.filter(
|
||||
(s) =>
|
||||
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||
)
|
||||
.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.scope})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="min-w-0 flex-1"
|
||||
size="md"
|
||||
options={[
|
||||
{ value: "", label: "— assign a skill —" },
|
||||
...skills
|
||||
.filter(
|
||||
(s) =>
|
||||
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||
)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.scope})`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="assign skill"
|
||||
|
||||
@ -73,6 +73,17 @@ async function waitForIdle() {
|
||||
});
|
||||
}
|
||||
|
||||
function openDropdown(label: string | RegExp) {
|
||||
const trigger = screen.getByLabelText(label);
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||
openDropdown(label);
|
||||
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
|
||||
async function createAgent(name: string, profileId?: string) {
|
||||
await waitForIdle();
|
||||
@ -84,7 +95,7 @@ async function createAgent(name: string, profileId?: string) {
|
||||
if (profileId) {
|
||||
const profileInput = screen.queryByLabelText("agent profile");
|
||||
if (profileInput) {
|
||||
fireEvent.change(profileInput, { target: { value: profileId } });
|
||||
chooseDropdownOption("agent profile", profileId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,9 +180,10 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
const labels = Array.from(
|
||||
screen.getByLabelText("agent profile").querySelectorAll("option"),
|
||||
).map((option) => option.textContent);
|
||||
openDropdown("agent profile");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
|
||||
expect(labels).toContain("Codex fast · gpt-5-mini");
|
||||
expect(labels).toContain("Claude deep · claude-opus-4-8");
|
||||
@ -319,8 +331,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
await waitForIdle();
|
||||
|
||||
// Select the template in the dropdown
|
||||
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
|
||||
fireEvent.change(templateSelect, { target: { value: t.id } });
|
||||
chooseDropdownOption("agent template", "My Template");
|
||||
|
||||
// Fill agent name
|
||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||
@ -359,9 +370,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
// The select element should contain the profile option.
|
||||
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
|
||||
const options = Array.from(select.options).map((o) => o.text);
|
||||
openDropdown("agent profile");
|
||||
const options = screen.getAllByRole("option").map((o) => o.textContent);
|
||||
expect(options).toContain("Claude Code");
|
||||
});
|
||||
|
||||
@ -375,8 +385,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
|
||||
const options = Array.from(select.options).map((o) => o.text);
|
||||
openDropdown("agent profile");
|
||||
const options = screen.getAllByRole("option").map((o) => o.textContent ?? "");
|
||||
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -506,8 +516,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "prof-2" } });
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
|
||||
// No gateway call yet — only the dialog appears.
|
||||
expect(swapSpy).not.toHaveBeenCalled();
|
||||
@ -523,9 +532,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
||||
target: { value: "prof-2" },
|
||||
});
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "cancel profile change" }),
|
||||
);
|
||||
@ -546,9 +553,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
||||
target: { value: "prof-2" },
|
||||
});
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "confirm profile change" }),
|
||||
);
|
||||
@ -571,9 +576,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
await waitForIdle();
|
||||
await screen.findByText("Swap");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
||||
target: { value: "prof-2" },
|
||||
});
|
||||
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const text = dialog.textContent ?? "";
|
||||
|
||||
Reference in New Issue
Block a user