fix(tui,desktop): let authored link text outrank the fetched page title

Both chat renderers resolve link titles over the network and render them
in place of the link's text, unconditionally — so they also replaced text
the agent deliberately wrote. `[#71706](url)` rendered as the whole GitHub
page title, and prose labels were swapped out mid-sentence.

The TUI ordered `fetched || label` and always passed the URL to
useLinkTitle. Desktop looked guarded but wasn't: chat markdown hands
authored text to PrettyLink as `fallbackLabel`, not `label`, so
`useLinkTitle(label ? null : target)` never skipped the fetch and
`fetched || label || fallbackLabel` still let the title win.

Treat an authored label as the intent: it wins, and it skips the fetch. A
label that is just the URL still resolves, since `[url](url)` and `<url>`
are bare links wearing markdown syntax — desktop already applied that same
URL-equality rule before handing the label over.

Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-25 22:52:34 -05:00
parent 6c9718151c
commit f244deffee
2 changed files with 18 additions and 13 deletions

View file

@ -251,10 +251,13 @@ interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
fallbackLabel?: string
}
// Title resolution is a fallback, not an override. Both props carry authored
// text — chat markdown passes `fallbackLabel` — so either one skips the fetch.
export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) {
const target = useMemo(() => normalizeExternalUrl(href), [href])
const fetched = useLinkTitle(label ? null : target)
const display = fetched || label?.trim() || fallbackLabel?.trim() || urlSlugTitleLabel(target)
const authoredLabel = label?.trim() || fallbackLabel?.trim()
const fetched = useLinkTitle(authoredLabel ? null : target)
const display = authoredLabel || fetched || urlSlugTitleLabel(target)
return (
<ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}>

View file

@ -153,25 +153,27 @@ const autolinkUrl = (raw: string) =>
const defaultLinkLabel = (url: string) =>
url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : /^https?:\/\//i.test(url) ? urlSlugTitleLabel(url) : url
const pickFallbackLabel = (label: string | undefined, target: string): string | undefined => {
// A label only counts as authored if it says something the URL doesn't:
// `[https://example.com](https://example.com)` and `<https://example.com>`
// are bare links wearing markdown syntax, so they still want a page title.
const pickAuthoredLabel = (label: string | undefined, target: string): string | undefined => {
const trimmed = label?.trim()
if (!trimmed) {
return undefined
}
return normalizeExternalUrl(trimmed) === target ? undefined : trimmed
return trimmed && normalizeExternalUrl(trimmed) !== target ? trimmed : undefined
}
interface ResolvedLinkProps {
fallbackLabel?: string
authoredLabel?: string
t: Theme
url: string
}
function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) {
const fetched = useLinkTitle(url)
const display = fetched || fallbackLabel || defaultLinkLabel(url)
// Title resolution is a fallback for links with no text of their own, not an
// override — replacing `[Read the RFC](url)` with the page title throws away
// better wording than we can derive, and mangles labels like `#71706`.
function ResolvedLink({ authoredLabel, t, url }: ResolvedLinkProps) {
const fetched = useLinkTitle(authoredLabel ? null : url)
const display = authoredLabel || fetched || defaultLinkLabel(url)
return (
<Link url={url}>
@ -185,7 +187,7 @@ function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) {
const renderResolvedLink = (k: number, t: Theme, rawUrl: string, label?: string) => {
const target = normalizeExternalUrl(rawUrl)
return <ResolvedLink fallbackLabel={pickFallbackLabel(label, target)} key={k} t={t} url={target} />
return <ResolvedLink authoredLabel={pickAuthoredLabel(label, target)} key={k} t={t} url={target} />
}
export const stripInlineMarkup = (v: string) =>