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:
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal file
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal file
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
frontend/src/features/settings/SettingsView.tsx
Normal file
85
frontend/src/features/settings/SettingsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/features/settings/desktop-only.test.ts
Normal file
60
frontend/src/features/settings/desktop-only.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
frontend/src/features/settings/index.ts
Normal file
11
frontend/src/features/settings/index.ts
Normal 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";
|
||||
253
frontend/src/features/settings/useDeployment.ts
Normal file
253
frontend/src/features/settings/useDeployment.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user