merge feature/ticket114-uniform-dropdowns dans develop (#114: uniformisation des droplist dynamiques)

QA vert : 148+8+1032 tests passés, typecheck OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:56:14 +02:00
24 changed files with 714 additions and 371 deletions

View File

@ -109,3 +109,16 @@ Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et
--- ---
*Dernière mise à jour : 2026-06-20* *Dernière mise à jour : 2026-06-20*
---
## Découverte exhaustive des outils MCP IdeA
Codex charge les outils MCP de façon différée : une recherche sémantique peut ne retourner qu'un sous-ensemble des outils IdeA disponibles. Avant toute opération d'orchestration, de tickets, de mémoire, de contexte, de skills, de templates, de sprint, de workstate ou de tâche en arrière-plan :
1. Inspecte le registre complet des outils disponibles et filtre le préfixe `mcp__idea__`.
2. Choisis l'outil natif IdeA le plus spécifique dans cet inventaire exhaustif.
3. N'utilise pas l'absence d'un outil dans les résultats partiels de recherche comme preuve de son indisponibilité.
4. Appelle les outils différés par leur nom exact via le registre lorsqu'ils ne sont pas exposés directement.
Cette vérification de découverte est obligatoire au début de chaque workflow IdeA, afin que les outils natifs soient utilisés spontanément et pas seulement lorsqu'un utilisateur en rappelle le nom.

View File

@ -289,7 +289,7 @@
} }
}, },
{ {
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", "agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"policy": { "policy": {
"allowedTools": [ "allowedTools": [
"idea_list_agents", "idea_list_agents",
@ -326,7 +326,7 @@
} }
}, },
{ {
"agentId": "dce19c75-9669-4e45-b8de-9950025157da", "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"policy": { "policy": {
"allowedTools": [ "allowedTools": [
"idea_list_agents", "idea_list_agents",
@ -358,7 +358,8 @@
"idea_template_create", "idea_template_create",
"idea_template_update", "idea_template_update",
"idea_template_delete", "idea_template_delete",
"idea_run_in_background" "idea_run_in_background",
"idea_ask_agents"
] ]
} }
} }

View File

@ -1,6 +1,69 @@
--- ---
issueRef: "#111" issueRef: "#111"
version: 2 version: 3
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785331658707 updatedAt: 1785390694060
--- ---
## Enquete `/tmp` IdeA — inventaire et fenetres de cleanup
### Methode
- Scan du code sur les usages `std::env::temp_dir()` / prexifes `idea-*` hors `frontend`.
- Lecture des blocs source pour separer `runtime` vs `#[cfg(test)]`.
- Verification du contenu live de `/tmp` au moment de l'analyse : aucun dossier `idea-*` visible a la racine de `/tmp` pendant ce tour.
### Conclusion courte
- Les noms cites dans le ticket (`idea-structured-session-factory-*`, `idea-web-root-*`, `idea-server-test-*`, `idea-openai-compat-*`, etc.) ne correspondent pas, dans l'etat actuel du code, a des artefacts runtime produit. Ils proviennent majoritairement de tests embarques dans les crates Rust.
- Le seul artefact runtime clair lie a IdeA/MCP trouve pendant l'analyse est le repertoire parent `idea-mcp` qui heberge les sockets Unix par projet quand le runtime tombe sur `/tmp` comme base.
- Donc le modele `un call MCP -> un dossier temp -> suppression en fin de call` n'est pas le bon modele pour la majorite des cas observes.
### Runtime reel identifie
#### 1. `idea-mcp` (runtime MCP par projet)
- Source : `crates/backend/src/mcp_endpoint.rs` et miroir `crates/app-tauri/src/mcp_endpoint.rs`.
- Role : repertoire parent des sockets Unix du serveur MCP local, ex. `<runtime-dir>/idea-mcp/<project-id>.sock`.
- Creation : a la determination du endpoint MCP du projet si le sous-dossier `idea-mcp` n'existe pas deja.
- Granularite : par projet / par listener MCP, pas par appel d'outil MCP.
- Cleanup deja present :
- le fichier socket est unlink sur fermeture propre via `reclaim_name(true)` ;
- un socket cadavre est supprime avant rebind si c'est bien un socket stale ;
- refs utiles : `crates/backend/src/lib.rs` autour de `bind_endpoint()` et `crates/backend/src/mcp_endpoint.rs`.
- Cleanup opportun :
- **pas** a la fin de chaque call MCP ;
- **oui** a la fermeture du listener / fermeture du projet pour le fichier `.sock` ;
- **oui** au prochain demarrage / prochain bind pour nettoyer un socket stale d'un crash precedent ;
- **amelioration possible** : supprimer aussi le dossier parent `idea-mcp` s'il devient vide apres drop du dernier socket, ou faire un sweep best-effort au demarrage des endpoints MCP.
- Importance : c'est le seul candidat clairement runtime et potentiellement visible sous `/tmp` si `XDG_RUNTIME_DIR` / `TMPDIR` ne sont pas utilisables et que le fallback tombe sur `/tmp`.
### Faux positifs / test-only observes
Ces prefixes existent dans le code mais dans des blocs de tests ou helpers de test. Ils ne semblent pas correspondre a des dossiers runtime utilisateur :
- `idea-openai-compat-*` : `crates/infrastructure/src/session/openai_compat.rs`
- `idea-structured-session-*` : `crates/infrastructure/src/session/mod.rs`
- `idea-web-root-*`, `idea-server-test-*`, `idea-server-lock-*`, `idea-app-data-env-*`, `idea-shared-core-*`, `idea-empty-web-*` : `crates/web-server/src/lib.rs`
- `idea-embedded-server-*` : `crates/app-tauri/src/embedded_server.rs`
- `idea-openai-mcp-tools-list-*`, `idea-openai-mcp-permissions-*`, variantes `app-tauri-*` : tests dans `crates/backend/src/openai_tools.rs` et `crates/app-tauri/src/openai_tools.rs`
- `idea-pty-sandbox-*`, `idea-landlock-*`, `idea-opencode-*`, `idea-devices-store-*`, `idea-secrets-store-*`, etc. : helpers de tests avec `Drop`/`remove_dir_all`.
### Lecture sur leur cleanup
- Dans la majorite des cas de tests lus, le cleanup nominal existe deja (`Drop`, `remove_dir_all`, `remove_file`).
- Le vrai risque residuel de ces dossiers est surtout : crash/abort de test, kill brutal, ou interruption d'une suite de tests. Dans ce cas, le residue reste dans `/tmp`.
- Comme `/tmp` est borne a 16 Go, ces residues peuvent devenir un probleme d'hygiene, mais ce n'est pas un sujet de lifecycle MCP par call ; c'est plutot un sujet de hygiene de tests / sweep de reliquats.
### Fenetres de cleanup recommandes
#### Runtime produit
- `idea-mcp/<project>.sock` : cleanup a la fermeture du listener MCP (deja en place) ; stale cleanup avant bind (deja en place).
- `idea-mcp/` parent dir : cleanup best-effort si vide apres fermeture du dernier listener, ou sweep au demarrage de l'app / ouverture projet.
#### Artefacts de tests
- Cleanup nominal dans chaque test/helper (souvent deja fait).
- Ajouter si besoin un sweep best-effort des prefixes `idea-*` generes par les tests au debut/fin des jobs de test locaux/CI.
- Ne pas melanger cela avec la logique runtime MCP : c'est un chantier distinct.
### Recommandation de perimetre pour le ticket #111
1. Cadrer `#111` sur les **artefacts runtime** visibles par l'utilisateur.
2. Traiter en priorite `idea-mcp` :
- verifier si le dossier parent vide reste parfois en place ;
- si oui, le supprimer quand il devient vide ou au prochain demarrage.
3. Ouvrir si necessaire un second ticket dedie a l'hygiene des reliquats de tests sous `/tmp`.
### Reponse a la question produit initiale
- Oui, IdeA peut produire un artefact sous `/tmp` pour le MCP runtime (`idea-mcp`), mais ce n'est pas cree par appel MCP individuel.
- Non, les dossiers cites dans la description ne semblent pas, a ce stade, etre crees par les appels MCP IdeA de production ; ils sont majoritairement issus de tests.

