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/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 03c01170f..ec6cfe3d3 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -4,14 +4,46 @@ import { buildPairingUrl, formatHeadlessServeOutput, renderTerminalQrCode, + resolveAdvertisedServerUrl, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, 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", () => { @@ -19,6 +51,57 @@ 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", () => { + 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 43df7e6f9..3487987a4 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -42,30 +42,43 @@ 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(), ): 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); } - 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 })) ?? [], ); - if (externalIpv4) { - return externalIpv4.address; + const externalIpv4 = interfaceEntries.filter( + ({ entry }) => !entry.internal && isIpv4Family(entry.family), + ); + 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 = ( @@ -77,6 +90,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" && 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={ + + } + /> + ); +} + 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/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/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 : (
- {canCopyToClipboard ? ( - <> - {shareablePairingUrl ? ( - - - - - - } - > - - - - {renderGroupedCopyMenuItems()} - - - - ) : ( - - )} - + {canCopyToClipboard && shareablePairingUrl ? ( + + + + + + } + > + + + + {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."} -