From d44b37435b270fc43c83a81a3fdfd72b4c7619bd Mon Sep 17 00:00:00 2001 From: F4llen Date: Sat, 5 Sep 2026 02:30:05 -0400 Subject: [PATCH 1/2] fix(web): show live progress while adding an environment --- .../ConnectionsSettings.logic.test.ts | 57 +++++++++++ .../settings/ConnectionsSettings.logic.ts | 46 +++++++++ .../settings/ConnectionsSettings.tsx | 95 +++++++++++++------ 3 files changed, 170 insertions(+), 28 deletions(-) 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..82a320b9bb11 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,14 @@ export function ConnectionsSettings() { // Shared by manual SSH submission and discovered-host selection. const connectSavedBackendSshTarget = useCallback( async (target: DesktopSshEnvironmentTarget) => { - setIsAddingSavedBackend(true); + setAddEnvironmentProgress({ mode: "ssh", host: target.alias, 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 +2229,7 @@ export function ConnectionsSettings() { title: "Environment connected", description: `${target.alias} is ready over an SSH-managed tunnel.`, }); - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); }, [connectSshEnvironment], ); @@ -2214,7 +2252,6 @@ export function ConnectionsSettings() { return; } - setIsAddingSavedBackend(true); setSavedBackendError(null); let remotePairingInput: ReturnType; try { @@ -2232,10 +2269,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 +2291,7 @@ export function ConnectionsSettings() { }), ); } - setIsAddingSavedBackend(false); + setAddEnvironmentProgress(null); return; } @@ -2265,7 +2306,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 +2334,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 +2342,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 +2577,14 @@ export function ConnectionsSettings() {
{renderRemoteFields()} {savedBackendError ?

{savedBackendError}

: null} - + {addEnvironmentProgress ? ( + + ) : ( + + )}
); const renderSshFields = () => ( @@ -2657,15 +2697,14 @@ export function ConnectionsSettings() { {savedBackendError ?? discoveredSshHostsError} ) : null} - + {addEnvironmentProgress ? ( + + ) : ( + + )} ); From a81d807886cf3cb1ad2bbb7235abe585068c65ca Mon Sep 17 00:00:00 2001 From: F4llen Date: Sat, 5 Sep 2026 02:58:44 -0400 Subject: [PATCH 2/2] fix(web): keep the SSH progress clock running across host resolution Picking a discovered SSH host started the timer before resolving the alias, then the shared connect helper restarted it. Keep the earlier start so the elapsed counter and slow hint span the whole wait. --- apps/web/src/components/settings/ConnectionsSettings.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 82a320b9bb11..ec94ff376a26 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2207,7 +2207,12 @@ export function ConnectionsSettings() { // Shared by manual SSH submission and discovered-host selection. const connectSavedBackendSshTarget = useCallback( async (target: DesktopSshEnvironmentTarget) => { - setAddEnvironmentProgress({ mode: "ssh", host: target.alias, startedAtMs: Date.now() }); + // 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") {