feat(app): improve workspace service controls

Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.

Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
This commit is contained in:
Mohamed Boudra
2026-07-21 13:44:23 +02:00
parent afeb4d18f8
commit 8aa55db1e8
20 changed files with 1242 additions and 428 deletions
@@ -2,9 +2,17 @@ import { parseHostPort } from "@getpaseo/protocol/daemon-endpoints";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import type { ActiveConnection } from "@/runtime/host-runtime";
export type WorkspaceScriptLinkKind = "public" | "paseo" | "direct";
export interface WorkspaceScriptLinkTarget {
kind: WorkspaceScriptLinkKind;
label: string;
url: string;
}
export interface ResolvedWorkspaceScriptLink {
openUrl: string | null;
labelUrl: string | null;
primary: WorkspaceScriptLinkTarget | null;
targets: WorkspaceScriptLinkTarget[];
}
function isLoopbackHost(host: string): boolean {
@@ -15,77 +23,70 @@ function isLoopbackHost(host: string): boolean {
}
function isLocalOnlyUrl(url: string | null | undefined): boolean {
if (!url) {
return true;
}
if (!url) return true;
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
const hostname = new URL(url).hostname.toLowerCase();
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
} catch {
return true;
}
}
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
function stripUrlProtocol(url: string): string {
return url.replace(/^https?:\/\//, "");
}
function buildDirectServiceUrl(
activeConnection: ActiveConnection | null,
port: number | null,
): string | null {
if (port === null) return null;
if (activeConnection?.type !== "directTcp") {
return `http://localhost:${port}`;
}
try {
const { host, isIpv6 } = parseHostPort(endpoint);
const base = isIpv6 ? `[${host}]` : host;
const { host, isIpv6 } = parseHostPort(activeConnection.endpoint);
let base = host;
if (isLoopbackHost(host)) {
base = "localhost";
} else if (isIpv6) {
base = `[${host}]`;
}
return `http://${base}:${port}`;
} catch {
return null;
return `http://localhost:${port}`;
}
}
function addTarget(
targets: WorkspaceScriptLinkTarget[],
kind: WorkspaceScriptLinkKind,
url: string | null | undefined,
): void {
if (!url || targets.some((target) => target.url === url)) return;
targets.push({ kind, label: stripUrlProtocol(url), url });
}
export function resolveWorkspaceScriptLink(input: {
script: WorkspaceScriptPayload;
activeConnection: ActiveConnection | null;
}): ResolvedWorkspaceScriptLink {
const { script, activeConnection } = input;
if (script.type !== "service" || script.lifecycle !== "running") {
return { openUrl: null, labelUrl: null };
return { primary: null, targets: [] };
}
if (!activeConnection) {
return { openUrl: null, labelUrl: script.proxyUrl };
}
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
// COMPAT(workspaceScriptSplitUrls): added in v0.2.0, remove after 2027-01-21.
// Old daemons only send proxyUrl, so classify it by reachability.
const localProxyUrl =
script.localProxyUrl ?? (isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const publicProxyUrl =
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
if (activeConnection.type === "relay") {
return {
openUrl: publicProxyUrl,
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
};
}
const targets: WorkspaceScriptLinkTarget[] = [];
addTarget(targets, "public", publicProxyUrl);
addTarget(targets, "paseo", localProxyUrl);
addTarget(targets, "direct", buildDirectServiceUrl(activeConnection, script.port));
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
try {
const { host } = parseHostPort(activeConnection.endpoint);
if (isLoopbackHost(host)) {
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
} catch {
return { openUrl: null, labelUrl: preferredProxyUrl };
}
if (publicProxyUrl) {
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
}
if (script.port === null) {
return { openUrl: null, labelUrl: script.proxyUrl };
}
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
return {
openUrl: directUrl,
labelUrl: directUrl ?? preferredProxyUrl,
};
return { primary: targets[0] ?? null, targets };
}