diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 5728ac6637de2..c03ee60bba638 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -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]().")).toEqual([ + { + href: "t3-thread://v2/env-here/thread_a", + environmentId: here, + threadId: "thread_a", + link: true, + }, + ]); + }); }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 19ab61336b894..bc837aef99969 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -62,7 +62,7 @@ export function threadChipTargets( ): ReadonlyArray { const targets = new Map(); if (hasThreadLinks(markdown)) { - for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) { + for (const [, href = ""] of markdown.matchAll(/\]\()]+)>?\)/g)) { const link = parseThreadLinkHref(href); if (link) { targets.set(href, { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 1625998fdc629..41237b95c8a7c 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -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"; @@ -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(null).pipe( Atom.withLabel("mobile-project:empty"), @@ -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).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))); } diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index 68ab4cd569e0d..6de6a28445fee 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -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"; @@ -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); diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index aa1c02fd4bed2..6f4cb8dc81b83 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -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]() and [older]()", + local, + title, + ), + ).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)"); + expect( + relabelThreadLinks("[Kept label]()", local, () => undefined), + ).toBe("Kept label"); + expect(threadLinkRefs("[a]()", local)).toEqual([ref("there", "t2")]); + }); + it("turns a v2 link it cannot resolve into plain text, never the raw link", () => { expect( relabelThreadLinks( diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index aceb87635c238..ca2d2acd339c2 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -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](). const THREAD_LINK_OUTSIDE_CODE = - /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[(?