From 3c703fcb6fa19066a0b2005b7ac143d45c81b190 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Wed, 14 Oct 2026 10:34:54 +0200 Subject: [PATCH] fix(web): a prompt link opened from a thread no longer lands in that thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadLinkPrompt acted on the app-wide location without checking that it belonged to its thread. During a pending navigation to /new?prompt=… the location already showed the next URL while the params still named the old thread, so the prompt was placed there and stripped from the URL, and the new-thread route then reported that the link had no prompt. Return early unless the location's pathname is the thread's own route. --- .../components/promptLinks/ThreadLinkPrompt.tsx | 3 +++ apps/web/src/threadRoutes.test.ts | 14 ++++++++++++++ apps/web/src/threadRoutes.ts | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx index 6fae431bb021..1fd33fb23ce0 100644 --- a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx +++ b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx @@ -3,6 +3,7 @@ import { parseThreadLinkPrompt, withoutPromptLinkParams } from "@t3tools/shared/ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { isThreadRoutePathname } from "../../threadRoutes"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { placeLinkedPrompt } from "./linkedPrompt"; @@ -24,6 +25,8 @@ export function ThreadLinkPrompt({ threadRef }: { readonly threadRef: ScopedThre }); useEffect(() => { + // A pending navigation shows the next location while the params still name this thread. + if (!isThreadRoutePathname(location.pathname, threadRef)) return; if (!new URLSearchParams(location.search).has("prompt")) return; if (!handledLinkKeys.has(location.key)) { handledLinkKeys.add(location.key); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 4a9b00a42be6..b89e8dd942eb 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + isThreadRoutePathname, isThreadRouteSnapshotAuthoritative, resolveActiveThreadRouteRef, resolveThreadRouteRenderState, @@ -45,6 +46,19 @@ describe("threadRoutes", () => { expect(resolveThreadRouteRef({ threadId: "thread-1" })).toBeNull(); }); + it("matches only the thread's own route pathname", () => { + const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1")); + + expect(isThreadRoutePathname("/env-1/thread-1", ref)).toBe(true); + expect(isThreadRoutePathname("/env-1/thread-1/", ref)).toBe(true); + // A pending navigation away from the thread already shows these locations. + expect(isThreadRoutePathname("/new", ref)).toBe(false); + expect(isThreadRoutePathname("/env-1/thread-2", ref)).toBe(false); + expect(isThreadRoutePathname("/env-2/thread-1", ref)).toBe(false); + expect(isThreadRoutePathname("/draft/thread-1", ref)).toBe(false); + expect(isThreadRoutePathname("/env-1/thread-1/extra", ref)).toBe(false); + }); + it("builds canonical draft route params from a draft id", () => { expect(buildDraftThreadRouteParams(DraftId.make("draft-1"))).toEqual({ draftId: "draft-1", diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 9220141740d3..51a7cd86b6f6 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -52,6 +52,12 @@ export function buildThreadRouteParams(ref: ScopedThreadRef): { }; } +/** Whether a (decoded) location pathname is `ref`'s own thread route, `/$environmentId/$threadId`. */ +export function isThreadRoutePathname(pathname: string, ref: ScopedThreadRef): boolean { + const { environmentId, threadId } = buildThreadRouteParams(ref); + return pathname.replace(/\/$/, "") === `/${environmentId}/${threadId}`; +} + export function buildDraftThreadRouteParams(draftId: DraftId): { draftId: DraftId; } {