From 7efa634f2386fb0e9124ba189e01cb2a12103d34 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 23:43:37 +0200 Subject: [PATCH] feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/adapters/desktopServer.ts | 82 ++++ frontend/src/adapters/http/index.ts | 4 + frontend/src/adapters/http/unsupported.ts | 46 ++- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 148 ++++++++ frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/domain/index.ts | 91 +++++ .../src/features/projects/ProjectsView.tsx | 64 ++-- .../src/features/projects/projects.test.tsx | 27 +- .../settings/DeploymentSettings.test.tsx | 209 +++++++++++ .../features/settings/DeploymentSettings.tsx | 355 ++++++++++++++++++ .../src/features/settings/SettingsView.tsx | 85 +++++ .../features/settings/desktop-only.test.ts | 60 +++ frontend/src/features/settings/index.ts | 11 + .../src/features/settings/useDeployment.ts | 253 +++++++++++++ frontend/src/ports/index.ts | 50 +++ 16 files changed, 1458 insertions(+), 31 deletions(-) create mode 100644 frontend/src/adapters/desktopServer.ts create mode 100644 frontend/src/features/settings/DeploymentSettings.test.tsx create mode 100644 frontend/src/features/settings/DeploymentSettings.tsx create mode 100644 frontend/src/features/settings/SettingsView.tsx create mode 100644 frontend/src/features/settings/desktop-only.test.ts create mode 100644 frontend/src/features/settings/index.ts create mode 100644 frontend/src/features/settings/useDeployment.ts diff --git a/frontend/src/adapters/desktopServer.ts b/frontend/src/adapters/desktopServer.ts new file mode 100644 index 0000000..52a9402 --- /dev/null +++ b/frontend/src/adapters/desktopServer.ts @@ -0,0 +1,82 @@ +/** + * Tauri adapter for {@link DesktopServerGateway} (ticket #68). One of the only + * places that calls `invoke()`; features reach it exclusively through the port. + * + * Commands and payload keys are camelCase, matching the backend DTO convention. + * `save_server_exposure_settings` / `preview_server_exposure_settings` take the + * settings directly under a `settings` key (no `request` wrapper). + * + * **Polling note.** The backend exposes no status *event* — `embedded_server.rs` + * emits nothing — so `onStatusChanged` is implemented here by polling + * `embedded_server_status`. That keeps the port shape push-like (and swappable + * for a real event later) while the polling schedule stays an adapter detail. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { + EmbeddedServerStatus, + ServerExposurePreview, + ServerExposureSettings, + Unsubscribe, +} from "@/domain"; +import type { DesktopServerGateway } from "@/ports"; + +/** Status poll interval (ms). Cheap, in-process command; transitions are coarse. */ +const STATUS_POLL_MS = 2000; + +export class TauriDesktopServerGateway implements DesktopServerGateway { + constructor(private readonly pollMs: number = STATUS_POLL_MS) {} + + getExposureSettings(): Promise { + return invoke("get_server_exposure_settings"); + } + + async saveExposureSettings(settings: ServerExposureSettings): Promise { + await invoke("save_server_exposure_settings", { settings }); + } + + previewExposure( + settings: ServerExposureSettings, + ): Promise { + return invoke("preview_server_exposure_settings", { + settings, + }); + } + + status(): Promise { + return invoke("embedded_server_status"); + } + + start(): Promise { + return invoke("embedded_server_start"); + } + + stop(): Promise { + return invoke("embedded_server_stop"); + } + + async onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise { + let stopped = false; + const timer = setInterval(() => { + void this.status().then( + (status) => { + // The unsubscribe may land while a poll is in flight; drop late results + // so a torn-down consumer never gets called. + if (!stopped) handler(status); + }, + () => { + // A failed poll is not a status: swallow it and let the next tick try + // again, rather than tearing the subscription down. + }, + ); + }, this.pollMs); + + return () => { + stopped = true; + clearInterval(timer); + }; + } +} diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 0f1871b..7873f36 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -42,6 +42,7 @@ import { HttpTicketGateway, } from "./streamGateways"; import { + WebDesktopServerGateway, WebFocusedProjectGateway, WebRemoteGateway, WebWindowGateway, @@ -121,6 +122,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway remote: new WebRemoteGateway(), profile: new HttpProfileGateway(http), modelServer: new HttpModelServerGateway(http), + // Desktop-only (#68): the web client is served *by* the embedded server and + // must not reconfigure it. + desktopServer: new WebDesktopServerGateway(), template: new HttpTemplateGateway(http), skill: new HttpSkillGateway(http), memory: new HttpMemoryGateway(http), diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index a407a5a..7add974 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -9,8 +9,15 @@ * browser to replace `pickFolder`), it gets its own lot. */ -import type { GatewayError, Unsubscribe } from "@/domain"; import type { + EmbeddedServerStatus, + GatewayError, + ServerExposurePreview, + ServerExposureSettings, + Unsubscribe, +} from "@/domain"; +import type { + DesktopServerGateway, FocusedProject, FocusedProjectGateway, RemoteGateway, @@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway { return unsupportedOnWeb("Remote (SSH/WSL) connection"); } } + +/** + * Web stub: the embedded server is desktop-only (ticket #68). A web client is + * *served by* this very server, so letting it reconfigure or stop the server + * would let a remote device saw off the branch it sits on. The desktop app owns + * this surface; every call fails explicitly rather than reaching for Tauri. + */ +export class WebDesktopServerGateway implements DesktopServerGateway { + // `async` on purpose: these must *reject* rather than throw synchronously, so + // callers using `.then(ok, err)` (not just `await`) still see the failure. + async getExposureSettings(): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async saveExposureSettings(_settings: ServerExposureSettings): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async previewExposure( + _settings: ServerExposureSettings, + ): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async status(): Promise { + return unsupportedOnWeb("Embedded server status"); + } + async start(): Promise { + return unsupportedOnWeb("Starting the embedded server"); + } + async stop(): Promise { + return unsupportedOnWeb("Stopping the embedded server"); + } + onStatusChanged( + _handler: (status: EmbeddedServerStatus) => void, + ): Promise { + // Never fires on web; a no-op unsubscribe keeps callers' teardown honest. + return Promise.resolve(() => {}); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index cb06a3c..0d5dcb2 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -20,6 +20,7 @@ import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; import { TauriModelServerGateway } from "./modelServer"; +import { TauriDesktopServerGateway } from "./desktopServer"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; @@ -60,6 +61,7 @@ export function createTauriGateways(): Gateways { remote: new TauriRemoteGateway(), profile: new TauriProfileGateway(), modelServer: new TauriModelServerGateway(), + desktopServer: new TauriDesktopServerGateway(), template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), @@ -83,6 +85,7 @@ export { TauriLayoutGateway, TauriProfileGateway, TauriModelServerGateway, + TauriDesktopServerGateway, TauriTemplateGateway, TauriSkillGateway, TauriMemoryGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f972b69..3e377cc 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -8,9 +8,11 @@ import type { Agent, AgentDrift, AgentProfile, + DiagnosticWarning, DomainEvent, EmbedderEngines, EmbedderProfile, + EmbeddedServerStatus, FirstRunState, GatewayError, GitBranches, @@ -36,6 +38,8 @@ import type { ProjectWorkState, ProfileAvailability, ResumableAgent, + ServerExposurePreview, + ServerExposureSettings, Skill, ReplyChunk, SkillScope, @@ -63,6 +67,7 @@ import type { CreateAgentInput, CreateMemoryInput, CreateSkillInput, + DesktopServerGateway, EmbedderGateway, CreateTemplateInput, Gateways, @@ -1373,6 +1378,148 @@ export class MockModelServerGateway implements ModelServerGateway { } } +/** Mirror of the backend `default_settings()` (ticket #68). */ +const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = { + mode: "localOnly", + port: 17373, + trustedProxies: [], +}; + +/** Fixed LAN candidates so the offline UI has something to pick from. */ +const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"]; + +function invalid(message: string): never { + const err: GatewayError = { code: "INVALID", message }; + throw err; +} + +/** + * In-memory embedded-server gateway (ticket #68). + * + * Mirrors the backend `validate_settings` / `preview_settings` closely enough + * that the Deployment UI — including its rejection paths — is testable offline. + * It is never the source of truth: production runs the real Tauri commands. + * `candidateLanAddresses` is fixed data here precisely because the UI must take + * addresses from the gateway rather than deriving them. + */ +export class MockDesktopServerGateway implements DesktopServerGateway { + private settings: ServerExposureSettings = structuredClone( + DEFAULT_EXPOSURE_SETTINGS, + ); + private current: EmbeddedServerStatus = { state: "stopped" }; + private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>(); + + async getExposureSettings(): Promise { + return structuredClone(this.settings); + } + + async saveExposureSettings(settings: ServerExposureSettings): Promise { + this.validate(settings); + this.settings = structuredClone(settings); + } + + async previewExposure( + settings: ServerExposureSettings, + ): Promise { + // The real `preview_server_exposure_settings` validates *before* previewing, + // so an incomplete draft is rejected rather than previewed. Mirrored here — + // the UI relies on it as its validation authority. + this.validate(settings); + const warnings: DiagnosticWarning[] = []; + if ( + settings.mode === "remoteProxyOtherMachine" && + settings.trustedProxies.length === 0 + ) { + // Mirrors the backend's `missingTrustedProxy` warning — which the backend + // itself cannot currently reach, since `validate_settings` rejects an + // empty `trustedProxies` for this mode first. Kept aligned on purpose: if + // the backend reorders, the mock already matches. + warnings.push({ + code: "missingTrustedProxy", + message: + "Add the IP address or CIDR of the reverse proxy that connects to this server.", + }); + } + const bindAddress = + settings.mode === "remoteProxyOtherMachine" + ? settings.lanBindAddress + : "127.0.0.1"; + const upstreamUrl = + settings.mode !== "localOnly" && bindAddress + ? `http://${bindAddress}:${settings.port}` + : undefined; + return { + candidateLanAddresses: [...MOCK_LAN_ADDRESSES], + upstreamUrl, + warnings, + }; + } + + async status(): Promise { + return structuredClone(this.current); + } + + async start(): Promise { + // The backend re-validates on start; a bad config fails there, not silently. + this.validate(this.settings); + const preview = await this.previewExposure(this.settings); + this.emit({ + state: "running", + localUrl: `http://127.0.0.1:${this.settings.port}`, + publicUrl: + this.settings.mode === "localOnly" + ? undefined + : this.settings.publicOrigin, + upstreamUrl: preview.upstreamUrl, + // Runtime-only, regenerated per start — never persisted. + pairingCode: "MOCK-PAIR-4242", + }); + return structuredClone(this.current); + } + + async stop(): Promise { + this.emit({ state: "stopped" }); + return structuredClone(this.current); + } + + async onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + /** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */ + setStatus(status: EmbeddedServerStatus): void { + this.emit(status); + } + + private emit(status: EmbeddedServerStatus): void { + this.current = status; + for (const handler of this.handlers) handler(structuredClone(status)); + } + + private validate(settings: ServerExposureSettings): void { + if (settings.mode !== "localOnly") { + const origin = settings.publicOrigin; + if (!origin) invalid("remote exposure requires publicOrigin"); + if (!origin.startsWith("https://")) { + invalid("remote exposure requires publicOrigin to start with https://"); + } + if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard"); + if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'"); + } + if (settings.mode === "remoteProxyOtherMachine") { + if (!settings.lanBindAddress) { + invalid("remoteProxyOtherMachine requires lanBindAddress"); + } + if (settings.trustedProxies.length === 0) { + invalid("remoteProxyOtherMachine requires at least one trustedProxies entry"); + } + } + } +} + /** * Stateful in-memory template gateway. * @@ -2630,6 +2777,7 @@ export function createMockGateways(): Gateways { remote: new MockRemoteGateway(), profile: new MockProfileGateway(), modelServer: new MockModelServerGateway(), + desktopServer: new MockDesktopServerGateway(), template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 64b8712..693f254 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -16,6 +16,7 @@ describe("createMockGateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", "conversation", + "desktopServer", "embedder", "focusedProject", "git", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1bcfd68..aeb14a9 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -117,6 +117,97 @@ export interface ModelServerCommandPreview { display: string; } +/** + * How the embedded web server is exposed (ticket #68, mirror of the backend + * `ServerExposureMode`). + * + * - `localOnly` — binds loopback, no remote access. + * - `remoteProxyLocal` — binds loopback; an HTTPS reverse proxy runs on *this* + * machine and reaches IdeA over loopback. + * - `remoteProxyOtherMachine` — binds a concrete LAN address so a proxy on + * *another* host can reach it; that proxy must be declared in + * `trustedProxies`. + */ +export type ServerExposureMode = + | "localOnly" + | "remoteProxyLocal" + | "remoteProxyOtherMachine"; + +/** + * Persisted embedded-server exposure settings (mirror of the backend + * `ServerExposureSettingsDto`). + * + * The backend validates every field on save/start; the UI never derives network + * facts (LAN addresses, upstream URL) from these — it asks `previewExposure`. + */ +export interface ServerExposureSettings { + mode: ServerExposureMode; + /** TCP port to bind. `0` asks the OS for an ephemeral port. */ + port: number; + /** Public HTTPS origin, required by both remote modes. */ + publicOrigin?: string; + /** + * Peers allowed to contact IdeA, as IPs or CIDRs. **Not** a listen address. + * Required (non-empty) by `remoteProxyOtherMachine`. + */ + trustedProxies: string[]; + /** + * Concrete LAN address to bind, required by `remoteProxyOtherMachine`. Must + * come from {@link ServerExposurePreview.candidateLanAddresses} — the UI is + * never allowed to invent one. + */ + lanBindAddress?: string; +} + +/** A non-fatal diagnostic from the exposure preview (never blocks a start). */ +export interface DiagnosticWarning { + /** Stable warning code (e.g. `missingTrustedProxy`). */ + code: string; + /** Human-readable, actionable message. */ + message: string; +} + +/** + * Backend-derived preview of a draft exposure config (mirror of + * `ServerExposurePreviewDto`). The backend is the sole authority on LAN + * addresses and the proxy upstream URL. + */ +export interface ServerExposurePreview { + /** LAN addresses the backend discovered on this host. */ + candidateLanAddresses: string[]; + /** URL the reverse proxy should target; absent in `localOnly`. */ + upstreamUrl?: string; + /** Non-fatal diagnostics to surface alongside the form. */ + warnings: DiagnosticWarning[]; +} + +/** Embedded-server lifecycle state (mirror of `EmbeddedServerStatusStateDto`). */ +export type EmbeddedServerState = + | "stopped" + | "starting" + | "running" + | "stopping" + | "failed"; + +/** + * Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` + * is a runtime-only secret: it exists while the server runs, is never + * persisted, and must never be written into a settings field. + */ +export interface EmbeddedServerStatus { + state: EmbeddedServerState; + /** Local URL, when running. */ + localUrl?: string; + /** Public URL, when a remote mode is configured. */ + publicUrl?: string; + /** Upstream URL to hand to the reverse proxy. */ + upstreamUrl?: string; + /** Runtime pairing code — present only while running. */ + pairingCode?: string; + /** Last failure, when `state` is `failed`. */ + error?: GatewayError; +} + /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index ec6c660..016f7a8 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -9,9 +9,13 @@ * │ MENU BAR Panneaux │ Settings │ * ├───────────────────────────────────────────────────────┤ * │ MAIN — LayoutGrid (fills the FULL width) │ - * │ — or the projects manager / welcome when no project │ + * │ — or Settings / the projects manager / welcome │ * └───────────────────────────────────────────────────────┘ * + * **Settings** (`Settings → AI Profiles | Deployment`, #68) is a main surface + * with its own internal section nav — it has no `PanelId` and is outside the + * `viewPlacement` model, unlike every panel below. + * * The former left sidebar is gone: every panel (context, work, tickets, agents, * templates, skills, permissions, memory, git, projects) is reached from the * single **Panneaux** menu (#26), whose per-panel submenu picks the placement @@ -34,7 +38,12 @@ import { useEffect, useState, type ReactNode } from "react"; import type { DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; -import { ProfilesSettings } from "@/features/first-run"; +import { + SettingsView, + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABEL, + type SettingsSection, +} from "@/features/settings"; import { GitGraphView } from "@/features/git"; import { Button, @@ -114,10 +123,13 @@ export function ProjectsView() { // Width (px) of each dock column, driven by the DockRegion resize handle. const [leftDockWidth, setLeftDockWidth] = useState(340); const [rightDockWidth, setRightDockWidth] = useState(340); - // Top-level view switch (#16): when true, the main area shows the AI Profiles - // settings instead of the project surface. The single menu bar stays visible - // so the user can toggle back from Settings → AI Profiles. - const [showSettings, setShowSettings] = useState(false); + // Top-level view switch (#16, extended in #68): the open Settings section, or + // null when the main area shows the project surface. Settings is a main + // surface with its own internal nav — deliberately not a placeable panel. The + // menu bar stays visible above it. + const [settingsSection, setSettingsSection] = useState( + null, + ); // The active layout (id + kind), reported by LayoutTabs — the single source of // truth. `kind` decides whether the main area is the terminal grid or the git // graph view. @@ -390,17 +402,17 @@ export function ProjectsView() { { id: "settings", label: "Settings", - items: [ - { - id: "ai-profiles", - label: showSettings ? "Close AI Profiles" : "AI Profiles", - active: showSettings, - onSelect: () => { - setShowSettings((v) => !v); - dismissFloating(); - }, + // One entry per section (#68). The entries name sections and mark the open + // one; closing lives in the view ("Close Settings"), so no label alternates. + items: SETTINGS_SECTIONS.map((section) => ({ + id: section, + label: SETTINGS_SECTION_LABEL[section], + active: settingsSection === section, + onSelect: () => { + setSettingsSection(section); + dismissFloating(); }, - ], + })), }, ]; @@ -621,7 +633,7 @@ export function ProjectsView() { onClose={(id) => void vm.closeTab(id)} projectsPanelOpen={placementOf(placements, "projects") !== "closed"} onOpenProjectsPanel={() => { - setShowSettings(false); + setSettingsSection(null); setPlacement("projects", "floating"); }} /> @@ -645,14 +657,14 @@ export function ProjectsView() { {/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
- {showSettings ? ( - // Top-level view switch (#16): AI Profiles settings takes over the main - // area while the menu bar above stays visible to toggle back. -
-
- -
-
+ {settingsSection ? ( + // Top-level view switch (#16/#68): Settings takes over the main area + // while the menu bar above stays visible. + setSettingsSection(null)} + /> ) : active && viewerConversationId ? ( tab.id === toast.projectId, ); if (projectOpen) vm.activateTab(toast.projectId); - setShowSettings(false); + setSettingsSection(null); setViewerConversationId(null); setPlacement("work", "floating"); setTaskToasts((prev) => diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index e30fbaa..9dfa5ce 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -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"); diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx new file mode 100644 index 0000000..937f974 --- /dev/null +++ b/frontend/src/features/settings/DeploymentSettings.test.tsx @@ -0,0 +1,209 @@ +/** + * Ticket #68 — `Settings → Deployment`, driven through the real `DIProvider` + * and the mock gateway (no backend). + * + * These pin the UX invariants the screen exists for, not its styling: the mode + * is a radio choice with consequences, the authorized-proxy field is explicitly + * *not* a listen address, addresses come from the backend, a refusal is + * actionable, and the pairing code is runtime-only. + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; + +import { MockDesktopServerGateway } from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { DeploymentSettings } from "./DeploymentSettings"; + +function renderView(desktopServer = new MockDesktopServerGateway()) { + const gateways = { desktopServer } as unknown as Gateways; + return { + desktopServer, + ...render( + + + , + ), + }; +} + +/** Waits past the hook's preview debounce. */ +async function settle() { + await screen.findByRole("radiogroup", { name: "exposure mode" }); + await waitFor(() => expect(screen.getByLabelText("Port")).toBeTruthy()); +} + +function selectMode(title: string) { + fireEvent.click(screen.getByRole("radio", { name: new RegExp(title) })); +} + +describe("DeploymentSettings", () => { + it("offers the three exposure modes as radio cards with their consequences", async () => { + renderView(); + await settle(); + + const group = screen.getByRole("radiogroup", { name: "exposure mode" }); + expect(within(group).getAllByRole("radio")).toHaveLength(3); + + // The plain-language consequence is part of the choice, not a tooltip. + expect( + within(group).getByText(/Remote devices cannot connect/), + ).toBeTruthy(); + expect( + within(group).getByText(/same machine as IdeA Desktop/), + ).toBeTruthy(); + expect( + within(group).getByText(/only accept traffic from that proxy/), + ).toBeTruthy(); + }); + + it("shows no exposure fields in 'This computer only'", async () => { + renderView(); + await settle(); + + // Default is localOnly: nothing to configure, nothing to get wrong. + expect(screen.queryByLabelText("Public origin")).toBeNull(); + expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull(); + expect(screen.queryByLabelText("LAN address to bind")).toBeNull(); + }); + + it("asks only for a public origin when the proxy is on this computer", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on this computer"); + + expect(await screen.findByLabelText("Public origin")).toBeTruthy(); + // The proxy is local, so there is nothing to authorize and nothing to bind. + expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull(); + expect(screen.queryByLabelText("LAN address to bind")).toBeNull(); + }); + + it("explains that the authorized proxy is not a listen address, and warns about the mode", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on another machine"); + + // This help text is the whole point of the screen: it corrects the + // "that's where IdeA listens" misreading. + expect( + await screen.findByText( + "This is not where IdeA listens. It is the machine allowed to contact IdeA.", + ), + ).toBeTruthy(); + + // The permanent warning about the silent-timeout failure mode. + expect( + screen.getByText(/the proxy may time out without an IdeA error/), + ).toBeTruthy(); + }); + + it("offers LAN addresses from the backend rather than deriving any", async () => { + const gateway = new MockDesktopServerGateway(); + const preview = vi.spyOn(gateway, "previewExposure"); + renderView(gateway); + await settle(); + selectMode("Remote access, proxy on another machine"); + + const select = await screen.findByLabelText("LAN address to bind"); + const offered = within(select as HTMLElement) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value) + .filter(Boolean); + + // Exactly the gateway's candidates — the UI invents nothing. + const fromBackend = (await preview.mock.results[0]!.value).candidateLanAddresses; + expect(offered).toEqual(fromBackend); + }); + + it("surfaces the backend's refusal as a concrete correction", async () => { + renderView(); + await settle(); + // A remote mode with no origin: the backend says exactly what to fix. + selectMode("Remote access, proxy on this computer"); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/requires publicOrigin/); + }); + + it("shows the upstream to paste, read-only, once the config is valid", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on this computer"); + fireEvent.change(await screen.findByLabelText("Public origin"), { + target: { value: "https://idea.example.com" }, + }); + + const upstream = await screen.findByRole("button", { + name: "copy upstream url", + }); + expect(upstream).toBeTruthy(); + // The upstream is IdeA-provided, never a field the user edits. + expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); + }); + + it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => { + renderView(); + await settle(); + + expect( + screen.getByText("Start the server to generate a pairing code."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + expect( + await screen.findByRole("button", { name: "copy pairing code" }), + ).toBeTruthy(); + expect( + screen.getByText(/Temporary code. It disappears when the server stops/), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Stop" })); + await waitFor(() => + expect( + screen.getByText("Start the server to generate a pairing code."), + ).toBeTruthy(), + ); + }); + + it("starts the server and reports the local URL", async () => { + renderView(); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + await waitFor(() => expect(screen.getByText("Running")).toBeTruthy()); + expect(screen.getByText(/Local URL:/)).toBeTruthy(); + }); + + it("persists the draft before starting, so what runs is what is shown", async () => { + const gateway = new MockDesktopServerGateway(); + const save = vi.spyOn(gateway, "saveExposureSettings"); + renderView(gateway); + await settle(); + + fireEvent.change(screen.getByLabelText("Port"), { target: { value: "18080" } }); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(save.mock.calls[0]![0]).toMatchObject({ port: 18080 }); + await waitFor(() => + expect(screen.getByText("http://127.0.0.1:18080")).toBeTruthy(), + ); + }); + + it("reports a failed start with the backend message", async () => { + const gateway = new MockDesktopServerGateway(); + renderView(gateway); + await settle(); + + gateway.setStatus({ + state: "failed", + error: { code: "INVALID", message: "port 17373 already in use" }, + }); + + await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy()); + expect(screen.getByRole("alert").textContent).toMatch(/already in use/); + }); +}); diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx new file mode 100644 index 0000000..cf7130b --- /dev/null +++ b/frontend/src/features/settings/DeploymentSettings.tsx @@ -0,0 +1,355 @@ +/** + * `Settings → Deployment` (ticket #68) — turn IdeA Desktop into a server other + * devices can reach, without a command line. + * + * Pure presentation over {@link useDeployment}; no `invoke()`, and no address is + * ever derived here — LAN candidates and the upstream URL come from the backend + * preview (`DesktopServerGateway`). + * + * Two UX invariants this screen exists to protect: + * + * - **The exposure mode is a decision, not a setting.** It is rendered as radio + * *cards* with plain-language consequences, never a technical select, because + * choosing wrong (proxy elsewhere, mode "on this computer") fails as a silent + * proxy timeout with no IdeA error to read. + * - **The authorized-proxy field is not a listen address.** That confusion is + * the whole reason this screen is worded the way it is; the help text under + * the field says so explicitly. + * + * The pairing code is runtime-only: shown while running, never rendered into a + * persisted field, never mixed with the upstream value. + */ + +import { useState } from "react"; + +import type { ServerExposureMode } from "@/domain"; +import { Button, Field, Input, Panel, cn } from "@/shared"; +import { useDeployment } from "./useDeployment"; + +interface ModeOption { + mode: ServerExposureMode; + title: string; + description: string; +} + +/** The three exposure choices, in increasing order of reach. */ +const MODE_OPTIONS: ModeOption[] = [ + { + mode: "localOnly", + title: "This computer only", + description: + "For using IdeA on this desktop only. Remote devices cannot connect.", + }, + { + mode: "remoteProxyLocal", + title: "Remote access, proxy on this computer", + description: + "Use this when your HTTPS proxy runs on the same machine as IdeA Desktop.", + }, + { + mode: "remoteProxyOtherMachine", + title: "Remote access, proxy on another machine", + description: + "Use this when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy.", + }, +]; + +const STATE_LABEL: Record = { + stopped: "Stopped", + starting: "Starting…", + running: "Running", + stopping: "Stopping…", + failed: "Failed", +}; + +/** Copy-to-clipboard button; degrades to disabled where the API is absent. */ +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + const supported = + typeof navigator !== "undefined" && Boolean(navigator.clipboard); + return ( + + ); +} + +/** A read-only value the user is meant to copy elsewhere (never editable). */ +function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) { + return ( +
+ + {value} + + +
+ ); +} + +export function DeploymentSettings() { + const vm = useDeployment(); + + if (!vm.ready || !vm.settings) { + return ( + +

Loading…

+
+ ); + } + + const { settings, status } = vm; + const running = status.state === "running"; + const remote = settings.mode !== "localOnly"; + const otherMachine = settings.mode === "remoteProxyOtherMachine"; + const transitioning = status.state === "starting" || status.state === "stopping"; + + return ( +
+ {/* ── Status ────────────────────────────────────────────────────────── */} + void vm.stop()} + disabled={vm.busy || transitioning} + > + Stop + + ) : ( + + ) + } + > +
+

+ + {STATE_LABEL[status.state]} +

+ + {status.localUrl && ( +

+ Local URL: {status.localUrl} +

+ )} + {status.publicUrl && ( +

+ Public URL: {status.publicUrl} +

+ )} + + {/* A failure carries the backend's message: it is the correction. */} + {status.state === "failed" && status.error && ( +

+ {status.error.message} +

+ )} + {vm.actionError && ( +

+ {vm.actionError} +

+ )} +
+
+ + {/* ── Exposure ──────────────────────────────────────────────────────── */} + +
+
+ {MODE_OPTIONS.map((option) => { + const selected = settings.mode === option.mode; + return ( + + ); + })} +
+ + + {({ id }) => ( + vm.setPort(Number(e.target.value))} + /> + )} + + + {remote && ( + + {({ id, describedBy }) => ( + vm.setPublicOrigin(e.target.value)} + /> + )} + + )} + + {otherMachine && ( + <> + {/* Addresses come from the backend probe — never invented here. */} + + {({ id, describedBy }) => + vm.candidateLanAddresses.length > 0 ? ( + + ) : ( + vm.setLanBindAddress(e.target.value)} + /> + ) + } + + + + {({ id, describedBy }) => ( + vm.setTrustedProxies(e.target.value)} + /> + )} + + +

+ If your proxy is not on this computer, choose this mode. + Otherwise the proxy may time out without an IdeA error. +

+ + )} + + {/* Warnings inform; they never block a start. */} + {vm.warnings.map((warning) => ( +

+ {warning.message} +

+ ))} + + {vm.validationError && ( +

+ {vm.validationError} +

+ )} +
+
+ + {/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */} + {remote && ( + +
+

+ Point your HTTPS reverse proxy at this upstream. +

+ {vm.upstreamUrl ? ( + + ) : ( +

+ Complete the settings above to get the upstream URL. +

+ )} +
+
+ )} + + {/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} + + {running && status.pairingCode ? ( +
+ +

+ Temporary code. It disappears when the server stops. Do not save it + in configuration files. +

+
+ ) : ( +

+ Start the server to generate a pairing code. +

+ )} +
+
+ ); +} diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx new file mode 100644 index 0000000..bfe7613 --- /dev/null +++ b/frontend/src/features/settings/SettingsView.tsx @@ -0,0 +1,85 @@ +/** + * `SettingsView` — the Settings surface shell (ticket #68). + * + * Settings is a **main surface**, not a dockable project view: it deliberately + * has no `PanelId` and stays outside the `viewPlacement` model. It takes over + * the main area while the menu bar stays visible above it. + * + * Ticket #68 gives it a second section, so the section list becomes real + * navigation (a left column) instead of the old single toggle. That also kills + * the alternating "Close AI Profiles" menu label, which never scaled past one + * entry: the menu now names sections, the active one is marked, and closing is + * an explicit action inside the view. + * + * `EmbedderSettings` / `ModelServersPanel` are **not** pulled in here — that is + * a separate lateral rework. The section list is the seam they would slot into. + */ + +import { Button, cn } from "@/shared"; +import { ProfilesSettings } from "@/features/first-run"; +import { DeploymentSettings } from "./DeploymentSettings"; + +/** The Settings sections, in menu/nav order. */ +export type SettingsSection = "aiProfiles" | "deployment"; + +/** Human labels, shared by the nav column and the `Settings` menu. */ +export const SETTINGS_SECTION_LABEL: Record = { + aiProfiles: "AI Profiles", + deployment: "Deployment", +}; + +/** Section order — the single source of truth for both nav and menu. */ +export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"]; + +interface SettingsViewProps { + section: SettingsSection; + onSectionChange: (section: SettingsSection) => void; + onClose: () => void; +} + +export function SettingsView({ + section, + onSectionChange, + onClose, +}: SettingsViewProps) { + return ( +
+ + +
+
+ {section === "aiProfiles" ? : } +
+
+
+ ); +} diff --git a/frontend/src/features/settings/desktop-only.test.ts b/frontend/src/features/settings/desktop-only.test.ts new file mode 100644 index 0000000..bb72815 --- /dev/null +++ b/frontend/src/features/settings/desktop-only.test.ts @@ -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 = { 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(); + }); +}); diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts new file mode 100644 index 0000000..09a0cc4 --- /dev/null +++ b/frontend/src/features/settings/index.ts @@ -0,0 +1,11 @@ +/** Settings surface (ticket #68): section shell + the Deployment screen. */ + +export { + SettingsView, + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABEL, + type SettingsSection, +} from "./SettingsView"; +export { DeploymentSettings } from "./DeploymentSettings"; +export { useDeployment } from "./useDeployment"; +export type { DeploymentVm } from "./useDeployment"; diff --git a/frontend/src/features/settings/useDeployment.ts b/frontend/src/features/settings/useDeployment.ts new file mode 100644 index 0000000..ae9a0a8 --- /dev/null +++ b/frontend/src/features/settings/useDeployment.ts @@ -0,0 +1,253 @@ +/** + * `useDeployment` — view-model for `Settings → Deployment` (ticket #68). + * + * Owns the draft exposure config, the backend-derived preview, and the embedded + * server lifecycle. All behaviour lives here so `DeploymentSettings` stays + * presentation-only (no `invoke()`, no network reasoning in JSX). + * + * **The backend owns every network fact.** LAN candidates and the proxy upstream + * URL are read from `previewExposure`; nothing here derives an address. + * + * Two backend behaviours shape this hook: + * + * 1. `preview_server_exposure_settings` *validates* before previewing, so an + * incomplete draft is rejected rather than previewed. That makes it the + * UI's validation authority (it returns the same message `start` would), but + * it also means a `remoteProxyOtherMachine` draft cannot be previewed until + * it already carries a LAN bind address — which is what the preview is for. + * So the LAN candidate list is fetched with a separate always-valid + * `localOnly` probe; the backend builds that list independently of the mode. + * 2. Only `start` reports a runtime failure, so a rejected save/start surfaces + * the backend message verbatim — it is the concrete correction to apply. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { + DiagnosticWarning, + EmbeddedServerStatus, + GatewayError, + ServerExposureMode, + ServerExposureSettings, +} from "@/domain"; +import { useGateways } from "@/app/di"; + +/** Debounce before previewing a draft, so typing doesn't spam the backend. */ +const PREVIEW_DEBOUNCE_MS = 250; + +function messageOf(e: unknown): string { + return e && typeof e === "object" && "message" in e + ? String((e as GatewayError).message) + : String(e); +} + +export interface DeploymentVm { + /** False until the persisted settings have loaded. */ + ready: boolean; + /** The draft config being edited (persisted config until the user edits). */ + settings: ServerExposureSettings | null; + /** Current server status. */ + status: EmbeddedServerStatus; + /** LAN addresses discovered by the backend (never derived client-side). */ + candidateLanAddresses: string[]; + /** Backend-built upstream URL for the current draft; absent when invalid. */ + upstreamUrl?: string; + /** Non-fatal diagnostics for the draft — informational, never blocking. */ + warnings: DiagnosticWarning[]; + /** Why the draft is rejected, as told by the backend. Actionable, not decorative. */ + validationError: string | null; + /** Last save/start/stop failure. */ + actionError: string | null; + /** True while a start/stop is in flight. */ + busy: boolean; + setMode: (mode: ServerExposureMode) => void; + setPublicOrigin: (origin: string) => void; + setLanBindAddress: (address: string) => void; + setTrustedProxies: (raw: string) => void; + setPort: (port: number) => void; + /** Persists the draft, then starts the server so what runs is what is shown. */ + start: () => Promise; + stop: () => Promise; +} + +export function useDeployment(): DeploymentVm { + const { desktopServer } = useGateways(); + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState({ + state: "stopped", + }); + const [candidateLanAddresses, setCandidates] = useState([]); + const [upstreamUrl, setUpstreamUrl] = useState(); + const [warnings, setWarnings] = useState([]); + const [validationError, setValidationError] = useState(null); + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(false); + // Guards against a stale in-flight preview overwriting a newer one. + const previewSeq = useRef(0); + + useEffect(() => { + let alive = true; + void (async () => { + try { + const [persisted, current] = await Promise.all([ + desktopServer.getExposureSettings(), + desktopServer.status(), + ]); + if (!alive) return; + setSettings(persisted); + setStatus(current); + } catch (e) { + if (alive) setActionError(messageOf(e)); + } + })(); + return () => { + alive = false; + }; + }, [desktopServer]); + + // LAN candidates via an always-valid `localOnly` probe (see the header note). + useEffect(() => { + let alive = true; + void (async () => { + try { + const preview = await desktopServer.previewExposure({ + mode: "localOnly", + port: 0, + trustedProxies: [], + }); + if (alive) setCandidates(preview.candidateLanAddresses); + } catch { + // No candidates ⇒ the LAN field falls back to free text; the backend + // still rejects a bad address on save. + } + })(); + return () => { + alive = false; + }; + }, [desktopServer]); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void desktopServer.onStatusChanged(setStatus).then((u) => { + // The effect may have torn down while the subscription was resolving. + if (cancelled) u(); + else unsubscribe = u; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [desktopServer]); + + // Preview (and thereby validate) the draft, debounced. + useEffect(() => { + if (!settings) return; + const seq = ++previewSeq.current; + const timer = setTimeout(() => { + void desktopServer.previewExposure(settings).then( + (preview) => { + if (seq !== previewSeq.current) return; + setUpstreamUrl(preview.upstreamUrl); + setWarnings(preview.warnings); + setValidationError(null); + }, + (e) => { + if (seq !== previewSeq.current) return; + // The draft is incomplete/invalid: the backend message *is* the fix. + setUpstreamUrl(undefined); + setWarnings([]); + setValidationError(messageOf(e)); + }, + ); + }, PREVIEW_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [desktopServer, settings]); + + const patch = useCallback((change: Partial) => { + setActionError(null); + setSettings((prev) => (prev ? { ...prev, ...change } : prev)); + }, []); + + const setMode = useCallback( + (mode: ServerExposureMode) => { + // Dropping to a narrower mode clears the fields that mode does not use, so + // a stale origin/proxy can never be persisted behind the user's back. + if (mode === "localOnly") { + patch({ mode, publicOrigin: undefined, lanBindAddress: undefined, trustedProxies: [] }); + } else if (mode === "remoteProxyLocal") { + patch({ mode, lanBindAddress: undefined, trustedProxies: [] }); + } else { + patch({ mode }); + } + }, + [patch], + ); + + const setPublicOrigin = useCallback( + (origin: string) => patch({ publicOrigin: origin.trim() || undefined }), + [patch], + ); + const setLanBindAddress = useCallback( + (address: string) => patch({ lanBindAddress: address || undefined }), + [patch], + ); + const setTrustedProxies = useCallback( + (raw: string) => + patch({ + trustedProxies: raw + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean), + }), + [patch], + ); + const setPort = useCallback((port: number) => patch({ port }), [patch]); + + const start = useCallback(async () => { + if (!settings) return; + setBusy(true); + setActionError(null); + try { + // `start` runs the *persisted* config, so persist the draft first — + // otherwise the server would run something other than what is on screen. + await desktopServer.saveExposureSettings(settings); + setStatus(await desktopServer.start()); + } catch (e) { + setActionError(messageOf(e)); + } finally { + setBusy(false); + } + }, [desktopServer, settings]); + + const stop = useCallback(async () => { + setBusy(true); + setActionError(null); + try { + setStatus(await desktopServer.stop()); + } catch (e) { + setActionError(messageOf(e)); + } finally { + setBusy(false); + } + }, [desktopServer]); + + return { + ready: settings !== null, + settings, + status, + candidateLanAddresses, + upstreamUrl, + warnings, + validationError, + actionError, + busy, + setMode, + setPublicOrigin, + setLanBindAddress, + setTrustedProxies, + setPort, + start, + stop, + }; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index b1b6137..6dc481f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -15,6 +15,7 @@ import type { DomainEvent, EmbedderEngines, EmbedderProfile, + EmbeddedServerStatus, FirstRunState, GitBranches, GitCommit, @@ -41,6 +42,8 @@ import type { ProfileAvailability, ResumableAgent, ReplyChunk, + ServerExposurePreview, + ServerExposureSettings, Skill, SkillScope, Sprint, @@ -678,6 +681,52 @@ export interface ModelServerGateway { ): Promise; } +/** + * Embedded web server lifecycle + exposure settings (ticket #68). Backs the + * desktop `Settings → Deployment` surface, so the user can expose IdeA to + * remote devices without a command line. + * + * **Desktop-only.** Only the Tauri transport implements it for real; the web + * transport rejects (a web client is *served by* this server and must not + * reconfigure it). See `desktop-only.test.ts`. + * + * The backend owns every network fact: LAN candidates and the proxy upstream + * URL come from {@link previewExposure}, never from client-side derivation. + */ +export interface DesktopServerGateway { + /** Reads the persisted exposure settings. */ + getExposureSettings(): Promise; + /** + * Validates and persists exposure settings. Rejects with a `GatewayError` + * (`INVALID`) when the config is inconsistent — e.g. a remote mode without an + * `https://` `publicOrigin`, or `remoteProxyOtherMachine` without a concrete + * `lanBindAddress` / a non-empty `trustedProxies`. + */ + saveExposureSettings(settings: ServerExposureSettings): Promise; + /** + * Derives LAN candidates, the upstream URL and non-fatal warnings for a + * **draft** config, without persisting it. The single source of truth for the + * addresses the UI displays. + */ + previewExposure( + settings: ServerExposureSettings, + ): Promise; + /** Current server status. */ + status(): Promise; + /** Starts the server with the persisted settings; resolves with the new status. */ + start(): Promise; + /** Stops the server; resolves with the new status. */ + stop(): Promise; + /** + * Observes status transitions. The backend emits no status event today, so + * the Tauri adapter polls `embedded_server_status`; the schedule is a + * transport detail owned by the adapter, and callers must not depend on it. + */ + onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise; +} + /** Input for {@link EmbedderGateway.saveEmbedderProfile}. */ export interface SaveEmbedderProfileInput { profile: EmbedderProfile; @@ -1076,6 +1125,7 @@ export interface Gateways { remote: RemoteGateway; profile: ProfileGateway; modelServer: ModelServerGateway; + desktopServer: DesktopServerGateway; template: TemplateGateway; skill: SkillGateway; memory: MemoryGateway;