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
}
}