From 9af4585fdd1e400a8c23d0b2dafd7f11d54b809f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:03:25 +0000 Subject: [PATCH 1/2] fix(web): a link to a thread on a linked machine no longer opens a blank pane A v2 thread link can name a thread on a machine the server links to but this client has no connection to. The chip linked into a route whose environment shell never goes live, so the pane stayed empty forever. The chip now shows 'Thread on another machine' for an environment the client does not know, and the thread route goes home once the connection catalog is ready and does not hold the route's environment. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ThreadRouteView.tsx | 9 ++++++- .../chat/MarkdownThreadLink.browser.test.tsx | 24 +++++++++++++++++++ .../components/chat/MarkdownThreadLink.tsx | 9 +++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a7b7da0a6b53b..cfbe080a0854e 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -18,6 +18,7 @@ import { useQuietThreadShell, } from "../state/entities"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentCatalog } from "../connection/catalog"; import { useEnvironmentShellStatus } from "../state/shell"; import { environmentThreadShells, threadFamilies, useEnvironmentThread } from "../state/threads"; import { @@ -71,6 +72,12 @@ export function ThreadRouteView({ const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus); + // A thread on a machine this client has no connection to would wait on its shell forever. + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const environmentUnknown = + serverThreadRef !== null && + catalog.isReady && + !catalog.entries.has(serverThreadRef.environmentId); const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); @@ -208,7 +215,7 @@ export function ThreadRouteView({ } else { redirect = ; } - } else if (hiddenByTagFilter) { + } else if (hiddenByTagFilter || environmentUnknown) { redirect = ; } else if (offShellBlocked) { // Before the shell bootstraps, a listed visible thread can still look off-shell. diff --git a/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx b/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx index a379a6afc580a..6febe0ff8b343 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx @@ -21,6 +21,12 @@ vi.mock(import("../../state/entities"), () => ({ useProject: (ref: { projectId: string } | null) => ref?.projectId === "essay-project" ? ({ title: "Essays" } as never) : null, })); +vi.mock(import("../../state/environments"), () => ({ + useEnvironmentIdentities: () => [ + { environmentId: EnvironmentId.make("environment"), label: "Here" }, + { environmentId: EnvironmentId.make("remote"), label: "Remote" }, + ], +})); vi.mock(import("../ProjectFavicon"), () => ({ ProjectFavicon: () => ( @@ -132,3 +138,21 @@ it.each([ } }, ); + +it("names a thread on a machine this client has no connection to, without waiting on it", async () => { + const { registry } = setup(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + await using screen = await render( + + + , + ); + await expect + .element(screen.getByText("Thread on another machine", { exact: true })) + .toBeVisible(); + await expect.element(screen.getByRole("link")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index a88535167db7d..9e897f822ef3f 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -9,6 +9,7 @@ import { import { configuredHostedAppUrl } from "../../hostedPairing"; import { useThreadRefs, useThreadShell } from "../../state/entities"; +import { useEnvironmentIdentities } from "../../state/environments"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; import { UnresolvedChip } from "../contextChipParts"; @@ -23,8 +24,12 @@ export function MarkdownThreadLink(props: { const candidates = threadLinkCandidates(props.link, props.environmentId); const writtenRef = candidates[0]!; const written = useThreadShell(writtenRef); + // A linked machine's threads are reachable only through its server, not from this client. + const reachable = useEnvironmentIdentities().some( + (environment) => environment.environmentId === writtenRef.environmentId, + ); const lookup = useEnvironmentQuery( - written === null + written === null && reachable ? orchestrationEnvironment.threadLinkMetadata({ environmentId: writtenRef.environmentId, input: { threadIds: candidates.map((ref) => ref.threadId) }, @@ -39,7 +44,7 @@ export function MarkdownThreadLink(props: { const metadata = lookup.data?.[ref.threadId]; return metadata ? [{ ref, metadata }] : []; })[0] ?? null); - const missing = target === null && settled; + const missing = target === null && (settled || !reachable); const guess = missing && props.link.environmentId === null; const refs = useThreadRefs(guess); const remoteRef = guess From 6bb9d58034f2e8f28d77de8953b9d14546d1c370 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:16:31 +0000 Subject: [PATCH 2/2] fix(web): keep the thread route waiting for environments that register late The connection catalog can be ready before platform environments (the primary, desktop-local backends) register, so its absence cannot prove a route's environment is unreachable. Drop the route redirect; the thread link chip already stops linking into the blank route. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ThreadRouteView.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index cfbe080a0854e..a7b7da0a6b53b 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -18,7 +18,6 @@ import { useQuietThreadShell, } from "../state/entities"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentCatalog } from "../connection/catalog"; import { useEnvironmentShellStatus } from "../state/shell"; import { environmentThreadShells, threadFamilies, useEnvironmentThread } from "../state/threads"; import { @@ -72,12 +71,6 @@ export function ThreadRouteView({ const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus); - // A thread on a machine this client has no connection to would wait on its shell forever. - const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const environmentUnknown = - serverThreadRef !== null && - catalog.isReady && - !catalog.entries.has(serverThreadRef.environmentId); const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); @@ -215,7 +208,7 @@ export function ThreadRouteView({ } else { redirect = ; } - } else if (hiddenByTagFilter || environmentUnknown) { + } else if (hiddenByTagFilter) { redirect = ; } else if (offShellBlocked) { // Before the shell bootstraps, a listed visible thread can still look off-shell.