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,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<ServerExposureSettings> {
return invoke<ServerExposureSettings>("get_server_exposure_settings");
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
await invoke("save_server_exposure_settings", { settings });
}
previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
return invoke<ServerExposurePreview>("preview_server_exposure_settings", {
settings,
});
}
status(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_status");
}
start(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_start");
}
stop(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_stop");
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
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);
};
}
}

View File

@ -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),

View File

@ -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<ServerExposureSettings> {
return unsupportedOnWeb("Embedded server settings");
}
async saveExposureSettings(_settings: ServerExposureSettings): Promise<void> {
return unsupportedOnWeb("Embedded server settings");
}
async previewExposure(
_settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
return unsupportedOnWeb("Embedded server settings");
}
async status(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Embedded server status");
}
async start(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Starting the embedded server");
}
async stop(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Stopping the embedded server");
}
onStatusChanged(
_handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
// Never fires on web; a no-op unsubscribe keeps callers' teardown honest.
return Promise.resolve(() => {});
}
}

View File

@ -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,

View File

@ -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<ServerExposureSettings> {
return structuredClone(this.settings);
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
this.validate(settings);
this.settings = structuredClone(settings);
}
async previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
// 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<EmbeddedServerStatus> {
return structuredClone(this.current);
}
async start(): Promise<EmbeddedServerStatus> {
// 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<EmbeddedServerStatus> {
this.emit({ state: "stopped" });
return structuredClone(this.current);
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
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(),

View File

@ -16,6 +16,7 @@ describe("createMockGateways", () => {
expect(Object.keys(gateways).sort()).toEqual([
"agent",
"conversation",
"desktopServer",
"embedder",
"focusedProject",
"git",

View File

@ -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 }

View File

@ -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<SettingsSection | null>(
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,
// 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: () => {
setShowSettings((v) => !v);
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 ── */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{showSettings ? (
// Top-level view switch (#16): AI Profiles settings takes over the main
// area while the menu bar above stays visible to toggle back.
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl">
<ProfilesSettings />
</div>
</div>
{settingsSection ? (
// Top-level view switch (#16/#68): Settings takes over the main area
// while the menu bar above stays visible.
<SettingsView
section={settingsSection}
onSectionChange={setSettingsSection}
onClose={() => setSettingsSection(null)}
/>
) : active && viewerConversationId ? (
<ConversationViewer
key={`${active.id}-${viewerConversationId}`}
@ -740,7 +752,7 @@ export function ProjectsView() {
(tab) => tab.id === toast.projectId,
);
if (projectOpen) vm.activateTab(toast.projectId);
setShowSettings(false);
setSettingsSection(null);
setViewerConversationId(null);
setPlacement("work", "floating");
setTaskToasts((prev) =>

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");

View File

@ -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(
<DIProvider gateways={gateways}>
<DeploymentSettings />
</DIProvider>,
),
};
}
/** 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/);
});
});

View File

@ -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<string, string> = {
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 (
<Button
size="sm"
variant="ghost"
aria-label={label}
disabled={!supported}
onClick={() => {
void navigator.clipboard.writeText(value).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
},
() => setCopied(false),
);
}}
>
{copied ? "Copied" : "Copy"}
</Button>
);
}
/** A read-only value the user is meant to copy elsewhere (never editable). */
function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) {
return (
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-md border border-border bg-raised px-2 py-1.5 font-mono text-xs text-content">
{value}
</code>
<CopyButton value={value} label={copyLabel} />
</div>
);
}
export function DeploymentSettings() {
const vm = useDeployment();
if (!vm.ready || !vm.settings) {
return (
<Panel aria-label="deployment settings" title="Deployment">
<p className="text-sm text-muted">Loading</p>
</Panel>
);
}
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 (
<div aria-label="deployment settings" className="flex flex-col gap-4">
{/* ── Status ────────────────────────────────────────────────────────── */}
<Panel
title="Server"
actions={
running || status.state === "stopping" ? (
<Button
size="sm"
onClick={() => void vm.stop()}
disabled={vm.busy || transitioning}
>
Stop
</Button>
) : (
<Button
size="sm"
onClick={() => void vm.start()}
disabled={vm.busy || transitioning}
>
Start
</Button>
)
}
>
<div className="flex flex-col gap-2">
<p className="flex items-center gap-2 text-sm">
<span
aria-hidden
className={cn(
"size-2 rounded-full",
running && "bg-success",
status.state === "failed" && "bg-danger",
(status.state === "stopped" || transitioning) && "bg-faint",
)}
/>
<span className="text-content">{STATE_LABEL[status.state]}</span>
</p>
{status.localUrl && (
<p className="text-xs text-muted">
Local URL: <code className="font-mono text-content">{status.localUrl}</code>
</p>
)}
{status.publicUrl && (
<p className="text-xs text-muted">
Public URL: <code className="font-mono text-content">{status.publicUrl}</code>
</p>
)}
{/* A failure carries the backend's message: it is the correction. */}
{status.state === "failed" && status.error && (
<p role="alert" className="text-sm text-danger">
{status.error.message}
</p>
)}
{vm.actionError && (
<p role="alert" className="text-sm text-danger">
{vm.actionError}
</p>
)}
</div>
</Panel>
{/* ── Exposure ──────────────────────────────────────────────────────── */}
<Panel title="Exposure">
<div className="flex flex-col gap-4">
<div
role="radiogroup"
aria-label="exposure mode"
className="flex flex-col gap-2"
>
{MODE_OPTIONS.map((option) => {
const selected = settings.mode === option.mode;
return (
<label
key={option.mode}
className={cn(
"flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors",
selected
? "border-border-strong bg-raised"
: "border-border hover:bg-raised",
)}
>
<input
type="radio"
name="exposure-mode"
className="mt-1"
checked={selected}
onChange={() => vm.setMode(option.mode)}
/>
<span className="flex flex-col gap-1">
<span className="text-sm font-semibold text-content">
{option.title}
</span>
<span className="text-xs text-muted">{option.description}</span>
</span>
</label>
);
})}
</div>
<Field label="Port">
{({ id }) => (
<Input
id={id}
type="number"
value={String(settings.port)}
onChange={(e) => vm.setPort(Number(e.target.value))}
/>
)}
</Field>
{remote && (
<Field label="Public origin" hint="Example: https://idea.example.com">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
placeholder="https://idea.example.com"
value={settings.publicOrigin ?? ""}
onChange={(e) => vm.setPublicOrigin(e.target.value)}
/>
)}
</Field>
)}
{otherMachine && (
<>
{/* Addresses come from the backend probe — never invented here. */}
<Field
label="LAN address to bind"
hint="The address on this machine that the proxy will connect to."
>
{({ id, describedBy }) =>
vm.candidateLanAddresses.length > 0 ? (
<select
id={id}
aria-describedby={describedBy}
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-content"
value={settings.lanBindAddress ?? ""}
onChange={(e) => vm.setLanBindAddress(e.target.value)}
>
<option value="">Select an address</option>
{vm.candidateLanAddresses.map((address) => (
<option key={address} value={address}>
{address}
</option>
))}
</select>
) : (
<Input
id={id}
aria-describedby={describedBy}
placeholder="192.168.1.42"
value={settings.lanBindAddress ?? ""}
onChange={(e) => vm.setLanBindAddress(e.target.value)}
/>
)
}
</Field>
<Field
label="Authorized proxy IP/CIDR"
hint="This is not where IdeA listens. It is the machine allowed to contact IdeA."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
placeholder="203.0.113.7 or 203.0.113.0/24"
value={settings.trustedProxies.join(", ")}
onChange={(e) => vm.setTrustedProxies(e.target.value)}
/>
)}
</Field>
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
If your proxy is not on this computer, choose this mode.
Otherwise the proxy may time out without an IdeA error.
</p>
</>
)}
{/* Warnings inform; they never block a start. */}
{vm.warnings.map((warning) => (
<p key={warning.code} className="text-xs text-warning">
{warning.message}
</p>
))}
{vm.validationError && (
<p role="alert" className="text-sm text-danger">
{vm.validationError}
</p>
)}
</div>
</Panel>
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
{remote && (
<Panel title="Proxy setup">
<div className="flex flex-col gap-2">
<p className="text-xs text-muted">
Point your HTTPS reverse proxy at this upstream.
</p>
{vm.upstreamUrl ? (
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copy upstream url" />
) : (
<p className="text-xs text-faint">
Complete the settings above to get the upstream URL.
</p>
)}
</div>
</Panel>
)}
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */}
<Panel title="Pairing">
{running && status.pairingCode ? (
<div className="flex flex-col gap-2">
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" />
<p className="text-xs text-muted">
Temporary code. It disappears when the server stops. Do not save it
in configuration files.
</p>
</div>
) : (
<p className="text-sm text-muted">
Start the server to generate a pairing code.
</p>
)}
</Panel>
</div>
);
}

View File

@ -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<SettingsSection, string> = {
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 (
<div className="flex min-h-0 flex-1 overflow-hidden">
<nav
aria-label="settings sections"
className="flex w-52 shrink-0 flex-col gap-1 border-r border-border p-3"
>
{SETTINGS_SECTIONS.map((id) => {
const active = id === section;
return (
<button
key={id}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSectionChange(id)}
className={cn(
"rounded-md px-3 py-2 text-left text-sm transition-colors",
active
? "bg-raised font-semibold text-content"
: "text-muted hover:bg-raised hover:text-content",
)}
>
{SETTINGS_SECTION_LABEL[id]}
</button>
);
})}
<div className="mt-auto pt-2">
<Button size="sm" variant="ghost" className="w-full" onClick={onClose}>
Close Settings
</Button>
</div>
</nav>
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl">
{section === "aiProfiles" ? <ProfilesSettings /> : <DeploymentSettings />}
</div>
</div>
</div>
);
}

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();
});
});

View File

@ -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";

View File

@ -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<void>;
stop: () => Promise<void>;
}
export function useDeployment(): DeploymentVm {
const { desktopServer } = useGateways();
const [settings, setSettings] = useState<ServerExposureSettings | null>(null);
const [status, setStatus] = useState<EmbeddedServerStatus>({
state: "stopped",
});
const [candidateLanAddresses, setCandidates] = useState<string[]>([]);
const [upstreamUrl, setUpstreamUrl] = useState<string | undefined>();
const [warnings, setWarnings] = useState<DiagnosticWarning[]>([]);
const [validationError, setValidationError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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<ServerExposureSettings>) => {
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,
};
}

View File

@ -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<ModelServerCommandPreview>;
}
/**
* 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<ServerExposureSettings>;
/**
* 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<void>;
/**
* 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<ServerExposurePreview>;
/** Current server status. */
status(): Promise<EmbeddedServerStatus>;
/** Starts the server with the persisted settings; resolves with the new status. */
start(): Promise<EmbeddedServerStatus>;
/** Stops the server; resolves with the new status. */
stop(): Promise<EmbeddedServerStatus>;
/**
* 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<Unsubscribe>;
}
/** 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;