diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 74283796a8e2..155ed740d257 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -2,6 +2,8 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, + describeAddEnvironmentProgress, + displayPairingHost, isQrShareableEndpoint, isWslSettingsRowVisible, selectQrEndpointOption, @@ -183,3 +185,58 @@ describe("selectQrEndpointOption", () => { expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); }); }); + +describe("describeAddEnvironmentProgress", () => { + const describeAt = (mode: "remote" | "ssh", elapsedMs: number) => + describeAddEnvironmentProgress({ mode, host: "devbox", elapsedMs }); + + it("formats elapsed time as m:ss, flooring partial seconds", () => { + expect(describeAt("ssh", 0).elapsedLabel).toBe("0:00"); + expect(describeAt("ssh", 4_000).elapsedLabel).toBe("0:04"); + expect(describeAt("ssh", 92_000).elapsedLabel).toBe("1:32"); + expect(describeAt("ssh", 725_000).elapsedLabel).toBe("12:05"); + expect(describeAt("ssh", 4_999).elapsedLabel).toBe("0:04"); + }); + + it("swaps the remote detail for the slow hint at the threshold", () => { + expect(describeAt("remote", 7_999).detail).toBe( + "Verifying the pairing code and saving the environment.", + ); + expect(describeAt("remote", 8_000).detail).toBe( + "Still waiting for the host. Check that it is reachable from this device.", + ); + }); + + it("swaps the SSH detail for the slow hint at the threshold", () => { + expect(describeAt("ssh", 7_999).detail).toBe( + "Starting the T3 Code server on the remote machine.", + ); + expect(describeAt("ssh", 8_000).detail).toBe( + "Still working. First-time setup installs T3 Code on the remote machine and can take a few minutes.", + ); + }); + + it("names the host being contacted in each mode", () => { + expect( + describeAddEnvironmentProgress({ mode: "remote", host: "backend.example.com", elapsedMs: 0 }) + .title, + ).toBe("Contacting backend.example.com…"); + expect( + describeAddEnvironmentProgress({ mode: "ssh", host: "devbox", elapsedMs: 0 }).title, + ).toBe("Connecting to devbox over SSH…"); + }); +}); + +describe("displayPairingHost", () => { + it("keeps a bare host or host:port as typed", () => { + expect(displayPairingHost("backend.example.com")).toBe("backend.example.com"); + expect(displayPairingHost(" 10.13.37.3:3773 ")).toBe("10.13.37.3:3773"); + }); + + it("drops the scheme, path, and pairing token from a URL", () => { + expect(displayPairingHost("https://backend.example.com/pair#token=ABC")).toBe( + "backend.example.com", + ); + expect(displayPairingHost("http://10.13.37.3:3773")).toBe("10.13.37.3:3773"); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index d683efab3a4a..3ec94b2f0add 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -70,3 +70,49 @@ export async function applyWslEnableSelection(input: { } return await bridge.setWslBackendEnabled(true); } + +/** Sits under the 10s remote request timeout so the hint lands before a dead host errors out. */ +export const ADD_ENVIRONMENT_SLOW_HINT_MS = 8_000; + +export function describeAddEnvironmentProgress(input: { + readonly mode: "remote" | "ssh"; + readonly host: string; + readonly elapsedMs: number; +}): { readonly title: string; readonly detail: string; readonly elapsedLabel: string } { + const { mode, host, elapsedMs } = input; + const totalSeconds = Math.floor(elapsedMs / 1000); + const seconds = totalSeconds % 60; + const elapsedLabel = `${Math.floor(totalSeconds / 60)}:${String(seconds).padStart(2, "0")}`; + const isSlow = elapsedMs >= ADD_ENVIRONMENT_SLOW_HINT_MS; + + if (mode === "ssh") { + return { + title: `Connecting to ${host} over SSH…`, + detail: isSlow + ? "Still working. First-time setup installs T3 Code on the remote machine and can take a few minutes." + : "Starting the T3 Code server on the remote machine.", + elapsedLabel, + }; + } + return { + title: `Contacting ${host}…`, + detail: isSlow + ? "Still waiting for the host. Check that it is reachable from this device." + : "Verifying the pairing code and saving the environment.", + elapsedLabel, + }; +} + +/** The host a user typed, reduced to what identifies the server: no scheme, path, or pairing token. */ +export function displayPairingHost(input: string): string { + const raw = input.trim(); + for (const candidate of [raw, `http://${raw}`]) { + try { + const host = new URL(candidate).host; + if (host) return host; + } catch { + // fall through to the next candidate + } + } + return raw; +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5a6f3ebd70b0..ec94ff376a26 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -48,6 +48,8 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection, + describeAddEnvironmentProgress, + displayPairingHost, isQrShareableEndpoint, isWslSettingsRowVisible, selectQrEndpointOption, @@ -540,6 +542,40 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { } } +type AddEnvironmentProgress = { + readonly mode: "remote" | "ssh"; + readonly host: string; + readonly startedAtMs: number; +}; + +const AddEnvironmentProgressPanel = memo(function AddEnvironmentProgressPanel({ + progress, +}: { + progress: AddEnvironmentProgress; +}) { + const nowMs = useRelativeTimeTick(1_000); + const { title, detail, elapsedLabel } = describeAddEnvironmentProgress({ + mode: progress.mode, + host: progress.host, + elapsedMs: Math.max(0, nowMs - progress.startedAtMs), + }); + return ( +
+ +
+ {title} +

{detail}

+
+ + {elapsedLabel} + +
+ ); +}); + type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; credential: string | undefined; @@ -1841,7 +1877,9 @@ export function ConnectionsSettings() { // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text. const highlightedSshHostRef = useRef(undefined); const [savedBackendError, setSavedBackendError] = useState(null); - const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); + const [addEnvironmentProgress, setAddEnvironmentProgress] = + useState(null); + const isAddingSavedBackend = addEnvironmentProgress !== null; const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = useState(null); const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); @@ -2169,14 +2207,19 @@ export function ConnectionsSettings() { // Shared by manual SSH submission and discovered-host selection. const connectSavedBackendSshTarget = useCallback( async (target: DesktopSshEnvironmentTarget) => { - setIsAddingSavedBackend(true); + // A discovered-host pick already started the clock before resolving the alias. + setAddEnvironmentProgress((current) => ({ + mode: "ssh", + host: target.alias, + startedAtMs: current?.startedAtMs ?? Date.now(), + })); setSavedBackendError(null); const result = await connectSshEnvironment({ target, label: "" }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); } - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); return; } @@ -2191,7 +2234,7 @@ export function ConnectionsSettings() { title: "Environment connected", description: `${target.alias} is ready over an SSH-managed tunnel.`, }); - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); }, [connectSshEnvironment], ); @@ -2214,7 +2257,6 @@ export function ConnectionsSettings() { return; } - setIsAddingSavedBackend(true); setSavedBackendError(null); let remotePairingInput: ReturnType; try { @@ -2232,10 +2274,14 @@ export function ConnectionsSettings() { description: message, }), ); - setIsAddingSavedBackend(false); return; } + setAddEnvironmentProgress({ + mode: "remote", + host: displayPairingHost(remotePairingInput.host), + startedAtMs: Date.now(), + }); const result = await connectPairing(remotePairingInput); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -2250,7 +2296,7 @@ export function ConnectionsSettings() { }), ); } - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); return; } @@ -2265,7 +2311,7 @@ export function ConnectionsSettings() { title: "Backend added", description: "The environment is saved and will reconnect on app startup.", }); - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); }, [ connectPairing, connectSavedBackendSshTarget, @@ -2293,7 +2339,7 @@ export function ConnectionsSettings() { async (target: DesktopDiscoveredSshHost) => { if (isAddingSavedBackend || !desktopBridge) return; - setIsAddingSavedBackend(true); + setAddEnvironmentProgress({ mode: "ssh", host: target.alias, startedAtMs: Date.now() }); setSavedBackendError(null); setSavedBackendSshHost(target.alias); let resolved: DesktopSshEnvironmentTarget; @@ -2301,7 +2347,7 @@ export function ConnectionsSettings() { resolved = await desktopBridge.resolveSshHost(target.alias); } catch (error) { setSavedBackendError(formatDesktopSshConnectionError(error)); - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); return; } setSavedBackendSshUsername(resolved.username ?? ""); @@ -2536,15 +2582,14 @@ export function ConnectionsSettings() {
{renderRemoteFields()} {savedBackendError ?

{savedBackendError}

: null} - + {addEnvironmentProgress ? ( + + ) : ( + + )}
); const renderSshFields = () => ( @@ -2657,15 +2702,14 @@ export function ConnectionsSettings() { {savedBackendError ?? discoveredSshHostsError} ) : null} - + {addEnvironmentProgress ? ( + + ) : ( + + )} );