Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 22 additions & 35 deletions apps/web/src/components/OmegentDeepLinkCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { ThreadId } from "@t3tools/contracts";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useLayoutEffect, useRef } from "react";

import {
clearPendingDeepLink,
captureDeepLinkFromWindowLocation,
markDeepLinkNavigationIssued,
peekPendingDeepLink,
setPendingDeepLink,
} from "../deepLinkStore";
import { parseOmegentDeepLink } from "../deepLinks";
import { buildThreadRouteParams } from "../threadRoutes";
import {
findThreadRef,
useAllEnvironmentShellsBootstrapped,
useThreadRefs,
} from "../state/entities";
import { findThreadRef, useThreadRefs } from "../state/entities";
import { usePrimaryEnvironmentId } from "../state/environments";

function stripThreadQueryFromLocation(): void {
const next = new URL(window.location.href);
Expand All @@ -27,38 +25,24 @@ function stripThreadQueryFromLocation(): void {

/**
* Consumes `/?thread={id}#message-{messageId}` deep links:
* stashes intent immediately (before the index auto-draft can wipe `?thread=`),
* then navigates to the thread route once shells are bootstrapped.
* stashes intent immediately (before index auto-draft / welcome bootstrap can
* wipe `?thread=`), then navigates to the thread route.
*
* Does not require the thread to already be in the shell list — falls back to
* the primary environment id. The thread route owns missing/loading states.
*/
export function OmegentDeepLinkCoordinator() {
const navigate = useNavigate();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const threadRefs = useThreadRefs();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const handledThreadIdRef = useRef<string | null>(null);

// Capture before paint so sibling index-route effects that also wait on
// bootstrap cannot replace the URL with a new draft first.
// Capture before paint so sibling landing effects cannot replace the URL 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 });
captureDeepLinkFromWindowLocation();
}, []);

useEffect(() => {
if (!bootstrapped) return;

const fromUrl = parseOmegentDeepLink(new URL(window.location.href));
const pending = peekPendingDeepLink();
const threadId = fromUrl.threadId ?? pending?.threadId ?? null;
Expand All @@ -73,13 +57,16 @@ export function OmegentDeepLinkCoordinator() {
awaitingNavigation: pending?.awaitingNavigation ?? true,
});

const threadRef = findThreadRef(ThreadId.make(threadId));
const knownRef = findThreadRef(ThreadId.make(threadId));
// Prefer the shell list (correct env in multi-env). Fall back to primary
// so we never sit forever on `/` waiting for a ref that is slow/empty.
const threadRef =
knownRef ??
(primaryEnvironmentId !== null
? scopeThreadRef(primaryEnvironmentId, ThreadId.make(threadId))
: null);
if (threadRef === null) {
// 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();
// No environment yet — retry when catalog/primary becomes available.
return;
}

Expand All @@ -94,7 +81,7 @@ export function OmegentDeepLinkCoordinator() {
}).then(() => {
stripThreadQueryFromLocation();
});
}, [bootstrapped, navigate, threadRefs]);
}, [navigate, primaryEnvironmentId, threadRefs]);

return null;
}
54 changes: 52 additions & 2 deletions apps/web/src/deepLinkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* 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.
* Capture as early as possible (module load + layout) so index auto-draft /
* welcome bootstrap cannot wipe `?thread=` before navigation.
*/

import { parseOmegentDeepLink } from "./deepLinks";

export type PendingDeepLinkTarget = {
readonly threadId: string;
readonly messageId: string | null;
Expand Down Expand Up @@ -42,6 +44,24 @@ export function hasAwaitingThreadDeepLink(): boolean {
return pending?.awaitingNavigation === true;
}

/**
* True when a thread deep link still owns landing: live `?thread=` query and/or
* pending store still awaiting navigation.
*/
export function hasThreadDeepLinkIntent(): boolean {
if (hasAwaitingThreadDeepLink()) {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return parseOmegentDeepLink(new URL(window.location.href)).threadId !== null;
} catch {
return false;
}
}

/** 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;
Expand All @@ -60,3 +80,33 @@ export function takePendingDeepLinkMessage(threadId: string): string | null {
export function clearPendingDeepLink(): void {
pending = null;
}

/** Best-effort capture from the current URL (safe to call more than once). */
export function captureDeepLinkFromWindowLocation(): void {
if (typeof window === "undefined") {
return;
}
try {
const { threadId, messageId } = parseOmegentDeepLink(new URL(window.location.href));
if (threadId === null) {
return;
}
const existing = pending;
if (existing !== null && existing.threadId === threadId) {
if (messageId !== null && existing.messageId === null) {
setPendingDeepLink({
threadId,
messageId,
awaitingNavigation: existing.awaitingNavigation,
});
}
return;
}
setPendingDeepLink({ threadId, messageId, awaitingNavigation: true });
} catch {
// ignore invalid location
}
}

// Capture before React mounts so other landing effects cannot race the URL alone.
captureDeepLinkFromWindowLocation();
4 changes: 4 additions & 0 deletions apps/web/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { messageDeepLinkHash, parseMessageIdFromHash, parseOmegentDeepLink } fro
import {
clearPendingDeepLink,
hasAwaitingThreadDeepLink,
hasThreadDeepLinkIntent,
markDeepLinkNavigationIssued,
peekPendingDeepLink,
setPendingDeepLink,
Expand Down Expand Up @@ -57,8 +58,11 @@ describe("deepLinkStore", () => {
setPendingDeepLink({ threadId: "tid-1", messageId: null });
expect(peekPendingDeepLink()?.awaitingNavigation).toBe(true);
expect(hasAwaitingThreadDeepLink()).toBe(true);
expect(hasThreadDeepLinkIntent()).toBe(true);
markDeepLinkNavigationIssued("tid-1");
expect(hasAwaitingThreadDeepLink()).toBe(false);
// Without a live ?thread= query, intent ends once navigation is issued.
expect(hasThreadDeepLinkIntent()).toBe(false);
expect(peekPendingDeepLink()).toEqual({
threadId: "tid-1",
messageId: null,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt
import { OmegentDeepLinkCoordinator } from "../components/OmegentDeepLinkCoordinator";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { hasThreadDeepLinkIntent } from "../deepLinkStore";
import { Button } from "../components/ui/button";
import {
AnchoredToastProvider,
Expand Down Expand Up @@ -322,6 +323,10 @@ function EventRouter() {
if (readPathname() !== "/") {
return;
}
// Do not steal `/?thread=` landings for the server's bootstrap thread.
if (hasThreadDeepLinkIntent()) {
return;
}
if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
return;
}
Expand Down
45 changes: 6 additions & 39 deletions apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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";
Expand All @@ -9,14 +8,11 @@ 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 { hasThreadDeepLinkIntent } from "../deepLinkStore";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import {
findThreadRef,
useAllEnvironmentShellsBootstrapped,
useProjects,
useThreadRefs,
useThreadShells,
} from "../state/entities";
import { useEnvironments } from "../state/environments";
Expand All @@ -36,44 +32,24 @@ function ChatIndexRouteView() {
return <IndexDraftLanding />;
}

/**
* 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.
*
* While `?thread=` (or a pending deep-link store entry) is present, skip
* auto-draft so OmegentDeepLinkCoordinator can open the real thread.
*/

function IndexDraftLanding() {
const projects = useProjects();
const threads = useThreadShells();
const threadRefs = useThreadRefs();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const handleNewThread = useNewThreadHandler();
const startingRef = useRef(false);
const [startState, setStartState] = useState({ failed: false, retryRequest: 0 });
// Re-read each render: store is module-level; coordinator clears/marks it.
const deferForDeepLink = hasThreadDeepLinkIntent();

const mostRecentProject = useMemo(
() =>
Expand All @@ -83,15 +59,6 @@ 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 || deferForDeepLink) {
return;
Expand Down
Loading