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
72 changes: 56 additions & 16 deletions apps/web/src/components/OmegentDeepLinkCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,48 +16,83 @@ 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();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const threadRefs = useThreadRefs();
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.
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",
params: buildThreadRouteParams(threadRef),
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]);

Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/deepLinkStore.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,19 +29,31 @@ export function setPendingDeepLink(target: PendingDeepLinkTarget): void {
pending = {
threadId,
messageId: messageId === "" ? null : messageId,
awaitingNavigation: target.awaitingNavigation ?? true,
};
}

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;
}

Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
46 changes: 43 additions & 3 deletions apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -31,14 +36,40 @@ 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.
*/

function IndexDraftLanding() {
const projects = useProjects();
const threads = useThreadShells();
const threadRefs = useThreadRefs();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const handleNewThread = useNewThreadHandler();
const startingRef = useRef(false);
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
Loading