From 4764f02d044b916641b08e41be540f4df0ad807b Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:01:33 +0200 Subject: [PATCH] feat(web): move message deep-link wiring onto fork/changes Discord #266 landed OmegentDeepLinkCoordinator, deepLinks helpers, and ChatView scroll on the discord overlay. Those are shared web surfaces and collided with identity's IdentityClaimGate mount in __root during compose. Keep them on fork/changes so overlays only own client-specific code; Discord bot ResponseBridge stays on fork/discord. --- apps/web/src/components/ChatView.tsx | 48 ++++++++++++++- .../components/OmegentDeepLinkCoordinator.tsx | 60 +++++++++++++++++++ apps/web/src/deepLinkStore.ts | 41 +++++++++++++ apps/web/src/deepLinks.test.ts | 48 +++++++++++++++ apps/web/src/deepLinks.ts | 34 +++++++++++ apps/web/src/forkSurfaceExistence.test.ts | 11 ++++ apps/web/src/routes/__root.tsx | 2 + 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/OmegentDeepLinkCoordinator.tsx create mode 100644 apps/web/src/deepLinkStore.ts create mode 100644 apps/web/src/deepLinks.test.ts create mode 100644 apps/web/src/deepLinks.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b05898553e..e17983ebb64 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,7 +3,7 @@ import { DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, - type MessageId, + MessageId, type ModelSelection, type ProjectScript, type ProjectId, @@ -232,6 +232,8 @@ import { useThreadRefs, useThreadShell, } from "../state/entities"; +import { parseMessageIdFromHash } from "../deepLinks"; +import { peekPendingDeepLink, takePendingDeepLinkMessage } from "../deepLinkStore"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; @@ -4028,6 +4030,50 @@ function ChatViewContent(props: ChatViewProps) { // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); + // Omegent deep link: scroll a target message into view (`#message-{id}` / + // pending store from `?thread=` navigation). + const deepLinkScrollHandledForThreadRef = useRef(null); + useEffect(() => { + if (!activeThread || activeThreadKey === null) return; + if (deepLinkScrollHandledForThreadRef.current === activeThread.id) return; + + const pending = peekPendingDeepLink(); + let targetMessageId: string | null = null; + if (pending !== null && pending.threadId === activeThread.id && pending.messageId !== null) { + targetMessageId = pending.messageId; + } else { + targetMessageId = parseMessageIdFromHash(window.location.hash); + } + if (targetMessageId === null) return; + + const messageExists = activeThread.messages.some( + (message) => String(message.id) === targetMessageId, + ); + if (!messageExists) { + // Messages may still be loading; retry when the list updates. + return; + } + + deepLinkScrollHandledForThreadRef.current = activeThread.id; + if (pending !== null && pending.threadId === activeThread.id) { + takePendingDeepLinkMessage(activeThread.id); + } + + const messageId = MessageId.make(targetMessageId); + pendingTimelineAnchorRef.current = messageId; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = null; + setMaintainTimelineAtEnd(false); + setShowScrollToBottom(false); + setTimelineAnchor({ + threadKey: activeThreadKey, + messageId, + }); + }, [activeThread, activeThread?.messages, activeThreadKey]); + // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { diff --git a/apps/web/src/components/OmegentDeepLinkCoordinator.tsx b/apps/web/src/components/OmegentDeepLinkCoordinator.tsx new file mode 100644 index 00000000000..3ed28c55cba --- /dev/null +++ b/apps/web/src/components/OmegentDeepLinkCoordinator.tsx @@ -0,0 +1,60 @@ +import { ThreadId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; + +import { setPendingDeepLink } from "../deepLinkStore"; +import { parseOmegentDeepLink } from "../deepLinks"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { + findThreadRef, + useAllEnvironmentShellsBootstrapped, + useThreadRefs, +} from "../state/entities"; + +/** + * 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. + */ +export function OmegentDeepLinkCoordinator() { + const navigate = useNavigate(); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const threadRefs = useThreadRefs(); + const handledThreadIdRef = useRef(null); + + useEffect(() => { + if (!bootstrapped) return; + + const url = new URL(window.location.href); + const { threadId, messageId } = parseOmegentDeepLink(url); + if (threadId === null) return; + if (handledThreadIdRef.current === threadId) return; + + const threadRef = findThreadRef(ThreadId.make(threadId)); + if (threadRef === null) { + // Shell list may still be catching up after bootstrap. + return; + } + + handledThreadIdRef.current = threadId; + setPendingDeepLink({ threadId, messageId }); + + 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); + } + }); + }, [bootstrapped, navigate, threadRefs]); + + return null; +} diff --git a/apps/web/src/deepLinkStore.ts b/apps/web/src/deepLinkStore.ts new file mode 100644 index 00000000000..b1b27e5f6b7 --- /dev/null +++ b/apps/web/src/deepLinkStore.ts @@ -0,0 +1,41 @@ +/** + * Pending deep-link target for scroll-into-view after navigation / thread load. + * Written when consuming `?thread=` / `#message-` URLs; taken once by ChatView. + */ + +export type PendingDeepLinkTarget = { + readonly threadId: string; + readonly messageId: string | null; +}; + +let pending: PendingDeepLinkTarget | null = null; + +export function setPendingDeepLink(target: PendingDeepLinkTarget): void { + const threadId = target.threadId.trim(); + if (threadId === "") { + pending = null; + return; + } + const messageId = target.messageId?.trim() || null; + pending = { + threadId, + messageId: messageId === "" ? null : messageId, + }; +} + +export function peekPendingDeepLink(): PendingDeepLinkTarget | null { + return pending; +} + +/** Consume a pending message scroll for this thread (thread-level target remains until navigated). */ +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 }; + return messageId; +} + +export function clearPendingDeepLink(): void { + pending = null; +} diff --git a/apps/web/src/deepLinks.test.ts b/apps/web/src/deepLinks.test.ts new file mode 100644 index 00000000000..8010f746cd7 --- /dev/null +++ b/apps/web/src/deepLinks.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { messageDeepLinkHash, parseMessageIdFromHash, parseOmegentDeepLink } from "./deepLinks.ts"; +import { + clearPendingDeepLink, + peekPendingDeepLink, + setPendingDeepLink, + takePendingDeepLinkMessage, +} from "./deepLinkStore.ts"; + +describe("parseOmegentDeepLink", () => { + it("parses thread query and message hash", () => { + const url = new URL("https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1"); + expect(parseOmegentDeepLink(url)).toEqual({ + threadId: "tid-1", + messageId: "msg-1", + }); + }); + + it("handles thread-only and message-only forms", () => { + expect(parseOmegentDeepLink(new URL("https://t3vm/?thread=tid-2"))).toEqual({ + threadId: "tid-2", + messageId: null, + }); + expect(parseOmegentDeepLink(new URL("https://t3vm/#message-msg-9"))).toEqual({ + threadId: null, + messageId: "msg-9", + }); + }); + + it("requires the message- prefix on the hash", () => { + expect(parseMessageIdFromHash("#msg-1")).toBeNull(); + expect(parseMessageIdFromHash("#message-msg-1")).toBe("msg-1"); + expect(messageDeepLinkHash("msg-1")).toBe("#message-msg-1"); + }); +}); + +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(takePendingDeepLinkMessage("tid-2")).toBeNull(); + expect(takePendingDeepLinkMessage("tid-1")).toBe("msg-1"); + expect(takePendingDeepLinkMessage("tid-1")).toBeNull(); + clearPendingDeepLink(); + }); +}); diff --git a/apps/web/src/deepLinks.ts b/apps/web/src/deepLinks.ts new file mode 100644 index 00000000000..1efa3be3722 --- /dev/null +++ b/apps/web/src/deepLinks.ts @@ -0,0 +1,34 @@ +/** + * Omegent web deep links: + * `/?thread={threadId}` + * `/?thread={threadId}#message-{messageId}` + * `/#message-{messageId}` (when already on a thread route) + */ + +export type OmegentDeepLink = { + readonly threadId: string | null; + readonly messageId: string | null; +}; + +/** Parse `#message-{id}` (or bare `#id` with message- prefix required). */ +export function parseMessageIdFromHash(hash: string | null | undefined): string | null { + const raw = (hash ?? "").trim(); + if (raw === "") return null; + const body = raw.startsWith("#") ? raw.slice(1) : raw; + const match = /^message-(.+)$/u.exec(body); + const id = match?.[1]?.trim() ?? ""; + return id === "" ? null : id; +} + +export function parseOmegentDeepLink(url: URL): OmegentDeepLink { + const threadParam = url.searchParams.get("thread")?.trim() ?? ""; + return { + threadId: threadParam === "" ? null : threadParam, + messageId: parseMessageIdFromHash(url.hash), + }; +} + +/** Build the client hash fragment for a chat message id. */ +export function messageDeepLinkHash(messageId: string): string { + return `#message-${messageId.trim()}`; +} diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts index b67ed4cd067..0a4d03a10d1 100644 --- a/apps/web/src/forkSurfaceExistence.test.ts +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -50,6 +50,17 @@ describe("fork surface existence (anti stack-drop)", () => { expect(sidebar).toContain("hasComposerDraftMessage"); }); + it("web deep links stay on shared changes (not discord-only)", () => { + const root = readSrc("routes/__root.tsx"); + expect(root).toContain("OmegentDeepLinkCoordinator"); + expect(readSrc("components/OmegentDeepLinkCoordinator.tsx")).toContain( + "OmegentDeepLinkCoordinator", + ); + expect(readSrc("deepLinks.ts")).toMatch(/thread|message/i); + const chat = readSrc("components/ChatView.tsx"); + expect(chat).toMatch(/deepLink|message-|scrollIntoView/i); + }); + it("chat header keeps remote Open in VS Code control markers", () => { const header = readSrc("components/chat/ChatHeader.tsx"); expect(header).toContain("shouldOfferRemoteVscodeOpen"); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..69baf7c07ee 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { OmegentDeepLinkCoordinator } from "../components/OmegentDeepLinkCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; @@ -135,6 +136,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell}