From fae79f6cc074b2d78ddb5e5dd8b5eba1c9a0423d Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:47:34 +0000 Subject: [PATCH] fix(web): open `?thread=` deep links instead of a new draft Discord and Omegent pins emit `/?thread={id}`, but the index landing always auto-started a new draft once shells bootstrapped, racing (and often winning) against OmegentDeepLinkCoordinator. Capture the deep-link intent early and skip the auto-draft while the target thread is still openable. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .../components/OmegentDeepLinkCoordinator.tsx | 72 ++++++++++++++----- apps/web/src/deepLinkStore.ts | 29 ++++++-- apps/web/src/deepLinks.test.ts | 23 +++++- apps/web/src/routes/_chat.index.tsx | 46 +++++++++++- 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/OmegentDeepLinkCoordinator.tsx b/apps/web/src/components/OmegentDeepLinkCoordinator.tsx index 3ed28c55cba..2e1d883e2fe 100644 --- a/apps/web/src/components/OmegentDeepLinkCoordinator.tsx +++ b/apps/web/src/components/OmegentDeepLinkCoordinator.tsx @@ -1,8 +1,13 @@ import { ThreadId } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; -import { setPendingDeepLink } from "../deepLinkStore"; +import { + clearPendingDeepLink, + markDeepLinkNavigationIssued, + peekPendingDeepLink, + setPendingDeepLink, +} from "../deepLinkStore"; import { parseOmegentDeepLink } from "../deepLinks"; import { buildThreadRouteParams } from "../threadRoutes"; import { @@ -11,10 +16,19 @@ import { useThreadRefs, } from "../state/entities"; +function stripThreadQueryFromLocation(): void { + const next = new URL(window.location.href); + if (!next.searchParams.has("thread")) return; + next.searchParams.delete("thread"); + const search = next.searchParams.toString(); + const path = `${next.pathname}${search === "" ? "" : `?${search}`}${next.hash}`; + window.history.replaceState(window.history.state, "", path); +} + /** * Consumes `/?thread={id}#message-{messageId}` deep links: - * navigates to the thread route once shells are bootstrapped and stashes a - * pending message target for ChatView scroll-into-view. + * stashes intent immediately (before the index auto-draft can wipe `?thread=`), + * then navigates to the thread route once shells are bootstrapped. */ export function OmegentDeepLinkCoordinator() { const navigate = useNavigate(); @@ -22,22 +36,55 @@ export function OmegentDeepLinkCoordinator() { const threadRefs = useThreadRefs(); const handledThreadIdRef = useRef(null); + // Capture before paint so sibling index-route effects that also wait on + // bootstrap cannot replace the URL with a new draft first. + useLayoutEffect(() => { + const { threadId, messageId } = parseOmegentDeepLink(new URL(window.location.href)); + if (threadId === null) return; + const existing = peekPendingDeepLink(); + if (existing !== null && existing.threadId === threadId) { + // Prefer a message id from the live URL when present. + if (messageId !== null && existing.messageId === null) { + setPendingDeepLink({ + threadId, + messageId, + awaitingNavigation: existing.awaitingNavigation, + }); + } + return; + } + setPendingDeepLink({ threadId, messageId, awaitingNavigation: true }); + }, []); + useEffect(() => { if (!bootstrapped) return; - const url = new URL(window.location.href); - const { threadId, messageId } = parseOmegentDeepLink(url); + const fromUrl = parseOmegentDeepLink(new URL(window.location.href)); + const pending = peekPendingDeepLink(); + const threadId = fromUrl.threadId ?? pending?.threadId ?? null; + const messageId = fromUrl.messageId ?? pending?.messageId ?? null; if (threadId === null) return; if (handledThreadIdRef.current === threadId) return; + // Keep store in sync when we only had URL intent (or vice versa). + setPendingDeepLink({ + threadId, + messageId, + awaitingNavigation: pending?.awaitingNavigation ?? true, + }); + const threadRef = findThreadRef(ThreadId.make(threadId)); if (threadRef === null) { - // Shell list may still be catching up after bootstrap. + // Shells are bootstrapped: this id is not in the open shell list. + // Drop the deep link so the index route can fall through to a new draft. + handledThreadIdRef.current = threadId; + clearPendingDeepLink(); + stripThreadQueryFromLocation(); return; } handledThreadIdRef.current = threadId; - setPendingDeepLink({ threadId, messageId }); + markDeepLinkNavigationIssued(threadId); void navigate({ to: "/$environmentId/$threadId", @@ -45,14 +92,7 @@ export function OmegentDeepLinkCoordinator() { replace: true, ...(messageId !== null ? { hash: `message-${messageId}` } : {}), }).then(() => { - // Drop the query form so the address bar matches the canonical route. - const next = new URL(window.location.href); - if (next.searchParams.has("thread")) { - next.searchParams.delete("thread"); - const search = next.searchParams.toString(); - const path = `${next.pathname}${search === "" ? "" : `?${search}`}${next.hash}`; - window.history.replaceState(window.history.state, "", path); - } + stripThreadQueryFromLocation(); }); }, [bootstrapped, navigate, threadRefs]); diff --git a/apps/web/src/deepLinkStore.ts b/apps/web/src/deepLinkStore.ts index b1b27e5f6b7..c64f76d44c3 100644 --- a/apps/web/src/deepLinkStore.ts +++ b/apps/web/src/deepLinkStore.ts @@ -1,16 +1,25 @@ /** - * Pending deep-link target for scroll-into-view after navigation / thread load. + * Pending deep-link target for open + scroll-into-view after navigation / thread load. * Written when consuming `?thread=` / `#message-` URLs; taken once by ChatView. + * + * Capture early (before bootstrap) so the index "new draft" landing cannot wipe + * `?thread=` before OmegentDeepLinkCoordinator navigates. */ export type PendingDeepLinkTarget = { readonly threadId: string; readonly messageId: string | null; + /** Still waiting to navigate to the thread route (blocks index auto-draft). */ + readonly awaitingNavigation: boolean; }; let pending: PendingDeepLinkTarget | null = null; -export function setPendingDeepLink(target: PendingDeepLinkTarget): void { +export function setPendingDeepLink(target: { + readonly threadId: string; + readonly messageId: string | null; + readonly awaitingNavigation?: boolean; +}): void { const threadId = target.threadId.trim(); if (threadId === "") { pending = null; @@ -20,6 +29,7 @@ export function setPendingDeepLink(target: PendingDeepLinkTarget): void { pending = { threadId, messageId: messageId === "" ? null : messageId, + awaitingNavigation: target.awaitingNavigation ?? true, }; } @@ -27,12 +37,23 @@ export function peekPendingDeepLink(): PendingDeepLinkTarget | null { return pending; } -/** Consume a pending message scroll for this thread (thread-level target remains until navigated). */ +/** True while a `?thread=` open is in flight (index must not auto-start a draft). */ +export function hasAwaitingThreadDeepLink(): boolean { + return pending?.awaitingNavigation === true; +} + +/** Mark that the thread route navigation has been issued (index may resume if still on `/`). */ +export function markDeepLinkNavigationIssued(threadId: string): void { + if (pending === null || pending.threadId !== threadId) return; + pending = { ...pending, awaitingNavigation: false }; +} + +/** Consume a pending message scroll for this thread (thread-level target remains until cleared). */ export function takePendingDeepLinkMessage(threadId: string): string | null { if (pending === null) return null; if (pending.threadId !== threadId) return null; const messageId = pending.messageId; - pending = { threadId: pending.threadId, messageId: null }; + pending = { threadId: pending.threadId, messageId: null, awaitingNavigation: false }; return messageId; } diff --git a/apps/web/src/deepLinks.test.ts b/apps/web/src/deepLinks.test.ts index 8010f746cd7..8e450f92162 100644 --- a/apps/web/src/deepLinks.test.ts +++ b/apps/web/src/deepLinks.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { messageDeepLinkHash, parseMessageIdFromHash, parseOmegentDeepLink } from "./deepLinks.ts"; import { clearPendingDeepLink, + hasAwaitingThreadDeepLink, + markDeepLinkNavigationIssued, peekPendingDeepLink, setPendingDeepLink, takePendingDeepLinkMessage, @@ -39,10 +41,29 @@ describe("deepLinkStore", () => { it("hands off message scroll once per thread", () => { clearPendingDeepLink(); setPendingDeepLink({ threadId: "tid-1", messageId: "msg-1" }); - expect(peekPendingDeepLink()).toEqual({ threadId: "tid-1", messageId: "msg-1" }); + expect(peekPendingDeepLink()).toEqual({ + threadId: "tid-1", + messageId: "msg-1", + awaitingNavigation: true, + }); expect(takePendingDeepLinkMessage("tid-2")).toBeNull(); expect(takePendingDeepLinkMessage("tid-1")).toBe("msg-1"); expect(takePendingDeepLinkMessage("tid-1")).toBeNull(); clearPendingDeepLink(); }); + + it("tracks awaiting navigation for index deferral", () => { + clearPendingDeepLink(); + setPendingDeepLink({ threadId: "tid-1", messageId: null }); + expect(peekPendingDeepLink()?.awaitingNavigation).toBe(true); + expect(hasAwaitingThreadDeepLink()).toBe(true); + markDeepLinkNavigationIssued("tid-1"); + expect(hasAwaitingThreadDeepLink()).toBe(false); + expect(peekPendingDeepLink()).toEqual({ + threadId: "tid-1", + messageId: null, + awaitingNavigation: false, + }); + clearPendingDeepLink(); + }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 6e8dbe33ff5..6e9beedea99 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { ThreadId } from "@t3tools/contracts"; import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -8,10 +9,14 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; +import { peekPendingDeepLink } from "../deepLinkStore"; +import { parseOmegentDeepLink } from "../deepLinks"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { + findThreadRef, useAllEnvironmentShellsBootstrapped, useProjects, + useThreadRefs, useThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; @@ -31,14 +36,40 @@ function ChatIndexRouteView() { return ; } +/** + * True while a `/?thread=` deep link should own the index landing instead of + * auto-opening a new draft. Index route effects run in the same commit as the + * deep-link coordinator, so this must be decided from the URL / pending store + * and shell membership — not by waiting for the coordinator. + */ +function shouldDeferIndexDraftForDeepLink(bootstrapped: boolean): boolean { + if (typeof window === "undefined") { + return false; + } + const fromUrl = parseOmegentDeepLink(new URL(window.location.href)); + const threadId = fromUrl.threadId ?? peekPendingDeepLink()?.threadId ?? null; + if (threadId === null) { + return false; + } + // Before shells load, always wait — the target thread may still appear. + if (!bootstrapped) { + return true; + } + // After bootstrap: only defer when the shell list has the thread + // (coordinator will navigate). Missing/unknown ids fall through to draft. + return findThreadRef(ThreadId.make(threadId)) !== null; +} + /** * Landing on the index route drops straight into a draft thread for the most * recently active project, so the first screen is a prompt instead of a dead * end. Falls back to an add-project hero when no project exists yet. */ + function IndexDraftLanding() { const projects = useProjects(); const threads = useThreadShells(); + const threadRefs = useThreadRefs(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); const startingRef = useRef(false); @@ -52,8 +83,17 @@ function IndexDraftLanding() { [bootstrapped, projects, threads], ); + // Recompute when shell refs change so a resolved/missing deep link can + // unblock the auto-draft path without a full reload. + const deferForDeepLink = useMemo( + () => shouldDeferIndexDraftForDeepLink(bootstrapped), + // threadRefs: shell membership for the target id can appear after bootstrap. + // startState.retryRequest: keep in sync with the start effect below. + [bootstrapped, threadRefs, startState.retryRequest], + ); + useEffect(() => { - if (mostRecentProject === null || startingRef.current) { + if (mostRecentProject === null || startingRef.current || deferForDeepLink) { return; } startingRef.current = true; @@ -63,9 +103,9 @@ function IndexDraftLanding() { startingRef.current = false; setStartState((state) => ({ ...state, failed: true })); }); - }, [handleNewThread, mostRecentProject, startState.retryRequest]); + }, [deferForDeepLink, handleNewThread, mostRecentProject, startState.retryRequest]); - if (!bootstrapped) { + if (!bootstrapped || deferForDeepLink) { return null; } if (mostRecentProject !== null) {