diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.linklabels.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.linklabels.test.tsx new file mode 100644 index 0000000000..b9019f9a43 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.linklabels.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { MarkdownTextContent } from './markdown-text' + +// Regression for #121321: authored markdown link labels are the text the +// model wrote, and formatted labels count. An inline-code label +// ([`v1.0.1`](url)) used to be dropped by childrenToText (it only handled +// plain strings), so the link fell through to a title fetch or a URL-slug +// fallback label that title-cased the identifier (`V1.0.1`). The authored +// label must win, with its exact casing preserved. Bare URLs keep their +// identifier casing too when no title is fetched (`README.md`, not +// `README.Md`). +describe('MarkdownLink authored labels', () => { + afterEach(cleanup) + + it('preserves an inline-code link label with its exact casing', async () => { + render( + + ) + + await screen.findByText('v1.0.1') + const anchor = screen.getByRole('link') as HTMLAnchorElement + + expect(anchor.getAttribute('href')).toBe('https://example.com/releases/tag/v1.0.1') + expect(anchor.textContent).toContain('v1.0.1') + }) + + it('prefers the authored inline-code label over a fetched page title', async () => { + const fetchLinkTitle = vi.fn().mockResolvedValue('Releases · example') + + ;(window as unknown as { hermesDesktop: object }).hermesDesktop = { + fetchLinkTitle, + openExternal: vi.fn().mockResolvedValue(undefined) + } + + try { + render( + + ) + + await screen.findByText('v1.0.1') + expect(fetchLinkTitle).not.toHaveBeenCalled() + } finally { + delete (window as unknown as { hermesDesktop?: object }).hermesDesktop + } + }) + + it('keeps a bare URL fully visible with its exact casing', async () => { + render() + + const anchor = (await screen.findByRole('link')) as HTMLAnchorElement + + expect(anchor.getAttribute('href')).toBe('https://example.com/repository/blob/main/README.md') + // The full URL is the label (#121007), so its identifier casing is + // exactly what the sender wrote — never a title-cased `README.Md`. + expect(anchor.textContent).toContain('README.md') + expect(anchor.textContent).not.toContain('README.Md') + }) + + it('keeps a plain authored label untouched', () => { + render() + + expect(screen.getByRole('link').textContent).toContain('v1.0.1') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2a4b3441ba..c081edff6f 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -8,7 +8,7 @@ import { tailBoundedRemend } from '@assistant-ui/react-streamdown' import type { code as streamdownCode } from '@streamdown/code' -import { type ComponentProps, memo, type ReactNode, useEffect, useMemo, useState } from 'react' +import { type ComponentProps, isValidElement, memo, type ReactNode, useEffect, useMemo, useState } from 'react' import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' @@ -249,13 +249,29 @@ function MediaPlaybackAttachment({ path }: { path: string }) { ) } +// Authored labels can be formatted markdown — an inline-code label like +// [`v1.0.1`](url) arrives as a element, not a plain string. Extract +// the text so MarkdownLink can pass it as `fallbackLabel`; dropping it sent +// the link down the title-fetch / URL-slug fallback path instead (#121321). function childrenToText(children: unknown): string { - if (typeof children === 'string' || typeof children === 'number') { - return String(children).trim() + return flattenChildrenToText(children).trim() +} + +function flattenChildrenToText(node: unknown): string { + if (node === null || node === undefined || typeof node === 'boolean') { + return '' } - if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) { - return children.join('').trim() + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + + if (Array.isArray(node)) { + return node.map(flattenChildrenToText).join('') + } + + if (isValidElement<{ children?: unknown }>(node)) { + return flattenChildrenToText(node.props.children) } return '' diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx index 141b8f54d8..e7d669b5d4 100644 --- a/apps/desktop/src/lib/external-link.test.tsx +++ b/apps/desktop/src/lib/external-link.test.tsx @@ -70,6 +70,17 @@ describe('external link helpers', () => { ).toBe('From Fajardo Icacos Island Full Day Catamaran Trip') }) + // Regression for #121321: a separator-less slug token that looks like a + // case-sensitive identifier (a digit, a dot, or mixed case — release + // tags, filenames) must keep its exact casing; title-casing it invented a + // different identifier (`V1.0.1`, `README.Md`) than the one authored. A + // plain lowercase word token is not an identifier and still title-cases. + it('keeps identifier casing in separator-less slug tokens but still title-cases words', () => { + expect(urlSlugTitleLabel('https://example.com/releases/tag/v1.0.1')).toBe('v1.0.1') + expect(urlSlugTitleLabel('https://example.com/repository/blob/main/README.md')).toBe('README.md') + expect(urlSlugTitleLabel('https://example.com/p/quantumcomputing')).toBe('Quantumcomputing') + }) + it('filters out local/non-http targets for title fetches', () => { expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true) expect(isTitleFetchable('http://localhost:5174')).toBe(false) diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx index 5314ecafd8..13508586cd 100644 --- a/apps/desktop/src/lib/external-link.tsx +++ b/apps/desktop/src/lib/external-link.tsx @@ -104,10 +104,21 @@ export function urlSlugTitleLabel(value: string): string { continue } - const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + // Title-case word slugs (`some-guide` → `Some Guide`), but keep the + // exact casing of a separator-less token that looks like a + // case-sensitive identifier — it carries a digit, a dot, or mixed case, + // as in a release tag (`v1.0.1`) or a filename (`README.md`) — so the + // link never invents a different identifier. A plain lowercase word + // (`quantumcomputing` → `Quantumcomputing`) still title-cases. (#121321) + const looksLikeIdentifier = /\d/.test(cleaned) || /[A-Z]/.test(cleaned) || cleaned.includes('.') - if (titled.length >= 4) { - return titled + const label = + looksLikeIdentifier && !cleaned.includes(' ') + ? cleaned + : cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + + if (label.length >= 4) { + return label } }