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
48 changes: 47 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
DEFAULT_MODEL,
defaultInstanceIdForDriver,
type EnvironmentId,
type MessageId,
MessageId,
type ModelSelection,
type ProjectScript,
type ProjectId,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(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(() => {
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/components/OmegentDeepLinkCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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;
}
41 changes: 41 additions & 0 deletions apps/web/src/deepLinkStore.ts
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions apps/web/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
34 changes: 34 additions & 0 deletions apps/web/src/deepLinks.ts
Original file line number Diff line number Diff line change
@@ -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()}`;
}
11 changes: 11 additions & 0 deletions apps/web/src/forkSurfaceExistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,6 +136,7 @@ function RootRouteView() {
<SlowRpcRequestToastCoordinator />
<HostedStaticEnvironmentBootstrap />
{primaryEnvironmentAuthenticated ? <EventRouter /> : null}
{primaryEnvironmentAuthenticated ? <OmegentDeepLinkCoordinator /> : null}
{primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
{appShell}
</AnchoredToastProvider>
Expand Down
Loading