From 60cb7f6dd18cd627b9de883cc162ad3c510cada5 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 31 Oct 2026 00:33:16 -0400 Subject: [PATCH 01/10] feat: copy shareable thread links that paste as context cards --- .../src/lib/composerContextClipboard.test.ts | 30 ++++++++ .../src/lib/composerContextClipboard.ts | 11 +-- .../composerInlineTokenPaste.test.ts | 67 ++++++++++++++++++ .../components/composerInlineTokenPaste.ts | 14 +++- .../promptLinks/HostedPromptLinkLanding.tsx | 8 ++- docs/user/composer.md | 4 +- .../src/threadReference.test.ts | 6 +- .../client-runtime/src/threadReference.ts | 11 ++- packages/shared/package.json | 4 ++ .../shared/src/composerContextClipboard.ts | 51 ++++++++++++++ packages/shared/src/promptLinks.test.ts | 1 + packages/shared/src/promptLinks.ts | 4 +- packages/shared/src/threadDeepLinks.test.ts | 69 +++++++++++++++++++ packages/shared/src/threadDeepLinks.ts | 34 +++++++++ 14 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/composerInlineTokenPaste.test.ts create mode 100644 packages/shared/src/threadDeepLinks.test.ts create mode 100644 packages/shared/src/threadDeepLinks.ts diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index afabd7afb550c..72a4a970336ef 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -331,3 +331,33 @@ describe("mobile context clipboard imports", () => { ).toEqual(result!.context.records.map((record) => record.contextId)); }); }); + +describe("public thread URL paste", () => { + it("imports a plain link without rich clipboard metadata", async () => { + const result = await importComposerContextClipboard( + { text: "https://app.t3.codes/destination/local-thread", fragment: "", html: "" }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + expect(result?.context.records).toMatchObject([ + { kind: "thread", environmentId: "destination", threadId: "local-thread" }, + ]); + expect(collectComposerContextReferences(result!.text)).toHaveLength(1); + }); + it("imports an app link from a machine the destination links to", async () => { + const result = await importComposerContextClipboard( + { text: "t3code://threads/source/foreign-thread", fragment: "", html: "" }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + new Map([[EnvironmentId.make("source"), "Source"]]), + ); + expect(result?.context.records).toMatchObject([ + { kind: "thread", environmentId: "source", threadId: "foreign-thread" }, + ]); + expect(result?.skippedThreads).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index e7c7201840569..f62b2998b8317 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { decodeComposerContextClipboardHtml, + normalizeThreadClipboard, decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; @@ -70,11 +71,13 @@ export async function importComposerContextClipboard( /** The machines the destination links to, whose threads its agent can read too. */ linkedEnvironments: ReadonlyMap = new Map(), ) { - const fragment = - decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); + const { text, fragment } = normalizeThreadClipboard( + input.text, + decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html), + ); if (!fragment) return null; - const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records }); - const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); + const selected = referencedComposerContext(text, { version: 1, records: fragment.records }); + const imported = reidentifyComposerContext(text, selected?.records ?? [], uuidv4); const skippedThreads = destinationEnvironmentId === undefined ? [] diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts new file mode 100644 index 0000000000000..91453d4cfdb0c --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -0,0 +1,67 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { + collectComposerContextReferences, + formatComposerContextReference, + threadContextRecord, +} from "@t3tools/shared/composerContextReferences"; +import { threadDeepLink } from "@t3tools/shared/threadDeepLinks"; +import { importPastedComposerText, readPastedComposerContext } from "./composerInlineTokenPaste"; +const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; +const record = threadContextRecord(ref, "Fix login"); +function clipboard(text: string, rich = false) { + return { + getData: (type: string) => + type === "text/plain" + ? text + : rich && type === COMPOSER_CONTEXT_CLIPBOARD_MIME + ? encodeComposerContextFragment({ + version: 1, + source: { environmentId: ref.environmentId }, + records: [record], + })! + : "", + }; +} +describe("pasted thread deep links", () => { + it("imports a copied thread card while the plain clipboard stays a public URL", () => { + const data = clipboard(threadDeepLink(ref), true); + const fragment = readPastedComposerContext(data)!; + expect(fragment.records).toEqual([record]); + const result = importPastedComposerText( + data, + (f) => new Map(f.records.map((r) => [r.contextId, r.contextId])), + ); + expect(result).toBe(formatComposerContextReference(record)); + expect(data.getData("text/plain")).toBe("https://app.t3.codes/env/thread"); + }); + it("imports public URLs after clipboard metadata has been stripped", () => { + const data = clipboard(threadDeepLink(ref)); + const result = importPastedComposerText( + data, + (f) => new Map(f.records.map((r) => [r.contextId, "imported-thread"])), + ); + expect(collectComposerContextReferences(result)).toMatchObject([ + { contextId: "imported-thread", kind: "thread" }, + ]); + }); + it("keeps raw URLs when there is no importer or the machine cannot read the thread", () => { + const data = clipboard(threadDeepLink(ref)); + expect(importPastedComposerText(data)).toBe(threadDeepLink(ref)); + expect(importPastedComposerText(data, () => new Map())).toBe(threadDeepLink(ref)); + }); + it("preserves legacy context reference paste", () => { + const data = clipboard(formatComposerContextReference(record), true); + expect(readPastedComposerContext(data)?.records).toEqual([record]); + expect( + importPastedComposerText( + data, + (f) => new Map(f.records.map((r) => [r.contextId, r.contextId])), + ), + ).toBe(formatComposerContextReference(record)); + }); +}); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 32722ad6a9571..155c650bde779 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,6 +1,7 @@ import { ComposerContextId } from "@t3tools/contracts"; import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; import { + normalizeThreadClipboard, COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, decodeComposerContextClipboardHtml, @@ -13,10 +14,14 @@ import { export function readPastedComposerContext( clipboardData: Pick, ): ComposerContextClipboardFragment | null { - const pastedText = clipboardData.getData("text/plain"); - const decodedFragment = + const rawText = clipboardData.getData("text/plain"); + const decoded = decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); + const { text: pastedText, fragment: decodedFragment } = normalizeThreadClipboard( + rawText, + decoded, + ); if (decodedFragment === null) return null; const pastedIds = new Set( collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), @@ -43,10 +48,13 @@ export function importPastedComposerText( fragment: ComposerContextClipboardFragment, ) => ReadonlyMap, ): string { - const pastedText = clipboardData.getData("text/plain"); + const rawText = clipboardData.getData("text/plain"); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; + const pastedText = rewrittenIds + ? normalizeThreadClipboard(rawText, fragment, rewrittenIds).text + : rawText; const text = rewrittenIds && rewrittenIds.size > 0 ? replaceComposerContextReferences(pastedText, (occurrence) => { diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index 24facbea1911f..77c6d1f5c90ab 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -123,8 +123,12 @@ function HostedPromptLinkLanding({ return ( {prompt !== null && prompt.hiddenCharacters > 0 ? ( diff --git a/docs/user/composer.md b/docs/user/composer.md index 714572dfd5005..286bc1d952bbc 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -285,7 +285,9 @@ Another thread can be context too. Type `@` followed by part of its title to pic the same server, or from a machine it links to while the Linked environments beta is on; those show the machine's name. On web and desktop you can also drag a thread out of the sidebar and drop it on the composer; a multi-selection drops together. On any client, **Copy thread** puts a -thread on the clipboard that pastes into a composer as its chip. The chip shows the thread's +plain thread URL on the clipboard. Paste it into a composer to attach the thread as a chip, or +share it elsewhere to open the thread in a browser, desktop, or mobile app. Opening the link +requires access to the thread's environment. The chip shows the thread's current title and opens it when selected. Your prompt only carries a reference: the agent reads the thread's history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 9fa3245df5754..334303d61f60d 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { + normalizeThreadClipboard, decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; @@ -82,6 +83,7 @@ describe("resolveThreadReferenceCopyTarget", () => { expect(target).toMatchObject({ kind: "thread", + value: "https://app.t3.codes/env-1/thread-1", description: "Fix the login flow", successTitle: "Thread copied", }); @@ -99,7 +101,9 @@ describe("resolveThreadReferenceCopyTarget", () => { ], }); // A paste keeps only the records its plain text references. - expect(collectComposerContextReferences(target!.value)).toEqual([ + expect( + collectComposerContextReferences(normalizeThreadClipboard(target!.value, pasted).text), + ).toEqual([ expect.objectContaining({ kind: "thread", contextId: pasted!.records[0]!.contextId, diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 94f68202a62cf..60c4cc8f94b78 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -3,16 +3,15 @@ import type { ScopedThreadRef, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { - formatComposerContextReference, - threadContextRecord, -} from "@t3tools/shared/composerContextReferences"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; + +import { threadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a thread chip's reference link. */ + /** The plain text: a PR URL or a public thread deep link. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; @@ -31,7 +30,7 @@ export function threadChipCopyTarget( const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: formatComposerContextReference(record), + value: threadDeepLink(threadRef), description: record.title, fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", diff --git a/packages/shared/package.json b/packages/shared/package.json index e3d0b2ce57cf7..94ac832151099 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -514,6 +514,10 @@ "./cloneUrl": { "types": "./src/cloneUrl.ts", "import": "./src/cloneUrl.ts" + }, + "./threadDeepLinks": { + "types": "./src/threadDeepLinks.ts", + "import": "./src/threadDeepLinks.ts" } }, "scripts": { diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts index 6036795a780cb..9411142cc7b13 100644 --- a/packages/shared/src/composerContextClipboard.ts +++ b/packages/shared/src/composerContextClipboard.ts @@ -5,8 +5,59 @@ import { import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + formatComposerContextReference, + threadContextRecord, + toKindScopedComposerContextId, +} from "./composerContextReferences.ts"; +import { parseThreadDeepLink } from "./threadDeepLinks.ts"; + export { COMPOSER_CONTEXT_CLIPBOARD_MIME }; +/** Normalize public URLs before filtering clipboard records, including plain-text-only pastes. */ +export function normalizeThreadClipboard( + text: string, + fragment: ComposerContextClipboardFragment | null, + acceptedIds?: ReadonlyMap, +) { + const records = [...(fragment?.records ?? [])]; + let source = fragment?.source; + // Skip code spans/fences and consume Markdown links as a unit to avoid nesting references. + const normalized = text.replace( + /(`{3,}|~{3,})[\s\S]*?(?:\1|$)|(`+)[^\n]*?\2|\[([^\]\n]*)\]\((https:\/\/app\.t3\.codes\/[^\s)]+|t3code(?:-dev|-preview)?:\/\/threads\/[^\s)]+)\)|`]+>?/g, + (value, fence, span, label, href) => { + if (fence || span) return value; + const ref = parseThreadDeepLink(href ?? value.replace(/^<|>$/g, "")); + if (!ref) return value; + const existing = records.find( + (record) => + record.kind === "thread" && + !("payload" in record) && + record.environmentId === ref.environmentId && + record.threadId === ref.threadId, + ); + let record = existing ?? threadContextRecord(ref, label || ref.threadId); + if (!existing && records.some((candidate) => candidate.contextId === record.contextId)) { + record = { + ...record, + contextId: toKindScopedComposerContextId( + "thread", + ref.environmentId + "/" + ref.threadId, + ), + }; + } + if (acceptedIds && !acceptedIds.has(record.contextId)) return value; + if (!existing) records.push(record); + source ??= { environmentId: ref.environmentId }; + return formatComposerContextReference(record); + }, + ); + return { + text: normalized, + fragment: source ? { version: 1 as const, source, records } : fragment, + }; +} + const MAX_FRAGMENT_CHARS = 16_000_000; const decodeFragment = Schema.decodeUnknownOption(ComposerContextClipboardFragment); diff --git a/packages/shared/src/promptLinks.test.ts b/packages/shared/src/promptLinks.test.ts index afc5644f9ac3d..7530e626067d7 100644 --- a/packages/shared/src/promptLinks.test.ts +++ b/packages/shared/src/promptLinks.test.ts @@ -111,6 +111,7 @@ describe("link round trips", () => { expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( "/env-1/thread-1?prompt=go", ); + expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1")).toBe("/env-1/thread-1"); expect(promptLinkAppPath("https://app.t3.codes/settings/general")).toBeNull(); expect(promptLinkAppPath("https://evil.test/new?prompt=hi")).toBeNull(); expect(promptLinkAppPath("t3code://app/index.html")).toBeNull(); diff --git a/packages/shared/src/promptLinks.ts b/packages/shared/src/promptLinks.ts index adff0135bfc02..8a24e77490037 100644 --- a/packages/shared/src/promptLinks.ts +++ b/packages/shared/src/promptLinks.ts @@ -182,7 +182,7 @@ export function buildNewThreadLink(input: { } const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/; -const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/; +const THREAD_PATH = /^\/(?!settings\/|projects\/|draft\/)([^/]+)\/([^/]+)\/?$/; export function promptLinkAppPath( value: string, @@ -199,7 +199,7 @@ export function promptLinkAppPath( } if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null; if (url.pathname === "/new") return `/new${url.search}`; - const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null; + const thread = THREAD_PATH.exec(url.pathname); return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; } diff --git a/packages/shared/src/threadDeepLinks.test.ts b/packages/shared/src/threadDeepLinks.test.ts new file mode 100644 index 0000000000000..49713881e5d7e --- /dev/null +++ b/packages/shared/src/threadDeepLinks.test.ts @@ -0,0 +1,69 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { threadDeepLink, parseThreadDeepLink } from "./threadDeepLinks.ts"; +import { normalizeThreadClipboard } from "./composerContextClipboard.ts"; +import { + collectComposerContextReferences, + threadContextRecord, +} from "./composerContextReferences.ts"; +const ref = { environmentId: EnvironmentId.make("env-1"), threadId: ThreadId.make("thread:1%2F") }; + +describe("public thread links", () => { + it("round-trips encoded ids without losing literal escapes", () => { + expect(parseThreadDeepLink(threadDeepLink(ref))).toEqual(ref); + expect(parseThreadDeepLink("t3code://threads/env-1/thread%3A1%252F")).toEqual(ref); + }); + it.each([ + "https://evil.test/env/thread", + "https://app.t3.codes/settings/general", + "https://app.t3.codes/projects/p", + "https://app.t3.codes/draft/d", + "t3code://threads/env/thread?token=secret", + "https://app.t3.codes/env/%ZZ", + ])("ignores unrelated or malformed URLs: %s", (value) => { + expect(parseThreadDeepLink(value)).toBeNull(); + }); + it("turns plain URLs into cards without clipboard metadata", () => { + const result = normalizeThreadClipboard("See " + threadDeepLink(ref), null); + expect(result.fragment?.records).toEqual([threadContextRecord(ref, ref.threadId)]); + expect(collectComposerContextReferences(result.text)).toHaveLength(1); + }); + it("keeps copied titles and deduplicates repeated links", () => { + const record = threadContextRecord(ref, "Fix login"); + const result = normalizeThreadClipboard(threadDeepLink(ref) + " " + threadDeepLink(ref), { + version: 1, + source: { environmentId: ref.environmentId }, + records: [record], + }); + expect(result.fragment?.records).toEqual([record]); + expect(collectComposerContextReferences(result.text).map((r) => r.label)).toEqual([ + "Fix login", + "Fix login", + ]); + }); + it("leaves code examples literal and supports app links and Markdown wrappers", () => { + const value = threadDeepLink(ref); + const result = normalizeThreadClipboard( + "`" + value + "` [Work](" + value + ") ", + null, + ); + expect(result.text.startsWith("`" + value + "`")).toBe(true); + expect(collectComposerContextReferences(result.text).map((r) => r.label)).toEqual([ + "Work", + "thread-2", + ]); + }); +}); + +it("keeps identical thread ids on different machines distinct", () => { + const result = normalizeThreadClipboard( + "https://app.t3.codes/one/thread https://app.t3.codes/two/thread", + null, + ); + const records = result.fragment!.records; + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => record.contextId)).size).toBe(2); + expect(collectComposerContextReferences(result.text).map((record) => record.contextId)).toEqual( + records.map((record) => record.contextId), + ); +}); diff --git a/packages/shared/src/threadDeepLinks.ts b/packages/shared/src/threadDeepLinks.ts new file mode 100644 index 0000000000000..7cf3358f5cdf6 --- /dev/null +++ b/packages/shared/src/threadDeepLinks.ts @@ -0,0 +1,34 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts"; + +const decodeEnvironmentId = Schema.decodeOption(EnvironmentId); +const decodeThreadId = Schema.decodeOption(ThreadId); + +export function threadDeepLink(ref: ScopedThreadRef): string { + return new URL( + `/${encodeURIComponent(ref.environmentId)}/${encodeURIComponent(ref.threadId)}`, + DEFAULT_HOSTED_APP_URL, + ).href; +} + +/** Public thread addresses carry identity only; connecting still requires authorization. */ +export function parseThreadDeepLink(value: string): ScopedThreadRef | null { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash) return null; + const web = url.origin === DEFAULT_HOSTED_APP_URL; + const app = + ["t3code:", "t3code-dev:", "t3code-preview:"].includes(url.protocol) && + url.host === "threads"; + if (!web && !app) return null; + const match = /^\/(?!settings\/|projects\/|draft\/)([^/]+)\/([^/]+)\/?$/.exec(url.pathname); + if (!match) return null; + const environmentId = Option.getOrNull(decodeEnvironmentId(decodeURIComponent(match[1]!))); + const threadId = Option.getOrNull(decodeThreadId(decodeURIComponent(match[2]!))); + return environmentId === null || threadId === null ? null : { environmentId, threadId }; + } catch { + return null; + } +} From cbb60412fd1920257a55ea40ac4aea68a52a6f96 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 31 Oct 2026 01:18:10 -0400 Subject: [PATCH 02/10] fix: address thread deep-link review across client paste paths --- .../t3composereditor/T3ComposerEditorView.kt | 9 +++ .../t3composereditor/ComposerPasteTest.kt | 25 ++++++++ .../ios/T3ComposerEditorView.swift | 5 +- .../src/lib/composerContextClipboard.test.ts | 13 ++++ .../src/lib/composerContextClipboard.ts | 19 +++++- apps/mobile/src/lib/copyThreadReference.ts | 15 +---- apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../chat/useComposerContextImport.ts | 1 + .../composerInlineTokenPaste.test.ts | 13 +++- .../components/composerInlineTokenPaste.ts | 11 ++++ .../desktop/DesktopWebLinkCoordinator.tsx | 19 +++++- .../promptLinks/HostedPromptLinkLanding.tsx | 9 +++ .../promptLinks/hostedThreadLink.test.ts | 19 ++++++ .../promptLinks/hostedThreadLink.ts | 12 ++++ apps/web/src/lib/copyThreadReference.ts | 12 +--- .../src/threadReference.test.ts | 33 ++-------- .../client-runtime/src/threadReference.ts | 9 +-- .../shared/src/composerContextClipboard.ts | 61 ++++++++++++------- packages/shared/src/promptLinks.ts | 10 +-- packages/shared/src/threadDeepLinks.test.ts | 31 ++++++++++ packages/shared/src/threadDeepLinks.ts | 24 ++++++-- 21 files changed, 254 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/components/promptLinks/hostedThreadLink.test.ts create mode 100644 apps/web/src/components/promptLinks/hostedThreadLink.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ecc37df17c993..58432aedb346b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -701,6 +701,15 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { pasteContextListener?.invoke(payload) return true } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = clipboard?.primaryClip + val plainText = clip?.plainText() + if (plainText != null && (plainText.contains("app.t3.codes/") || plainText.contains("://threads/"))) { + if (!pasteInterceptedText(clip)) { + pasteContextListener?.invoke(payload + ("text" to plainText)) + } + return true + } return pasteImagesOrInterceptedText() } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index ca15fb2b1ba19..fc7ac85e276c0 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -169,4 +169,29 @@ class ComposerPasteTest { assertEquals("unchanged", editor.text.toString()) assertNull(intercepted) } + + @Test + fun regularPasteSendsPlainThreadUrlsToTheContextImporter() { + for (url in listOf("https://app.t3.codes/environment/thread", "t3code://threads/environment/thread")) { + clipboard.setPrimaryClip(ClipData.newPlainText("test", url)) + editor.setText("before") + editor.setSelection(6) + var payload: Map? = null + editor.pasteContextListener = { payload = it } + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(url, payload?.get("text")) + assertEquals("before", editor.text.toString()) + } + } + + @Test + fun incidentalThreadUrlsDoNotBypassLargeTextFolding() { + val text = "log line\n".repeat(5000) + "https://app.t3.codes/environment/thread" + clipboard.setPrimaryClip(ClipData.newPlainText("test", text)) + editor.pasteContextListener = { error("Large logs must use the text folding path") } + var intercepted: String? = null + editor.pasteTextListener = { value, _, _ -> intercepted = value } + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(text, intercepted) + } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index f54f75caf5f50..8aca4ee96bbe7 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -201,7 +201,10 @@ private final class ComposerTextView: UITextView { } let pasteboard = UIPasteboard.general let context = T3ComposerClipboard.read() - if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") { + let text = context["text", default: ""] + let hasThreadLink = text.contains("app.t3.codes/") || text.contains("://threads/") + if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") || + (!pasteboard.hasImages && !bypassTextPasteInterception && hasThreadLink && !shouldInterceptTextPaste(text)) { onPasteContext?(context) return } diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index 72a4a970336ef..4f0db7c9e9e02 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -361,3 +361,16 @@ describe("public thread URL paste", () => { expect(result?.skippedThreads).toEqual([]); }); }); + +it("preserves a public URL when the destination cannot read its environment", async () => { + const text = "See https://app.t3.codes/unknown/thread"; + const result = await importComposerContextClipboard( + { text, fragment: "", html: "" }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + expect(result?.text).toBe(text); + expect(result?.context.records).toEqual([]); +}); diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index f62b2998b8317..5f37779e82bc7 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -71,11 +71,28 @@ export async function importComposerContextClipboard( /** The machines the destination links to, whose threads its agent can read too. */ linkedEnvironments: ReadonlyMap = new Map(), ) { - const { text, fragment } = normalizeThreadClipboard( + const normalized = normalizeThreadClipboard( input.text, decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html), ); + const fragment = normalized.fragment; if (!fragment) return null; + const outside = + destinationEnvironmentId === undefined + ? [] + : threadContextsOutsideEnvironment({ + text: normalized.text, + records: fragment.records, + environmentId: destinationEnvironmentId, + linkedEnvironments, + }); + const outsideIds = new Set(outside.map((record) => record.contextId)); + const accepted = new Map( + fragment.records + .filter((record) => !outsideIds.has(record.contextId)) + .map((record) => [record.contextId, record.contextId]), + ); + const text = normalizeThreadClipboard(input.text, fragment, accepted).text; const selected = referencedComposerContext(text, { version: 1, records: fragment.records }); const imported = reidentifyComposerContext(text, selected?.records ?? [], uuidv4); const skippedThreads = diff --git a/apps/mobile/src/lib/copyThreadReference.ts b/apps/mobile/src/lib/copyThreadReference.ts index f3921fb2605a7..35ba714b9721f 100644 --- a/apps/mobile/src/lib/copyThreadReference.ts +++ b/apps/mobile/src/lib/copyThreadReference.ts @@ -1,19 +1,6 @@ import type { ThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import * as Haptics from "expo-haptics"; - -import { writeComposerContextClipboard } from "./composerContextClipboard"; import { tryCopyTextWithHaptic } from "./copyTextWithHaptic"; -/** Resolves false, after logging why, when the clipboard write fails. */ export async function copyThreadReference(target: ThreadReferenceCopyTarget): Promise { - if (!target.fragment) - return tryCopyTextWithHaptic(target.value, { target: target.clipboardTarget }); - try { - await writeComposerContextClipboard(target.value, target.fragment); - } catch (error) { - console.error(`Failed to copy ${target.clipboardTarget} to the clipboard.`, error); - return false; - } - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); - return true; + return tryCopyTextWithHaptic(target.value, { target: target.clipboardTarget }); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c4963ad73ba3a..bf594eac3deaf 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,7 +9,7 @@ import { import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; -import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; +import { importPastedComposerText, isComposerContextPaste } from "../composerInlineTokenPaste"; import { questionAttachmentDraftId, trackOpenQuestionAttachmentDraft, @@ -2503,7 +2503,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } - if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; + if (isComposerContextPaste(event.clipboardData)) return; if (!foldPastedText(plainText, bypassAutoAttachment)) { return; } diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 74e97a5076d7d..de56d0bd530e3 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -350,6 +350,7 @@ export function useComposerContextImport(input: { if (record.kind === "preview-annotation" && record.screenshotContextId) { skippedDependentAttachmentIds.add(record.screenshotContextId); } + if (record.kind === "thread") rewritten.set(record.contextId, record.contextId); continue; } const conflicts = existing !== undefined; diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts index 91453d4cfdb0c..16ea3df49872a 100644 --- a/apps/web/src/components/composerInlineTokenPaste.test.ts +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -10,7 +10,11 @@ import { threadContextRecord, } from "@t3tools/shared/composerContextReferences"; import { threadDeepLink } from "@t3tools/shared/threadDeepLinks"; -import { importPastedComposerText, readPastedComposerContext } from "./composerInlineTokenPaste"; +import { + importPastedComposerText, + readPastedComposerContext, + isComposerContextPaste, +} from "./composerInlineTokenPaste"; const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; const record = threadContextRecord(ref, "Fix login"); function clipboard(text: string, rich = false) { @@ -65,3 +69,10 @@ describe("pasted thread deep links", () => { ).toBe(formatComposerContextReference(record)); }); }); + +it("allows large logs with an incidental thread URL to fold", () => { + expect(isComposerContextPaste(clipboard("log line\n".repeat(10000) + threadDeepLink(ref)))).toBe( + false, + ); + expect(isComposerContextPaste(clipboard(threadDeepLink(ref)))).toBe(true); +}); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 155c650bde779..c3bf3f489a2af 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,3 +1,4 @@ +import { parseThreadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { ComposerContextId } from "@t3tools/contracts"; import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; import { @@ -70,3 +71,13 @@ export function importPastedComposerText( : pastedText; return text; } + +export function isComposerContextPaste(clipboardData: Pick): boolean { + if ( + decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) || + decodeComposerContextClipboardHtml(clipboardData.getData("text/html")) + ) + return true; + const text = clipboardData.getData("text/plain").trim(); + return text.length > 0 && text.split(/\s+/).every((value) => parseThreadDeepLink(value) !== null); +} diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index ddd892d02bd12..3e3889b6b6ead 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -5,6 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { parseThreadDeepLink, threadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import { readPairingAppLink, resolveRemotePairingTarget } from "@t3tools/shared/remote"; import { useRouter } from "@tanstack/react-router"; @@ -28,7 +29,11 @@ import { appAtomRegistry } from "../../rpc/atomRegistry"; import { resolveThreadRouteTarget } from "../../threadRoutes"; import { assetEnvironment } from "../../state/assets"; import { readProject, readThreadShell } from "../../state/entities"; -import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments"; +import { + useEnvironments, + useEnvironmentHttpBaseUrl, + usePrimaryEnvironment, +} from "../../state/environments"; import { previewEnvironment } from "../../state/preview"; import { allReachableShellsLiveAtom } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -81,6 +86,7 @@ function threadShowingBrowser( export function DesktopWebLinkCoordinator() { const router = useRouter(); const primaryEnvironment = usePrimaryEnvironment(); + const { environments } = useEnvironments(); const openPullRequestsInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); const openChangeRequest = useOpenChangeRequestLink(); const { scratchEnvironmentId, openScratchProject } = useScratchProject(); @@ -207,10 +213,19 @@ export function DesktopWebLinkCoordinator() { return; } const appPath = promptLinkAppPath(url); - if (appPath !== null) { + const bareThread = parseThreadDeepLink(url); + if ( + appPath !== null && + (bareThread === null || + environments.some((e) => e.environmentId === bareThread.environmentId)) + ) { await router.navigate({ href: appPath }); return; } + if (bareThread !== null) { + await openInBrowser(threadDeepLink(bareThread)); + return; + } const routeParams = router.state.matches.at(-1)?.params ?? {}; const routeTarget = resolveThreadRouteTarget(routeParams); const activeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : undefined; diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index 77c6d1f5c90ab..5ada3f3120dca 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -19,6 +19,8 @@ import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; +import { hostedThreadLinkAction } from "./hostedThreadLink"; + const OPENER_STORAGE_KEY = "t3code:prompt-link-opener"; const Opener = Schema.NullOr(Schema.Literals(["app", "browser"])); @@ -69,6 +71,13 @@ export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null { ? promptLinkAppPath(new URL(href, window.location.origin).href, window.location.origin) : null; if (appPath === null || continuedHref === href) return null; + const threadAction = hostedThreadLinkAction( + appPath, + isReady, + environments.map((e) => e.environmentId), + ); + if (threadAction === "wait") return <>; + if (threadAction === "continue") return null; const canContinue = isReady && environments.length > 0; if (opener === "browser") { if (canContinue) return null; diff --git a/apps/web/src/components/promptLinks/hostedThreadLink.test.ts b/apps/web/src/components/promptLinks/hostedThreadLink.test.ts new file mode 100644 index 0000000000000..f32da19efd6f2 --- /dev/null +++ b/apps/web/src/components/promptLinks/hostedThreadLink.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hostedThreadLinkAction } from "./hostedThreadLink"; +describe("bare hosted thread navigation", () => { + it("waits for environment discovery on refresh", () => { + expect(hostedThreadLinkAction("/env/thread", false, [])).toBe("wait"); + }); + it.each(["/env/first", "/env/second"])( + "keeps connected sidebar navigation and refresh in the browser: %s", + (path) => { + expect(hostedThreadLinkAction(path, true, ["env"])).toBe("continue"); + }, + ); + it("offers the opener for an unknown environment", () => { + expect(hostedThreadLinkAction("/unknown/thread", true, ["env"])).toBe("choose"); + }); + it("keeps prompt links on the consent path", () => { + expect(hostedThreadLinkAction("/env/thread?prompt=go", true, ["env"])).toBe("choose"); + }); +}); diff --git a/apps/web/src/components/promptLinks/hostedThreadLink.ts b/apps/web/src/components/promptLinks/hostedThreadLink.ts new file mode 100644 index 0000000000000..2379f23f98981 --- /dev/null +++ b/apps/web/src/components/promptLinks/hostedThreadLink.ts @@ -0,0 +1,12 @@ +import { parseThreadPath } from "@t3tools/shared/threadDeepLinks"; +export function hostedThreadLinkAction( + appPath: string, + ready: boolean, + environmentIds: ReadonlyArray, +) { + const url = new URL(appPath, "https://app.invalid"); + const ref = parseThreadPath(url.pathname); + if (!ref || url.searchParams.has("prompt")) return "choose"; + if (!ready) return "wait"; + return environmentIds.includes(ref.environmentId) ? "continue" : "choose"; +} diff --git a/apps/web/src/lib/copyThreadReference.ts b/apps/web/src/lib/copyThreadReference.ts index bbfa3b73a2c9c..3efbd1c1bd103 100644 --- a/apps/web/src/lib/copyThreadReference.ts +++ b/apps/web/src/lib/copyThreadReference.ts @@ -1,20 +1,10 @@ import type { ThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { - COMPOSER_CONTEXT_CLIPBOARD_MIME, - encodeComposerContextFragment, -} from "@t3tools/shared/composerContextClipboard"; - import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; export async function copyThreadReference(target: ThreadReferenceCopyTarget): Promise { - const fragment = target.fragment ? encodeComposerContextFragment(target.fragment) : null; try { - const didCopy = await writeTextToClipboard( - target.value, - target.clipboardTarget, - fragment ? { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment } : undefined, - ); + const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget); if (!didCopy) return; toastManager.add({ type: "success", diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 334303d61f60d..99a45a149c949 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -1,9 +1,5 @@ import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; -import { - normalizeThreadClipboard, - decodeComposerContextFragment, - encodeComposerContextFragment, -} from "@t3tools/shared/composerContextClipboard"; +import { normalizeThreadClipboard } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; @@ -87,28 +83,11 @@ describe("resolveThreadReferenceCopyTarget", () => { description: "Fix the login flow", successTitle: "Thread copied", }); - const pasted = decodeComposerContextFragment(encodeComposerContextFragment(target!.fragment!)); - expect(pasted).toEqual({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - expect.objectContaining({ - kind: "thread", - environmentId: "env-1", - threadId: "thread-1", - title: "Fix the login flow", - }), - ], - }); - // A paste keeps only the records its plain text references. - expect( - collectComposerContextReferences(normalizeThreadClipboard(target!.value, pasted).text), - ).toEqual([ - expect.objectContaining({ - kind: "thread", - contextId: pasted!.records[0]!.contextId, - label: "Fix the login flow", - }), + expect(target).not.toHaveProperty("fragment"); + const pasted = normalizeThreadClipboard(target!.value, null); + expect(pasted.fragment?.records).toMatchObject([ + { kind: "thread", environmentId: "env-1", threadId: "thread-1" }, ]); + expect(collectComposerContextReferences(pasted.text)).toMatchObject([{ kind: "thread" }]); }); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 60c4cc8f94b78..3478ab13ba8fa 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -1,8 +1,4 @@ -import type { - ComposerContextClipboardFragment, - ScopedThreadRef, - ThreadPullRequestLink, -} from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { threadDeepLink } from "@t3tools/shared/threadDeepLinks"; @@ -15,8 +11,6 @@ export interface ThreadReferenceCopyTarget { readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; - /** T3's clipboard format, which pastes a thread back into a composer as a chip. */ - readonly fragment?: ComposerContextClipboardFragment; readonly clipboardTarget: string; readonly successTitle: string; readonly failureTitle: string; @@ -32,7 +26,6 @@ export function threadChipCopyTarget( kind: "thread", value: threadDeepLink(threadRef), description: record.title, - fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", successTitle: "Thread copied", failureTitle: "Failed to copy thread", diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts index 9411142cc7b13..2fcccf98ae8db 100644 --- a/packages/shared/src/composerContextClipboard.ts +++ b/packages/shared/src/composerContextClipboard.ts @@ -20,36 +20,51 @@ export function normalizeThreadClipboard( fragment: ComposerContextClipboardFragment | null, acceptedIds?: ReadonlyMap, ) { + if (!text.includes("app.t3.codes/") && !text.includes("://threads/")) return { text, fragment }; const records = [...(fragment?.records ?? [])]; + const byThread = new Map( + records.flatMap((record) => + record.kind === "thread" && !("payload" in record) + ? [[JSON.stringify([record.environmentId, record.threadId]), record] as const] + : [], + ), + ); + const ids = new Set(records.map((record) => record.contextId)); let source = fragment?.source; - // Skip code spans/fences and consume Markdown links as a unit to avoid nesting references. + // Consume code and every Markdown link as a unit; never rewrite a URL inside another link. const normalized = text.replace( - /(`{3,}|~{3,})[\s\S]*?(?:\1|$)|(`+)[^\n]*?\2|\[([^\]\n]*)\]\((https:\/\/app\.t3\.codes\/[^\s)]+|t3code(?:-dev|-preview)?:\/\/threads\/[^\s)]+)\)|`]+>?/g, - (value, fence, span, label, href) => { - if (fence || span) return value; - const ref = parseThreadDeepLink(href ?? value.replace(/^<|>$/g, "")); + /(?`{3,}|~{3,})[\s\S]*?(?:\k|$)|(?`+)[^\n]*?\k|!?\[(?