View File

@ -9,9 +9,9 @@ links: []
agentRefs: [] agentRefs: []
attachments: [] attachments: []
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1785331481527 createdAt: 1785331481527
updatedAt: 1785331658707 updatedAt: 1785390694060
version: 2 version: 3
--- ---
dans le dossier /tmp, je vois enormement de dossier du genre : idea-structured-session-factory-openai-e5bbdcac-0405-4ce3-be82-00d9639ee648 ou encore idea-web-root-4fbc4144-065c-4915-9e05-9f6fec20aa3b ou idea-server-test-863efbe5-5098-4f34-aab8-1ba5047f4209 ou idea-openai-compat-unreachable-f713f5fa-5469-4875-96f9-242f7913441b ou idea-openai-compat-tools-rejected-d000d319-8c11-4585-8a16-6ae8a3b45c49 ou idea-openai-compat-status-422-9fd4b27f-e0c1-4043-9fb9-72dcf8d2c2e1 ou idea-openai-compat-single-final-940b1d63-6d4a-40b0-953a-0f3279ec2bdc etc qui sont visiblement créé par IdeA. J'aiemrais que pour un maximum d'entre eux, on puisse clean ça au bon momment. Il faut donc identifier ce qui créé ce cache, puis le supprimer quand ce cache n'est plus utile. dans le dossier /tmp, je vois enormement de dossier du genre : idea-structured-session-factory-openai-e5bbdcac-0405-4ce3-be82-00d9639ee648 ou encore idea-web-root-4fbc4144-065c-4915-9e05-9f6fec20aa3b ou idea-server-test-863efbe5-5098-4f34-aab8-1ba5047f4209 ou idea-openai-compat-unreachable-f713f5fa-5469-4875-96f9-242f7913441b ou idea-openai-compat-tools-rejected-d000d319-8c11-4585-8a16-6ae8a3b45c49 ou idea-openai-compat-status-422-9fd4b27f-e0c1-4043-9fb9-72dcf8d2c2e1 ou idea-openai-compat-single-final-940b1d63-6d4a-40b0-953a-0f3279ec2bdc etc qui sont visiblement créé par IdeA. J'aiemrais que pour un maximum d'entre eux, on puisse clean ça au bon momment. Il faut donc identifier ce qui créé ce cache, puis le supprimer quand ce cache n'est plus utile.

View File

@ -0,0 +1,6 @@
---
issueRef: "#113"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1785395935541
---

View File

@ -0,0 +1,17 @@
---
id: "1c128e50-96bd-4689-a080-f6b5e0c5a6b6"
number: 113
title: "[Bug] Les espaces ne epuvent pas etre entrés dans les args du serveur llamacpp"
status: "open"
priority: "medium"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785395710201
updatedAt: 1785395935541
version: 4
---
Dnas les option de reglage llama.cpp de l'edition des serveurs locaux de modele llm, je ne peux pas entrer d'espaces dans le champs de texte Arguments supplémentaires. Il faut faire en sorte que ça soit possible

View File

@ -0,0 +1,6 @@
---
issueRef: "#114"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1785395923498
---

View File

@ -0,0 +1,17 @@
---
id: "a7602792-21c6-40f3-852a-5200d604db9d"
number: 114
title: "[UI] un iformiser les droplist"
status: "open"
priority: "medium"
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785395791597
updatedAt: 1785395923498
version: 4
---
Dans les différentes fenetres j'aimerais qu'on uniformise les droplist. C'est a dire que par exemple dans la fenetre de création de ticket, on a une droplist noire pour la selection d'un agent à lier, j'aimerais que ça soit la même droplist pour la selection du modele dans la fenetre des agents, dans la selection du template a la création d'un agent etc. Que toutes ces petites droplist dynamiques soient comme celle de selection de l'agent dans la fenetre de creation de tickets

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 113 "nextNumber": 115
} }

View File

@ -1461,7 +1461,7 @@
"createdBy": { "createdBy": {
"kind": "user" "kind": "user"
}, },
"updatedAt": 1785331658707 "updatedAt": 1785390694060
}, },
{ {
"issueRef": "#112", "issueRef": "#112",
@ -1477,6 +1477,36 @@
"kind": "user" "kind": "user"
}, },
"updatedAt": 1785341341049 "updatedAt": 1785341341049
},
{
"issueRef": "#113",
"path": "113",
"title": "[Bug] Les espaces ne epuvent pas etre entrés dans les args du serveur llamacpp",
"status": "open",
"priority": "medium",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"createdBy": {
"kind": "user"
},
"updatedAt": 1785395935541
},
{
"issueRef": "#114",
"path": "114",
"title": "[UI] un iformiser les droplist",
"status": "open",
"priority": "medium",
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"createdBy": {
"kind": "user"
},
"updatedAt": 1785395923498
} }
] ]
} }

View File

