From 18d277966c5fcb65f351930903ead3ee087f8777 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 21 Oct 2026 02:19:02 +0800 Subject: [PATCH 1/4] feat(web): links in the rich text composer look like links Bare http(s) URLs in the rich text composer now render blue with the site's icon in front, matching how the sent message shows them. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 42 +----------- ...rPromptEditorTiptap.input.browser.test.tsx | 16 +++++ .../components/ComposerPromptEditorTiptap.tsx | 2 + .../components/chat/MarkdownLinkFavicon.tsx | 45 +++++++++++++ .../src/components/composerLinkDecorations.ts | 66 +++++++++++++++++++ apps/web/src/composer-links.test.ts | 40 +++++++++++ apps/web/src/composer-links.ts | 38 +++++++++++ apps/web/src/index.css | 4 ++ 8 files changed, 212 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/components/chat/MarkdownLinkFavicon.tsx create mode 100644 apps/web/src/components/composerLinkDecorations.ts create mode 100644 apps/web/src/composer-links.test.ts create mode 100644 apps/web/src/composer-links.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index baf23d2b4cec5..48609d22284b7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -59,7 +59,6 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, @@ -161,7 +160,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; +import { MarkdownLinkFavicon } from "./chat/MarkdownLinkFavicon"; import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; @@ -1255,45 +1254,6 @@ function normalizeMarkdownLinkHrefKey(href: string): string { : rewrittenHref; } -const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; - -const failedFaviconHosts = new Set(); - -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {brandIcon ? ( - - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); - const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( "max-h-[30rem]", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index a5c1a1e87a15d..2c2327ebc76f3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -149,6 +149,22 @@ it.each([ expect(textWithMark(editor, "code")).toEqual(code); }); +it.each([true, false])("styles bare URLs as links (rich text: %s)", async (richTextEnabled) => { + await using screen = await mountEditor( + "see https://github.com/pingdotgg/t3code/pull/1. and `https://example.com`", + richTextEnabled, + ); + const links = Array.from( + screen.container.querySelectorAll(".composer-link"), + (link) => link.textContent, + ); + expect(links).toEqual(richTextEnabled ? ["https://github.com/pingdotgg/t3code/pull/1"] : []); + // github.com gets the brand icon rather than a fetched favicon. + expect(screen.container.querySelectorAll(".ProseMirror-widget svg")).toHaveLength( + richTextEnabled ? 1 : 0, + ); +}); + it("still italicizes text when typing a closing asterisk", async () => { await using screen = await mountEditor("clean *build"); const { editor } = screen; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index f03335afb9aa1..76a2cb4b6303d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -119,6 +119,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; +import { ComposerLinkExtension } from "./composerLinkDecorations"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -861,6 +862,7 @@ function createComposerExtensions(richText: boolean) { ? [ ComposerItalicExtension, ComposerCodeExtension, + ComposerLinkExtension, ComposerCodeBlockExtension.extend({ addNodeView() { return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { diff --git a/apps/web/src/components/chat/MarkdownLinkFavicon.tsx b/apps/web/src/components/chat/MarkdownLinkFavicon.tsx new file mode 100644 index 0000000000000..8d7fd747ee056 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownLinkFavicon.tsx @@ -0,0 +1,45 @@ +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; +import { GlobeIcon } from "lucide-react"; +import { memo, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { GitHubIcon } from "../Icons"; + +const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; + +const failedFaviconHosts = new Set(); + +function isGitHubHost(host: string): boolean { + const hostname = host.toLowerCase(); + return hostname === "github.com" || hostname.endsWith(".github.com"); +} + +export const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { + const [failedHost, setFailedHost] = useState(null); + const brandIcon = isGitHubHost(host); + const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); + return ( + + {brandIcon ? ( + + ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( + + ) : ( + { + failedFaviconHosts.add(host); + setFailedHost(host); + }} + /> + )} + + ); +}); diff --git a/apps/web/src/components/composerLinkDecorations.ts b/apps/web/src/components/composerLinkDecorations.ts new file mode 100644 index 0000000000000..a9f6efbaab6ef --- /dev/null +++ b/apps/web/src/components/composerLinkDecorations.ts @@ -0,0 +1,66 @@ +import { Decoration, Extension } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { ReactWidgetRenderer } from "@tiptap/react"; + +import { type ComposerLink, findComposerLinks } from "~/composer-links"; +import { MarkdownLinkFavicon } from "./chat/MarkdownLinkFavicon"; + +function collectComposerLinks(doc: ProseMirrorNode): ComposerLink[] { + const links: ComposerLink[] = []; + doc.descendants((node, pos) => { + if (!node.isTextblock) return true; + if (node.type.spec.code) return false; + // A URL can span several text nodes (part of it bold, say), but not a + // chip, a hard break, or inline code. + let run = ""; + let runStart = 0; + const flush = () => { + for (const link of findComposerLinks(run)) { + links.push({ from: runStart + link.from, to: runStart + link.to, host: link.host }); + } + run = ""; + }; + node.forEach((child, offset) => { + if (child.isText && !child.marks.some((mark) => mark.type.spec.code)) { + if (run === "") runStart = pos + 1 + offset; + run += child.text; + } else { + flush(); + } + }); + flush(); + return false; + }); + return links; +} + +/** Colors bare URLs like the sent message's links and leads each with its site icon. */ +export const ComposerLinkExtension = Extension.create({ + name: "composerLinks", + addDecorations() { + const editor = this.editor; + return { + create: ({ state }) => { + const decorations: Decoration[] = []; + const occurrencesByHost = new Map(); + for (const link of collectComposerLinks(state.doc)) { + // Widget keys must be unique and should not follow positions, or + // typing before a link would remount its icon on every keystroke. + const occurrence = occurrencesByHost.get(link.host) ?? 0; + occurrencesByHost.set(link.host, occurrence + 1); + decorations.push( + ReactWidgetRenderer(MarkdownLinkFavicon, { + editor, + pos: link.from, + key: `composer-link-favicon:${link.host}:${occurrence}`, + props: { host: link.host }, + side: -1, + }), + Decoration.Inline(link.from, link.to, { class: "composer-link" }), + ); + } + return decorations; + }, + }; + }, +}); diff --git a/apps/web/src/composer-links.test.ts b/apps/web/src/composer-links.test.ts new file mode 100644 index 0000000000000..146fe515f2725 --- /dev/null +++ b/apps/web/src/composer-links.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findComposerLinks } from "./composer-links"; + +function linkTexts(text: string): string[] { + return findComposerLinks(text).map(({ from, to }) => text.slice(from, to)); +} + +describe("findComposerLinks", () => { + it("finds http(s) URLs with their host", () => { + const url = "https://github.com/pingdotgg/t3code/pull/22426#pullrequestreview-1"; + expect(findComposerLinks(`see ${url} now`)).toEqual([ + { from: 4, to: 4 + url.length, host: "github.com" }, + ]); + }); + + it("drops trailing punctuation and unbalanced closing parens", () => { + expect( + linkTexts("(https://example.com/a), https://example.com/b. [x](https://example.com/c)"), + ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); + expect(linkTexts("https://en.wikipedia.org/wiki/Foo_(bar)!")).toEqual([ + "https://en.wikipedia.org/wiki/Foo_(bar)", + ]); + }); + + it("matches GFM's boundaries and keeps quotes inside the path", () => { + expect( + linkTexts('[https://github.com] 1https://example.com/a\'b "https://example.com/c"'), + ).toEqual(["https://github.com", "https://example.com/a'b", "https://example.com/c"]); + }); + + it("ignores URLs glued to a letter or without a host", () => { + expect(linkTexts("xhttps://example.com https:// ftp://example.com")).toEqual([]); + }); + + it("trims a long run of closing parens in linear time", () => { + const parens = ")".repeat(200_000); + expect(linkTexts(`https://example.com${parens}`)).toEqual(["https://example.com"]); + }); +}); diff --git a/apps/web/src/composer-links.ts b/apps/web/src/composer-links.ts new file mode 100644 index 0000000000000..1247210c7e6e6 --- /dev/null +++ b/apps/web/src/composer-links.ts @@ -0,0 +1,38 @@ +import { resolveExternalWebLinkHost } from "./components/chat/externalLinkContextMenu"; + +export interface ComposerLink { + readonly from: number; + readonly to: number; + readonly host: string; +} + +// Mirrors GFM's autolink literals in the sent message: an http(s) URL not +// glued to a preceding letter, minus trailing punctuation and unbalanced `)`. +const URL_PATTERN = /(? 0) { + const character = url[end - 1]!; + if (character === ")" && unbalancedCloses > 0) unbalancedCloses -= 1; + else if (!TRAILING_PUNCTUATION.has(character)) break; + end -= 1; + } + return url.slice(0, end); +} + +export function findComposerLinks(text: string): ComposerLink[] { + const links: ComposerLink[] = []; + for (const match of text.matchAll(URL_PATTERN)) { + const url = trimLinkEnd(match[0]); + const host = resolveExternalWebLinkHost(url); + if (host) links.push({ from: match.index, to: match.index + url.length, host }); + } + return links; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1a4727e40a172..d9f4cc3f45980 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2335,6 +2335,10 @@ code { padding: 0.05em 0.25em; } +.composer-tiptap .composer-link { + color: var(--info-foreground); +} + .composer-rich-marker { color: var(--muted-foreground); opacity: 0.75; From ad0d038d1ea88dc9fdac9ecd447f367e56b38572 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 21 Oct 2026 02:40:51 +0800 Subject: [PATCH 2/4] fix(web): composer links drop entity-like suffixes as GFM does Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-links.test.ts | 13 +++++++++++++ apps/web/src/composer-links.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/composer-links.test.ts b/apps/web/src/composer-links.test.ts index 146fe515f2725..623f4d3b60f29 100644 --- a/apps/web/src/composer-links.test.ts +++ b/apps/web/src/composer-links.test.ts @@ -23,6 +23,19 @@ describe("findComposerLinks", () => { ]); }); + it("drops entity-like suffixes whole, as GFM does", () => { + expect( + linkTexts( + "https://example.com?q=x&hl; https://example.com/a.&hl;> https://example.com/b&1; https://example.com/c&;", + ), + ).toEqual([ + "https://example.com?q=x", + "https://example.com/a", + "https://example.com/b&1", + "https://example.com/c&", + ]); + }); + it("matches GFM's boundaries and keeps quotes inside the path", () => { expect( linkTexts('[https://github.com] 1https://example.com/a\'b "https://example.com/c"'), diff --git a/apps/web/src/composer-links.ts b/apps/web/src/composer-links.ts index 1247210c7e6e6..f6c1cd92634c9 100644 --- a/apps/web/src/composer-links.ts +++ b/apps/web/src/composer-links.ts @@ -20,6 +20,15 @@ function trimLinkEnd(url: string): string { let end = url.length; while (end > 0) { const character = url[end - 1]!; + if (character === ";") { + // GFM drops an entity-like `&name;` suffix whole, not just its `;`. + let nameStart = end - 1; + while (nameStart > 0 && /[A-Za-z]/.test(url[nameStart - 1]!)) nameStart -= 1; + if (nameStart < end - 1 && url[nameStart - 1] === "&") { + end = nameStart - 1; + continue; + } + } if (character === ")" && unbalancedCloses > 0) unbalancedCloses -= 1; else if (!TRAILING_PUNCTUATION.has(character)) break; end -= 1; From b4d79341af92b9b77e2852d28823619ffba91761 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 18:46:41 +0800 Subject: [PATCH 3/4] fix(web): composer link icons use the link color Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/composerLinkDecorations.ts | 1 + apps/web/src/index.css | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/composerLinkDecorations.ts b/apps/web/src/components/composerLinkDecorations.ts index a9f6efbaab6ef..2498e6a78c64a 100644 --- a/apps/web/src/components/composerLinkDecorations.ts +++ b/apps/web/src/components/composerLinkDecorations.ts @@ -54,6 +54,7 @@ export const ComposerLinkExtension = Extension.create({ pos: link.from, key: `composer-link-favicon:${link.host}:${occurrence}`, props: { host: link.host }, + className: "composer-link-icon", side: -1, }), Decoration.Inline(link.from, link.to, { class: "composer-link" }), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 74ebf1399c7d4..e25081adf255b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2441,7 +2441,8 @@ code { padding: 0.05em 0.25em; } -.composer-tiptap .composer-link { +.composer-tiptap .composer-link, +.composer-tiptap .composer-link-icon { color: var(--info-foreground); } From ccdbe6531b30902232cd56bed4730a9e33aae44b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:33:56 +0000 Subject: [PATCH 4/4] fix(web): composer links skip inline code shown as Markdown source --- apps/web/src/components/composerLinkDecorations.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/composerLinkDecorations.ts b/apps/web/src/components/composerLinkDecorations.ts index 2498e6a78c64a..fc3c980a1c90b 100644 --- a/apps/web/src/components/composerLinkDecorations.ts +++ b/apps/web/src/components/composerLinkDecorations.ts @@ -3,8 +3,12 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { ReactWidgetRenderer } from "@tiptap/react"; import { type ComposerLink, findComposerLinks } from "~/composer-links"; +import { MARKDOWN_SOURCE_MARK } from "~/composer-rich-text"; import { MarkdownLinkFavicon } from "./chat/MarkdownLinkFavicon"; +// Revealed Markdown source shows inline code as backtick text without the code mark. +const CODE_SPAN = /(? { @@ -23,7 +27,9 @@ function collectComposerLinks(doc: ProseMirrorNode): ComposerLink[] { node.forEach((child, offset) => { if (child.isText && !child.marks.some((mark) => mark.type.spec.code)) { if (run === "") runStart = pos + 1 + offset; - run += child.text; + run += child.marks.some((mark) => mark.type.name === MARKDOWN_SOURCE_MARK) + ? child.text!.replace(CODE_SPAN, (span) => " ".repeat(span.length)) + : child.text; } else { flush(); }