From 1af0e434b916d05e872df0827950eb60525f92dd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 3 Nov 2026 23:24:27 -0800 Subject: [PATCH] Fix thread citations rendering as "Thread no longer available" Angle-bracketed link destinations ([label]()) were not parsed as labeled links: the inner <...> matched as an autolink, the label was discarded, and an unresolved title substituted "Thread no longer available" as the destination text. Thread titles were also resolved only from the shell snapshot, which omits idle subagent children and all but the 25 newest settled threads, so live threads cited from chat read as deleted. Mobile and web now mount the cited thread's family read (the on-demand rows search and the Settled shelf already use) until its title lands. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/lib/enrichedLinkPresentation.test.ts | 12 +++++++++ .../src/lib/enrichedLinkPresentation.ts | 2 +- apps/mobile/src/state/entities.ts | 25 ++++++++++++++++--- .../components/chat/MarkdownThreadLink.tsx | 14 +++++++++++ packages/shared/src/threadLinks.test.ts | 15 +++++++++++ packages/shared/src/threadLinks.ts | 3 ++- 6 files changed, 65 insertions(+), 6 deletions(-) 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 c5c33a5432e2b..c848ce18db6c6 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"), @@ -203,11 +204,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)|\[(?