feat: add machine setup dialog

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 12:12:25 +02:00
parent 193c9d0f8f
commit 93b50e61af
6 changed files with 241 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token.
+2
View File
@@ -36,6 +36,7 @@ export interface AppState {
authDialog: AuthDialogState | undefined;
actionPaletteOpen: boolean;
projectDialogOpen: boolean;
machineDialogOpen: boolean;
workspaceTool: QualifiedContributionId;
mainView: "navigation" | "chat" | QualifiedContributionId;
fileTree: FileTreeEntry[];
@@ -130,6 +131,7 @@ export function initialAppState(): AppState {
authDialog: undefined,
actionPaletteOpen: false,
projectDialogOpen: false,
machineDialogOpen: false,
workspaceTool: "core:workspace.files",
mainView: "chat",
fileTree: [],
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { machineBaseUrlValidationMessage, suggestedMachineNameFromUrl } from "./MachineDialog";
describe("suggestedMachineNameFromUrl", () => {
it("suggests the host without protocol or port", () => {
expect(suggestedMachineNameFromUrl("http://127.0.0.1:8504")).toBe("127.0.0.1");
expect(suggestedMachineNameFromUrl("https://devbox.example.test:8504/pi-web")).toBe("devbox.example.test");
});
it("also suggests a host while the URL protocol is being typed", () => {
expect(suggestedMachineNameFromUrl("devbox.local:8504")).toBe("devbox.local");
});
});
describe("machineBaseUrlValidationMessage", () => {
it("accepts http and https base URLs", () => {
expect(machineBaseUrlValidationMessage("http://127.0.0.1:8504")).toBeUndefined();
expect(machineBaseUrlValidationMessage("https://devbox.example.test/pi-web")).toBeUndefined();
});
it("explains invalid machine URLs", () => {
expect(machineBaseUrlValidationMessage("")).toBe("Remote PI WEB URL is required.");
expect(machineBaseUrlValidationMessage("devbox.local:8504")).toBe("Use an http:// or https:// URL.");
expect(machineBaseUrlValidationMessage("ftp://devbox.example.test")).toBe("Use an http:// or https:// URL.");
expect(machineBaseUrlValidationMessage("https://[email protected]")).toBe("Do not include credentials in the machine URL.");
expect(machineBaseUrlValidationMessage("https://devbox.example.test?q=1")).toBe("Do not include a query string or fragment.");
});
});
+192
View File
@@ -0,0 +1,192 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
export interface MachineDialogSubmit {
name: string;
baseUrl: string;
token?: string;
}
@customElement("machine-dialog")
export class MachineDialog extends LitElement {
@property({ attribute: false }) onSubmit?: (input: MachineDialogSubmit) => void | Promise<void>;
@property({ attribute: false }) onCancel?: () => void;
@property() error = "";
@state() private url = "";
@state() private name = "";
@state() private token = "";
@state() private submitting = false;
@query("input[name='baseUrl']") private urlInput?: HTMLInputElement;
@query("input[name='name']") private nameInput?: HTMLInputElement;
private nameEdited = false;
private previousSuggestedName = "";
override firstUpdated(): void {
this.urlInput?.focus();
}
private handleUrlInput(event: InputEvent): void {
if (!(event.target instanceof HTMLInputElement)) return;
const url = event.target.value;
const suggestedName = suggestedMachineNameFromUrl(url);
if (!this.nameEdited || this.name.trim() === "" || this.name === this.previousSuggestedName) this.name = suggestedName;
this.previousSuggestedName = suggestedName;
this.url = url;
}
private handleNameInput(event: InputEvent): void {
if (!(event.target instanceof HTMLInputElement)) return;
this.nameEdited = true;
this.name = event.target.value;
}
private handleTokenInput(event: InputEvent): void {
if (!(event.target instanceof HTMLInputElement)) return;
this.token = event.target.value;
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
event.preventDefault();
this.onCancel?.();
return;
}
if (event.key === "Enter" && event.target instanceof HTMLInputElement && event.target.name === "baseUrl" && machineBaseUrlValidationMessage(this.url) === undefined) {
event.preventDefault();
void this.updateComplete.then(() => {
this.nameInput?.focus();
this.nameInput?.select();
});
}
}
private handleSubmit(event: SubmitEvent): void {
event.preventDefault();
void this.submit();
}
private async submit(): Promise<void> {
const input = this.validInput();
if (input === undefined || this.submitting) return;
this.submitting = true;
try {
await this.onSubmit?.(input);
} finally {
if (this.isConnected) this.submitting = false;
}
}
private validInput(): MachineDialogSubmit | undefined {
const baseUrl = this.url.trim();
const name = this.name.trim();
if (baseUrl === "" || name === "" || machineBaseUrlValidationMessage(baseUrl) !== undefined) return undefined;
const token = this.token.trim();
return { name, baseUrl, ...(token === "" ? {} : { token }) };
}
override render() {
const hasUrl = this.url.trim() !== "";
const urlError = hasUrl ? machineBaseUrlValidationMessage(this.url) : undefined;
const canSubmit = this.validInput() !== undefined && !this.submitting;
return html`
<div class="backdrop" @click=${() => this.onCancel?.()}>
<section @click=${(event: Event) => { event.stopPropagation(); }}>
<form @submit=${(event: SubmitEvent) => { this.handleSubmit(event); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<strong>Add machine</strong>
<button type="button" @click=${() => { this.onCancel?.(); }} aria-label="Close">×</button>
</header>
<div class="body">
${this.error === "" ? null : html`<div class="dialog-error" role="alert">${this.error}</div>`}
<label>
Remote PI WEB URL
<input name="baseUrl" type="url" .value=${this.url} @input=${(event: InputEvent) => { this.handleUrlInput(event); }} placeholder="http://dev-box.local:8504" autocomplete="url" inputmode="url" autofocus />
</label>
<small class=${urlError === undefined ? "hint" : "field-error"}>${urlError ?? "Enter the reachable base URL first, including http:// or https://."}</small>
${hasUrl ? html`
<label>
Machine name
<input name="name" type="text" .value=${this.name} @input=${(event: InputEvent) => { this.handleNameInput(event); }} placeholder=${this.previousSuggestedName || "Dev Box"} autocomplete="off" />
</label>
<small class="hint">Suggested from the URL. Edit it to use a friendlier sidebar label.</small>
<label>
Bearer token <span class="optional">optional</span>
<input name="token" type="password" .value=${this.token} @input=${(event: InputEvent) => { this.handleTokenInput(event); }} placeholder="Leave blank if the remote machine does not require one" autocomplete="off" />
</label>
<small class="hint">Paste only the token value; PI WEB sends it as an Authorization: Bearer header.</small>
` : html`<p class="hint intro">After you enter a URL, PI WEB will suggest a machine name and let you add an optional bearer token.</p>`}
</div>
<footer>
<button type="button" @click=${() => { this.onCancel?.(); }}>Cancel</button>
<button class="primary" type="submit" ?disabled=${!canSubmit}>${this.submitting ? "Adding…" : "Add machine"}</button>
</footer>
</form>
</section>
</div>
`;
}
static override styles = css`
:host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.backdrop { display: grid; place-items: start center; width: 100%; height: 100%; padding-top: min(12vh, 90px); box-sizing: border-box; background: var(--pi-overlay); }
section { width: min(560px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
form { display: flex; flex-direction: column; max-height: inherit; min-height: 0; }
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
.body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; }
label { display: grid; gap: 6px; color: var(--pi-muted); }
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
.hint { color: var(--pi-muted); }
.intro { margin: 4px 0 0; line-height: 1.4; }
.optional { color: var(--pi-muted); font-weight: 400; }
.field-error { color: var(--pi-danger); }
.dialog-error { border: 1px solid var(--pi-danger); border-radius: 8px; background: color-mix(in srgb, var(--pi-danger) 10%, transparent); color: var(--pi-danger); padding: 9px; line-height: 1.35; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
header button { border: 0; background: transparent; color: var(--pi-muted); font-size: 22px; padding: 0 8px; }
.primary { border-color: var(--pi-success-border); background: var(--pi-success-border); }
button:disabled { opacity: .5; cursor: not-allowed; }
`;
}
export function suggestedMachineNameFromUrl(value: string): string {
const raw = value.trim();
if (raw === "") return "";
const parsed = parseUrlForSuggestion(raw) ?? parseUrlForSuggestion(`http://${raw.replace(/^\/+/u, "")}`);
if (parsed !== undefined && parsed.hostname !== "") return parsed.hostname.replace(/^\[(.*)\]$/u, "$1");
return fallbackSuggestedName(raw);
}
export function machineBaseUrlValidationMessage(value: string): string | undefined {
const raw = value.trim();
if (raw === "") return "Remote PI WEB URL is required.";
let url: URL;
try {
url = new URL(raw);
} catch {
return "Enter a valid URL including http:// or https://.";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "Use an http:// or https:// URL.";
if (url.username !== "" || url.password !== "") return "Do not include credentials in the machine URL.";
if (url.search !== "" || url.hash !== "") return "Do not include a query string or fragment.";
return undefined;
}
function parseUrlForSuggestion(value: string): URL | undefined {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch {
return undefined;
}
}
function fallbackSuggestedName(value: string): string {
const withoutProtocol = value.replace(/^[a-z][a-z\d+.-]*:\/\//iu, "");
const withoutCredentials = withoutProtocol.slice(withoutProtocol.lastIndexOf("@") + 1);
const host = withoutCredentials.split(/[/?#]/u)[0] ?? "";
if (host.startsWith("[") && host.includes("]")) return host.slice(1, host.indexOf("]"));
return host.replace(/:\d+$/u, "");
}
+11 -8
View File
@@ -46,6 +46,8 @@ import "./CommandPicker";
import "./ActionPalette";
import "./AuthDialog";
import "./ProjectDialog";
import "./MachineDialog";
import type { MachineDialogSubmit } from "./MachineDialog";
import "./SettingsDialog";
import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
@@ -949,7 +951,7 @@ export class PiWebApp extends LitElement {
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => { this.setState({ projectDialogOpen: true }); },
addMachine: () => this.addMachineFromPrompt(),
addMachine: () => { this.openMachineDialog(); },
refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
removeSelectedMachine: () => this.removeMachine(),
openSelectedMachine: () => { this.openSelectedMachine(); },
@@ -1082,13 +1084,13 @@ export class PiWebApp extends LitElement {
}
}
private async addMachineFromPrompt(): Promise<void> {
const name = window.prompt("Machine name", "Dev Box")?.trim();
if (name === undefined || name === "") return;
const baseUrl = window.prompt("Remote PI WEB base URL", "http://127.0.0.1:8504")?.trim();
if (baseUrl === undefined || baseUrl === "") return;
const token = window.prompt("Bearer token (optional)", "")?.trim();
await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) });
private openMachineDialog(): void {
this.setState({ machineDialogOpen: true, error: "" });
}
private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> {
const machine = await this.machines.addMachine(input);
if (machine !== undefined) this.setState({ machineDialogOpen: false });
}
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
@@ -1324,6 +1326,7 @@ export class PiWebApp extends LitElement {
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div>
@@ -49,14 +49,16 @@ export class MachineController {
void this.refreshMachineHealth(machine.id);
}
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<void> {
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> {
this.setState({ error: "" });
try {
const machine = await api.addMachine(input);
this.setState({ machines: [...this.getState().machines.filter((candidate) => candidate.id !== machine.id), machine] });
await this.selectMachine(machine);
return machine;
} catch (error) {
this.setState({ error: String(error) });
return undefined;
}
}