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
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/enrichedLinkPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,16 @@ describe("Enriched link presentation", () => {
]);
expect(threadChipTargets(here, "No links here.")).toEqual([]);
});

it("finds thread links with angle-bracketed destinations", () => {
const here = "env-here" as EnvironmentId;
expect(threadChipTargets(here, "See [A](<t3-thread://v2/env-here/thread_a>).")).toEqual([
{
href: "t3-thread://v2/env-here/thread_a",
environmentId: here,
threadId: "thread_a",
link: true,
},
]);
});
});
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/enrichedLinkPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function threadChipTargets(
): ReadonlyArray<ThreadChipTarget> {
const targets = new Map<string, ThreadChipTarget>();
if (hasThreadLinks(markdown)) {
for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) {
for (const [, href = ""] of markdown.matchAll(/\]\(<?(t3-thread:\/\/v[12]\/[^\s>)]+)>?\)/g)) {
const link = parseThreadLinkHref(href);
if (link) {
targets.set(href, {
Expand Down
25 changes: 21 additions & 4 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
import { useEffect } from "react";
import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution";

import { appAtomRegistry } from "./atom-registry";
Expand Down Expand Up @@ -34,7 +35,7 @@ import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIco
import { projectFaviconUrlAtom } from "./assets";
import { environmentProjects } from "./projects";
import { environmentServerConfigsAtom, serverEnvironment } from "./server";
import { environmentThreadDetails, environmentThreadShells } from "./threads";
import { environmentThreadDetails, environmentThreadShells, threadFamilies } from "./threads";

const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
Atom.withLabel("mobile-project:empty"),
Expand Down Expand Up @@ -208,11 +209,27 @@ export function useSideChats(ref: ScopedThreadRef) {
}

export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
const refs = hasThreadLinks(markdown) ? threadLinkRefs(markdown, environmentId) : [];
const titles = useAtomValue(
hasThreadLinks(markdown)
? threadLinkTitlesAtom(JSON.stringify(threadLinkRefs(markdown, environmentId).map(threadKey)))
: EMPTY_THREAD_LINK_TITLES_ATOM,
refs.length === 0
? EMPTY_THREAD_LINK_TITLES_ATOM
: threadLinkTitlesAtom(JSON.stringify(refs.map(threadKey))),
);
// Shells leave out idle subagent children and older settled threads, so a cited thread the
// shell lacks is read with its family until it lands; only a settled empty read proves it gone.
const missingKey = JSON.stringify(
refs.filter((ref) => !titles.has(threadKey(ref))).map(threadKey),
);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts family reads for cited threads the shell omits while this message shows them
useEffect(() => {
const releases = (JSON.parse(missingKey) as ReadonlyArray<string>).map((key) => {
const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
return threadFamilies.mount(appAtomRegistry, ref.environmentId, ref.threadId);
});
return () => {
for (const release of releases) release();
};
}, [missingKey]);
return relabelThreadLinks(markdown, environmentId, (ref) => titles.get(threadKey(ref)));
}

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/chat/MarkdownThreadLink.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId } from "@t3tools/contracts";
import {
Expand All @@ -8,7 +9,9 @@ import {
unknownThreadLinkLabel,
} from "@t3tools/shared/threadLinks";

import { appAtomRegistry } from "../../rpc/atomRegistry";
import { useThreadRefs, useThreadShell } from "../../state/entities";
import { threadFamilies } from "../../state/threads";
import { UnresolvedChip } from "../contextChipParts";
import { ThreadContextChip } from "../ThreadContextChip";

Expand All @@ -25,6 +28,17 @@ export function MarkdownThreadLink(props: {
written === null && decodedId !== null ? scopeThreadRef(environmentId, decodedId) : null,
);
const local = written ?? decoded;
// Shells leave out idle subagent children and older settled threads, so a cited thread the
// shell lacks is read with its family until it lands; only a settled empty read proves it gone.
const missingThreadId = local === null ? (decodedId ?? props.link.threadId) : null;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the family read for a cited thread the shell omits while this link shows it
useEffect(
() =>
missingThreadId === null
? undefined
: threadFamilies.mount(appAtomRegistry, environmentId, missingThreadId),
[environmentId, missingThreadId],
);
const guess = local === null && props.link.environmentId === null;
const refs = useThreadRefs(guess);
const remote = useThreadShell(guess ? guessThreadLinkRef(props.link, refs) : null);
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/threadLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ describe("thread links", () => {
).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)");
});

it("reads an angle-bracketed destination as the labeled link, not an autolink", () => {
const title = titlesIn({ local: { t1: "Here" }, there: { t1: "There" } });
expect(
relabelThreadLinks(
"[old](<t3-thread://v2/there/t1>) and [older](<t3-thread://v2/local/t1>)",
local,
title,
),
).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)");
expect(
relabelThreadLinks("[Kept label](<t3-thread://v2/local/t1>)", local, () => undefined),
).toBe("Kept label");
expect(threadLinkRefs("[a](<t3-thread://v2/there/t2>)", local)).toEqual([ref("there", "t2")]);
});

it("turns a v2 link it cannot resolve into plain text, never the raw link", () => {
expect(
relabelThreadLinks(
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/threadLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export const THREAD_LINK_PROTOCOL = "t3-thread";
const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`;
// Code comes first in the alternation so a link written inside a code span or fence is skipped.
// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](<href>).
const THREAD_LINK_OUTSIDE_CODE =
/(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[(?<label>[^\]\n]*)\]\((?<href>t3-thread:\/\/v[12]\/[^\s)]+)\)|<(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+)>/g;
/(?<fence>(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(`+)[^\n]*?\4)|\[(?<label>[^\]\n]*)\]\(<?(?<href>t3-thread:\/\/v[12]\/[^\s>)]+)>?\)|<(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+)>/g;

const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
Expand Down
Loading