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

@ -0,0 +1,60 @@
/**
* Ticket #68 — "Desktop only" is a code/test guard, not just a claim.
*
* The web client never mounts `ProjectsView` (it routes through `features/web`),
* so the Deployment surface is unreachable there *today*. This pins that: the
* web feature must not import the settings surface, and the web transport must
* refuse the embedded-server port rather than reach for Tauri (absent on web).
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { WebDesktopServerGateway } from "@/adapters/http/unsupported";
import type { GatewayError } from "@/domain";
const WEB_FEATURE_DIR = join(process.cwd(), "src", "features", "web");
function collectSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) collectSourceFiles(full, out);
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
describe("the Deployment surface is desktop-only", () => {
it("features/web does not import the settings surface", () => {
const offenders = collectSourceFiles(WEB_FEATURE_DIR).filter((file) =>
/@\/features\/settings/.test(readFileSync(file, "utf8")),
);
expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]);
});
it("the web transport refuses the embedded-server port", async () => {
const gateway = new WebDesktopServerGateway();
// Every real operation fails explicitly, with a stable code the UI can branch on.
for (const call of [
() => gateway.getExposureSettings(),
() => gateway.status(),
() => gateway.start(),
() => gateway.stop(),
() => gateway.saveExposureSettings({ mode: "localOnly", port: 0, trustedProxies: [] }),
() => gateway.previewExposure({ mode: "localOnly", port: 0, trustedProxies: [] }),
]) {
const error: Partial<GatewayError> = { code: "UNSUPPORTED_ON_WEB" };
await expect(call()).rejects.toMatchObject(error);
}
});
it("web status subscription is inert rather than throwing", async () => {
// Teardown must stay callable: consumers unsubscribe unconditionally.
const unsubscribe = await new WebDesktopServerGateway().onStatusChanged(() => {
throw new Error("must never fire on web");
});
expect(() => unsubscribe()).not.toThrow();
});
});