diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts index 30f2b0a3080c..a7906ea6db0e 100644 --- a/apps/discord-bot/src/features/ResponseBridge.ts +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -25,13 +25,16 @@ import * as Semaphore from "effect/Semaphore"; import { DiscordBotConfig } from "../config.ts"; import { + appendT3DeepLinkToChunks, buildStreamHistoryMarkdownText, DISCORD_MAX_FILES_PER_MESSAGE, imageAttachmentsOf, + shouldAttachT3DeepLink, STREAM_HISTORY_MARKDOWN_NAME, streamHistoryHasAdditionalContent, unpostedAttachments, } from "../presentation/attachments.ts"; +import { buildOmegentThreadMessageUrl } from "../presentation/discordPrAttribution.ts"; import { createMessageWithAttachments, DiscordUploadError, @@ -64,10 +67,7 @@ import { stripMarkdownImages, type MarkdownImageRef, } from "../presentation/markdownImages.ts"; -import { - chunkDiscordContentPreservingTables, - rewriteMarkdownTablesForDiscord, -} from "../presentation/asciiTables.ts"; +import { hasMarkdownTables } from "../presentation/asciiTables.ts"; import { chunkDiscordContent, formatInProgressChunk, @@ -3076,7 +3076,8 @@ export const runBridge = ( * - tip ends with italic _Working.._ (optional · N tool calls on the same line) * * On turn complete: stream messages are deleted, archived as stream-history.md, - * and the final answer is posted as normal Discord message content (+ real image files). + * and the final answer is posted as Discord markdown (links live; no ASCII tables), + * with a T3 deep link when the answer is long or has GFM tables. */ const postOrEditAssistantUnlocked = (args: { readonly turnId: string | null; @@ -3485,9 +3486,10 @@ export const runBridge = ( /** * Final delivery for a completed assistant turn: - * 1. Post the final answer as normal Discord message content (chunked if needed) - * 2. Attach stream-history.md + chat image attachments + local markdown images as files - * 3. Delete the in-progress stream messages so only the final answer remains visible + * 1. Post Discord markdown content (chunked if needed; links stay clickable) + * 2. Append · [T3](…#message-…) when multi-chunk or tables (full render in Omegent) + * 3. Attach stream-history.md + chat/local images as files + * 4. Delete the in-progress stream messages so only the final answer remains visible */ const finalizeAssistantMessage = (args: { readonly turnId: string | null; @@ -3686,15 +3688,18 @@ export const runBridge = ( const postedFromFiles = pendingImages.slice(0, imageFiles.length).map((entry) => entry.id); - // Split once for local-file rewrite notes; re-split after optional table .txt attachments. - const initialSplit = splitFilesForDiscordUpload(files); - const oversizedByName = new Set(initialSplit.oversized.map((file) => file.name)); + // Split once for local-file rewrite notes (no table .txt attachments). + const { batches: uploadBatches, oversized: oversizedFiles } = + splitFilesForDiscordUpload(files); + const oversizedByName = new Set(oversizedFiles.map((file) => file.name)); const attachedFileNames = new Set( - initialSplit.batches.flatMap((batch) => batch.map((file) => file.name)), + uploadBatches.flatMap((batch) => batch.map((file) => file.name)), ); // Final channel text: strip image embeds but keep readable local file references. // Never leave Working.. or the stream placeholder. + // Keep Discord markdown as-is (links stay clickable). Do not ASCII-ify tables — + // long / table-heavy answers get a T3 deep link for full rendering in Omegent. const finalText = rewriteMarkdownLocalFileLinksForDiscord({ text: stripWorkingIndicator(stripMarkdownImages(text)), githubUrlsBySrc, @@ -3709,29 +3714,17 @@ export const runBridge = ( extractInlinePathCodeSpanRefs(finalText), worktreePath, ); - const pathRewrittenFinalText = rewriteInlinePathCodeSpansForDiscord({ + const renderedFinalText = rewriteInlinePathCodeSpansForDiscord({ text: finalText, githubUrlsByToken: finalInlineGitHubUrlsByToken, }); - // Discord does not render GFM pipe tables — convert to fenced ASCII grids. - const tableRewrite = rewriteMarkdownTablesForDiscord(pathRewrittenFinalText, { - style: "rounded", - messageLimit: DISCORD_LIMIT, - }); - for (const attachment of tableRewrite.attachments) { - files.push(textFile(attachment.name, attachment.body, "text/plain;charset=utf-8")); - } - const renderedFinalText = tableRewrite.text; - - const { batches: uploadBatches, oversized: oversizedFiles } = - tableRewrite.attachments.length > 0 ? splitFilesForDiscordUpload(files) : initialSplit; // Avoid posting a lone "…" placeholder (what you saw in Discord when an image-only // turn failed to attach and had no remaining text). Prefer empty content + files, // or a short failure note if we expected images but loaded none. const baseFinalChunks: string[] = renderedFinalText !== "" - ? chunkDiscordContentPreservingTables(renderedFinalText, DISCORD_LIMIT) + ? chunkDiscordContent(renderedFinalText, DISCORD_LIMIT) : files.length > 0 ? [""] : pendingMarkdown.length > 0 || @@ -3750,7 +3743,24 @@ export const runBridge = ( turnId, latestTurn: statsThread?.latestTurn ?? null, }); - const finalChunks = appendStatsToMessageChunks(baseFinalChunks, statsLine, DISCORD_LIMIT); + let finalChunks = appendStatsToMessageChunks(baseFinalChunks, statsLine, DISCORD_LIMIT); + + // Long multi-message finals and any answer with GFM tables → · [T3](deep link). + if ( + shouldAttachT3DeepLink({ + text: renderedFinalText, + hasMarkdownTables: hasMarkdownTables(renderedFinalText), + messageChunkCount: finalChunks.length, + }) + ) { + const botConfig = yield* DiscordBotConfig; + const t3Url = buildOmegentThreadMessageUrl({ + webUiBaseUrl: botConfig.webUiBaseUrl, + threadId: input.t3ThreadId, + messageId: t3MessageId, + }); + finalChunks = appendT3DeepLinkToChunks(finalChunks, t3Url, DISCORD_LIMIT); + } if (finalChunks.length === 0 && files.length === 0) { // Nothing useful to post — just clear any leftover Working.. stream messages. diff --git a/apps/discord-bot/src/presentation/asciiTables.ts b/apps/discord-bot/src/presentation/asciiTables.ts index bfe0a6081deb..e0bcdd287e15 100644 --- a/apps/discord-bot/src/presentation/asciiTables.ts +++ b/apps/discord-bot/src/presentation/asciiTables.ts @@ -217,6 +217,11 @@ export function extractMarkdownTables(text: string): TableMatch[] { return matches; } +/** True when `text` contains at least one GFM pipe table. */ +export function hasMarkdownTables(text: string): boolean { + return extractMarkdownTables(text).length > 0; +} + /** Exclusive end offset for `lineIndex`, including its trailing newline when present. */ function lineEndExclusive( text: string, diff --git a/apps/discord-bot/src/presentation/attachments.test.ts b/apps/discord-bot/src/presentation/attachments.test.ts index a89ff5671de3..9c34c12d551d 100644 --- a/apps/discord-bot/src/presentation/attachments.test.ts +++ b/apps/discord-bot/src/presentation/attachments.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "vite-plus/test"; import { + appendT3DeepLinkToChunks, attachmentKey, buildStreamHistoryMarkdownText, imageAttachmentsOf, + shouldAttachT3DeepLink, STREAM_HISTORY_MARKDOWN_NAME, streamHistoryHasAdditionalContent, unpostedAttachments, + withT3DeepLink, } from "./attachments.ts"; describe("imageAttachmentsOf", () => { @@ -90,3 +93,45 @@ describe("buildStreamHistoryMarkdownText", () => { expect(buildStreamHistoryMarkdownText(" \n")).toBeNull(); }); }); + +describe("T3 deep link caption helpers", () => { + it("appends a short same-line T3 link", () => { + expect( + withT3DeepLink("Summary", "https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1"), + ).toBe("Summary · [T3](https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1)"); + expect(withT3DeepLink("Summary", null)).toBe("Summary"); + }); + + it("links when the answer has tables or needs multiple chunks", () => { + expect( + shouldAttachT3DeepLink({ + text: "short", + hasMarkdownTables: false, + messageChunkCount: 1, + }), + ).toBe(false); + expect( + shouldAttachT3DeepLink({ + text: "long", + hasMarkdownTables: false, + messageChunkCount: 2, + }), + ).toBe(true); + expect( + shouldAttachT3DeepLink({ + text: "| A | B |\n|---|---|\n| 1 | 2 |", + hasMarkdownTables: true, + messageChunkCount: 1, + }), + ).toBe(true); + }); + + it("appends the link onto the last chunk without overflowing", () => { + const url = "https://t3vm.example/?thread=t#message-m"; + expect(appendT3DeepLinkToChunks(["hello"], url, 2000)).toEqual([`hello · [T3](${url})`]); + const almostFull = "x".repeat(1990); + const next = appendT3DeepLinkToChunks([almostFull], url, 2000); + expect(next).toHaveLength(2); + expect(next[1]).toBe(`[T3](${url})`); + }); +}); diff --git a/apps/discord-bot/src/presentation/attachments.ts b/apps/discord-bot/src/presentation/attachments.ts index 986779032220..ac011686558f 100644 --- a/apps/discord-bot/src/presentation/attachments.ts +++ b/apps/discord-bot/src/presentation/attachments.ts @@ -64,3 +64,59 @@ export function buildStreamHistoryMarkdownFile(streamText: string): File | null type: "text/markdown;charset=utf-8", }); } + +/** + * Append a compact T3 deep link on a Discord caption/chunk. + * Same-line ` · [T3](url)` — clickable Discord markdown, short label. + */ +export function withT3DeepLink(caption: string, t3Url: string | null | undefined): string { + const url = t3Url?.trim() ?? ""; + if (url === "") return caption; + const link = `[T3](${url})`; + const body = caption.trimEnd(); + if (body === "") return link; + return `${body} · ${link}`; +} + +/** + * When the final answer would need multi-message chunking or contains GFM tables, + * surface a T3 deep link so the full rendered answer is one click away. + * Short single-message prose without tables stays link-free. + */ +export function shouldAttachT3DeepLink(input: { + readonly text: string; + readonly hasMarkdownTables: boolean; + readonly messageChunkCount: number; +}): boolean { + if (input.text.trim() === "") return false; + if (input.hasMarkdownTables) return true; + return input.messageChunkCount > 1; +} + +/** + * Append a T3 deep link onto the last message chunk, respecting the Discord limit. + * If the link would overflow the last chunk, emit it as its own trailing chunk. + */ +export function appendT3DeepLinkToChunks( + chunks: ReadonlyArray, + t3Url: string | null | undefined, + limit: number, +): string[] { + const url = t3Url?.trim() ?? ""; + if (url === "" || chunks.length === 0) return [...chunks]; + + const out = [...chunks]; + const lastIndex = out.length - 1; + const last = out[lastIndex] ?? ""; + const linked = withT3DeepLink(last, url); + if (linked.length <= limit) { + out[lastIndex] = linked; + return out; + } + + const solo = withT3DeepLink("", url); + if (solo.length <= limit) { + out.push(solo); + } + return out; +} diff --git a/apps/discord-bot/src/presentation/discordPrAttribution.test.ts b/apps/discord-bot/src/presentation/discordPrAttribution.test.ts index 816a1b73ec88..7ab46d468a93 100644 --- a/apps/discord-bot/src/presentation/discordPrAttribution.test.ts +++ b/apps/discord-bot/src/presentation/discordPrAttribution.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { appendDiscordPrAttributionFooter, buildDiscordThreadJumpUrl, + buildOmegentThreadMessageUrl, buildT3WebThreadUrl, DISCORD_PR_ATTRIBUTION_MARKER, ensureDiscordPrAttributionFooters, @@ -103,6 +104,23 @@ describe("T3 thread URL helpers", () => { expect(once).toBe(`${base} · [T3](https://t3vm/?thread=1)`); expect(withT3ThreadLink(once, "https://t3vm/?thread=1")).toBe(once); }); + + it("builds message deep links from the configured web UI base", () => { + expect( + buildOmegentThreadMessageUrl({ + webUiBaseUrl: "https://t3vm.tail86038f.ts.net/", + threadId: "tid-1", + messageId: "msg-1", + }), + ).toBe("https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1"); + expect( + buildOmegentThreadMessageUrl({ + webUiBaseUrl: undefined, + threadId: "tid-1", + messageId: "msg-1", + }), + ).toBeNull(); + }); }); describe("buildDiscordThreadJumpUrl", () => { diff --git a/apps/discord-bot/src/presentation/discordPrAttribution.ts b/apps/discord-bot/src/presentation/discordPrAttribution.ts index 2de8608f721a..efbe628a784d 100644 --- a/apps/discord-bot/src/presentation/discordPrAttribution.ts +++ b/apps/discord-bot/src/presentation/discordPrAttribution.ts @@ -96,6 +96,32 @@ export function buildT3WebThreadUrl( return `${base.replace(/\/+$/u, "")}/?thread=${id}`; } +/** + * Same web UI base as the pin's "Open in Omegent" (`T3_WEB_UI_BASE_URL`), + * plus `#message-{messageId}` for client scroll-into-view. + * Does not invent a short `t3vm` host — that rewrite is only for public PR bodies. + */ +export function buildOmegentThreadMessageUrl(input: { + readonly webUiBaseUrl?: string | null | undefined; + readonly threadId: string | undefined | null; + readonly messageId: string | undefined | null; +}): string | null { + const messageId = input.messageId?.trim() ?? ""; + if (messageId === "") return null; + + const threadUrl = buildT3WebThreadUrl(input.webUiBaseUrl, input.threadId); + if (threadUrl === null) return null; + + try { + const url = new URL(threadUrl); + url.hash = `message-${messageId}`; + return url.toString(); + } catch { + const withoutHash = threadUrl.replace(/#.*$/u, ""); + return `${withoutHash}#message-${messageId}`; + } +} + /** * Public-safe short form: same URL with hostname forced to `t3vm` (no port). * Example: `https://t3vm.tail….ts.net/?thread=x` → `https://t3vm/?thread=x` diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b05898553e8..e17983ebb646 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 000000000000..3ed28c55cba2 --- /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 000000000000..b1b27e5f6b7a --- /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 000000000000..8010f746cd7a --- /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 000000000000..1efa3be37221 --- /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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114de..69baf7c07eee 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}