@ -16,7 +16,7 @@
import { useEffect, useState } from "react"; 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 { TerminalView } from "@/features/terminals/TerminalView";
import { AnnouncementsPreview } from "@/features/announcements"; import { AnnouncementsPreview } from "@/features/announcements";
import { useDrift } from "@/features/templates/useDrift"; import { useDrift } from "@/features/templates/useDrift";
@ -287,25 +287,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
> >
Template Template
</label> </label>
<select <SmallDropdown
id="agent-template-select" id="agent-template-select"
aria-label="agent template" aria-label="agent template"
value={newTemplateId} value={newTemplateId}
onChange={(e) => setNewTemplateId(e.target.value)} onChange={setNewTemplateId}
className={cn( className="w-full"
"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",
)}
disabled={vm.busy} disabled={vm.busy}
> size="md"
<option value="">(none / from scratch)</option> options={[
{templates.map((t) => ( { value: "", label: "(none / from scratch)" },
<option key={t.id} value={t.id}> ...templates.map((t) => ({ value: t.id, label: t.name })),
{t.name} ]}
</option> />
))}
</select>
</div> </div>
{/* Profile selector — hidden when a template is chosen */} {/* Profile selector — hidden when a template is chosen */}
@ -321,24 +315,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
)} )}
</label> </label>
{vm.profiles.length > 0 ? ( {vm.profiles.length > 0 ? (
<select <SmallDropdown
id="agent-profile-select" id="agent-profile-select"
aria-label="agent profile" aria-label="agent profile"
value={newProfileId} value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)} onChange={setNewProfileId}
className={cn( className="w-full"
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content", size="md"
"border border-border outline-none transition-colors", options={[
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", { value: "", label: "— select profile —" },
)} ...vm.profiles.map((p) => ({
> value: p.id,
<option value=""> select profile </option> label: profileLabel(p),
{vm.profiles.map((p) => ( })),
<option key={p.id} value={p.id}> ]}
{profileLabel(p)} />
</option>
))}
</select>
) : ( ) : (
<Input <Input
id="agent-profile-select" id="agent-profile-select"
@ -469,31 +460,25 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{/* Profile hot-swap selector (Chantier A). Changing the {/* Profile hot-swap selector (Chantier A). Changing the
engine abandons the conversation history → confirmation. */} engine abandons the conversation history → confirmation. */}
{vm.profiles.length > 0 && ( {vm.profiles.length > 0 && (
<select <SmallDropdown
aria-label={`profile for ${a.name}`} aria-label={`profile for ${a.name}`}
value={a.profileId} value={a.profileId}
disabled={vm.busy} disabled={vm.busy}
onChange={(e) => { onChange={(profileId) => {
const profileId = e.target.value;
if (profileId && profileId !== a.profileId) { if (profileId && profileId !== a.profileId) {
setPendingProfileChange({ agentId: a.id, profileId }); setPendingProfileChange({ agentId: a.id, profileId });
} }
}} }}
className={cn( options={[
"h-8 rounded-md bg-raised px-2 text-xs text-content", ...(vm.profiles.every((p) => p.id !== a.profileId)
"border border-border outline-none transition-colors", ? [{ value: a.profileId, label: a.profileId }]
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", : []),
)} ...vm.profiles.map((p) => ({
> value: p.id,
{vm.profiles.every((p) => p.id !== a.profileId) && ( label: profileLabel(p),
<option value={a.profileId}>{a.profileId}</option> })),
)} ]}
{vm.profiles.map((p) => ( />
<option key={p.id} value={p.id}>
{profileLabel(p)}
</option>
))}
</select>
)} )}
{agentDrift && ( {agentDrift && (
<Button <Button
@ -612,29 +597,26 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{/* Assign selector */} {/* Assign selector */}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<select <SmallDropdown
aria-label="skill to assign" aria-label="skill to assign"
value={skillToAssign} value={skillToAssign}
onChange={(e) => setSkillToAssign(e.target.value)} onChange={setSkillToAssign}
disabled={vm.busy} disabled={vm.busy}
className={cn( className="min-w-0 flex-1"
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content", size="md"
"border border-border outline-none transition-colors", options={[
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", { value: "", label: "— assign a skill —" },
)} ...skills
>
<option value=""> assign a skill </option>
{skills
.filter( .filter(
(s) => (s) =>
!selectedAgent.skills.some((r) => r.skillId === s.id), !selectedAgent.skills.some((r) => r.skillId === s.id),
) )
.map((s) => ( .map((s) => ({
<option key={s.id} value={s.id}> value: s.id,
{s.name} ({s.scope}) label: `${s.name} (${s.scope})`,
</option> })),
))} ]}
</select> />
<Button <Button
variant="primary" variant="primary"
aria-label="assign skill" aria-label="assign skill"

View File

@ -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. */ /** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
async function createAgent(name: string, profileId?: string) { async function createAgent(name: string, profileId?: string) {
await waitForIdle(); await waitForIdle();
@ -84,7 +95,7 @@ async function createAgent(name: string, profileId?: string) {
if (profileId) { if (profileId) {
const profileInput = screen.queryByLabelText("agent profile"); const profileInput = screen.queryByLabelText("agent profile");
if (profileInput) { if (profileInput) {
fireEvent.change(profileInput, { target: { value: profileId } }); chooseDropdownOption("agent profile", profileId);
} }
} }
@ -169,9 +180,10 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile); renderPanel(new MockAgentGateway(), profile);
await waitForIdle(); await waitForIdle();
const labels = Array.from( openDropdown("agent profile");
screen.getByLabelText("agent profile").querySelectorAll("option"), const labels = screen
).map((option) => option.textContent); .getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toContain("Codex fast · gpt-5-mini"); expect(labels).toContain("Codex fast · gpt-5-mini");
expect(labels).toContain("Claude deep · claude-opus-4-8"); expect(labels).toContain("Claude deep · claude-opus-4-8");
@ -319,8 +331,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await waitForIdle(); await waitForIdle();
// Select the template in the dropdown // Select the template in the dropdown
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement; chooseDropdownOption("agent template", "My Template");
fireEvent.change(templateSelect, { target: { value: t.id } });
// Fill agent name // Fill agent name
fireEvent.change(screen.getByLabelText("agent name"), { fireEvent.change(screen.getByLabelText("agent name"), {
@ -359,9 +370,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile); renderPanel(new MockAgentGateway(), profile);
await waitForIdle(); await waitForIdle();
// The select element should contain the profile option. openDropdown("agent profile");
const select = screen.getByLabelText("agent profile") as HTMLSelectElement; const options = screen.getAllByRole("option").map((o) => o.textContent);
const options = Array.from(select.options).map((o) => o.text);
expect(options).toContain("Claude Code"); expect(options).toContain("Claude Code");
}); });
@ -375,8 +385,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile); renderPanel(new MockAgentGateway(), profile);
await waitForIdle(); await waitForIdle();
const select = screen.getByLabelText("agent profile") as HTMLSelectElement; openDropdown("agent profile");
const options = Array.from(select.options).map((o) => o.text); const options = screen.getAllByRole("option").map((o) => o.textContent ?? "");
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true); expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
}); });
}); });
@ -506,8 +516,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle(); await waitForIdle();
await screen.findByText("Swap"); await screen.findByText("Swap");
const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement; chooseDropdownOption("profile for Swap", /Codex CLI/);
fireEvent.change(select, { target: { value: "prof-2" } });
// No gateway call yet — only the dialog appears. // No gateway call yet — only the dialog appears.
expect(swapSpy).not.toHaveBeenCalled(); expect(swapSpy).not.toHaveBeenCalled();
@ -523,9 +532,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle(); await waitForIdle();
await screen.findByText("Swap"); await screen.findByText("Swap");
fireEvent.change(screen.getByLabelText("profile for Swap"), { chooseDropdownOption("profile for Swap", /Codex CLI/);
target: { value: "prof-2" },
});
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "cancel profile change" }), screen.getByRole("button", { name: "cancel profile change" }),
); );
@ -546,9 +553,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle(); await waitForIdle();
await screen.findByText("Swap"); await screen.findByText("Swap");
fireEvent.change(screen.getByLabelText("profile for Swap"), { chooseDropdownOption("profile for Swap", /Codex CLI/);
target: { value: "prof-2" },
});
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "confirm profile change" }), screen.getByRole("button", { name: "confirm profile change" }),
); );
@ -571,9 +576,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle(); await waitForIdle();
await screen.findByText("Swap"); await screen.findByText("Swap");
fireEvent.change(screen.getByLabelText("profile for Swap"), { chooseDropdownOption("profile for Swap", /Codex CLI/);
target: { value: "prof-2" },
});
const dialog = screen.getByRole("dialog"); const dialog = screen.getByRole("dialog");
const text = dialog.textContent ?? ""; const text = dialog.textContent ?? "";

View File

@ -25,6 +25,11 @@ import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard"; import { FirstRunWizard } from "./FirstRunWizard";
import { DETECT_TIMEOUT_MS } from "./useFirstRun"; import { DETECT_TIMEOUT_MS } from "./useFirstRun";
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
function renderWizard( function renderWizard(
profile: MockProfileGateway = new MockProfileGateway(), profile: MockProfileGateway = new MockProfileGateway(),
onDone = vi.fn(), onDone = vi.fn(),
@ -474,11 +479,11 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
await waitForLoaded(); await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`); await screen.findByLabelText(`${OPENCODE} provider`);
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement; const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
expect(modelSelect.disabled).toBe(true); expect(modelSelect.disabled).toBe(true);
fireEvent.change(providerSelect, { target: { value: "anthropic" } }); chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
expect(modelSelect.disabled).toBe(false); expect(modelSelect.disabled).toBe(false);
const saveButton = screen.getByRole("button", { const saveButton = screen.getByRole("button", {
@ -486,7 +491,7 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
}) as HTMLButtonElement; }) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true); expect(saveButton.disabled).toBe(true);
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } }); chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
expect(saveButton.disabled).toBe(true); expect(saveButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
@ -500,12 +505,9 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
await waitForLoaded(); await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), { await screen.findByLabelText(`${OPENCODE} provider`);
target: { value: "anthropic" }, chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
}); chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "claude-sonnet-5" },
});
const apiKeyInput = screen.getByLabelText( const apiKeyInput = screen.getByLabelText(
`${OPENCODE} api key`, `${OPENCODE} api key`,
) as HTMLInputElement; ) as HTMLInputElement;
@ -547,8 +549,8 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
async function goToCloudCustomMode() { async function goToCloudCustomMode() {
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`); await screen.findByLabelText(`${OPENCODE} provider`);
fireEvent.change(providerSelect, { target: { value: "__custom__" } }); chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
} }
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => { it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
@ -904,8 +906,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
expect( expect(
screen.queryByLabelText(`${CLONE1} local model server id`), screen.queryByLabelText(`${CLONE1} local model server id`),
).toBeNull(); ).toBeNull();
const select = await screen.findByLabelText(`${CLONE1} local model server`); await screen.findByLabelText(`${CLONE1} local model server`);
fireEvent.change(select, { target: { value: server.id } }); chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => { await waitFor(async () => {

View File

@ -8,7 +8,7 @@ import type {
OpenCodeProviderCatalogEntry, OpenCodeProviderCatalogEntry,
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { Button, IconButton, Input, cn } from "@/shared"; import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
import { ModelServerSelect } from "@/features/model-servers"; import { ModelServerSelect } from "@/features/model-servers";
import { import {
defaultOpenCodeConfig, defaultOpenCodeConfig,
@ -300,12 +300,11 @@ function OpenCodeProviderFields({
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none" className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/> />
)} )}
<select <SmallDropdown
aria-label={`${profile.name} provider`} aria-label={`${profile.name} provider`}
value={providerId} value={providerId}
disabled={!catalogReady} disabled={!catalogReady}
onChange={(e) => { onChange={(v) => {
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) { if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom"); setMode("custom");
setProviderId(""); setProviderId("");
@ -316,22 +315,27 @@ function OpenCodeProviderFields({
} }
setFieldErrors((prev) => ({ ...prev, providerId: undefined })); setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}} }}
className={cn( className="w-full"
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none", size="md"
"disabled:cursor-not-allowed disabled:opacity-50", invalid={Boolean(fieldErrors.providerId)}
fieldErrors.providerId ? "border-danger" : "border-border", options={[
)} {
> value: "",
<option value="" disabled> label: catalog.loading
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"} ? "Chargement des providers…"
</option> : "Choisir un provider…",
{filteredProviders.map((p) => ( disabled: true,
<option key={p.providerId} value={p.providerId}> },
{p.displayName} ...filteredProviders.map((p) => ({
</option> value: p.providerId,
))} label: p.displayName,
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option> })),
</select> {
value: CUSTOM_PROVIDER_VALUE,
label: "Autre / personnalisé…",
},
]}
/>
{fieldErrors.providerId && ( {fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small> <small className="text-xs text-danger">{fieldErrors.providerId}</small>
)} )}
@ -339,29 +343,26 @@ function OpenCodeProviderFields({
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<Caption>Modèle</Caption> <Caption>Modèle</Caption>
<select <SmallDropdown
aria-label={`${profile.name} model`} aria-label={`${profile.name} model`}
value={model} value={model}
disabled={providerId.length === 0} disabled={providerId.length === 0}
onChange={(e) => { onChange={(next) => {
setModel(e.target.value); setModel(next);
setFieldErrors((prev) => ({ ...prev, model: undefined })); setFieldErrors((prev) => ({ ...prev, model: undefined }));
}} }}
className={cn( className="w-full"
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none", size="md"
"disabled:cursor-not-allowed disabled:opacity-50", invalid={Boolean(fieldErrors.model)}
fieldErrors.model ? "border-danger" : "border-border", options={[
)} {
> value: "",
<option value="" disabled> label: providerId.length === 0 ? "—" : "Choisir un modèle…",
{providerId.length === 0 ? "—" : "Choisir un modèle…"} disabled: true,
</option> },
{models.map((m) => ( ...models.map((m) => ({ value: m, label: m })),
<option key={m} value={m}> ]}
{m} />
</option>
))}
</select>
{fieldErrors.model && ( {fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small> <small className="text-xs text-danger">{fieldErrors.model}</small>
)} )}

View File

@ -22,6 +22,11 @@ function renderSettings(
}; };
} }
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
async function waitReady() { async function waitReady() {
await waitFor(() => await waitFor(() =>
expect( expect(
@ -191,12 +196,17 @@ describe("ProfilesSettings", () => {
expect(screen.getByText("Local A")).toBeTruthy(); expect(screen.getByText("Local A")).toBeTruthy();
await createProfile(); await createProfile();
const select = await screen.findByLabelText( await screen.findByLabelText(
"OpenCode + llama.cpp copy local model server", "OpenCode + llama.cpp copy local model server",
); );
fireEvent.change(select, { target: { value: server.id } }); chooseDropdownOption(
"OpenCode + llama.cpp copy local model server",
/Local A/,
);
const profileRow = select.closest("li"); const profileRow = screen
.getByLabelText("OpenCode + llama.cpp copy local model server")
.closest("li");
expect(profileRow).not.toBeNull(); expect(profileRow).not.toBeNull();
fireEvent.click( fireEvent.click(
within(profileRow as HTMLElement).getByRole("button", { within(profileRow as HTMLElement).getByRole("button", {

View File

@ -6,7 +6,7 @@
*/ */
import type { LocalModelServerConfig } from "@/domain"; import type { LocalModelServerConfig } from "@/domain";
import { cn } from "@/shared"; import { SmallDropdown } from "@/shared";
/** The sentinel option value standing for "no managed server". */ /** The sentinel option value standing for "no managed server". */
const NONE = ""; const NONE = "";
@ -34,24 +34,20 @@ export function ModelServerSelect({
value !== undefined && !servers.some((s) => s.id === value); value !== undefined && !servers.some((s) => s.id === value);
return ( return (
<select <SmallDropdown
aria-label={ariaLabel} aria-label={ariaLabel}
value={value ?? NONE} value={value ?? NONE}
onChange={(e) => onChange(e.target.value === NONE ? undefined : e.target.value)} onChange={(next) => onChange(next === NONE ? undefined : next)}
className={cn( options={[
"h-8 rounded-md bg-raised px-2 text-xs text-content", { value: NONE, label: "None (external endpoint)" },
"border border-border outline-none focus:border-primary", ...servers.map((s) => ({
)} value: s.id,
> label: `${s.name}${s.servedModelName}`,
<option value={NONE}>None (external endpoint)</option> })),
{servers.map((s) => ( ...(missing && value !== undefined
<option key={s.id} value={s.id}> ? [{ value, label: `${value} (unknown / removed)` }]
{s.name} {s.servedModelName} : []),
</option> ]}
))} />
{missing && (
<option value={value}>{value} (unknown / removed)</option>
)}
</select>
); );
} }

View File

@ -40,6 +40,11 @@ const SERVER: LocalModelServerConfig = {
stopPolicy: "stopOnAppExit", stopPolicy: "stopOnAppExit",
}; };
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
function setup(modelServer = new MockModelServerGateway()) { function setup(modelServer = new MockModelServerGateway()) {
const gateways = { modelServer } as unknown as Gateways; const gateways = { modelServer } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => ( const wrapper = ({ children }: { children: React.ReactNode }) => (
@ -433,9 +438,10 @@ describe("ModelServerSelect (F35)", () => {
onChange={(id) => (picked = id)} onChange={(id) => (picked = id)}
/>, />,
); );
const select = screen.getByLabelText("local model server") as HTMLSelectElement; expect(screen.getByLabelText("local model server").textContent).toContain(
expect(select.value).toBe(""); "None (external endpoint)",
fireEvent.change(select, { target: { value: SERVER.id } }); );
chooseDropdownOption("local model server", /Local A/);
expect(picked).toBe(SERVER.id); expect(picked).toBe(SERVER.id);
}); });
@ -448,9 +454,7 @@ describe("ModelServerSelect (F35)", () => {
onChange={(id) => (picked = id)} onChange={(id) => (picked = id)}
/>, />,
); );
fireEvent.change(screen.getByLabelText("local model server"), { chooseDropdownOption("local model server", "None (external endpoint)");
target: { value: "" },
});
expect(picked).toBeUndefined(); expect(picked).toBeUndefined();
}); });

View File

@ -58,6 +58,11 @@ async function waitForSkillsIdle() {
}); });
} }
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
/** Opens the SkillEditor, fills it, and saves. */ /** Opens the SkillEditor, fills it, and saves. */
async function createSkill( async function createSkill(
name: string, name: string,
@ -192,9 +197,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
// Choose the skill and assign // Choose the skill and assign
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0]; const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
fireEvent.change(screen.getByLabelText("skill to assign"), { chooseDropdownOption("skill to assign", "Deploy (project)");
target: { value: sk.id },
});
fireEvent.click(screen.getByRole("button", { name: "assign skill" })); fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
// The agent record now carries the skill ref // The agent record now carries the skill ref

View File

@ -16,7 +16,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { AgentProfile, Template } from "@/domain"; import type { AgentProfile, Template } from "@/domain";
import { Button, Input, cn } from "@/shared"; import { Button, Input, SmallDropdown, cn } from "@/shared";
export interface TemplateEditorProps { export interface TemplateEditorProps {
/** /**
@ -114,25 +114,19 @@ export function TemplateEditor({
Default profile Default profile
</label> </label>
{profiles.length > 0 ? ( {profiles.length > 0 ? (
<select <SmallDropdown
id="te-profile" id="te-profile"
aria-label="template default profile" aria-label="template default profile"
value={defaultProfileId} value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)} onChange={setDefaultProfileId}
disabled={busy} disabled={busy}
className={cn( className="w-full"
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content", size="md"
"border border-border outline-none transition-colors", options={[
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", { value: "", label: "— none —" },
)} ...profiles.map((p) => ({ value: p.id, label: p.name })),
> ]}
<option value=""> none </option> />
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
) : ( ) : (
<Input <Input
id="te-profile" id="te-profile"

View File

@ -86,6 +86,11 @@ async function waitForAgentsIdle() {
}); });
} }
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
/** /**
* Opens the TemplateEditor overlay, fills the create-template form, and saves it. * Opens the TemplateEditor overlay, fills the create-template form, and saves it.
* Adapted for the new fullscreen-editor flow: * Adapted for the new fullscreen-editor flow:
@ -115,9 +120,12 @@ async function createTemplate(
target: { value: content }, target: { value: content },
}); });
if (profileId) { if (profileId) {
fireEvent.change(screen.getByLabelText("template default profile"), { const profileField = screen.getByLabelText("template default profile");
target: { value: profileId }, if (profileField.tagName === "INPUT") {
}); fireEvent.change(profileField, { target: { value: profileId } });
} else {
chooseDropdownOption("template default profile", profileId);
}
} }
// Save via the "Save template" button (aria-label on the submit button) // Save via the "Save template" button (aria-label on the submit button)

View File

@ -1,13 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { SmallDropdown, type SmallDropdownOption } from "@/shared";
import { createPortal } from "react-dom";
import { cn, zIndex } from "@/shared"; export type TicketViewportSelectOption = SmallDropdownOption;
export interface TicketViewportSelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface TicketViewportSelectProps { export interface TicketViewportSelectProps {
ariaLabel?: string; ariaLabel?: string;
@ -19,18 +12,6 @@ export interface TicketViewportSelectProps {
disabled?: boolean; disabled?: boolean;
} }
interface PopupPosition {
top: number;
left: number;
width: number;
maxHeight: number;
placement: "top" | "bottom";
}
const VIEWPORT_MARGIN = 8;
const MIN_POPUP_HEIGHT = 96;
const MAX_POPUP_HEIGHT = 280;
export function TicketViewportSelect({ export function TicketViewportSelect({
ariaLabel: ariaLabelProp, ariaLabel: ariaLabelProp,
"aria-label": ariaLabelAttribute, "aria-label": ariaLabelAttribute,
@ -40,153 +21,14 @@ export function TicketViewportSelect({
className, className,
disabled = false, disabled = false,
}: TicketViewportSelectProps) { }: TicketViewportSelectProps) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PopupPosition | null>(null);
const selected = options.find((option) => option.value === value) ?? options[0];
const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const popupWidth = Math.max(rect.width, 160);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
const spaceAbove = rect.top - VIEWPORT_MARGIN;
const placement =
spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
const available = Math.max(
MIN_POPUP_HEIGHT,
placement === "top" ? spaceAbove : spaceBelow,
);
const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
const left = Math.min(
Math.max(VIEWPORT_MARGIN, rect.left),
Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
);
const top =
placement === "top"
? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
: Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
setPosition({ top, left, width: popupWidth, maxHeight, placement });
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (
target &&
(triggerRef.current?.contains(target) || popupRef.current?.contains(target))
) {
return;
}
setOpen(false);
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
}
const onViewportChange = () => {
updatePosition();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
window.visualViewport?.addEventListener("resize", onViewportChange);
window.visualViewport?.addEventListener("scroll", onViewportChange);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.visualViewport?.removeEventListener("resize", onViewportChange);
window.visualViewport?.removeEventListener("scroll", onViewportChange);
};
}, [open, updatePosition]);
return ( return (
<> <SmallDropdown
<button aria-label={ariaLabelAttribute ?? ariaLabelProp}
ref={triggerRef} value={value}
type="button" options={options}
aria-label={ariaLabel} onChange={onChange}
aria-haspopup="listbox" className={className}
aria-expanded={open}
disabled={disabled} disabled={disabled}
onClick={() => setOpen((current) => !current)} />
className={cn(
"inline-flex h-8 items-center justify-between gap-2 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",
className,
)}
>
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
<span aria-hidden="true" className="text-muted"></span>
</button>
{open &&
position &&
createPortal(
<div
ref={popupRef}
role="listbox"
aria-label={ariaLabel}
data-placement={position.placement}
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
style={{
top: position.top,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
zIndex: zIndex.transientPopup,
}}
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
onChange(option.value);
setOpen(false);
triggerRef.current?.focus();
}}
className={cn(
"flex min-h-8 w-full items-center px-2 text-left text-xs text-content",
"hover:bg-raised focus:bg-raised focus:outline-none",
option.value === value && "bg-raised",
option.disabled && "cursor-not-allowed opacity-50",
)}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)}
</>
); );
} }

View File

@ -43,3 +43,6 @@ export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
export { DockRegion } from "./ui/DockRegion"; export { DockRegion } from "./ui/DockRegion";
export type { DockRegionProps, DockSide } from "./ui/DockRegion"; export type { DockRegionProps, DockSide } from "./ui/DockRegion";
export { SmallDropdown } from "./ui/SmallDropdown";
export type { SmallDropdownProps, SmallDropdownOption } from "./ui/SmallDropdown";

View File

@ -0,0 +1,296 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "@/shared/lib/cn";
import { zIndex } from "./zIndex";
export interface SmallDropdownOption {
value: string;
label: string;
disabled?: boolean;
}
export interface SmallDropdownProps {
ariaLabel?: string;
"aria-label"?: string;
value: string;
options: SmallDropdownOption[];
onChange: (value: string) => void;
id?: string;
className?: string;
disabled?: boolean;
size?: "sm" | "md";
invalid?: boolean;
}
interface PopupPosition {
top: number;
left: number;
width: number;
maxHeight: number;
placement: "top" | "bottom";
}
const VIEWPORT_MARGIN = 8;
const MIN_POPUP_HEIGHT = 96;
const MAX_POPUP_HEIGHT = 280;
function firstEnabledIndex(options: SmallDropdownOption[]): number {
return options.findIndex((option) => !option.disabled);
}
function nextEnabledIndex(
options: SmallDropdownOption[],
from: number,
direction: 1 | -1,
): number {
if (options.length === 0) return -1;
for (let offset = 1; offset <= options.length; offset += 1) {
const index = (from + offset * direction + options.length) % options.length;
if (!options[index]?.disabled) return index;
}
return -1;
}
export function SmallDropdown({
ariaLabel: ariaLabelProp,
"aria-label": ariaLabelAttribute,
value,
options,
onChange,
id,
className,
disabled = false,
size = "sm",
invalid = false,
}: SmallDropdownProps) {
const idBase = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PopupPosition | null>(null);
const selectedIndex = options.findIndex((option) => option.value === value);
const selected = selectedIndex >= 0 ? options[selectedIndex] : options[0];
const [activeIndex, setActiveIndex] = useState(
selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options),
);
const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const popupWidth = Math.max(rect.width, 160);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
const spaceAbove = rect.top - VIEWPORT_MARGIN;
const placement =
spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
const available = Math.max(
MIN_POPUP_HEIGHT,
placement === "top" ? spaceAbove : spaceBelow,
);
const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
const left = Math.min(
Math.max(VIEWPORT_MARGIN, rect.left),
Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
);
const top =
placement === "top"
? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
: Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
setPosition({ top, left, width: popupWidth, maxHeight, placement });
}, []);
const openPopup = useCallback(() => {
if (disabled) return;
const nextActive = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options);
setActiveIndex(nextActive);
setOpen(true);
}, [disabled, options, selectedIndex]);
const closePopup = useCallback(() => {
setOpen(false);
}, []);
const selectOption = useCallback(
(option: SmallDropdownOption | undefined) => {
if (!option || option.disabled) return;
onChange(option.value);
closePopup();
triggerRef.current?.focus();
},
[closePopup, onChange],
);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (
target &&
(triggerRef.current?.contains(target) || popupRef.current?.contains(target))
) {
return;
}
closePopup();
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
closePopup();
triggerRef.current?.focus();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) =>
nextEnabledIndex(options, current, event.key === "ArrowDown" ? 1 : -1),
);
return;
}
if (event.key === "Home") {
event.preventDefault();
setActiveIndex(firstEnabledIndex(options));
return;
}
if (event.key === "End") {
event.preventDefault();
setActiveIndex(nextEnabledIndex(options, 0, -1));
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectOption(options[activeIndex]);
}
}
const onViewportChange = () => {
updatePosition();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
window.visualViewport?.addEventListener("resize", onViewportChange);
window.visualViewport?.addEventListener("scroll", onViewportChange);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.visualViewport?.removeEventListener("resize", onViewportChange);
window.visualViewport?.removeEventListener("scroll", onViewportChange);
};
}, [activeIndex, closePopup, open, options, selectOption, updatePosition]);
const popupId = `${idBase}-listbox`;
const activeOptionId =
open && activeIndex >= 0 ? `${idBase}-option-${activeIndex}` : undefined;
return (
<>
<button
ref={triggerRef}
id={id}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? popupId : undefined}
aria-activedescendant={activeOptionId}
aria-invalid={invalid ? "true" : undefined}
disabled={disabled}
onClick={() => (open ? closePopup() : openPopup())}
onKeyDown={(event) => {
if (
!open &&
(event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
openPopup();
}
}}
className={cn(
"inline-flex items-center justify-between gap-2 rounded-md bg-raised text-content",
"border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
invalid ? "border-danger" : "border-border",
size === "md" ? "h-9 px-3 text-sm" : "h-8 px-2 text-xs",
className,
)}
>
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
<span aria-hidden="true" className="text-muted">
</span>
</button>
{open &&
position &&
createPortal(
<div
ref={popupRef}
id={popupId}
role="listbox"
aria-label={ariaLabel}
data-placement={position.placement}
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
style={{
top: position.top,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
zIndex: zIndex.transientPopup,
}}
>
{options.map((option, index) => (
<button
key={`${option.value}-${index}`}
id={`${idBase}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setActiveIndex(index);
}}
onClick={() => selectOption(option)}
className={cn(
"flex min-h-8 w-full items-center px-2 text-left text-content",
"hover:bg-raised focus:bg-raised focus:outline-none",
size === "md" ? "text-sm" : "text-xs",
(option.value === value || index === activeIndex) && "bg-raised",
option.disabled && "cursor-not-allowed opacity-50",
)}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)}
</>
);
}

View File

@ -11,6 +11,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./Button"; import { Button } from "./Button";
import { Input } from "./Input"; import { Input } from "./Input";
import { Field } from "./Field"; import { Field } from "./Field";
import { SmallDropdown } from "./SmallDropdown";
import { Tabs } from "./Tabs"; import { Tabs } from "./Tabs";
describe("Button", () => { describe("Button", () => {
@ -115,3 +116,48 @@ describe("Tabs", () => {
expect(onClose).toHaveBeenCalledWith("b"); expect(onClose).toHaveBeenCalledWith("b");
}); });
}); });
describe("SmallDropdown", () => {
const options = [
{ value: "", label: "None" },
{ value: "a", label: "Alpha" },
{ value: "b", label: "Beta", disabled: true },
];
it("opens a listbox and reports the selected value", () => {
const onChange = vi.fn();
render(
<SmallDropdown
aria-label="test dropdown"
value=""
options={options}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("test dropdown"));
fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
expect(onChange).toHaveBeenCalledWith("a");
expect(screen.queryByRole("listbox")).toBeNull();
});
it("supports keyboard selection and ignores disabled options", () => {
const onChange = vi.fn();
render(
<SmallDropdown
aria-label="test dropdown"
value=""
options={options}
onChange={onChange}
/>,
);
const trigger = screen.getByLabelText("test dropdown");
fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("a");
});
});