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:
2026-07-30 12:55:57 +02:00
parent fda4126a5f
commit 9b1ced50be
14 changed files with 550 additions and 360 deletions

View File

@ -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"