From 64dbdb955134f447873ada83564acf08aa7aa3f2 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 7 Aug 2026 15:24:06 -0400
Subject: [PATCH 1/6] Make phone pairing survive real networks
A phone pairs over plain http on the LAN, which is an insecure browser
context, and the app assumed secure-context APIs throughout. Every
direct crypto.randomUUID call threw there (the existing fallback was a
decoy that re-threw the same error), leaving a freshly paired phone in
an empty shell. UUIDs now come from one shared helper that falls back
to RFC 4122 v4 over getRandomValues; the eight ad-hoc clipboard call
sites share one helper with an execCommand fallback; getUserMedia's
error names the insecure origin when that is the cause.
The pairing surfaces got the same treatment end to end: serve mode
prints a pairing URL and QR on a reachable interface instead of
localhost (display only; binding is unchanged); the reveal dialog shows
a labelled setup link, labelled pairing code, QR, and working copy
buttons on LAN origins; Add computer no longer hangs on Adding forever
when its unbounded getConfig await outlives a socket swap (bounded
reconnect-retrying read, plus timeboxed remote auth fetches); and the
desktop Devices list says it is loading instead of rendering an empty
region that reads as no devices while revoke sits disabled.
---
apps/server/src/startupAccess.test.ts | 37 ++++-
apps/server/src/startupAccess.ts | 9 +-
apps/web/src/components/DiffPanel.tsx | 11 +-
.../src/components/browser/BrowserPanel.tsx | 3 +-
.../src/components/chat/copyTextWithToast.ts | 15 +-
.../file-viewer/FileViewerOverlay.tsx | 16 +-
.../settings/ConnectionsSettings.tsx | 156 ++++++++++--------
.../settings/DiagnosticsSettings.tsx | 4 +-
.../settings/ExtensionsSettings.tsx | 14 +-
.../settings/SettingsPanels.browser.tsx | 90 +++++++++-
.../source-control/SourceControlPanel.tsx | 14 +-
apps/web/src/environments/remote/api.test.ts | 6 +
apps/web/src/environments/remote/api.ts | 10 ++
apps/web/src/hooks/useCopyToClipboard.ts | 9 +-
apps/web/src/lib/clipboard.browser.tsx | 106 ++++++++++++
apps/web/src/lib/clipboard.ts | 111 +++++++++++++
apps/web/src/lib/utils.ts | 15 +-
apps/web/src/realtimeAudio.ts | 8 +-
apps/web/src/rpc/wsRpcClient.test.ts | 28 ++++
apps/web/src/rpc/wsRpcClient.ts | 13 +-
packages/shared/src/uuid.test.ts | 47 ++++++
packages/shared/src/uuid.ts | 46 +++++-
22 files changed, 612 insertions(+), 156 deletions(-)
create mode 100644 apps/web/src/lib/clipboard.browser.tsx
create mode 100644 apps/web/src/lib/clipboard.ts
create mode 100644 packages/shared/src/uuid.test.ts
diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts
index 03c01170f..71c2c368d 100644
--- a/apps/server/src/startupAccess.test.ts
+++ b/apps/server/src/startupAccess.test.ts
@@ -9,9 +9,40 @@ import {
resolveListeningPort,
} from "./startupAccess.ts";
-it("prefers localhost when no explicit host is configured", () => {
- expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost");
- expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773");
+const LAN_INTERFACES = {
+ en0: [
+ {
+ address: "192.168.1.42",
+ netmask: "255.255.255.0",
+ family: "IPv4" as const,
+ mac: "00:00:00:00:00:00",
+ internal: false,
+ cidr: "192.168.1.42/24",
+ },
+ ],
+ lo0: [
+ {
+ address: "127.0.0.1",
+ netmask: "255.0.0.0",
+ family: "IPv4" as const,
+ mac: "00:00:00:00:00:00",
+ internal: true,
+ cidr: "127.0.0.1/8",
+ },
+ ],
+};
+
+// An unset host binds every interface, so the advertised URL has to be one
+// another device can open. A loopback URL here is a dead pairing link.
+it("resolves an unset host to a reachable interface", () => {
+ expect(resolveHeadlessConnectionHost(undefined, LAN_INTERFACES)).toBe("192.168.1.42");
+ expect(resolveHeadlessConnectionString(undefined, 3773, LAN_INTERFACES)).toBe(
+ "http://192.168.1.42:3773",
+ );
+});
+
+it("falls back to localhost when no external interface exists", () => {
+ expect(resolveHeadlessConnectionHost(undefined, { lo0: LAN_INTERFACES.lo0 })).toBe("localhost");
});
it("keeps explicit bind hosts in the connection string", () => {
diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts
index 43df7e6f9..a998701e1 100644
--- a/apps/server/src/startupAccess.ts
+++ b/apps/server/src/startupAccess.ts
@@ -46,11 +46,10 @@ export const resolveHeadlessConnectionHost = (
host: string | undefined,
interfaces: NetworkInterfacesMap = networkInterfaces(),
): string => {
- if (!host) {
- return "localhost";
- }
-
- if (!isWildcardHost(host)) {
+ // An unset host binds every interface, exactly like an explicit wildcard.
+ // Reporting `localhost` for it printed a pairing URL only this machine could
+ // open, which is useless for the one thing `serve` exists to do: pair a phone.
+ if (host !== undefined && !isWildcardHost(host)) {
return normalizeHost(host);
}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
index 2bb0c39f0..00379fd02 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -30,6 +30,7 @@ import {
gitWorkingTreeDiffQueryOptions,
invalidateGitWorkingTreeDiffQueries,
} from "~/lib/gitReactQuery";
+import { copyTextToClipboard } from "~/lib/clipboard";
import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
@@ -945,15 +946,7 @@ export default function DiffPanel({
[activeCwd],
);
const copyDiffFilePath = useCallback((filePath: string) => {
- if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
- toastManager.add({
- type: "error",
- title: "Failed to copy path",
- description: "Clipboard API unavailable.",
- });
- return;
- }
- void navigator.clipboard.writeText(filePath).then(
+ void copyTextToClipboard(filePath).then(
() => {
toastManager.add({ type: "success", title: "Path copied", description: filePath });
},
diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx
index a71758baf..d666360a4 100644
--- a/apps/web/src/components/browser/BrowserPanel.tsx
+++ b/apps/web/src/components/browser/BrowserPanel.tsx
@@ -52,6 +52,7 @@ import {
} from "../../browserPanelStore";
import { isElectron } from "../../env";
import { useTheme } from "../../hooks/useTheme";
+import { copyTextToClipboard } from "../../lib/clipboard";
import { cn } from "../../lib/utils";
import {
Menu,
@@ -854,7 +855,7 @@ export function BrowserPanel({
disabled={activeUrl === null}
onClick={() => {
if (activeUrl !== null) {
- void navigator.clipboard.writeText(activeUrl).catch(() => {});
+ void copyTextToClipboard(activeUrl).catch(() => {});
}
}}
>
diff --git a/apps/web/src/components/chat/copyTextWithToast.ts b/apps/web/src/components/chat/copyTextWithToast.ts
index 37497dd40..90f73cce6 100644
--- a/apps/web/src/components/chat/copyTextWithToast.ts
+++ b/apps/web/src/components/chat/copyTextWithToast.ts
@@ -1,3 +1,5 @@
+import { copyTextToClipboard } from "~/lib/clipboard";
+
import { stackedThreadToast, toastManager } from "../ui/toast";
/**
@@ -8,18 +10,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
* that says nothing reads as the menu item being broken.
*/
export function copyTextWithToast(value: string, title: string): void {
- if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: `Failed to copy ${title.toLowerCase()}`,
- description: "Clipboard API unavailable.",
- }),
- );
- return;
- }
-
- void navigator.clipboard.writeText(value).then(
+ void copyTextToClipboard(value).then(
() => {
toastManager.add({
type: "success",
diff --git a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx
index 754bcbb4b..0338541b9 100644
--- a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx
+++ b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx
@@ -22,7 +22,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EditorId } from "@threadlines/contracts";
import { readLocalApi } from "../../localApi";
-import { cn, isMacPlatform } from "~/lib/utils";
+import { copyTextToClipboard } from "~/lib/clipboard";
+import { cn, isMacPlatform, randomUUID } from "~/lib/utils";
import { usePreferredEditor } from "../../editorPreferences";
import { useServerAvailableEditors } from "../../rpc/serverState";
import { useComposerDraftStore } from "../../composerDraftStore";
@@ -143,9 +144,14 @@ function workspaceAbsolutePath(cwd: string, path: string): string {
}
function copyRelativePathToClipboard(path: string): void {
- void navigator.clipboard?.writeText(path).then(() => {
- toastManager.add({ type: "success", title: "Path copied", description: path });
- });
+ void copyTextToClipboard(path).then(
+ () => {
+ toastManager.add({ type: "success", title: "Path copied", description: path });
+ },
+ () => {
+ toastManager.add({ type: "error", title: "Failed to copy path" });
+ },
+ );
}
function openPathInEditor(cwd: string, path: string, editor: EditorId): void {
@@ -590,7 +596,7 @@ function AddSelectionToChatFooter({
// selection the quoted lines are serialized at send time; without one the
// whole file is attached as an `@path` mention the agent reads itself.
addFileSelectionContext(threadRef, {
- id: crypto.randomUUID(),
+ id: randomUUID(),
threadId: threadRef.threadId,
createdAt: new Date().toISOString(),
relativePath: activePath,
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 33d4936ee..4f7b8cd02 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -28,6 +28,7 @@ import * as DateTime from "effect/DateTime";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick";
+import { isClipboardCopySupported } from "../../lib/clipboard";
import { cn } from "../../lib/utils";
import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
@@ -698,13 +699,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
: isLoopbackHostname(window.location.hostname)
? null
: currentOriginPairingUrl);
- const revealValue = shareablePairingUrl ?? pairingLink.credential;
const isShareableHostedAppPairingUrl =
shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);
- const canCopyToClipboard =
- typeof window !== "undefined" &&
- window.isSecureContext &&
- navigator.clipboard?.writeText != null;
+ // Pairing links are minted on the LAN origin (plain http), where the async
+ // Clipboard API is missing. `isClipboardCopySupported` also accepts the
+ // execCommand fallback, so the copy buttons survive there.
+ const canCopyToClipboard = isClipboardCopySupported();
const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({
onCopy: (kind) => {
@@ -847,6 +847,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
>
) : null}
+
+ setIsRevealDialogOpen(true)}>
+ Show link, code, and QR
+
>
);
@@ -899,49 +903,43 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
{shareablePairingUrl === null ? (
- Copy the code and use it with this computer's connection address.
+ Open Threadlines at this computer's network address to get a link a phone can
+ scan. From localhost there is no address a phone can reach, so pair with the code
+ instead.
) : null}
- {canCopyToClipboard ? (
- <>
- {shareablePairingUrl ? (
-
-
- Copy link for: {defaultEndpointCopyLabel}
-
-
-
-
- }
- >
-
-
-
- {renderGroupedCopyMenuItems()}
-
-
-
- ) : (
-
- Copy code
-
- )}
- >
+ {canCopyToClipboard && shareablePairingUrl ? (
+
+
+ Copy link for: {defaultEndpointCopyLabel}
+
+
+
+
+ }
+ >
+
+
+
+ {renderGroupedCopyMenuItems()}
+
+
+
) : (
}>
{shareablePairingUrl ? "Show link" : "Show code"}
@@ -949,30 +947,43 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
)}
-
- {shareablePairingUrl
- ? isShareableHostedAppPairingUrl
- ? "Device link"
- : "Device link"
- : "Pairing code"}
-
+ {shareablePairingUrl ? "Device link" : "Pairing code"}
{shareablePairingUrl
? isShareableHostedAppPairingUrl
- ? "Clipboard copy is unavailable here. Open or manually copy this link on your phone or tablet."
- : "Clipboard copy is unavailable here. Open or manually copy this link on the device you want to connect."
- : "Clipboard copy is unavailable here. Manually copy this code into another device."}
+ ? "Scan the code or open this link in the browser on your phone or tablet. You can also type the pairing code by hand."
+ : "Scan the code or open this link on the device you want to connect. You can also type the pairing code by hand."
+ : "Enter this code on the device you want to connect."}
-
setIsRevealDialogOpen(false)}>
Done
{canCopyToClipboard ? (
-
- Copy code
-
+ <>
+
+ Copy code
+
+ {shareablePairingUrl ? (
+ Copy link
+ ) : null}
+ >
) : null}
@@ -1241,10 +1263,12 @@ const PairingClientsList = memo(function PairingClientsList({
/>
))}
- {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? (
+ {/* An empty section with no copy at all reads as "nothing is paired",
+ which is a lie while the access snapshot is still in flight. */}
+ {pairingLinks.length === 0 && clientSessions.length === 0 ? (
- No phones or tablets are connected yet.
+ {isLoading ? "Loading devices..." : "No phones or tablets are connected yet."}
) : null}
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 290b0069d..41a4b88af 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -17,6 +17,7 @@ import * as Option from "effect/Option";
import { ensureLocalApi } from "../../localApi";
import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick";
+import { copyTextToClipboard } from "../../lib/clipboard";
import { cn } from "../../lib/utils";
import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
import { formatRelativeTime } from "../../timestampFormat";
@@ -296,8 +297,7 @@ function DiagnosticsTable({
function TraceIdCell({ traceId }: { traceId: string }) {
const [copied, setCopied] = useState(false);
const copyTraceId = useCallback(() => {
- void navigator.clipboard
- ?.writeText(traceId)
+ void copyTextToClipboard(traceId)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1_200);
diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx
index 2c794d0de..5caee6ef7 100644
--- a/apps/web/src/components/settings/ExtensionsSettings.tsx
+++ b/apps/web/src/components/settings/ExtensionsSettings.tsx
@@ -107,6 +107,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
import { deriveSettingsProjectOptions } from "./settingsProjectOptions";
+import { copyTextToClipboard } from "../../lib/clipboard";
import { cn } from "../../lib/utils";
const EXTENSION_SECTION_PREVIEW_LIMIT = 10;
@@ -951,18 +952,7 @@ function extensionClipboardDetails(item: ExtensionItem): string {
}
function copyText(value: string, label: string) {
- if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: `Failed to copy ${label.toLowerCase()}`,
- description: "Clipboard API unavailable.",
- }),
- );
- return;
- }
-
- void navigator.clipboard.writeText(value).then(
+ void copyTextToClipboard(value).then(
() => {
const preview = value.length > 180 ? `${value.slice(0, 177).trimEnd()}...` : value;
toastManager.add({
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx
index 775df9c3a..f7a7fe0d1 100644
--- a/apps/web/src/components/settings/SettingsPanels.browser.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx
@@ -63,6 +63,7 @@ const authAccessHarness = vi.hoisted(() => {
clientSessions: [],
};
let revision = 1;
+ let deferSnapshot = false;
const listeners = new Set<(event: AuthAccessStreamEvent) => void>();
const emitEvent = (event: AuthAccessStreamEvent) => {
@@ -78,11 +79,16 @@ const authAccessHarness = vi.hoisted(() => {
clientSessions: [],
};
revision = 1;
+ deferSnapshot = false;
listeners.clear();
},
setSnapshot(next: Snapshot) {
snapshot = next;
},
+ /** Hold the first snapshot back, the way a slow or reconnecting stream does. */
+ deferSnapshot() {
+ deferSnapshot = true;
+ },
emitSnapshot() {
emitEvent({
version: 1 as const,
@@ -133,12 +139,14 @@ const authAccessHarness = vi.hoisted(() => {
},
subscribe(listener: (event: AuthAccessStreamEvent) => void) {
listeners.add(listener);
- listener({
- version: 1,
- revision: 1,
- type: "snapshot",
- payload: snapshot,
- });
+ if (!deferSnapshot) {
+ listener({
+ version: 1,
+ revision: 1,
+ type: "snapshot",
+ payload: snapshot,
+ });
+ }
return () => {
listeners.delete(listener);
};
@@ -1462,6 +1470,76 @@ describe("GeneralSettingsPanel observability", () => {
expect(fetchMock).toHaveBeenCalled();
});
+ // An audit read the pre-snapshot section as "nothing paired" because it was
+ // rendered completely empty, and revoke stayed disabled with it.
+ it("says devices are loading until the access snapshot lands", async () => {
+ window.desktopBridge = createDesktopBridgeStub({
+ serverExposureState: {
+ mode: "network-accessible",
+ endpointUrl: "http://192.168.1.44:3773",
+ advertisedHost: "192.168.1.44",
+ tailscaleServeEnabled: false,
+ tailscaleServePort: 443,
+ },
+ });
+ const clientSessions = [
+ makeClientSession({
+ sessionId: "session-owner",
+ subject: "desktop-bootstrap",
+ role: "owner",
+ method: "browser-session-cookie",
+ client: { label: "This Mac", deviceType: "desktop", os: "macOS", browser: "Electron" },
+ issuedAt: "2036-04-05T00:00:00.000Z",
+ expiresAt: "2036-05-05T00:00:00.000Z",
+ connected: true,
+ current: true,
+ }),
+ makeClientSession({
+ sessionId: "session-client",
+ subject: "one-time-token",
+ role: "client",
+ method: "browser-session-cookie",
+ client: {
+ label: "Julius iPhone",
+ deviceType: "mobile",
+ os: "iOS",
+ browser: "Safari",
+ ipAddress: "192.168.1.88",
+ },
+ issuedAt: "2036-04-05T00:01:00.000Z",
+ expiresAt: "2036-05-05T00:01:00.000Z",
+ connected: true,
+ current: false,
+ }),
+ ];
+ authAccessHarness.setSnapshot({ pairingLinks: [], clientSessions });
+ authAccessHarness.deferSnapshot();
+
+ setServerConfigSnapshot(createBaseServerConfig());
+
+ mounted = await render(
+
+
+ ,
+ );
+
+ await expect.element(page.getByText("Loading devices...")).toBeInTheDocument();
+ await expect
+ .element(page.getByText("No phones or tablets are connected yet."))
+ .not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole("button", { name: "Remove other devices", exact: true }))
+ .toBeDisabled();
+
+ authAccessHarness.emitSnapshot();
+
+ await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument();
+ await expect.element(page.getByText("Loading devices...")).not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole("button", { name: "Remove other devices", exact: true }))
+ .not.toBeDisabled();
+ });
+
it("shows a disabled network access toggle with guidance in desktop builds", async () => {
const desktopBridge = createDesktopBridgeStub();
window.desktopBridge = desktopBridge;
diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx
index fc6b6e668..169c28a70 100644
--- a/apps/web/src/components/source-control/SourceControlPanel.tsx
+++ b/apps/web/src/components/source-control/SourceControlPanel.tsx
@@ -103,6 +103,7 @@ import {
refreshLocalGitStatus,
useGitStatus,
} from "~/lib/gitStatusState";
+import { copyTextToClipboard } from "~/lib/clipboard";
import { cn, newCommandId, newThreadId, randomUUID } from "~/lib/utils";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useSettings } from "~/hooks/useSettings";
@@ -3334,18 +3335,7 @@ export function SourceControlPanel({
const copyCommitValue = useCallback(
(value: string, title: string, options?: CopyCommitValueOptions) => {
- if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: `Failed to copy ${title.toLowerCase()}`,
- description: "Clipboard API unavailable.",
- }),
- );
- return Promise.resolve(false);
- }
-
- return navigator.clipboard.writeText(value).then(
+ return copyTextToClipboard(value).then(
() => {
if (options?.successToast !== false) {
const description = value.length > 240 ? `${value.slice(0, 240)}...` : value;
diff --git a/apps/web/src/environments/remote/api.test.ts b/apps/web/src/environments/remote/api.test.ts
index ba486660f..cd88332fa 100644
--- a/apps/web/src/environments/remote/api.test.ts
+++ b/apps/web/src/environments/remote/api.test.ts
@@ -141,6 +141,9 @@ describe("remote environment api", () => {
body: JSON.stringify({
credential: "pairing-token",
}),
+ // Pairing runs over an unknown network; every auth call is time-boxed so
+ // a stalled socket cannot hang the caller.
+ signal: expect.any(AbortSignal),
});
});
@@ -226,6 +229,7 @@ describe("remote environment api", () => {
{
method: "GET",
headers: {},
+ signal: expect.any(AbortSignal),
},
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "https://remote.example.com/api/auth/session", {
@@ -233,12 +237,14 @@ describe("remote environment api", () => {
headers: {
authorization: "Bearer bearer-token",
},
+ signal: expect.any(AbortSignal),
});
expect(fetchMock).toHaveBeenNthCalledWith(3, "https://remote.example.com/api/auth/ws-token", {
method: "POST",
headers: {
authorization: "Bearer bearer-token",
},
+ signal: expect.any(AbortSignal),
});
});
diff --git a/apps/web/src/environments/remote/api.ts b/apps/web/src/environments/remote/api.ts
index 48c03b17e..9f93746d9 100644
--- a/apps/web/src/environments/remote/api.ts
+++ b/apps/web/src/environments/remote/api.ts
@@ -50,6 +50,13 @@ async function readRemoteAuthErrorMessage(
return text;
}
+/**
+ * Pairing runs over whatever network is between the phone and the computer, and
+ * a stalled socket there used to hang the "Add computer" dialog forever. Every
+ * auth call is small, so cap it and surface a real error instead.
+ */
+const REMOTE_AUTH_REQUEST_TIMEOUT_MS = 20_000;
+
async function fetchRemoteJson(input: {
readonly httpBaseUrl: string;
readonly pathname: string;
@@ -67,6 +74,9 @@ async function fetchRemoteJson(input: {
...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}),
},
...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
+ ...(typeof AbortSignal.timeout === "function"
+ ? { signal: AbortSignal.timeout(REMOTE_AUTH_REQUEST_TIMEOUT_MS) }
+ : {}),
});
} catch (error) {
throw new Error(
diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts
index d1feb6211..e1b78ed62 100644
--- a/apps/web/src/hooks/useCopyToClipboard.ts
+++ b/apps/web/src/hooks/useCopyToClipboard.ts
@@ -1,5 +1,7 @@
import * as React from "react";
+import { copyTextToClipboard } from "~/lib/clipboard";
+
export function useCopyToClipboard({
timeout = 2000,
onCopy,
@@ -20,14 +22,9 @@ export function useCopyToClipboard({
timeoutRef.current = timeout;
const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => {
- if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
- onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx);
- return;
- }
-
if (!value) return;
- navigator.clipboard.writeText(value).then(
+ void copyTextToClipboard(value).then(
() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
diff --git a/apps/web/src/lib/clipboard.browser.tsx b/apps/web/src/lib/clipboard.browser.tsx
new file mode 100644
index 000000000..1edf3216b
--- /dev/null
+++ b/apps/web/src/lib/clipboard.browser.tsx
@@ -0,0 +1,106 @@
+import { userEvent } from "vite-plus/test/browser";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import {
+ ClipboardUnavailableError,
+ copyTextToClipboard,
+ isClipboardCopySupported,
+} from "./clipboard";
+
+const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(
+ Navigator.prototype,
+ "clipboard",
+);
+const originalExecCommand = document.execCommand;
+
+/**
+ * Insecure contexts (a phone paired over plain `http://`) do not expose
+ * `navigator.clipboard` at all.
+ */
+function removeAsyncClipboard(): void {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+}
+
+function failAsyncClipboard(): void {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: () => Promise.reject(new Error("Write permission denied.")) },
+ });
+}
+
+function removeExecCommand(): void {
+ Object.defineProperty(document, "execCommand", { configurable: true, value: undefined });
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(navigator, "clipboard");
+ if (originalClipboardDescriptor) {
+ Object.defineProperty(Navigator.prototype, "clipboard", originalClipboardDescriptor);
+ }
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: originalExecCommand,
+ });
+});
+
+/**
+ * Copies the way the app does: from a real click, because `execCommand("copy")`
+ * needs user activation. Asserts on the `copy` event rather than reading the
+ * clipboard back, which the test browser will not permit.
+ */
+async function captureCopyFromClick(value: string): Promise {
+ let copied = "";
+ let failure: unknown = null;
+ const onCopy = () => {
+ copied = document.getSelection()?.toString() ?? "";
+ };
+ const trigger = document.createElement("button");
+ trigger.textContent = "Copy";
+ trigger.addEventListener("click", () => {
+ void copyTextToClipboard(value).catch((error: unknown) => {
+ failure = error;
+ });
+ });
+ document.body.append(trigger);
+ document.addEventListener("copy", onCopy);
+ try {
+ await userEvent.click(trigger);
+ } finally {
+ document.removeEventListener("copy", onCopy);
+ trigger.remove();
+ }
+ if (failure) throw failure;
+ return copied;
+}
+
+describe("copyTextToClipboard", () => {
+ it("copies through execCommand when the async Clipboard API is missing", async () => {
+ removeAsyncClipboard();
+
+ await expect(captureCopyFromClick("http://192.168.1.213:8266/pair#token=ABC123")).resolves.toBe(
+ "http://192.168.1.213:8266/pair#token=ABC123",
+ );
+ });
+
+ it("falls back to execCommand when the async Clipboard API rejects", async () => {
+ failAsyncClipboard();
+
+ await expect(captureCopyFromClick("WAKYKHXBJ55Y")).resolves.toBe("WAKYKHXBJ55Y");
+ });
+
+ it("reports the value as uncopyable when no copy path exists", async () => {
+ removeAsyncClipboard();
+ removeExecCommand();
+
+ expect(isClipboardCopySupported()).toBe(false);
+ await expect(copyTextToClipboard("WAKYKHXBJ55Y")).rejects.toBeInstanceOf(
+ ClipboardUnavailableError,
+ );
+ });
+
+ it("still offers copy when only the execCommand fallback is available", () => {
+ removeAsyncClipboard();
+
+ expect(isClipboardCopySupported()).toBe(true);
+ });
+});
diff --git a/apps/web/src/lib/clipboard.ts b/apps/web/src/lib/clipboard.ts
new file mode 100644
index 000000000..44dc072ad
--- /dev/null
+++ b/apps/web/src/lib/clipboard.ts
@@ -0,0 +1,111 @@
+/**
+ * Clipboard access for every surface in the app, including insecure ones.
+ *
+ * `navigator.clipboard` is a secure-context API. A phone paired over plain
+ * `http://:` does not get it, which used to leave those surfaces
+ * with no copy affordance at all. `document.execCommand("copy")` is deprecated
+ * but still works in insecure contexts, so it is the fallback. When neither is
+ * available the caller is expected to reveal the value so the user can select
+ * it by hand.
+ */
+
+export class ClipboardUnavailableError extends Error {
+ constructor() {
+ super("Clipboard copy is unavailable in this browser context.");
+ this.name = "ClipboardUnavailableError";
+ }
+}
+
+function hasAsyncClipboard(): boolean {
+ return typeof navigator !== "undefined" && navigator.clipboard?.writeText != null;
+}
+
+function hasExecCommandCopy(): boolean {
+ return typeof document !== "undefined" && typeof document.execCommand === "function";
+}
+
+/**
+ * Whether a copy action can be offered at all. False only when the browser has
+ * neither the async Clipboard API nor `execCommand`, in which case UI should
+ * show the value instead of a copy button.
+ */
+export function isClipboardCopySupported(): boolean {
+ if (typeof window === "undefined") {
+ return false;
+ }
+ return hasAsyncClipboard() || hasExecCommandCopy();
+}
+
+function copyWithExecCommand(value: string): boolean {
+ if (!hasExecCommandCopy()) {
+ return false;
+ }
+
+ const textarea = document.createElement("textarea");
+ textarea.value = value;
+ // Keep the node off-screen but still selectable: `display: none` and
+ // `visibility: hidden` both make the selection (and therefore the copy) fail.
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.top = "0";
+ textarea.style.left = "0";
+ textarea.style.opacity = "0";
+ textarea.style.pointerEvents = "none";
+ document.body.append(textarea);
+
+ const previousSelectionRange =
+ (document.getSelection()?.rangeCount ?? 0) > 0
+ ? document.getSelection()?.getRangeAt(0)
+ : undefined;
+ const previouslyFocused = document.activeElement;
+
+ try {
+ // `execCommand("copy")` copies the document selection, and the browser only
+ // honours it while the copying element owns focus.
+ textarea.focus({ preventScroll: true });
+ textarea.select();
+ textarea.setSelectionRange(0, value.length);
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ textarea.remove();
+ if (previousSelectionRange) {
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(previousSelectionRange);
+ }
+ if (previouslyFocused instanceof HTMLElement) {
+ previouslyFocused.focus({ preventScroll: true });
+ }
+ }
+}
+
+/**
+ * Copies `value`, preferring the async Clipboard API and falling back to
+ * `execCommand`. Rejects with {@link ClipboardUnavailableError} when neither
+ * path can copy, so callers can reveal the value instead.
+ */
+export async function copyTextToClipboard(value: string): Promise {
+ if (typeof window === "undefined") {
+ throw new ClipboardUnavailableError();
+ }
+
+ if (hasAsyncClipboard()) {
+ try {
+ await navigator.clipboard.writeText(value);
+ return;
+ } catch (error) {
+ // Permission-denied and non-user-gesture failures are recoverable via
+ // execCommand, so only give up once that has been tried too.
+ if (!copyWithExecCommand(value)) {
+ throw error instanceof Error ? error : new ClipboardUnavailableError();
+ }
+ return;
+ }
+ }
+
+ if (!copyWithExecCommand(value)) {
+ throw new ClipboardUnavailableError();
+ }
+}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index 99ecde5d2..f85f203f2 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -1,8 +1,7 @@
import { CommandId, MessageId, ProjectId, ThreadId } from "@threadlines/contracts";
import { type CxOptions, cx } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
-import { randomUUIDv4 } from "@threadlines/shared/uuid";
-import * as Effect from "effect/Effect";
+import { randomUUIDv4Sync } from "@threadlines/shared/uuid";
import { DraftId } from "../composerDraftStore";
export function cn(...inputs: CxOptions) {
@@ -21,12 +20,12 @@ export function isLinuxPlatform(platform: string): boolean {
return /linux/i.test(platform);
}
-export function randomUUID(): string {
- if (typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- return Effect.runSync(randomUUIDv4);
-}
+/**
+ * Single UUID entry point for the web bundle. `crypto.randomUUID` is missing in
+ * insecure contexts (a phone paired over plain `http://`), so every
+ * call site goes through the shared helper's `getRandomValues` fallback.
+ */
+export const randomUUID = randomUUIDv4Sync;
export const newCommandId = (): CommandId => CommandId.make(randomUUID());
diff --git a/apps/web/src/realtimeAudio.ts b/apps/web/src/realtimeAudio.ts
index 252b2b522..4bc9576a5 100644
--- a/apps/web/src/realtimeAudio.ts
+++ b/apps/web/src/realtimeAudio.ts
@@ -57,7 +57,13 @@ export class RealtimeMicCapture {
static async start(onChunk: RealtimeMicChunkListener): Promise {
if (!navigator.mediaDevices?.getUserMedia) {
- throw new Error("This browser does not support microphone capture.");
+ // `mediaDevices` is secure-context only and there is no fallback, so a
+ // phone paired over plain http:// lands here. Say why.
+ throw new Error(
+ typeof window !== "undefined" && !window.isSecureContext
+ ? "Microphone capture needs a secure connection. Open Threadlines over HTTPS or on this computer to use voice."
+ : "This browser does not support microphone capture.",
+ );
}
const stream = await navigator.mediaDevices.getUserMedia({
diff --git a/apps/web/src/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts
index c6bba43c5..ae1b0ab30 100644
--- a/apps/web/src/rpc/wsRpcClient.test.ts
+++ b/apps/web/src/rpc/wsRpcClient.test.ts
@@ -159,6 +159,34 @@ describe("wsRpcClient", () => {
expect(request).toHaveBeenCalledTimes(1);
expect(requestWithReconnectRetry).not.toHaveBeenCalled();
});
+
+ // A bare request is pinned to the transport session it started on. Pairing a
+ // phone swaps sessions, which orphaned this read and left "Add computer"
+ // stuck on "Adding..." while the connection itself came up fine.
+ it("reads the server config through the bounded reconnect-retrying path", () => {
+ const request = vi.fn();
+ const requestWithReconnectRetry = vi.fn(async (_execute: unknown, _options?: unknown) => ({}));
+ const transport = {
+ dispose: vi.fn(async () => undefined),
+ reconnect: vi.fn(async () => undefined),
+ request,
+ requestStream: vi.fn(),
+ requestWithReconnectRetry,
+ subscribe: vi.fn(() => () => undefined),
+ };
+
+ const client = createWsRpcClient(transport as unknown as WsTransport);
+ void client.server.getConfig();
+
+ expect(request).not.toHaveBeenCalled();
+ expect(requestWithReconnectRetry).toHaveBeenCalledTimes(1);
+ const options = requestWithReconnectRetry.mock.calls[0]?.[1] as {
+ readonly attemptTimeoutMs: number;
+ readonly totalBudgetMs: number;
+ };
+ expect(options.attemptTimeoutMs).toBeGreaterThan(0);
+ expect(options.totalBudgetMs).toBeGreaterThan(options.attemptTimeoutMs);
+ });
});
describe("dispatchCommandRetryOptions", () => {
diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts
index a665496a2..e44357e21 100644
--- a/apps/web/src/rpc/wsRpcClient.ts
+++ b/apps/web/src/rpc/wsRpcClient.ts
@@ -532,7 +532,18 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
transport.request((client) => client[WS_METHODS.gitApplyAuthRemediation](input)),
},
server: {
- getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
+ // Pure read, and the only thing standing between "Add computer" and a
+ // closed dialog. A plain request is pinned to the transport session it
+ // started on, so a socket drop or session swap mid-pairing left it
+ // pending forever and the dialog stuck on "Adding...".
+ getConfig: () =>
+ transport.requestWithReconnectRetry((client) => client[WS_METHODS.serverGetConfig]({}), {
+ label: WS_METHODS.serverGetConfig,
+ // Tighter than the shared default: this is a small read that gates a
+ // modal, so failing loudly beats spinning.
+ attemptTimeoutMs: 10_000,
+ totalBudgetMs: 30_000,
+ }),
refreshProviders: (input) =>
transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
startProviderReview: (input) =>
diff --git a/packages/shared/src/uuid.test.ts b/packages/shared/src/uuid.test.ts
new file mode 100644
index 000000000..3796df920
--- /dev/null
+++ b/packages/shared/src/uuid.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { randomUUIDv4Sync } from "./uuid.ts";
+
+const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
+
+const originalRandomUUID = globalThis.crypto.randomUUID;
+
+/**
+ * Insecure browser contexts (a phone paired over plain `http://`) do
+ * not expose `crypto.randomUUID` at all.
+ */
+function withoutRandomUUID(): void {
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
+ configurable: true,
+ value: undefined,
+ });
+}
+
+afterEach(() => {
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
+ configurable: true,
+ value: originalRandomUUID,
+ });
+});
+
+describe("randomUUIDv4Sync", () => {
+ it("returns distinct RFC 4122 v4 identifiers", () => {
+ const ids = Array.from({ length: 200 }, () => randomUUIDv4Sync());
+
+ for (const id of ids) {
+ expect(id).toMatch(UUID_V4_RE);
+ }
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("still returns distinct v4 identifiers when crypto.randomUUID is unavailable", () => {
+ withoutRandomUUID();
+
+ const ids = Array.from({ length: 200 }, () => randomUUIDv4Sync());
+
+ for (const id of ids) {
+ expect(id).toMatch(UUID_V4_RE);
+ }
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/packages/shared/src/uuid.ts b/packages/shared/src/uuid.ts
index 862886fd1..acffcd605 100644
--- a/packages/shared/src/uuid.ts
+++ b/packages/shared/src/uuid.ts
@@ -1,15 +1,47 @@
// @effect-diagnostics cryptoRandomUUIDInEffect:off
import * as Effect from "effect/Effect";
+const BYTE_TO_HEX: ReadonlyArray = Array.from({ length: 256 }, (_unused, byte) =>
+ byte.toString(16).padStart(2, "0"),
+);
+
+const hexAt = (bytes: Uint8Array, index: number): string => BYTE_TO_HEX[bytes[index] ?? 0] ?? "00";
+
/**
- * Cryptographically random UUIDv4.
+ * Cryptographically random UUIDv4 that also works in insecure browser contexts.
+ *
+ * `crypto.randomUUID` is secure-context only. A phone paired over plain
+ * `http://:` gets `undefined` for it, so every direct call site
+ * threw `crypto.randomUUID is not a function` and took the app down with it.
+ * `crypto.getRandomValues` has no such restriction, so fall back to formatting
+ * RFC 4122 v4 bytes by hand when the one-shot API is missing.
*
* Effect 4.0.0-beta.97 moved UUID generation from `Random` onto the `Crypto`
* service, which has no default implementation. Threading that service through
- * every caller buys us nothing on our supported runtimes (Node >= 22 and every
- * evergreen browser ship `crypto.randomUUID`), so this wrapper keeps UUIDs
- * dependency-free.
+ * every caller buys us nothing, so this wrapper keeps UUIDs dependency-free.
*/
-export const randomUUIDv4: Effect.Effect = Effect.sync(() =>
- globalThis.crypto.randomUUID(),
-);
+export function randomUUIDv4Sync(): string {
+ const webCrypto = globalThis.crypto;
+ if (typeof webCrypto?.randomUUID === "function") {
+ return webCrypto.randomUUID();
+ }
+
+ if (typeof webCrypto?.getRandomValues !== "function") {
+ throw new Error("No Web Crypto random source is available to generate a UUID.");
+ }
+
+ const bytes = webCrypto.getRandomValues(new Uint8Array(16));
+ // Version 4 (random) and RFC 4122 variant bits.
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
+
+ return [
+ `${hexAt(bytes, 0)}${hexAt(bytes, 1)}${hexAt(bytes, 2)}${hexAt(bytes, 3)}`,
+ `${hexAt(bytes, 4)}${hexAt(bytes, 5)}`,
+ `${hexAt(bytes, 6)}${hexAt(bytes, 7)}`,
+ `${hexAt(bytes, 8)}${hexAt(bytes, 9)}`,
+ `${hexAt(bytes, 10)}${hexAt(bytes, 11)}${hexAt(bytes, 12)}${hexAt(bytes, 13)}${hexAt(bytes, 14)}${hexAt(bytes, 15)}`,
+ ].join("-");
+}
+
+export const randomUUIDv4: Effect.Effect = Effect.sync(randomUUIDv4Sync);
From 05497b2903b824e9b8a8eec5b7c0c20dba91a1fb Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 7 Aug 2026 16:02:47 -0400
Subject: [PATCH 2/6] Label nightly updates by their run number
On the nightly channel the update chip compacted the target to its base
triple, so "v0.3.2 available" could mean the stable or any of the
day's nightlies. The release workflow's run counter is monotonic across
days, so a same-base nightly target now labels as just ".222" (shorter
than the old ambiguous label), a cross-base nightly as
"v0.3.3-nightly", and stables keep the exact triple. Tooltips always
carry the full version string.
---
.../components/desktopUpdate.logic.test.ts | 54 ++++++++++++++++++-
.../web/src/components/desktopUpdate.logic.ts | 53 +++++++++++++++---
2 files changed, 98 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts
index 12c5a2afa..aec920179 100644
--- a/apps/web/src/components/desktopUpdate.logic.test.ts
+++ b/apps/web/src/components/desktopUpdate.logic.test.ts
@@ -3,6 +3,7 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@threadlines
import {
canCheckForUpdate,
+ discriminatingVersionLabel,
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
@@ -236,6 +237,36 @@ describe("desktop update UI helpers", () => {
});
});
+describe("discriminatingVersionLabel", () => {
+ it("keeps only the run suffix for a same-day nightly", () => {
+ expect(
+ discriminatingVersionLabel("0.3.2-nightly.20260807.222", "0.3.2-nightly.20260807.221"),
+ ).toBe(".222");
+ });
+
+ it("keeps only the run suffix across days too, since the counter never resets", () => {
+ expect(
+ discriminatingVersionLabel("0.3.2-nightly.20260808.225", "0.3.2-nightly.20260807.221"),
+ ).toBe(".225");
+ });
+
+ it("names the channel when a nightly's base version moved", () => {
+ expect(
+ discriminatingVersionLabel("0.3.3-nightly.20260808.1", "0.3.2-nightly.20260807.221"),
+ ).toBe("v0.3.3-nightly");
+ });
+
+ it("keeps the exact compact triple for stable targets", () => {
+ expect(discriminatingVersionLabel("0.3.3", "0.3.2-nightly.20260807.221")).toBe("v0.3.3");
+ });
+
+ it("marks a nightly target as nightly when running a stable", () => {
+ expect(discriminatingVersionLabel("0.3.2-nightly.20260807.222", "0.3.1")).toBe(
+ "v0.3.2-nightly",
+ );
+ });
+});
+
describe("getSidebarDesktopUpdateTagPresentation", () => {
it("shows the compact app version when no update action is available", () => {
expect(getSidebarDesktopUpdateTagPresentation(baseState, "1.0.0")).toEqual({
@@ -368,7 +399,7 @@ describe("getSidebarDesktopUpdateTagPresentation", () => {
});
});
- it("compacts prerelease tails out of the target version label", () => {
+ it("compacts the chip label but keeps the full version in the tooltip", () => {
const state: DesktopUpdateState = {
...baseState,
status: "downloaded",
@@ -376,9 +407,28 @@ describe("getSidebarDesktopUpdateTagPresentation", () => {
downloadedVersion: "1.1.0-nightly.4",
};
+ // A short prerelease tail without the dated nightly shape compacts to the
+ // triple on the chip; the tooltip always carries the exact target so a
+ // nightly can never be mistaken for the stable of the same base.
expect(getSidebarDesktopUpdateTagPresentation(state, "1.0.0-nightly.2")).toMatchObject({
label: "v1.1.0",
- tooltip: "Restart to install v1.1.0",
+ tooltip: "Restart to install v1.1.0-nightly.4",
+ });
+ });
+
+ it("labels a same-day nightly by its run suffix", () => {
+ const state: DesktopUpdateState = {
+ ...baseState,
+ currentVersion: "0.3.2-nightly.20260807.221",
+ status: "available",
+ availableVersion: "0.3.2-nightly.20260807.222",
+ };
+
+ expect(
+ getSidebarDesktopUpdateTagPresentation(state, "0.3.2-nightly.20260807.221"),
+ ).toMatchObject({
+ label: ".222",
+ tooltip: "v0.3.2-nightly.20260807.222 available",
});
});
});
diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts
index 0b496932d..266187e94 100644
--- a/apps/web/src/components/desktopUpdate.logic.ts
+++ b/apps/web/src/components/desktopUpdate.logic.ts
@@ -120,15 +120,47 @@ export function compactVersionLabel(version: string): string {
return releaseTriple.startsWith("v") ? releaseTriple : `v${releaseTriple}`;
}
+const NIGHTLY_VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)-nightly\.(\d{8})\.(\d+)$/;
+
+/**
+ * Label for a target version shown beside the one already running. Stripping
+ * a nightly to its base triple made "v0.3.2 available" ambiguous on the
+ * nightly channel (stable 0.3.2? which of today's nightlies?), while the full
+ * string is too long for a chip. The nightly run number is the release
+ * workflow's run counter — monotonic across days, so it alone distinguishes
+ * any two nightlies of the same base: ".222". A different base keeps the
+ * triple plus the channel ("v0.3.3-nightly") so it can never read as a
+ * stable release; non-nightly targets keep the compact triple, which is
+ * exact for stables. Tooltips carry the full string either way.
+ */
+export function discriminatingVersionLabel(target: string, current: string): string {
+ const targetNightly = NIGHTLY_VERSION_PATTERN.exec(target);
+ if (!targetNightly) {
+ return compactVersionLabel(target);
+ }
+ const [, base, , run] = targetNightly;
+ const currentNightly = NIGHTLY_VERSION_PATTERN.exec(current);
+ if (currentNightly && currentNightly[1] === base) {
+ return `.${run}`;
+ }
+ return `${compactVersionLabel(target)}-nightly`;
+}
+
function getSidebarDesktopUpdateTagTooltip(input: {
readonly action: DesktopUpdateButtonAction;
readonly isDownloading: boolean;
readonly isError: boolean;
readonly downloadPercent: number | null;
- readonly targetLabel: string | null;
+ /** Full target version string: the tooltip is where ambiguity goes to die. */
+ readonly targetVersion: string | null;
}): string {
+ const fullLabel = input.targetVersion
+ ? input.targetVersion.startsWith("v")
+ ? input.targetVersion
+ : `v${input.targetVersion}`
+ : null;
if (input.isDownloading) {
- const subject = input.targetLabel ? `Downloading ${input.targetLabel}` : "Downloading";
+ const subject = fullLabel ? `Downloading ${fullLabel}` : "Downloading";
return input.downloadPercent !== null
? `${subject} · ${Math.floor(input.downloadPercent)}%`
: subject;
@@ -137,9 +169,9 @@ function getSidebarDesktopUpdateTagTooltip(input: {
return input.action === "install" ? "Install failed" : "Download failed";
}
if (input.action === "install") {
- return input.targetLabel ? `Restart to install ${input.targetLabel}` : "Restart to install";
+ return fullLabel ? `Restart to install ${fullLabel}` : "Restart to install";
}
- return input.targetLabel ? `${input.targetLabel} available` : "Update available";
+ return fullLabel ? `${fullLabel} available` : "Update available";
}
export function getSidebarDesktopUpdateTagPresentation(
@@ -167,7 +199,9 @@ export function getSidebarDesktopUpdateTagPresentation(
// Active states label the chip with the version the action concerns, so
// "ready to restart" reads as the incoming release, not the running one.
const targetVersion = state.downloadedVersion ?? state.availableVersion;
- const targetLabel = targetVersion ? compactVersionLabel(targetVersion) : null;
+ const targetLabel = targetVersion
+ ? discriminatingVersionLabel(targetVersion, state.currentVersion ?? appVersion)
+ : null;
const downloadPercent = typeof state.downloadPercent === "number" ? state.downloadPercent : null;
const progressPercent = isDownloaded
? 100
@@ -194,7 +228,7 @@ export function getSidebarDesktopUpdateTagPresentation(
isDownloading,
isError,
downloadPercent: isDownloading && downloadPercent !== null ? progressPercent : null,
- targetLabel,
+ targetVersion: targetVersion ?? null,
}),
};
}
@@ -249,7 +283,12 @@ export function getDesktopUpdateStatusLine(
return { text: state.message ?? "Update failed", tone: "error" };
}
const targetVersion = state.downloadedVersion ?? state.availableVersion;
- const targetLabel = targetVersion ? compactVersionLabel(targetVersion) : null;
+ const targetLabel =
+ targetVersion && state.currentVersion
+ ? discriminatingVersionLabel(targetVersion, state.currentVersion)
+ : targetVersion
+ ? compactVersionLabel(targetVersion)
+ : null;
if (state.downloadedVersion || state.status === "downloaded") {
// "restart to install" lives on the action button right below.
return { text: `${targetLabel ?? "Update"} downloaded`, tone: "success" };
From 063d5a8e37c5ee3fe67780466e53dadd41047b11 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 7 Aug 2026 16:07:18 -0400
Subject: [PATCH 3/6] Keep the cross-base nightly label inside the chip's 68px
box
The chip fits about eleven characters, so v0.3.3-nightly would
ellipsize into exactly the part that mattered. The feed only serves the
selected channel, so the chip drops the suffix (v0.3.3 on the nightly
track can only be a nightly) while the popout row and tooltip keep the
explicit channel.
---
.../src/components/desktopUpdate.logic.test.ts | 12 ++++++++++++
apps/web/src/components/desktopUpdate.logic.ts | 15 ++++++++++++++-
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts
index aec920179..a7e1a4b3e 100644
--- a/apps/web/src/components/desktopUpdate.logic.test.ts
+++ b/apps/web/src/components/desktopUpdate.logic.test.ts
@@ -3,6 +3,7 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@threadlines
import {
canCheckForUpdate,
+ discriminatingChipVersionLabel,
discriminatingVersionLabel,
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
@@ -265,6 +266,17 @@ describe("discriminatingVersionLabel", () => {
"v0.3.2-nightly",
);
});
+
+ it("drops the channel suffix on the chip, where the feed already implies it", () => {
+ // "v0.3.3-nightly" is 14 characters and the chip fits about eleven; the
+ // popout row and tooltip keep the explicit channel.
+ expect(
+ discriminatingChipVersionLabel("0.3.3-nightly.20260808.230", "0.3.2-nightly.20260807.221"),
+ ).toBe("v0.3.3");
+ expect(
+ discriminatingChipVersionLabel("0.3.2-nightly.20260807.222", "0.3.2-nightly.20260807.221"),
+ ).toBe(".222");
+ });
});
describe("getSidebarDesktopUpdateTagPresentation", () => {
diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts
index 266187e94..88f825203 100644
--- a/apps/web/src/components/desktopUpdate.logic.ts
+++ b/apps/web/src/components/desktopUpdate.logic.ts
@@ -146,6 +146,19 @@ export function discriminatingVersionLabel(target: string, current: string): str
return `${compactVersionLabel(target)}-nightly`;
}
+/**
+ * Chip variant of `discriminatingVersionLabel`. The sidebar chip is a fixed
+ * 68px box that fits about eleven characters, so "v0.3.3-nightly" would
+ * ellipsize into exactly the part that mattered. The feed only ever serves
+ * the selected channel, so on the chip a cross-base target can drop the
+ * channel suffix: on the nightly track "v0.3.3" can only mean a nightly, and
+ * the popout row plus tooltip still spell it out.
+ */
+export function discriminatingChipVersionLabel(target: string, current: string): string {
+ const label = discriminatingVersionLabel(target, current);
+ return label.endsWith("-nightly") ? compactVersionLabel(target) : label;
+}
+
function getSidebarDesktopUpdateTagTooltip(input: {
readonly action: DesktopUpdateButtonAction;
readonly isDownloading: boolean;
@@ -200,7 +213,7 @@ export function getSidebarDesktopUpdateTagPresentation(
// "ready to restart" reads as the incoming release, not the running one.
const targetVersion = state.downloadedVersion ?? state.availableVersion;
const targetLabel = targetVersion
- ? discriminatingVersionLabel(targetVersion, state.currentVersion ?? appVersion)
+ ? discriminatingChipVersionLabel(targetVersion, state.currentVersion ?? appVersion)
: null;
const downloadPercent = typeof state.downloadPercent === "number" ? state.downloadPercent : null;
const progressPercent = isDownloaded
From 427f9bcf8b4589274df6d2e37886f5ccf89ae48b Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sat, 8 Aug 2026 00:24:26 -0400
Subject: [PATCH 4/6] Never claim an empty workspace before it loads, and tell
a revoked phone
The re-walk's remaining pairing gaps, root-caused:
- The empty no-composer shell a paired phone landed on was a cold-start
hydration lie: the app rendered "No projects yet" and the no-active-
thread state in the ~1s window before the first shell snapshot
arrived, on every entry path (the audit's owner-context anomaly was
the same window). The directly-paired path now shows a loading state
until bootstrap completes, exactly as the hosted path already did;
the sidebar's project list gets the same gate.
- Revoking a device only rewrote persistence, so an already-connected
phone kept streaming until it happened to reconnect. The websocket
now races a revocation watcher (subscribe-then-read, so no missed
signals; a failed watcher parks rather than killing a trusted
socket). Client-side, a rejected reconnect is indistinguishable from
a network drop in the browser, so after a disconnect the app asks
the session endpoint: an explicit not-authenticated answer stops the
retries and shows "Access removed. This device was disconnected from
the computer. Pair again to reconnect."; any probe failure keeps
reconnecting, so a network blink can never lock a device out.
- The manual pairing path on a direct LAN origin is the token form by
design ("Add computer" belongs to the hosted app, which has no local
backend); the form's happy and rejected paths gained the coverage
they were missing.
---
.../auth/Layers/SessionCredentialService.ts | 22 ++++
.../auth/Services/SessionCredentialService.ts | 16 +++
apps/server/src/server.test.ts | 101 ++++++++++++++++++
apps/server/src/ws.ts | 29 ++++-
...tsx => ConnectionStatusStates.browser.tsx} | 2 +-
...sStates.tsx => ConnectionStatusStates.tsx} | 90 ++++++++++++++--
apps/web/src/components/Sidebar.tsx | 13 ++-
.../WebSocketConnectionSurface.browser.tsx | 61 +++++++++++
.../WebSocketConnectionSurface.logic.test.ts | 46 ++++++++
.../components/WebSocketConnectionSurface.tsx | 64 ++++++++++-
.../auth/PairingRouteSurface.browser.tsx | 85 +++++++++++++++
.../primary/accessRemoval.test.ts | 40 +++++++
.../src/environments/primary/accessRemoval.ts | 50 +++++++++
apps/web/src/environments/primary/index.ts | 8 ++
...xState.test.ts => -chatIndexState.test.ts} | 39 ++++++-
...StaticIndexState.ts => -chatIndexState.ts} | 29 +++++
.../routes/_chat.$environmentId.$threadId.tsx | 2 +-
apps/web/src/routes/_chat.index.tsx | 57 +++++-----
18 files changed, 704 insertions(+), 50 deletions(-)
rename apps/web/src/components/{HostedStaticStatusStates.browser.tsx => ConnectionStatusStates.browser.tsx} (97%)
rename apps/web/src/components/{HostedStaticStatusStates.tsx => ConnectionStatusStates.tsx} (72%)
create mode 100644 apps/web/src/components/WebSocketConnectionSurface.browser.tsx
create mode 100644 apps/web/src/components/auth/PairingRouteSurface.browser.tsx
create mode 100644 apps/web/src/environments/primary/accessRemoval.test.ts
create mode 100644 apps/web/src/environments/primary/accessRemoval.ts
rename apps/web/src/routes/{-hostedStaticIndexState.test.ts => -chatIndexState.test.ts} (72%)
rename apps/web/src/routes/{-hostedStaticIndexState.ts => -chatIndexState.ts} (63%)
diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts
index b679d3290..47b6e9fb8 100644
--- a/apps/server/src/auth/Layers/SessionCredentialService.ts
+++ b/apps/server/src/auth/Layers/SessionCredentialService.ts
@@ -146,6 +146,27 @@ export const makeSessionCredentialService = Effect.gen(function* () {
);
});
+ const awaitRevoked: SessionCredentialServiceShape["awaitRevoked"] = (sessionId) =>
+ Effect.gen(function* () {
+ // Subscribe before re-reading the row: the subscription buffers every
+ // change published from this point, so the read below can only be stale
+ // in the safe direction (a revocation it misses is one the subscription
+ // already holds).
+ const subscription = yield* PubSub.subscribe(changesPubSub);
+ const row = yield* authSessions.getById({ sessionId });
+ if (Option.isNone(row) || row.value.revokedAt !== null) {
+ return;
+ }
+
+ yield* Stream.fromSubscription(subscription).pipe(
+ Stream.filter(
+ (change) => change.type === "clientRemoved" && change.sessionId === sessionId,
+ ),
+ Stream.take(1),
+ Stream.runDrain,
+ );
+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to watch session revocation.")));
+
const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
Ref.modify(connectedSessionsRef, (current) => {
const next = new Map(current);
@@ -519,6 +540,7 @@ export const makeSessionCredentialService = Effect.gen(function* () {
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
},
+ awaitRevoked,
revoke,
revokeAllExcept,
markConnected,
diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts
index f32c7e0c6..9092dd8b8 100644
--- a/apps/server/src/auth/Services/SessionCredentialService.ts
+++ b/apps/server/src/auth/Services/SessionCredentialService.ts
@@ -9,6 +9,7 @@ import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Context from "effect/Context";
import type * as Effect from "effect/Effect";
+import type * as Scope from "effect/Scope";
import type * as Stream from "effect/Stream";
export type SessionRole = "owner" | "client";
@@ -77,6 +78,21 @@ export interface SessionCredentialServiceShape {
SessionCredentialError
>;
readonly streamChanges: Stream.Stream;
+ /**
+ * Resolves once `sessionId` is no longer usable, and resolves immediately when
+ * it is already revoked or unknown.
+ *
+ * Callers that hold a live connection for a session (the websocket route) need
+ * to drop it the instant the owner revokes access. `streamChanges` cannot
+ * carry that invariant on its own: `Stream.fromPubSub` subscribes when the
+ * stream starts running, so a revocation published between "read the session"
+ * and "start consuming" is lost and the connection stays open forever. This
+ * subscribes first and only then re-reads the session, so neither ordering
+ * drops the signal.
+ */
+ readonly awaitRevoked: (
+ sessionId: AuthSessionId,
+ ) => Effect.Effect;
readonly revoke: (sessionId: AuthSessionId) => Effect.Effect;
readonly revokeAllExcept: (
sessionId: AuthSessionId,
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 693516bdb..ea7746800 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -32,6 +32,7 @@ import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
@@ -1107,6 +1108,19 @@ const assertBrowserApiCorsHeaders = (headers: Headers) => {
};
const crossOriginClientOrigin = "http://remote-client.test:3773";
+/**
+ * Suites here run on the test clock, so `Effect.sleep`/`Effect.timeout` never
+ * advance on their own. Assertions about real sockets closing need wall-clock
+ * time instead.
+ */
+const wallClockSleep = (durationMs: number) =>
+ Effect.promise(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(resolve, durationMs);
+ }),
+ );
+
const getWsServerUrl = (
pathname = "",
options?: { authenticated?: boolean; credential?: string },
@@ -1902,6 +1916,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("closes the live websocket of a revoked paired client session", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ config: {
+ host: "0.0.0.0",
+ },
+ // The default lifecycle mock completes immediately, which would make the
+ // subscription below end for reasons unrelated to revocation.
+ layers: {
+ serverLifecycleEvents: {
+ stream: Stream.never,
+ },
+ },
+ });
+
+ const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
+ const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
+ headers: {
+ cookie: ownerCookie,
+ },
+ });
+ const pairingBody = (yield* pairingResponse.json) as {
+ readonly credential: string;
+ };
+ const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(
+ pairingBody.credential,
+ );
+ const pairedWsUrl = appendSessionCookieToWsUrl(
+ yield* getWsServerUrl("/ws", { authenticated: false }),
+ pairedSessionCookie,
+ );
+
+ const { exitBeforeRevoke, exitAfterRevoke } = yield* Effect.scoped(
+ withWsRpcClient(pairedWsUrl, (client) =>
+ Effect.gen(function* () {
+ // One request first so the socket is actually open and registered as
+ // connected before the owner revokes it.
+ yield* client[WS_METHODS.serverGetConfig]({});
+ // The lifecycle stream never completes on its own, so it only ends
+ // here because the server dropped this session's socket.
+ const lifecycle = yield* Effect.forkChild(
+ Stream.runDrain(client[WS_METHODS.subscribeServerLifecycle]({})),
+ );
+ const clientsResponse = yield* HttpClient.get("/api/auth/clients", {
+ headers: {
+ cookie: ownerCookie,
+ },
+ });
+ const clients = (yield* clientsResponse.json) as ReadonlyArray<{
+ readonly sessionId: string;
+ readonly current: boolean;
+ readonly connected: boolean;
+ }>;
+ const pairedClient = clients.find((entry) => !entry.current);
+ assert.isDefined(pairedClient);
+ assert.isTrue(pairedClient?.connected);
+
+ yield* wallClockSleep(500);
+ const beforeRevoke = lifecycle.pollUnsafe();
+
+ yield* HttpClient.post("/api/auth/clients/revoke", {
+ headers: {
+ cookie: ownerCookie,
+ "content-type": "application/json",
+ },
+ body: HttpBody.text(
+ JSON.stringify({ sessionId: pairedClient?.sessionId }),
+ "application/json",
+ ),
+ });
+
+ return {
+ exitBeforeRevoke: beforeRevoke,
+ exitAfterRevoke: yield* Effect.raceFirst(
+ Fiber.await(lifecycle).pipe(Effect.as(true)),
+ wallClockSleep(10_000).pipe(Effect.as(false)),
+ ),
+ };
+ }),
+ ),
+ );
+
+ assert.isUndefined(exitBeforeRevoke, "the paired session stream ended before it was revoked");
+ assertTrue(exitAfterRevoke, "revoked session websocket stayed open");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("keeps the desktop bootstrap credential available after browser sign-in", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 53c7536c1..683ec05f4 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -52,7 +52,7 @@ import {
WsRpcGroup,
} from "@threadlines/contracts";
import { clamp } from "effect/Number";
-import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { resolveAttachmentPathById } from "./attachmentStore.ts";
@@ -2137,12 +2137,35 @@ export const websocketRpcRouteLayer = Layer.unwrap(
),
),
);
+ // Revoking a device only rewrites persistence, so a phone that is
+ // already connected would keep streaming live orchestration state on
+ // its existing socket until it happened to reconnect. Racing the served
+ // socket against the revocation watcher drops the connection the moment
+ // access is taken away; `awaitRevoked` never resolves for a session that
+ // stays valid, so a healthy socket is unaffected.
+ const closeWhenRevoked = sessions.awaitRevoked(session.sessionId).pipe(
+ Effect.tap(() =>
+ Effect.logInfo("auth.session.revoked.closing-websocket", {
+ sessionId: session.sessionId,
+ }),
+ ),
+ Effect.as(HttpServerResponse.empty({ status: 401 })),
+ // A failing watcher must never take down a socket the owner still
+ // trusts, so log it and let the socket decide the outcome.
+ Effect.catchCause((cause) =>
+ Effect.logWarning("auth.session.revocation-watch-failed", {
+ sessionId: session.sessionId,
+ cause,
+ }).pipe(Effect.andThen(Effect.never)),
+ ),
+ );
+
return yield* Effect.acquireUseRelease(
sessions.markConnected(session.sessionId),
- () => rpcWebSocketHttpEffect,
+ () => Effect.raceFirst(rpcWebSocketHttpEffect, closeWhenRevoked),
() => sessions.markDisconnected(session.sessionId),
);
- }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
+ }).pipe(Effect.scoped, Effect.catchTag("AuthError", respondToAuthError)),
),
),
);
diff --git a/apps/web/src/components/HostedStaticStatusStates.browser.tsx b/apps/web/src/components/ConnectionStatusStates.browser.tsx
similarity index 97%
rename from apps/web/src/components/HostedStaticStatusStates.browser.tsx
rename to apps/web/src/components/ConnectionStatusStates.browser.tsx
index b16df5315..6ceb97348 100644
--- a/apps/web/src/components/HostedStaticStatusStates.browser.tsx
+++ b/apps/web/src/components/ConnectionStatusStates.browser.tsx
@@ -12,7 +12,7 @@ import { page } from "vite-plus/test/browser";
import { afterEach, describe, expect, it } from "vite-plus/test";
import { render } from "vitest-browser-react";
-import { HostedStaticOnboardingState } from "./HostedStaticStatusStates";
+import { HostedStaticOnboardingState } from "./ConnectionStatusStates";
import { SidebarProvider } from "./ui/sidebar";
const DESKTOP_VIEWPORT = { height: 900, width: 1280 };
diff --git a/apps/web/src/components/HostedStaticStatusStates.tsx b/apps/web/src/components/ConnectionStatusStates.tsx
similarity index 72%
rename from apps/web/src/components/HostedStaticStatusStates.tsx
rename to apps/web/src/components/ConnectionStatusStates.tsx
index 62e123e26..2593e7d5d 100644
--- a/apps/web/src/components/HostedStaticStatusStates.tsx
+++ b/apps/web/src/components/ConnectionStatusStates.tsx
@@ -19,17 +19,20 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
import { SidebarInset, SidebarOpenTrigger } from "./ui/sidebar";
/**
- * Full-content status surfaces for hosted (phone) sessions, where the app has
- * no local backend and every route depends on the relay bootstrap. Routes
- * render these instead of nothing while that bootstrap is pending or failed.
+ * Full-content status surfaces for sessions that cannot show the app yet: the
+ * hosted (phone) app waiting on its relay bootstrap, a freshly paired browser
+ * waiting on its first workspace snapshot, and a device whose access was taken
+ * away. Routes render these instead of an empty shell that reads as "you have
+ * nothing here".
*/
-function HostedStaticStatusState({
+function ConnectionStatusState({
icon,
title,
description,
detail,
body,
action,
+ chrome = "sidebar-inset",
}: {
icon: ReactNode;
title: string;
@@ -38,18 +41,27 @@ function HostedStaticStatusState({
/** Full-width content between the description and the action. */
body?: ReactNode;
action?: ReactNode;
+ /**
+ * `sidebar-inset` is for states a route renders inside the app shell, where
+ * the sidebar is still there to navigate away to. `standalone` is for states
+ * that replace the whole app, which have no sidebar to open and must not
+ * depend on one being mounted.
+ */
+ chrome?: "sidebar-inset" | "standalone";
}) {
+ const Shell = chrome === "standalone" ? StandaloneShell : SidebarInsetShell;
+
return (
-
+
-
+ {chrome === "sidebar-inset" ? : null}
{APP_DISPLAY_NAME}
@@ -77,13 +89,29 @@ function HostedStaticStatusState({
+
+ );
+}
+
+function SidebarInsetShell({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
);
}
+function StandaloneShell({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function HostedStaticLoadingState({ label }: { label: string | null }) {
return (
- }
title="Loading your desktop"
description={
@@ -96,6 +124,46 @@ export function HostedStaticLoadingState({ label }: { label: string | null }) {
);
}
+/**
+ * What a browser paired directly to a computer sees between "the socket is up"
+ * and "the first workspace snapshot arrived". Without it the index route falls
+ * straight through to the cold-start empty state, so a phone that just scanned
+ * a QR code is told it has no projects a second before its projects appear.
+ */
+export function WorkspaceLoadingState() {
+ return (
+ }
+ title="Loading your workspace"
+ description="Connected. Loading projects and threads."
+ detail="This takes a moment after pairing, while the first workspace snapshot arrives."
+ />
+ );
+}
+
+/**
+ * What a device sees once the computer revokes its access. The socket is gone
+ * for good, so this replaces the app shell rather than sitting behind a
+ * reconnect spinner that can never succeed.
+ */
+export function AccessRemovedState() {
+ return (
+ }
+ title="Access removed"
+ description="This device was disconnected from the computer. Pair again to reconnect."
+ detail="On the computer, open Settings, then Devices, then Add device to create a new setup link."
+ action={
+ window.location.reload()}>
+
+ Reload
+
+ }
+ />
+ );
+}
+
export function HostedStaticConnectionErrorState({
label,
message,
@@ -104,7 +172,7 @@ export function HostedStaticConnectionErrorState({
message: string | null;
}) {
return (
- }
title="Could not load your desktop"
description={
@@ -169,7 +237,7 @@ const PAIRING_STEPS = [
*/
function HostedStaticPhoneOnboardingState() {
return (
- }
title="Pair with your computer"
description={`${APP_BASE_NAME} runs on your computer; this phone connects to it.`}
@@ -204,7 +272,7 @@ function HostedStaticPhoneOnboardingState() {
*/
function HostedStaticDesktopOnboardingState() {
return (
- }
title="Open the desktop app to get started"
description="Threadlines runs on your computer. Install the desktop app there, then pair this browser so it can reach your projects and threads."
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index ab4a70f8c..faec5baf7 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -35,6 +35,7 @@ import {
reopenThreadByKey,
} from "../lib/threadInboxSync";
import {
+ selectBootstrapCompleteForActiveEnvironment,
selectProjectByRef,
selectProjectsAcrossEnvironments,
selectSidebarThreadsAcrossEnvironments,
@@ -524,6 +525,10 @@ export default function Sidebar() {
[generalChatProjectKeys, resolveThreadProjectKey, sidebarThreads],
);
const hasWorkspaceProjects = sidebarProjects.some((project) => project.kind !== "general-chat");
+ // Until the first workspace snapshot arrives an empty list means "not loaded",
+ // not "nothing here" — a paired phone would otherwise be told it has no
+ // projects for the second before its projects appear.
+ const bootstrapComplete = useStore(selectBootstrapCompleteForActiveEnvironment);
const entries = useMemo(
() =>
@@ -1389,9 +1394,13 @@ export default function Sidebar() {
{liveEntries.length === 0 ? (
- {hasWorkspaceProjects ? "No threads yet" : "No projects yet"}
+ {!bootstrapComplete
+ ? "Loading projects"
+ : hasWorkspaceProjects
+ ? "No threads yet"
+ : "No projects yet"}
- {hasWorkspaceProjects ? null : (
+ {hasWorkspaceProjects || !bootstrapComplete ? null : (
(
+
+
+ Threads and composer
+
+
+ ),
+ });
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+
+ return render( );
+}
+
+describe("WebSocketConnectionSurface", () => {
+ afterEach(() => {
+ setPrimaryAccessRemoved(false);
+ document.body.innerHTML = "";
+ });
+
+ it("shows the app while this device still has access", async () => {
+ renderSurface();
+
+ await expect.element(page.getByText("Threads and composer")).toBeVisible();
+ });
+
+ it("replaces the app with a re-pair state once the computer removes this device", async () => {
+ renderSurface();
+ setPrimaryAccessRemoved(true);
+
+ await expect.element(page.getByText("Access removed")).toBeVisible();
+ await expect
+ .element(
+ page.getByText("This device was disconnected from the computer. Pair again to reconnect."),
+ )
+ .toBeVisible();
+ await expect.element(page.getByText("Threads and composer")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
index 20b6a0512..e005ee640 100644
--- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
+++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
@@ -4,6 +4,7 @@ import type { WsConnectionStatus } from "../rpc/wsConnectionState";
import { describeSlowRpcAckToast, formatSlowRpcTagLabel } from "../rpc/requestLatencyPresentation";
import {
shouldAutoReconnect,
+ shouldProbeAccessRemoval,
shouldRestartStalledReconnect,
shouldShowReconnectIssueToast,
} from "./WebSocketConnectionSurface";
@@ -160,6 +161,51 @@ describe("WebSocketConnectionSurface.logic", () => {
).toBe(true);
});
+ it("asks whether access was removed when an established socket drops", () => {
+ expect(
+ shouldProbeAccessRemoval(
+ makeStatus({
+ disconnectedAt: "2026-04-03T20:00:00.000Z",
+ hasConnected: true,
+ online: true,
+ phase: "disconnected",
+ reconnectAttemptCount: 1,
+ reconnectPhase: "waiting",
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("does not ask about access while the browser reports no network", () => {
+ expect(
+ shouldProbeAccessRemoval(
+ makeStatus({
+ disconnectedAt: "2026-04-03T20:00:00.000Z",
+ hasConnected: true,
+ online: false,
+ phase: "disconnected",
+ reconnectAttemptCount: 1,
+ reconnectPhase: "waiting",
+ }),
+ ),
+ ).toBe(false);
+ });
+
+ it("does not ask about access for a session that never connected", () => {
+ expect(
+ shouldProbeAccessRemoval(
+ makeStatus({
+ disconnectedAt: "2026-04-03T20:00:00.000Z",
+ hasConnected: false,
+ online: true,
+ phase: "disconnected",
+ reconnectAttemptCount: 1,
+ reconnectPhase: "waiting",
+ }),
+ ),
+ ).toBe(false);
+ });
+
it("formats slow RPC method tags for user-facing summaries", () => {
expect(formatSlowRpcTagLabel("server.refreshProviders")).toBe("Server refresh providers");
expect(formatSlowRpcTagLabel("vcs.refreshStatus")).toBe("Source control refresh status");
diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx
index 5885e68d7..4b2b9f3b9 100644
--- a/apps/web/src/components/WebSocketConnectionSurface.tsx
+++ b/apps/web/src/components/WebSocketConnectionSurface.tsx
@@ -10,6 +10,13 @@ import {
} from "../rpc/wsConnectionState";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { getPrimaryEnvironmentConnection } from "../environments/runtime";
+import {
+ isPrimaryAccessRemoved,
+ probePrimaryAccess,
+ setPrimaryAccessRemoved,
+ usePrimaryAccessRemoved,
+} from "../environments/primary";
+import { AccessRemovedState } from "./ConnectionStatusStates";
const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
const RECONNECT_TOAST_GRACE_MS = 10_000;
@@ -108,6 +115,16 @@ export function shouldRestartStalledReconnect(
);
}
+/**
+ * A dropped socket is worth an access probe only once the browser believes it
+ * has a network and the session had genuinely connected before: probing while
+ * offline just answers "unknown", and probing a session that never connected
+ * would race the pairing handshake.
+ */
+export function shouldProbeAccessRemoval(status: WsConnectionStatus): boolean {
+ return status.online && status.hasConnected && getWsConnectionUiState(status) === "reconnecting";
+}
+
export function shouldShowReconnectIssueToast(status: WsConnectionStatus, nowMs: number): boolean {
const disconnectedAtMs = parseConnectionMomentMs(status.disconnectedAt);
return (
@@ -120,6 +137,7 @@ export function shouldShowReconnectIssueToast(status: WsConnectionStatus, nowMs:
export function WebSocketConnectionCoordinator() {
const status = useWsConnectionStatus();
+ const accessRemoved = usePrimaryAccessRemoved();
const [nowMs, setNowMs] = useState(() => Date.now());
const lastForcedReconnectAtRef = useRef(0);
const toastIdRef = useRef | null>(null);
@@ -129,6 +147,10 @@ export function WebSocketConnectionCoordinator() {
const previousDisconnectedAtRef = useRef(status.disconnectedAt);
const runReconnect = useEffectEvent((showFailureToast: boolean) => {
+ // Nothing on this device can restore a revoked session, so stop knocking.
+ if (isPrimaryAccessRemoved()) {
+ return;
+ }
if (toastResetTimerRef.current !== null) {
window.clearTimeout(toastResetTimerRef.current);
toastResetTimerRef.current = null;
@@ -194,6 +216,36 @@ export function WebSocketConnectionCoordinator() {
};
}, []);
+ useEffect(() => {
+ if (!shouldProbeAccessRemoval(status)) {
+ return;
+ }
+
+ let cancelled = false;
+ void probePrimaryAccess().then((outcome) => {
+ if (cancelled || outcome !== "removed") {
+ return;
+ }
+ setPrimaryAccessRemoved(true);
+ // The socket can never come back, and its subscriptions retry forever, so
+ // tear the connection down instead of leaving it grinding behind the
+ // access-removed surface.
+ void getPrimaryEnvironmentConnection()
+ .dispose()
+ .catch(() => undefined);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [status.disconnectedAt, status.hasConnected, status.online, status.reconnectAttemptCount]);
+
+ useEffect(() => {
+ if (status.phase === "connected") {
+ setPrimaryAccessRemoved(false);
+ }
+ }, [status.phase]);
+
useEffect(() => {
if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) {
return;
@@ -245,8 +297,11 @@ export function WebSocketConnectionCoordinator() {
const uiState = getWsConnectionUiState(status);
const previousUiState = previousUiStateRef.current;
const previousDisconnectedAt = previousDisconnectedAtRef.current;
- const shouldShowReconnectToast = shouldShowReconnectIssueToast(status, nowMs);
- const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null;
+ // The access-removed surface already explains the disconnect; a reconnect
+ // countdown on top of it would promise a recovery that cannot happen.
+ const shouldShowReconnectToast = !accessRemoved && shouldShowReconnectIssueToast(status, nowMs);
+ const shouldShowOfflineToast =
+ !accessRemoved && uiState === "offline" && status.disconnectedAt !== null;
if (
toastResetTimerRef.current !== null &&
@@ -329,7 +384,7 @@ export function WebSocketConnectionCoordinator() {
previousUiStateRef.current = uiState;
previousDisconnectedAtRef.current = status.disconnectedAt;
- }, [nowMs, status]);
+ }, [accessRemoved, nowMs, status]);
useEffect(() => {
return () => {
@@ -343,5 +398,6 @@ export function WebSocketConnectionCoordinator() {
}
export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) {
- return children;
+ const accessRemoved = usePrimaryAccessRemoved();
+ return accessRemoved ? : children;
}
diff --git a/apps/web/src/components/auth/PairingRouteSurface.browser.tsx b/apps/web/src/components/auth/PairingRouteSurface.browser.tsx
new file mode 100644
index 000000000..b6b093de3
--- /dev/null
+++ b/apps/web/src/components/auth/PairingRouteSurface.browser.tsx
@@ -0,0 +1,85 @@
+import "../../index.css";
+
+import type { AuthSessionState } from "@threadlines/contracts";
+import { page } from "vite-plus/test/browser";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+
+import { PairingRouteSurface } from "./PairingRouteSurface";
+
+const auth: AuthSessionState["auth"] = {
+ policy: "loopback-browser",
+ bootstrapMethods: ["one-time-token"],
+ sessionMethods: ["browser-session-cookie"],
+ sessionCookieName: "threadlines_session",
+};
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ headers: { "content-type": "application/json" },
+ status,
+ });
+}
+
+/**
+ * A phone that opens a computer's LAN address without a token lands here: this
+ * form is the manual pairing path for a directly reachable server, so its happy
+ * path has to keep working.
+ */
+describe("PairingRouteSurface", () => {
+ beforeEach(async () => {
+ const { __resetServerAuthBootstrapForTests } = await import("../../environments/primary");
+ __resetServerAuthBootstrapForTests();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ it("pairs the browser with a token typed into the form", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ authenticated: true, sessionMethod: "browser-session-cookie" }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const onAuthenticated = vi.fn();
+
+ render( );
+
+ await page.getByLabelText("Pairing token").fill("PAIRCODE1234");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await expect.poll(() => onAuthenticated.mock.calls.length).toBe(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/auth/bootstrap"),
+ expect.objectContaining({
+ body: JSON.stringify({ credential: "PAIRCODE1234" }),
+ method: "POST",
+ }),
+ );
+ });
+
+ it("keeps the form usable and names the problem when the token is rejected", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ error: "Invalid bootstrap credential." }, 401)),
+ );
+ const onAuthenticated = vi.fn();
+
+ render( );
+
+ await page.getByLabelText("Pairing token").fill("NOPE");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await expect
+ .element(page.getByText("Invalid pairing token. Check the token and try again."))
+ .toBeVisible();
+ expect(onAuthenticated).not.toHaveBeenCalled();
+ await expect.element(page.getByRole("button", { name: "Continue" })).toBeEnabled();
+ });
+});
diff --git a/apps/web/src/environments/primary/accessRemoval.test.ts b/apps/web/src/environments/primary/accessRemoval.test.ts
new file mode 100644
index 000000000..a927af1b8
--- /dev/null
+++ b/apps/web/src/environments/primary/accessRemoval.test.ts
@@ -0,0 +1,40 @@
+import type { AuthSessionState } from "@threadlines/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { probePrimaryAccess } from "./accessRemoval";
+
+const auth: AuthSessionState["auth"] = {
+ policy: "loopback-browser",
+ bootstrapMethods: ["one-time-token"],
+ sessionMethods: ["browser-session-cookie"],
+ sessionCookieName: "threadlines_session",
+};
+
+describe("probePrimaryAccess", () => {
+ it("reports removed access when the computer no longer knows this session", async () => {
+ await expect(
+ probePrimaryAccess(async () => ({ authenticated: false, auth }) as AuthSessionState),
+ ).resolves.toBe("removed");
+ });
+
+ it("keeps a network failure inconclusive so reconnecting continues", async () => {
+ await expect(
+ probePrimaryAccess(async () => {
+ throw new TypeError("Failed to fetch");
+ }),
+ ).resolves.toBe("unknown");
+ });
+
+ it("reports active access when the session survives the disconnect", async () => {
+ await expect(
+ probePrimaryAccess(
+ async () =>
+ ({
+ authenticated: true,
+ auth,
+ sessionMethod: "browser-session-cookie",
+ }) as AuthSessionState,
+ ),
+ ).resolves.toBe("active");
+ });
+});
diff --git a/apps/web/src/environments/primary/accessRemoval.ts b/apps/web/src/environments/primary/accessRemoval.ts
new file mode 100644
index 000000000..4a56dbc86
--- /dev/null
+++ b/apps/web/src/environments/primary/accessRemoval.ts
@@ -0,0 +1,50 @@
+import type { AuthSessionState } from "@threadlines/contracts";
+import { Atom } from "effect/unstable/reactivity";
+import { useAtomValue } from "@effect/atom-react";
+
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { fetchSessionState } from "./auth";
+
+/**
+ * A websocket handshake that the server rejects for auth reasons is
+ * indistinguishable, from the browser, from one the network dropped: both
+ * surface as a generic socket error with close code 1006. Asking the server
+ * whether this session still exists is the only reliable way to tell "the
+ * computer removed this device" from "the Wi-Fi blinked", and the difference
+ * matters — one should stop reconnecting and say so, the other must keep
+ * retrying forever.
+ */
+export type PrimaryAccessProbeOutcome = "active" | "removed" | "unknown";
+
+export async function probePrimaryAccess(
+ readSessionState: () => Promise = fetchSessionState,
+): Promise {
+ try {
+ const session = await readSessionState();
+ return session.authenticated ? "active" : "removed";
+ } catch {
+ // The session endpoint is unreachable, which is exactly what a network
+ // outage looks like. Treat it as inconclusive and keep reconnecting.
+ return "unknown";
+ }
+}
+
+const primaryAccessRemovedAtom = Atom.make(false).pipe(
+ Atom.keepAlive,
+ Atom.withLabel("primary-access-removed"),
+);
+
+export function isPrimaryAccessRemoved(): boolean {
+ return appAtomRegistry.get(primaryAccessRemovedAtom);
+}
+
+export function setPrimaryAccessRemoved(removed: boolean): void {
+ if (appAtomRegistry.get(primaryAccessRemovedAtom) === removed) {
+ return;
+ }
+ appAtomRegistry.set(primaryAccessRemovedAtom, removed);
+}
+
+export function usePrimaryAccessRemoved(): boolean {
+ return useAtomValue(primaryAccessRemovedAtom);
+}
diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts
index 2614164b6..897031c4f 100644
--- a/apps/web/src/environments/primary/index.ts
+++ b/apps/web/src/environments/primary/index.ts
@@ -33,3 +33,11 @@ export {
} from "./auth";
export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
+
+export {
+ isPrimaryAccessRemoved,
+ probePrimaryAccess,
+ setPrimaryAccessRemoved,
+ usePrimaryAccessRemoved,
+ type PrimaryAccessProbeOutcome,
+} from "./accessRemoval";
diff --git a/apps/web/src/routes/-hostedStaticIndexState.test.ts b/apps/web/src/routes/-chatIndexState.test.ts
similarity index 72%
rename from apps/web/src/routes/-hostedStaticIndexState.test.ts
rename to apps/web/src/routes/-chatIndexState.test.ts
index 8282689a6..b7762f88e 100644
--- a/apps/web/src/routes/-hostedStaticIndexState.test.ts
+++ b/apps/web/src/routes/-chatIndexState.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import type { SavedEnvironmentRuntimeState } from "../environments/runtime";
import type { EnvironmentState } from "../store";
-import { deriveHostedStaticIndexState } from "./-hostedStaticIndexState";
+import { deriveChatIndexState, deriveHostedStaticIndexState } from "./-chatIndexState";
const environmentId = EnvironmentId.make("environment-1");
@@ -122,3 +122,40 @@ describe("deriveHostedStaticIndexState", () => {
).toEqual({ kind: "ready" });
});
});
+
+describe("deriveChatIndexState", () => {
+ const directlyPairedInput = {
+ hostedStatic: false,
+ savedEnvironments: [],
+ savedEnvironmentRuntimeById: {},
+ environmentStateById: {},
+ projectCount: 0,
+ } as const;
+
+ it("waits for the first workspace snapshot instead of showing a directly paired device an empty app", () => {
+ expect(deriveChatIndexState({ ...directlyPairedInput, bootstrapComplete: false })).toEqual({
+ kind: "workspace-loading",
+ });
+ });
+
+ it("shows the normal app to a directly paired device once its snapshot arrives, even with no projects", () => {
+ expect(deriveChatIndexState({ ...directlyPairedInput, bootstrapComplete: true })).toEqual({
+ kind: "ready",
+ });
+ });
+
+ it("keeps using the saved-desktop rules for the hosted app", () => {
+ expect(
+ deriveChatIndexState({
+ hostedStatic: true,
+ // The hosted app has no primary environment of its own, so its snapshot
+ // flag must not decide anything here.
+ bootstrapComplete: true,
+ savedEnvironments: [],
+ savedEnvironmentRuntimeById: {},
+ environmentStateById: {},
+ projectCount: 0,
+ }),
+ ).toEqual({ kind: "unpaired" });
+ });
+});
diff --git a/apps/web/src/routes/-hostedStaticIndexState.ts b/apps/web/src/routes/-chatIndexState.ts
similarity index 63%
rename from apps/web/src/routes/-hostedStaticIndexState.ts
rename to apps/web/src/routes/-chatIndexState.ts
index 1c39b63e7..fb5d27423 100644
--- a/apps/web/src/routes/-hostedStaticIndexState.ts
+++ b/apps/web/src/routes/-chatIndexState.ts
@@ -18,6 +18,35 @@ export type HostedStaticIndexState =
}
| { readonly kind: "ready" };
+export type ChatIndexState =
+ | HostedStaticIndexState
+ /** A directly paired browser waiting on its own first workspace snapshot. */
+ | { readonly kind: "workspace-loading" };
+
+/**
+ * Which surface `/` shows, for both ways of reaching this app: the hosted phone
+ * app (which depends on a saved desktop) and a browser paired straight to a
+ * computer.
+ *
+ * The shared rule is that an empty project list only means "nothing here" once
+ * a workspace snapshot has actually arrived. Before that, showing the cold-start
+ * empty state tells a device that just finished pairing to start its first
+ * thread, a second before its existing projects and threads appear.
+ */
+export function deriveChatIndexState(
+ input: {
+ readonly hostedStatic: boolean;
+ /** Whether the primary environment has applied a shell snapshot. */
+ readonly bootstrapComplete: boolean;
+ } & Parameters[0],
+): ChatIndexState {
+ if (input.hostedStatic) {
+ return deriveHostedStaticIndexState(input);
+ }
+
+ return input.bootstrapComplete ? { kind: "ready" } : { kind: "workspace-loading" };
+}
+
export function deriveHostedStaticIndexState(input: {
readonly savedEnvironments: ReadonlyArray;
readonly savedEnvironmentRuntimeById: Record;
diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
index 1312c4b8c..0b8566a4b 100644
--- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
@@ -6,7 +6,7 @@ import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react
import ChatView from "../components/ChatView";
import { ChatRightPanelInlineSidebar } from "../components/ChatRightPanelInlineSidebar";
-import { HostedStaticLoadingState } from "../components/HostedStaticStatusStates";
+import { HostedStaticLoadingState } from "../components/ConnectionStatusStates";
import { threadHasPromotableServerActivity } from "../components/ChatView.logic";
import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
import {
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index 48fe891c5..b3252fa35 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -7,15 +7,20 @@ import {
HostedStaticConnectionErrorState,
HostedStaticLoadingState,
HostedStaticOnboardingState,
-} from "../components/HostedStaticStatusStates";
+ WorkspaceLoadingState,
+} from "../components/ConnectionStatusStates";
import { NoActiveThreadState } from "../components/NoActiveThreadState";
import {
useSavedEnvironmentRegistryStore,
useSavedEnvironmentRuntimeStore,
} from "../environments/runtime";
-import { selectProjectsAcrossEnvironments, useStore } from "../store";
+import {
+ selectBootstrapCompleteForActiveEnvironment,
+ selectProjectsAcrossEnvironments,
+ useStore,
+} from "../store";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
-import { deriveHostedStaticIndexState } from "./-hostedStaticIndexState";
+import { deriveChatIndexState } from "./-chatIndexState";
function ChatIndexRouteView() {
const { authGateState } = Route.useRouteContext();
@@ -25,33 +30,31 @@ function ChatIndexRouteView() {
const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId);
const environmentStateById = useStore((state) => state.environmentStateById);
const projectCount = useStore((state) => selectProjectsAcrossEnvironments(state).length);
+ const bootstrapComplete = useStore(selectBootstrapCompleteForActiveEnvironment);
- if (authGateState.status === "hosted-static") {
- const hostedStaticState = deriveHostedStaticIndexState({
- savedEnvironments,
- savedEnvironmentRuntimeById,
- environmentStateById,
- projectCount,
- });
+ const indexState = deriveChatIndexState({
+ hostedStatic: authGateState.status === "hosted-static",
+ bootstrapComplete,
+ savedEnvironments,
+ savedEnvironmentRuntimeById,
+ environmentStateById,
+ projectCount,
+ });
- switch (hostedStaticState.kind) {
- case "unpaired":
- return ;
- case "loading":
- return ;
- case "connection-error":
- return (
-
- );
- case "ready":
- break;
- }
+ switch (indexState.kind) {
+ case "unpaired":
+ return ;
+ case "loading":
+ return ;
+ case "workspace-loading":
+ return ;
+ case "connection-error":
+ return (
+
+ );
+ case "ready":
+ return ;
}
-
- return ;
}
function DefaultProjectDraftRedirect() {
From 315a6d50d68eecaab62a1c6ca5e5cfbee16a0379 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sat, 8 Aug 2026 00:44:32 -0400
Subject: [PATCH 5/6] Advertise a reachable pairing URL for wildcard binds in
browser mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The boot log's pairing URL shared its derivation with the browser-open
target, so a server bound to every interface still advertised
localhost — a pairing link only this machine could open. The advertised
URL now follows the same rule headless serve uses: explicit hosts
verbatim, wildcard binds resolve a reachable interface, and the desktop
shell keeps localhost since its device URLs come from the Devices
dialog.
---
apps/server/src/serverRuntimeStartup.ts | 18 ++++++++++--------
apps/server/src/startupAccess.test.ts | 18 ++++++++++++++++++
apps/server/src/startupAccess.ts | 25 +++++++++++++++++++++++++
3 files changed, 53 insertions(+), 8 deletions(-)
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index dfcccd34b..5c312d9d9 100644
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -42,9 +42,8 @@ import { ProviderService } from "./provider/Services/ProviderService.ts";
import { SleepInhibitor } from "./power/Services/SleepInhibitor.ts";
import {
formatHeadlessServeOutput,
- formatHostForUrl,
- isWildcardHost,
issueHeadlessServeAccessInfo,
+ resolveAdvertisedServerUrl,
} from "./startupAccess.ts";
export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
@@ -253,12 +252,15 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
const resolveStartupBrowserTarget = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
const serverAuth = yield* ServerAuth;
- const localUrl = `http://localhost:${serverConfig.port}`;
- const bindUrl =
- serverConfig.host && !isWildcardHost(serverConfig.host)
- ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
- : localUrl;
- const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl;
+ // Dev servers keep the Vite dev URL: the client is only served there, and
+ // that harness retargets by hand anyway.
+ const baseTarget =
+ serverConfig.devUrl?.toString() ??
+ resolveAdvertisedServerUrl({
+ host: serverConfig.host,
+ port: serverConfig.port,
+ mode: serverConfig.mode,
+ });
return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe(
Effect.flatMap((target) =>
target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget),
diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts
index 71c2c368d..eda2cee69 100644
--- a/apps/server/src/startupAccess.test.ts
+++ b/apps/server/src/startupAccess.test.ts
@@ -4,6 +4,7 @@ import {
buildPairingUrl,
formatHeadlessServeOutput,
renderTerminalQrCode,
+ resolveAdvertisedServerUrl,
resolveHeadlessConnectionHost,
resolveHeadlessConnectionString,
resolveListeningPort,
@@ -50,6 +51,23 @@ it("keeps explicit bind hosts in the connection string", () => {
expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773");
});
+// The boot log's pairing URL uses the same rule as headless serve: a wildcard
+// bind advertises an address other devices can open, not localhost.
+it("advertises a reachable interface for wildcard binds in browser mode", () => {
+ expect(
+ resolveAdvertisedServerUrl({ host: "0.0.0.0", port: 8266, mode: "web" }, LAN_INTERFACES),
+ ).toBe("http://192.168.1.42:8266");
+});
+
+it("advertises explicit hosts verbatim and keeps desktop wildcard binds on localhost", () => {
+ expect(
+ resolveAdvertisedServerUrl({ host: "127.0.0.1", port: 8266, mode: "web" }, LAN_INTERFACES),
+ ).toBe("http://127.0.0.1:8266");
+ expect(
+ resolveAdvertisedServerUrl({ host: "0.0.0.0", port: 8266, mode: "desktop" }, LAN_INTERFACES),
+ ).toBe("http://localhost:8266");
+});
+
it("resolves wildcard hosts to a concrete external interface when one is available", () => {
const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, {
en0: [
diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts
index a998701e1..12eab4301 100644
--- a/apps/server/src/startupAccess.ts
+++ b/apps/server/src/startupAccess.ts
@@ -76,6 +76,31 @@ export const resolveHeadlessConnectionString = (
return `http://${formatHostForUrl(connectionHost)}:${port}`;
};
+/**
+ * The URL a starting server advertises (boot-log pairing URL and browser-open
+ * target). An explicit non-wildcard host is advertised verbatim. A wildcard
+ * bind exists so other devices can connect, so browser-mode servers advertise
+ * a reachable interface instead of localhost, which only reaches this
+ * machine; the desktop shell keeps localhost — its wildcard rebinds are for
+ * nearby devices whose URLs come from the Devices dialog, not this log line.
+ */
+export const resolveAdvertisedServerUrl = (
+ input: {
+ readonly host: string | undefined;
+ readonly port: number;
+ readonly mode: string;
+ },
+ interfaces: NetworkInterfacesMap = networkInterfaces(),
+): string => {
+ if (input.host && !isWildcardHost(input.host)) {
+ return `http://${formatHostForUrl(input.host)}:${input.port}`;
+ }
+ if (input.mode === "desktop") {
+ return `http://localhost:${input.port}`;
+ }
+ return resolveHeadlessConnectionString(input.host, input.port, interfaces);
+};
+
export const resolveListeningPort = (address: unknown, fallbackPort: number): number => {
if (
typeof address === "object" &&
From a30767c1e21b32a2c2dac2498aea757dd1e39b7c Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sat, 8 Aug 2026 00:55:37 -0400
Subject: [PATCH 6/6] Prefer physical interfaces over virtual adapters in
advertised URLs
The first external IPv4 on a developer machine is often a WSL, Hyper-V,
Docker, or VPN adapter whose subnet no phone can reach, so the pairing
URL advertised a dead address. Virtual-looking interface names are
deprioritized rather than excluded; a machine with only virtual
adapters still advertises its best candidate.
---
apps/server/src/startupAccess.test.ts | 34 +++++++++++++++++++++++++++
apps/server/src/startupAccess.ts | 28 ++++++++++++++++------
2 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts
index eda2cee69..ec6cfe3d3 100644
--- a/apps/server/src/startupAccess.test.ts
+++ b/apps/server/src/startupAccess.test.ts
@@ -51,6 +51,40 @@ it("keeps explicit bind hosts in the connection string", () => {
expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773");
});
+// A developer machine's first external interface is often a virtual adapter
+// (WSL, Hyper-V, Docker) whose subnet no phone can reach; the physical NIC
+// must win. Virtual-only machines still advertise their best candidate.
+it("prefers a physical interface over virtual adapters", () => {
+ const interfaces = {
+ "vEthernet (WSL (Hyper-V firewall))": [
+ {
+ address: "172.22.16.1",
+ netmask: "255.255.240.0",
+ family: "IPv4" as const,
+ mac: "00:15:5d:00:00:01",
+ internal: false,
+ cidr: "172.22.16.1/20",
+ },
+ ],
+ "Wi-Fi": [
+ {
+ address: "10.0.0.15",
+ netmask: "255.255.255.0",
+ family: "IPv4" as const,
+ mac: "aa:bb:cc:dd:ee:ff",
+ internal: false,
+ cidr: "10.0.0.15/24",
+ },
+ ],
+ };
+ expect(resolveHeadlessConnectionHost(undefined, interfaces)).toBe("10.0.0.15");
+ expect(
+ resolveHeadlessConnectionHost(undefined, {
+ "vEthernet (WSL (Hyper-V firewall))": interfaces["vEthernet (WSL (Hyper-V firewall))"],
+ }),
+ ).toBe("172.22.16.1");
+});
+
// The boot log's pairing URL uses the same rule as headless serve: a wildcard
// bind advertises an address other devices can open, not localhost.
it("advertises a reachable interface for wildcard binds in browser mode", () => {
diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts
index 12eab4301..3487987a4 100644
--- a/apps/server/src/startupAccess.ts
+++ b/apps/server/src/startupAccess.ts
@@ -42,6 +42,16 @@ const isIpv4Family = (family: string | number): boolean => family === "IPv4" ||
const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6;
+/**
+ * Adapters that exist for the host's own plumbing: their subnets are usually
+ * unreachable from other physical devices, so a pairing URL on one is a dead
+ * link for the phone it's meant for. Matched by interface name because the
+ * OS exposes nothing more structured; deprioritized rather than excluded so a
+ * machine with only virtual adapters still advertises something routable-ish.
+ */
+const VIRTUAL_INTERFACE_NAME_PATTERN =
+ /vethernet|wsl|hyper-v|docker|vmware|virtualbox|vbox|tailscale|zerotier|utun|tun[0-9]|tap[0-9]|bridge/i;
+
export const resolveHeadlessConnectionHost = (
host: string | undefined,
interfaces: NetworkInterfacesMap = networkInterfaces(),
@@ -53,18 +63,22 @@ export const resolveHeadlessConnectionHost = (
return normalizeHost(host);
}
- const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []);
- const externalIpv4 = interfaceEntries.find(
- (entry) => !entry.internal && isIpv4Family(entry.family),
+ const interfaceEntries = Object.entries(interfaces).flatMap(
+ ([name, entries]) => entries?.map((entry) => ({ name, entry })) ?? [],
+ );
+ const externalIpv4 = interfaceEntries.filter(
+ ({ entry }) => !entry.internal && isIpv4Family(entry.family),
);
- if (externalIpv4) {
- return externalIpv4.address;
+ const physicalIpv4 = externalIpv4.find(({ name }) => !VIRTUAL_INTERFACE_NAME_PATTERN.test(name));
+ const pickedIpv4 = physicalIpv4 ?? externalIpv4[0];
+ if (pickedIpv4) {
+ return pickedIpv4.entry.address;
}
const externalIpv6 = interfaceEntries.find(
- (entry) => !entry.internal && isIpv6Family(entry.family),
+ ({ entry }) => !entry.internal && isIpv6Family(entry.family),
);
- return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost";
+ return externalIpv6 ? normalizeHost(externalIpv6.entry.address) : "localhost";
};
export const resolveHeadlessConnectionString = (