Skip to content
Closed
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
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
} from "@t3tools/contracts";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { parseThreadLinkHref, resolveThreadLinkRef } from "@t3tools/shared/threadLinks";
import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
import {
parseComposerContextHref,
Expand Down Expand Up @@ -1789,9 +1789,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
(href: string, imageEmbed = false) => {
const linkedThreadId = parseThreadLinkHref(href);
if (linkedThreadId) {
const source = scopeThreadRef(props.environmentId, linkedThreadId);
const target =
resolveThreadLinkRef(
source,
appAtomRegistry.get(environmentThreadShells.threadRefsAtom),
) ?? source;
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(linkedThreadId),
environmentId: String(target.environmentId),
threadId: String(target.threadId),
});
return;
}
Expand Down
22 changes: 17 additions & 5 deletions apps/web/src/components/chat/MarkdownThreadLink.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks";
import {
formatThreadLink,
percentDecodedThreadLinkId,
resolveThreadLinkRef,
} from "@t3tools/shared/threadLinks";
import { Link } from "@tanstack/react-router";
import { MessageSquareTextIcon } from "lucide-react";

import { useProject, useThreadShell } from "../../state/entities";
import { useProject, useThreadRefs, useThreadShell } from "../../state/entities";
import { ProjectFavicon } from "../ProjectFavicon";

export function MarkdownThreadLink(props: {
Expand All @@ -17,16 +21,24 @@ export function MarkdownThreadLink(props: {
const decoded = useThreadShell(
written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null,
);
const thread = written ?? decoded;
const local = written ?? decoded;
const refs = useThreadRefs(local === null);
const remote = useThreadShell(
local === null
? resolveThreadLinkRef(scopeThreadRef(props.environmentId, props.threadId), refs)
: null,
);
const thread = local ?? remote;
const environmentId = thread?.environmentId ?? props.environmentId;
const threadId = thread?.id ?? props.threadId;
const project = useProject(
thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId),
thread === null ? null : scopeProjectRef(environmentId, thread.projectId),
);
const title = thread?.title.trim() || props.label;
return (
<Link
to="/$environmentId/$threadId"
params={{ environmentId: props.environmentId, threadId }}
params={{ environmentId, threadId }}
title={thread === null ? "Thread no longer available" : project?.title}
data-markdown-copy={formatThreadLink(threadId, title)}
>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export function setActiveEnvironmentId(environmentId: EnvironmentId | null): voi
appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);
}

export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
return useAtomValue(environmentThreadShells.threadRefsAtom);
export function useThreadRefs(enabled = true): ReadonlyArray<ScopedThreadRef> {
return useAtomValue(enabled ? environmentThreadShells.threadRefsAtom : EMPTY_THREAD_REFS_ATOM);
}

export function useEnvironmentThreadRefs(
Expand Down
5 changes: 3 additions & 2 deletions docs/orchestration-v2/orchestrator-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,9 @@ Actor and ingress are separate so agent-authored user-role messages remain
distinguishable from human-authored messages.

Agents mention another thread as `[title](t3-thread://v1/<threadId>)`. The
link carries only the id, which resolves in the environment of the message that
holds it. Clients show the thread's current title rather than the label, so a
link carries only the id. Clients first resolve it in the message's environment,
then use a unique match in another enabled environment. Ambiguous matches stay
unresolved. Clients show the thread's current title rather than the label, so a
rename never leaves a stale link.

List and read results report `snoozed` and `snoozedUntil`, and
Expand Down
69 changes: 68 additions & 1 deletion packages/shared/src/threadLinks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,73 @@
import { describe, expect, it } from "vite-plus/test";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";

import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts";
import {
formatThreadLink,
parseThreadLinkHref,
relabelThreadLinks,
resolveThreadLinkRef,
} from "./threadLinks.ts";

const ref = (environmentId: string, threadId = "chat-1") => ({
environmentId: EnvironmentId.make(environmentId),
threadId: ThreadId.make(threadId),
});

describe("thread link destinations", () => {
it.each([
["environment-a", "environment-b"],
["environment-b", "environment-a"],
])("finds a chat on %s from a message on %s", (target, source) => {
const destination = ref(target);
expect(resolveThreadLinkRef(ref(source), [ref(source, "other-chat"), destination])).toBe(
destination,
);
});

it("keeps the message's environment when the id exists in both", () => {
const local = ref("environment-a");
expect(resolveThreadLinkRef(local, [ref("environment-b"), local])).toBe(local);
});

it("does not choose between ambiguous remote destinations", () => {
expect(
resolveThreadLinkRef(ref("environment-a"), [ref("environment-b"), ref("environment-c")]),
).toBeNull();
});

it("preserves percent escapes that belong to a remote thread id", () => {
const destination = ref("environment-b", "provider%3A1");
expect(
resolveThreadLinkRef(ref("environment-a", "provider%3A1"), [
ref("environment-b", "provider:1"),
destination,
]),
).toBe(destination);
});

it("finds a remote thread when the agent encoded its id", () => {
const destination = ref("environment-b", "provider:1");
expect(resolveThreadLinkRef(ref("environment-a", "provider%3A1"), [destination])).toBe(
destination,
);
});

it("keeps a decoded local match ahead of a remote match", () => {
const local = ref("environment-a", "provider:1");
expect(
resolveThreadLinkRef(ref("environment-a", "provider%3A1"), [
ref("environment-b", "provider%3A1"),
local,
]),
).toBe(local);
});

it("leaves an unavailable chat unresolved", () => {
expect(
resolveThreadLinkRef(ref("environment-a"), [ref("environment-b", "other-chat")]),
).toBeNull();
});
});

describe("thread links", () => {
it("takes the thread id verbatim, percent escapes included", () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/shared/src/threadLinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ThreadId } from "@t3tools/contracts";
import { ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

Expand All @@ -25,6 +25,26 @@ export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null
}
}

export function resolveThreadLinkRef(
source: ScopedThreadRef,
refs: ReadonlyArray<ScopedThreadRef>,
): ScopedThreadRef | null {
const decodedId = percentDecodedThreadLinkId(source.threadId);
const ids = decodedId === null ? [source.threadId] : [source.threadId, decodedId];
for (const threadId of ids) {
const local = refs.find(
(ref) => ref.environmentId === source.environmentId && ref.threadId === threadId,
);
if (local) return local;
}
for (const threadId of ids) {
const matches = refs.filter((ref) => ref.threadId === threadId);
if (matches.length > 1) return null;
if (matches.length === 1) return matches[0]!;
}
return null;
}

export function formatThreadLink(threadId: string, label: string): string {
const cleaned = label
.replace(/[[\]\\\r\n]/g, " ")
Expand Down
Loading