diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts
index 9af7c96..f5e5e99 100644
--- a/frontend/src/adapters/mock/index.ts
+++ b/frontend/src/adapters/mock/index.ts
@@ -1410,6 +1410,7 @@ export class MockModelServerGateway implements ModelServerGateway {
/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
mode: "localOnly",
+ autoStart: false,
port: 17373,
trustedProxies: [],
};
diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts
index 5660abd..de88748 100644
--- a/frontend/src/domain/index.ts
+++ b/frontend/src/domain/index.ts
@@ -179,6 +179,12 @@ export type ServerExposureMode =
*/
export interface ServerExposureSettings {
mode: ServerExposureMode;
+ /**
+ * Whether the embedded server should start automatically when IdeA Desktop
+ * boots (ticket #89), using these persisted settings. Applied at app launch
+ * only — never implied by a manual `start()`/`stop()` call.
+ */
+ autoStart: boolean;
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
port: number;
/** Public HTTPS origin, required by both remote modes. */
diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx
index 071fc9b..7c3c318 100644
--- a/frontend/src/features/settings/DeploymentSettings.test.tsx
+++ b/frontend/src/features/settings/DeploymentSettings.test.tsx
@@ -210,4 +210,152 @@ describe("DeploymentSettings", () => {
await waitFor(() => expect(screen.getByText("Échec")).toBeTruthy());
expect(screen.getByRole("alert").textContent).toMatch(/already in use/);
});
+
+ describe("auto-start at launch (ticket #89)", () => {
+ it("shows the checkbox unchecked by default, with its exact label and help text", async () => {
+ renderView();
+ await settle();
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: "Démarrer le serveur au lancement d'IdeA",
+ });
+ expect(checkbox).toHaveProperty("checked", false);
+ expect(
+ screen.getByText(
+ "IdeA utilisera les réglages réseau enregistrés ci-dessous au prochain démarrage de l'application desktop.",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("persists autoStart:true on toggle ON without starting the server", async () => {
+ const gateway = new MockDesktopServerGateway();
+ const save = vi.spyOn(gateway, "saveExposureSettings");
+ const start = vi.spyOn(gateway, "start");
+ renderView(gateway);
+ await settle();
+
+ fireEvent.click(
+ screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
+ );
+
+ await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ autoStart: true })));
+ expect(start).not.toHaveBeenCalled();
+ await waitFor(() =>
+ expect(
+ screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
+ ).toHaveProperty("checked", true),
+ );
+ expect(screen.getByText("Arrêté")).toBeTruthy();
+ });
+
+ it("persists autoStart:false on toggle OFF", async () => {
+ const gateway = new MockDesktopServerGateway();
+ const save = vi.spyOn(gateway, "saveExposureSettings");
+ renderView(gateway);
+ await settle();
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: "Démarrer le serveur au lancement d'IdeA",
+ });
+ fireEvent.click(checkbox);
+ await waitFor(() => expect(checkbox).toHaveProperty("checked", true));
+
+ fireEvent.click(checkbox);
+ await waitFor(() =>
+ expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ autoStart: false })),
+ );
+ await waitFor(() => expect(checkbox).toHaveProperty("checked", false));
+ });
+
+ it("does not persist the toggle when the draft is otherwise invalid", async () => {
+ const gateway = new MockDesktopServerGateway();
+ renderView(gateway);
+ await settle();
+ // A remote mode with no public origin — saveExposureSettings must reject.
+ selectMode("Accès distant, proxy sur cet ordinateur");
+
+ const checkbox = await screen.findByRole("checkbox", {
+ name: "Démarrer le serveur au lancement d'IdeA",
+ });
+ fireEvent.click(checkbox);
+
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/publicOrigin/));
+ // Rejected save: the checkbox must not silently flip to checked.
+ expect(checkbox).toHaveProperty("checked", false);
+ });
+
+ it("leaves autoStart untouched when the server is stopped manually", async () => {
+ const gateway = new MockDesktopServerGateway();
+ const save = vi.spyOn(gateway, "saveExposureSettings");
+ renderView(gateway);
+ await settle();
+
+ fireEvent.click(
+ screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
+ );
+ await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
+ await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Arrêter" }));
+ await waitFor(() => expect(screen.getByText("Arrêté")).toBeTruthy());
+
+ // `stop()` never calls `saveExposureSettings` — autoStart is untouched.
+ expect(save).toHaveBeenCalledTimes(2); // the toggle, then the pre-start persist
+ expect(
+ screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
+ ).toHaveProperty("checked", true);
+ });
+ });
+
+ describe("port-conflict actionable error (ticket #89)", () => {
+ it("shows the dedicated message with « Modifier le port » and « Réessayer » actions", async () => {
+ const gateway = new MockDesktopServerGateway();
+ renderView(gateway);
+ await settle();
+
+ gateway.setStatus({
+ state: "failed",
+ error: { code: "PORT_IN_USE", message: "address already in use" },
+ });
+
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toBe(
+ "Le serveur n'a pas démarré automatiquement. address already in use",
+ );
+ expect(screen.getByRole("button", { name: "Modifier le port" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
+ });
+
+ it("« Modifier le port » focuses the Port field", async () => {
+ const gateway = new MockDesktopServerGateway();
+ renderView(gateway);
+ await settle();
+
+ gateway.setStatus({
+ state: "failed",
+ error: { code: "PORT_IN_USE", message: "address already in use" },
+ });
+ await screen.findByRole("button", { name: "Modifier le port" });
+
+ fireEvent.click(screen.getByRole("button", { name: "Modifier le port" }));
+ expect(screen.getByLabelText("Port")).toBe(document.activeElement);
+ });
+
+ it("« Réessayer » calls start() again", async () => {
+ const gateway = new MockDesktopServerGateway();
+ const start = vi.spyOn(gateway, "start");
+ renderView(gateway);
+ await settle();
+
+ gateway.setStatus({
+ state: "failed",
+ error: { code: "PORT_IN_USE", message: "address already in use" },
+ });
+ fireEvent.click(await screen.findByRole("button", { name: "Réessayer" }));
+
+ await waitFor(() => expect(start).toHaveBeenCalled());
+ });
+ });
});
diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx
index 71ff05a..f5b17d5 100644
--- a/frontend/src/features/settings/DeploymentSettings.tsx
+++ b/frontend/src/features/settings/DeploymentSettings.tsx
@@ -23,7 +23,7 @@
* a dead end.
*/
-import { useState } from "react";
+import { useRef, useState } from "react";
import type { ServerExposureMode } from "@/domain";
import { Button, Field, Input, Panel, cn } from "@/shared";
@@ -105,6 +105,9 @@ function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string
export function DeploymentSettings() {
const vm = useDeployment();
+ // "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the
+ // simplest correct answer for a cross-panel affordance on one scrolled page.
+ const portInputRef = useRef
- {status.error.message} -
+ status.error.code === "PORT_IN_USE" ? ( ++ Le serveur n'a pas démarré automatiquement. {status.error.message} +
++ {status.error.message} +
+ ) )} {vm.actionError && ({vm.actionError}
)} + + {/* Auto-start at launch (#89) — a persistence-only toggle, never a + start trigger; it never runs the server right now. */} + @@ -225,6 +283,7 @@ export function DeploymentSettings() {