feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)

Donne à l'utilisateur la surface pour activer le serveur depuis l'app.

- `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`.
- `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ;
  adapters mock et http/unsupported alignés (le mode web n'expose pas le
  contrôle du serveur qui l'héberge).
- `ProjectsView` : le `showSettings: boolean` devient une navigation interne
  `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles »
  disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu
  d'ajouter un `PanelId`.

CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite
de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux
champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette
loopback comme unspecified. Construit selon la spec, chaque save et chaque start
en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose
donc un select alimenté par `candidateLanAddresses` fourni par le backend :
la règle « le frontend n'invente jamais une IP » tient.

QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 ·
`npx vitest run` 87 files / 789 passed, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:43:37 +02:00
parent 49e1d5a157
commit 7efa634f23
16 changed files with 1458 additions and 31 deletions

View File

@ -12,7 +12,7 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock";
import { MockAgentGateway, MockDesktopServerGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
@ -25,6 +25,7 @@ function renderView(
// The project gateway drives the primary assertions; agent + profile stubs are
// required because ProjectsView now renders AgentsPanel for the active tab.
// template gateway is required because ProjectsView now renders TemplatesPanel.
// desktopServer is required by Settings → Deployment (#68).
const agentGateway = new MockAgentGateway();
const gateways = {
system,
@ -35,6 +36,7 @@ function renderView(
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
desktopServer: new MockDesktopServerGateway(),
} as unknown as Gateways;
return {
project,
@ -197,7 +199,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(betaTab.getAttribute("aria-selected")).toBe("true");
});
it("toggles the AI Profiles view from the single Settings menu (#16)", async () => {
it("opens the Settings surface from the Settings menu and closes it from the view (#16/#68)", async () => {
renderView();
await waitForIdle();
@ -210,14 +212,31 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("project name")).toBeNull();
// Settings → Close AI Profiles returns to the project surface.
openMenuItem("Settings", "Close AI Profiles");
// #68: closing is an explicit action in the view, not an alternating menu
// label — the label never scaled past one section.
fireEvent.click(screen.getByRole("button", { name: "Close Settings" }));
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
});
it("navigates between Settings sections from the menu and the nav column (#68)", async () => {
renderView();
await waitForIdle();
// The Settings menu now names sections; Deployment opens directly.
openMenuItem("Settings", "Deployment");
expect(await screen.findByLabelText("deployment settings")).toBeTruthy();
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// The internal nav column switches sections without leaving Settings.
const nav = screen.getByRole("navigation", { name: "settings sections" });
fireEvent.click(within(nav).getByRole("button", { name: "AI Profiles" }));
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("deployment settings")).toBeNull();
});
it("closing a tab removes it from the tab bar", async () => {
renderView();
await createProject("alpha", "/home/me/alpha");