+
{children}
)
@@ -403,7 +445,14 @@ const MARKDOWN_COMPONENTS = {
},
input({ type, checked }: { type?: string; checked?: boolean }) {
if (type === 'checkbox') {
- return
+ return (
+
+ )
}
return
},
@@ -705,7 +754,7 @@ function ChatContentInner({
-
+
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-presentation.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-presentation.test.tsx
new file mode 100644
index 00000000000..dc7728b0eda
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-presentation.test.tsx
@@ -0,0 +1,96 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) }))
+vi.mock('@/hooks/use-smooth-text', () => ({ useSmoothText: (text: string) => text }))
+vi.mock('@/hooks/queries/link-preview', () => ({
+ useLinkPreview: () => ({ data: { preview: null } }),
+}))
+vi.mock('@/lib/browser-agent/open-in-panel', () => ({
+ shouldOpenInBrowserPanel: () => false,
+ openInBrowserPanel: vi.fn(),
+}))
+
+import { ChatContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content'
+
+let container: HTMLDivElement
+let root: Root
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'matchMedia',
+ vi
+ .fn()
+ .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
+ )
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+async function render(content: string) {
+ await act(async () => root.render( ))
+}
+
+describe('mixed chat presentation', () => {
+ it('preserves full table content and gives links their own preview without competing tooltips', async () => {
+ const source = JSON.stringify({
+ url: 'https://example.com/review',
+ title: 'Release review',
+ connectorType: 'confluence',
+ })
+ await render(
+ `| Document | Status | Notes |\n| :--- | :---: | ---: |\n| **A long document title** | Ready | [Read *the guide*](https://example.com/guide) and \`search workspace\` |\n| Review | Done | ${source} |`
+ )
+ expect(container.querySelectorAll('th')).toHaveLength(3)
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
+ expect([...container.querySelectorAll('th')].map((cell) => cell.style.textAlign)).toEqual([
+ 'left',
+ 'center',
+ 'right',
+ ])
+ expect(container.querySelector('td [data-streamdown="strong"]')?.textContent).toBe(
+ 'A long document title'
+ )
+ expect(container.querySelector('td')?.querySelector('[data-overflow-text]')).toBeNull()
+ expect(container.querySelector('td code')?.textContent).toBe('search workspace')
+ expect(container.querySelector('td a[href="https://example.com/guide"] em')?.textContent).toBe(
+ 'the guide'
+ )
+ const citation = container.querySelector(
+ 'td a[href="https://example.com/review"]'
+ )!
+ expect(citation.closest('[data-overflow-text]')).toBeNull()
+ expect(
+ container
+ .querySelector('td a[href="https://example.com/guide"]')
+ ?.closest('[data-overflow-text]')
+ ).toBeNull()
+ expect(citation.textContent).toBe('Release review')
+ act(() => citation.focus())
+ expect(
+ document.querySelector('[role="dialog"][aria-label="Source preview"]')?.textContent
+ ).toContain('Release review')
+ })
+
+ it('keeps nested task content and marked link text without losing list semantics', async () => {
+ await render(
+ '- [x] **Parent** task\n - Nested bullet with [**bold** *italic* ~~removed~~ `code`](https://example.com/guide)\n - [ ] Nested task\n\n- [ ] Loose task\n\n A second paragraph.'
+ )
+ expect(container.querySelectorAll('ul ul').length).toBeGreaterThan(0)
+ expect(container.querySelectorAll('[role="checkbox"]')).toHaveLength(3)
+ const link = container.querySelector('a[href="https://example.com/guide"]')!
+ expect(link.querySelector('[data-streamdown="strong"]')?.textContent).toBe('bold')
+ expect(link.querySelector('em')?.textContent).toBe('italic')
+ expect(link.querySelector('del')?.textContent).toBe('removed')
+ expect(link.querySelector('code')?.textContent).toBe('code')
+ expect(container.textContent).toContain('A second paragraph.')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx
index 1bc3049cd02..41b9646d0b2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx
@@ -4,131 +4,146 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockPreview } = vi.hoisted(() => ({ mockPreview: vi.fn() }))
-
vi.mock('@/lib/browser-agent/open-in-panel', () => ({
shouldOpenInBrowserPanel: () => false,
openInBrowserPanel: vi.fn(),
}))
-vi.mock('@/hooks/queries/link-preview', () => ({
- useLinkPreview: () => ({ data: { preview: mockPreview() } }),
-}))
+vi.mock('@/lib/integrations/icon-mapping', () => ({ blockTypeToIconMap: {} }))
+vi.mock('@/hooks/queries/link-preview', () => ({ useLinkPreview: mockPreview }))
import {
ExternalLink,
- getExternalLinkTooltip,
LinkSourcesContext,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
-const HREF = 'https://mail.google.com/mail/u/0/#inbox/FMfcgzQ'
-const PREVIEW = { title: 'Preview title', description: 'Preview description', siteName: 'Gmail' }
-const SOURCE: SourceTagData = { url: HREF, title: 'Quarterly plan thread', siteName: 'Gmail' }
-
-describe('getExternalLinkTooltip', () => {
- it('prefers the cited source title, then the preview title, then the site name', () => {
- expect(getExternalLinkTooltip(HREF, SOURCE, PREVIEW)).toMatchObject({
- title: 'Quarterly plan thread',
- siteName: 'Gmail',
- })
- expect(getExternalLinkTooltip(HREF, undefined, PREVIEW)).toMatchObject({
- title: 'Preview title',
- siteName: 'Gmail',
- description: 'Preview description',
- })
- expect(getExternalLinkTooltip(HREF, undefined, undefined)).toEqual({
- title: 'mail.google.com',
- })
- expect(getExternalLinkTooltip('https://www.example.com/a', undefined, null)).toEqual({
- title: 'example.com',
- })
- })
+const HREF = 'https://docs.example.com/guide'
+const SOURCE: SourceTagData = {
+ url: HREF,
+ title: 'Quarterly plan',
+ snippet: 'Authorized source excerpt.',
+ connectorType: 'confluence',
+}
- it('never shows the raw URL, and takes a description only from the preview', () => {
- for (const tooltip of [
- getExternalLinkTooltip(HREF, SOURCE, undefined),
- getExternalLinkTooltip(
- HREF,
- { url: HREF },
- { title: ' ', description: null, siteName: null }
- ),
- getExternalLinkTooltip(HREF, undefined, undefined),
- ]) {
- expect(Object.values(tooltip)).not.toContain(HREF)
- expect(tooltip.description).toBeUndefined()
- }
- })
-})
-
-describe('ExternalLink', () => {
+describe('shared link previews', () => {
let root: Root
let container: HTMLDivElement
-
beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
- mockPreview.mockReturnValue(null)
+ mockPreview.mockReset().mockReturnValue({ data: { preview: null } })
})
-
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
})
-
- const render = (sources: ReadonlyMap = new Map()) =>
+ const render = (href = HREF, source?: SourceTagData) =>
act(() =>
root.render(
-
+
- See the{' '}
-
- Conversation
-
+ See the guide .
)
)
- const link = () => container.querySelector('a')!
- const hover = () =>
- act(() => {
- link().dispatchEvent(new MouseEvent('pointerover', { bubbles: true, clientX: 9, clientY: 9 }))
- })
+ const link = () => container.querySelector('a')!
+ const preview = () => document.querySelector('[role="dialog"][aria-label="Source preview"]')
+ const enter = () =>
+ act(() => link().dispatchEvent(new MouseEvent('pointerover', { bubbles: true })))
+ const leave = () =>
+ act(() => link().dispatchEvent(new MouseEvent('pointerout', { bubbles: true })))
- it('centers the favicon on the text middle without a pixel offset or an inline-flex link', () => {
+ it('does not load metadata during render or a passing hover', () => {
render()
- const icon = link().querySelector('img')!
- expect(icon.classList).toContain('align-middle')
- expect(icon.parentElement).toBe(link())
- const offsets = [...icon.classList].filter((name) => /^(-?top|relative|translate)/.test(name))
- expect(offsets).toEqual([])
+ expect(mockPreview).not.toHaveBeenCalled()
+ enter()
+ act(() => vi.advanceTimersByTime(200))
+ leave()
+ act(() => vi.advanceTimersByTime(500))
+ expect(mockPreview).not.toHaveBeenCalled()
+ expect(preview()).toBeNull()
+ })
+
+ it('loads public metadata on deliberate hover and preserves navigation', () => {
+ mockPreview.mockReturnValue({
+ data: {
+ preview: {
+ title: 'Guide',
+ siteName: 'Docs',
+ description: 'Useful instructions.',
+ image: 'data:image/webp;base64,AAAA',
+ },
+ },
+ })
+ render()
+ enter()
+ act(() => vi.advanceTimersByTime(300))
+ expect(mockPreview).toHaveBeenCalledWith(HREF)
+ expect(preview()?.textContent).toContain('Useful instructions.')
+ expect(preview()?.querySelector('img[src^="data:image/webp"]')).not.toBeNull()
+ expect(preview()?.querySelector('a')?.getAttribute('href')).toBe(HREF)
+ expect(link().getAttribute('href')).toBe(HREF)
expect(link().classList).not.toContain('inline-flex')
})
- it('underlines only on hover, with no fill, and keeps the keyboard focus outline', () => {
+ it('uses private source metadata and does not request public-page metadata', () => {
+ render(HREF, SOURCE)
+ act(() => link().focus())
+ expect(mockPreview).toHaveBeenCalledWith(undefined)
+ expect(preview()?.textContent).toContain('Quarterly plan')
+ expect(preview()?.textContent).toContain('Authorized source excerpt.')
+ expect(preview()?.textContent).not.toContain(HREF)
+ })
+
+ it('keeps a focused preview open on pointer leave and dismisses on Escape', () => {
+ render()
+ act(() => link().focus())
+ leave()
+ act(() => vi.advanceTimersByTime(500))
+ expect(preview()).not.toBeNull()
+ act(() =>
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ )
+ expect(preview()).toBeNull()
+ expect(document.activeElement).toBe(link())
+ })
+
+ it('keeps focused preview content open and returns focus on Escape', () => {
render()
- const classes = [...link().classList]
- expect(classes).toContain('no-underline')
- expect(classes).toContain('hover:underline')
- expect(classes).toContain('decoration-[var(--text-muted)]')
- expect(classes.some((name) => name.startsWith('hover:bg-'))).toBe(false)
- expect(classes).toContain('focus-visible:outline')
+ act(() => link().focus())
+ const openLink = preview()!.querySelector('a')!
+ act(() => openLink.focus())
+ act(() => openLink.dispatchEvent(new MouseEvent('pointerout', { bubbles: true })))
+ act(() => vi.advanceTimersByTime(500))
+ expect(preview()).not.toBeNull()
+ act(() =>
+ openLink.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ )
+ expect(preview()).toBeNull()
+ expect(document.activeElement).toBe(link())
})
- it('titles the tooltip with the cited source for this exact URL', () => {
- render(new Map([[HREF, SOURCE]]))
- hover()
- const tooltip = document.querySelector('[role="tooltip"]')!
- expect(tooltip.textContent).toContain('Quarterly plan thread')
- expect(tooltip.textContent).toContain('Gmail')
- expect(tooltip.textContent).not.toContain(HREF)
+ it('keeps the preview open when focus returns to its anchor', () => {
+ render()
+ act(() => link().focus())
+ act(() => preview()!.querySelector('a')!.focus())
+ act(() => link().focus())
+ act(() => vi.advanceTimersByTime(500))
+ expect(preview()).not.toBeNull()
})
- it('falls back to the site name instead of the URL for a private page with no preview', () => {
+ it('restores the favicon when a reused link changes hosts after an image error', () => {
render()
- hover()
- const tooltip = document.querySelector('[role="tooltip"]')!
- expect(tooltip.textContent).toContain('mail.google.com')
- expect(tooltip.textContent).not.toContain(HREF)
+ act(() => link().querySelector('img')!.dispatchEvent(new Event('error')))
+ expect(link().querySelector('img')).toBeNull()
+ render('https://other.example.com/guide')
+ expect(link().querySelector('img')).not.toBeNull()
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
index fbcac378d76..d9a7a7b6d51 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
@@ -1,140 +1,39 @@
'use client'
-import { createContext, useContext } from 'react'
-import { Tooltip } from '@sim/emcn'
-import type { LinkPreview } from '@/lib/api/contracts/link-preview'
-import { openInBrowserPanel, shouldOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
-import { faviconUrl } from '@/lib/core/utils/favicon'
+import { createContext, type ReactNode, useContext } from 'react'
+import { SourceIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon'
+import {
+ handleExternalLinkClick,
+ PROSE_LINK_CLASS,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
+import { SourcePreview } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-preview'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
-import { useLinkPreview } from '@/hooks/queries/link-preview'
-/** Hides a favicon img that failed to load so the link degrades to plain text. */
-export function hideBrokenFavicon(e: React.SyntheticEvent): void {
- e.currentTarget.style.display = 'none'
-}
-
-/**
- * Hostname for an external http(s) link, used to fetch its favicon. Returns
- * null for relative, anchor, mailto, and unparsable hrefs so those keep the
- * plain text treatment.
- */
-export function externalLinkHostname(href?: string): string | null {
- if (!href || !/^https?:\/\//i.test(href)) return null
- try {
- return new URL(href).hostname
- } catch {
- return null
- }
-}
-
-/** The site a link belongs to: its known site name, else its hostname without `www.`. */
-export function linkSiteName(url: string, siteName?: string | null): string {
- return siteName?.trim() || (externalLinkHostname(url) ?? url).replace(/^www\./, '')
-}
-
-/**
- * A prose link: no fill, a thin muted underline only on hover, and an outline
- * for keyboard focus.
- */
-export const PROSE_LINK_CLASS =
- 'not-prose text-[var(--text-primary)] no-underline decoration-1 decoration-[var(--text-muted)] underline-offset-2 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--text-primary)]'
-
-/**
- * The turn's retrieved sources by URL. A link the model writes to a document it
- * retrieved takes that document's title, which private pages (Gmail, Slack,
- * Drive) never expose through a link preview.
- */
+/** Retrieved metadata takes precedence over generic public-page metadata. */
export const LinkSourcesContext = createContext>(new Map())
-export interface ExternalLinkTooltip {
- title: string
- /** The site, shown muted beneath the title when it adds information. */
- siteName?: string
- /** A description, only ever from the link preview. */
- description?: string
-}
-
-/**
- * What a link's tooltip says, never the raw URL: the cited source's title for
- * this exact URL, else the link preview's title, else the site name.
- */
-export function getExternalLinkTooltip(
- href: string,
- source: SourceTagData | undefined,
- preview: LinkPreview | undefined
-): ExternalLinkTooltip {
- const siteName = linkSiteName(href, source?.siteName ?? preview?.siteName)
- const title = source?.title?.trim() || preview?.title?.trim() || siteName
- return {
- title,
- ...(title !== siteName ? { siteName } : {}),
- ...(preview?.description?.trim() ? { description: preview.description.trim() } : {}),
- }
-}
-
interface ExternalLinkProps {
href: string
- hostname: string
- children?: React.ReactNode
+ children?: ReactNode
}
-/**
- * In the desktop app, a plain click diverts into the embedded Sim browser
- * panel; modified clicks (Cmd/Ctrl/Shift/middle) keep the default behavior,
- * which the shell routes to the system browser. In a web browser this is a
- * no-op and the link opens a new tab as usual.
- */
-export function handleExternalLinkClick(
- event: React.MouseEvent,
- href: string
-): void {
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
- if (!shouldOpenInBrowserPanel(href)) return
- event.preventDefault()
- openInBrowserPanel(href)
-}
-
-/**
- * Favicon + understated external link with a titled tooltip. The favicon is
- * `align-middle`, like citation chips, so it tracks any font size without an
- * offset while the link text still wraps. The preview query fires on render, so
- * metadata is normally cached before the first hover. Previews are https-only:
- * fetching a plain-http link server-side would reach the URL validator's
- * self-host loopback exception.
- */
-export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
- const source = useContext(LinkSourcesContext).get(href)
- const { data } = useLinkPreview(href.startsWith('https://') ? href : undefined)
- const tooltip = getExternalLinkTooltip(href, source, data?.preview ?? undefined)
-
+/** The anchor stays inline so long link text wraps with the surrounding prose. */
+export function ExternalLink({ href, children }: ExternalLinkProps) {
+ const source = useContext(LinkSourcesContext).get(href) ?? { url: href }
return (
-
-
- handleExternalLinkClick(event, href)}
- >
-
- {children}
-
-
-
-
- {tooltip.title}
- {tooltip.description && (
- {tooltip.description}
- )}
- {tooltip.siteName && {tooltip.siteName}}
+
+ handleExternalLinkClick(event, href)}
+ >
+
+
-
-
+ {children}
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
index 305a02b97fe..ad1308ca832 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
@@ -1,7 +1,8 @@
'use client'
-import { Popover, PopoverContent, PopoverTrigger, Tooltip } from '@sim/emcn'
+import { cn, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@sim/emcn'
import { BookOpen } from '@sim/emcn/icons'
+import { inter } from '@/app/_styles/fonts/inter/inter'
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
@@ -36,7 +37,12 @@ export function MessageSources({ sources }: MessageSourcesProps) {
{label}
-
+
{sources.map((source) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/options/options.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/options/options.tsx
index 080edad2af7..baf863eb1b3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/options/options.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/options/options.tsx
@@ -1,4 +1,5 @@
-import type { OptionItem } from '../../../../types'
+import { Chip } from '@sim/emcn'
+import type { OptionItem } from '@/app/workspace/[workspaceId]/home/types'
interface OptionsProps {
items: OptionItem[]
@@ -11,14 +12,15 @@ export function Options({ items, onSelect }: OptionsProps) {
return (
{items.map((item) => (
-
+
))}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention.tsx
index a6f685cd35f..e284730012b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import { cn } from '@sim/emcn'
+import { chipHoverSurfaceClass, chipTagVariants, cn, OverflowText } from '@sim/emcn'
interface ResourceMentionProps {
icon: ReactNode
@@ -8,12 +8,11 @@ interface ResourceMentionProps {
}
export function ResourceMention({ icon, title, onSelect }: ResourceMentionProps) {
- const classes =
- 'inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] align-baseline font-[inherit] text-[inherit] leading-[inherit]'
+ const classes = cn(chipTagVariants({ variant: 'mono' }), 'max-w-full align-middle')
const content = (
<>
{icon}
- {title}
+
>
)
if (!onSelect) return {content}
@@ -21,7 +20,7 @@ export function ResourceMention({ icon, title, onSelect }: ResourceMentionProps)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
index aa5b351465a..3bdacac008b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
@@ -18,12 +18,12 @@ import {
import { Check, Link as LinkIcon, MoreHorizontal, Sparkles } from '@sim/emcn/icons'
import { formatDate } from '@sim/utils/formatting'
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
-import { handleExternalLinkClick } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
import {
SourceIcon,
sourceLabel,
sourceSiteName,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
+import { handleExternalLinkClick } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
const SOURCE_ROW_CLASSES = cn(
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx
index 6b9d0edcbd2..68991279403 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx
@@ -8,6 +8,8 @@ vi.mock('@/lib/browser-agent/open-in-panel', () => ({
shouldOpenInBrowserPanel: () => false,
openInBrowserPanel: vi.fn(),
}))
+vi.mock('@/hooks/queries/link-preview', () => ({ useLinkPreview: () => ({ data: undefined }) }))
+
vi.mock('@/lib/integrations/icon-mapping', () => ({ blockTypeToIconMap: {} }))
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
@@ -47,15 +49,11 @@ describe('citation labels', () => {
expect(link.textContent).toBe('#engineering')
expect(link.getAttribute('href')).toBe(source.url)
- act(() => {
- link.dispatchEvent(
- new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
- )
- })
- const tooltip = document.querySelector('[role="tooltip"]')!
+ act(() => link.focus())
+ const tooltip = document.querySelector('[role="dialog"][aria-label="Source preview"]')!
expect(tooltip.textContent).toContain(source.title)
- expect(tooltip.textContent).toContain(source.url)
- expect(link.getAttribute('aria-describedby')).toBe(tooltip.id)
+ expect(tooltip.textContent).not.toContain(source.url)
+ expect(tooltip.querySelector('a')?.getAttribute('href')).toBe(source.url)
})
it.each([
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx
index 2e383d00af9..68f430c2ebd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx
@@ -1,11 +1,12 @@
'use client'
-import { chipFilledFillTokens, chipHoverSurfaceClass, cn, OverflowText, Tooltip } from '@sim/emcn'
+import { chipFilledFillTokens, chipHoverSurfaceClass, cn, OverflowText } from '@sim/emcn'
+import { SourceIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon'
import {
handleExternalLinkClick,
linkSiteName,
-} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
-import { SourceIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon'
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
+import { SourcePreview } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-preview'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
/** The source's site or provider, separate from its document title. */
@@ -35,33 +36,21 @@ export function SourceChip({ source }: SourceChipProps) {
source.connectorType === 'slack' ? source.title?.match(/^(#[^:\s]+): /)?.[1] : undefined
return (
-
-
- handleExternalLinkClick(event, source.url)}
- className={cn(
- 'not-prose inline-flex h-[20px] max-w-[160px] shrink-0 items-center gap-1 rounded-full px-1.5 align-middle text-[var(--text-body)] text-caption no-underline transition-colors',
- chipFilledFillTokens,
- chipHoverSurfaceClass
- )}
- >
-
-
-
-
-
- {source.title ? (
-
- {source.title}
- {source.url}
-
- ) : (
- {source.url}
+
+ handleExternalLinkClick(event, source.url)}
+ className={cn(
+ 'not-prose inline-flex h-[20px] max-w-[160px] shrink-0 items-center gap-1 rounded-full px-1.5 align-middle font-normal text-[var(--text-body)] text-caption no-underline transition-colors',
+ chipFilledFillTokens,
+ chipHoverSurfaceClass
)}
-
-
+ >
+
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
index e6d500f3ec4..fd2b85da929 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
@@ -6,7 +6,7 @@ import { FileText } from '@sim/emcn/icons'
import { stripVersionSuffix } from '@sim/utils/string'
import { faviconUrl } from '@/lib/core/utils/favicon'
import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
-import { externalLinkHostname } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
+import { externalLinkHostname } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-link.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-link.ts
new file mode 100644
index 00000000000..bc6d82cb86b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-link.ts
@@ -0,0 +1,40 @@
+import type { MouseEvent } from 'react'
+import { openInBrowserPanel, shouldOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
+
+/**
+ * Hostname for an external http(s) link, used to fetch its favicon. Returns
+ * null for relative, anchor, mailto, and unparsable hrefs so those keep the
+ * plain text treatment.
+ */
+export function externalLinkHostname(href?: string): string | null {
+ if (!href || !/^https?:\/\//i.test(href)) return null
+ try {
+ return new URL(href).hostname
+ } catch {
+ return null
+ }
+}
+
+/** The site a link belongs to: its known site name, else its hostname without `www.`. */
+export function linkSiteName(url: string, siteName?: string | null): string {
+ return siteName?.trim() || (externalLinkHostname(url) ?? url).replace(/^www\./, '')
+}
+
+/**
+ * A prose link shares the platform blue and retains a visible keyboard focus outline.
+ */
+export const PROSE_LINK_CLASS =
+ 'not-prose [&_strong]:text-inherit [&_em]:text-inherit [&_code]:text-inherit [&_del]:text-inherit text-[var(--brand-blue)] no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--text-primary)]'
+
+/**
+ * In the desktop app, a plain click diverts into the embedded Sim browser
+ * panel; modified clicks (Cmd/Ctrl/Shift/middle) keep the default behavior,
+ * which the shell routes to the system browser. In a web browser this is a
+ * no-op and the link opens a new tab as usual.
+ */
+export function handleExternalLinkClick(event: MouseEvent, href: string): void {
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
+ if (!shouldOpenInBrowserPanel(href)) return
+ event.preventDefault()
+ openInBrowserPanel(href)
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx
new file mode 100644
index 00000000000..f03debe6891
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { type ReactElement, useEffect, useRef, useState } from 'react'
+import { cn, OverflowText, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
+import { ArrowUpRight } from '@sim/emcn/icons'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+import { SourceIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon'
+import {
+ handleExternalLinkClick,
+ linkSiteName,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { useLinkPreview } from '@/hooks/queries/link-preview'
+
+interface SourcePreviewProps {
+ source: SourceTagData
+ children: ReactElement
+}
+
+/** One anchored preview shared by prose links and citations, with a hover bridge to its Open link. */
+export function SourcePreview({ source, children }: SourcePreviewProps) {
+ const anchor = useRef(null)
+ const content = useRef(null)
+ const restoringFocus = useRef(false)
+ const timer = useRef | null>(null)
+ const [open, setOpen] = useState(false)
+
+ const cancelTimer = () => {
+ if (timer.current !== null) clearTimeout(timer.current)
+ timer.current = null
+ }
+ const changeOpen = (value: boolean) => {
+ cancelTimer()
+ setOpen(value)
+ }
+ const schedule = (value: boolean) => {
+ cancelTimer()
+ timer.current = setTimeout(
+ () => {
+ if (
+ !value &&
+ (anchor.current?.contains(document.activeElement) ||
+ content.current?.contains(document.activeElement))
+ )
+ return
+ setOpen(value)
+ },
+ value ? 300 : 150
+ )
+ }
+ useEffect(
+ () => () => {
+ if (timer.current !== null) clearTimeout(timer.current)
+ },
+ []
+ )
+
+ return (
+
+ {
+ anchor.current = event.currentTarget
+ if (event.pointerType !== 'touch') schedule(true)
+ }}
+ onPointerLeave={() => schedule(false)}
+ onFocus={(event) => {
+ anchor.current = event.currentTarget
+ if (!restoringFocus.current) changeOpen(true)
+ }}
+ onBlur={() => schedule(false)}
+ >
+ {children}
+
+ {open && (
+ {
+ const target = event.detail.originalEvent.target
+ if (target instanceof Node && anchor.current?.contains(target)) event.preventDefault()
+ }}
+ onEscapeKeyDown={(event) => {
+ event.preventDefault()
+ changeOpen(false)
+ restoringFocus.current = true
+ anchor.current?.focus({ preventScroll: true })
+ restoringFocus.current = false
+ }}
+ onPointerEnter={cancelTimer}
+ onPointerLeave={() => schedule(false)}
+ onFocusCapture={cancelTimer}
+ onBlur={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) schedule(false)
+ }}
+ >
+
+
+ )}
+
+ )
+}
+
+/** Mounting with the popover gates metadata and image work behind deliberate intent. */
+function SourcePreviewContent({ source }: Pick) {
+ const { data } = useLinkPreview(
+ (!source.connectorType || source.connectorType === 'github') &&
+ source.url.startsWith('https://')
+ ? source.url
+ : undefined
+ )
+ const preview = data?.preview
+ const siteName = linkSiteName(source.url, source.siteName ?? preview?.siteName)
+ const title = source.title?.trim() || preview?.title?.trim() || siteName
+ const description =
+ source.snippet?.trim() || (!source.connectorType && preview?.description?.trim())
+ return (
+
+
+ {preview?.image && (
+
+ )}
+
+ {title}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index 61839524295..6cb73ccb365 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -10,7 +10,15 @@ import {
useRef,
useState,
} from 'react'
-import { cn, Expandable, ExpandableContent, SecretReveal, Tooltip, toast } from '@sim/emcn'
+import {
+ ChipLink,
+ cn,
+ Expandable,
+ ExpandableContent,
+ SecretReveal,
+ Tooltip,
+ toast,
+} from '@sim/emcn'
import {
ArrowRight,
Check,
@@ -18,6 +26,7 @@ import {
Lock,
SquareArrowUpRight,
TerminalWindow,
+ TriangleAlert,
} from '@sim/emcn/icons'
import { isRecordLike, omit } from '@sim/utils/object'
import { useParams } from 'next/navigation'
@@ -2027,7 +2036,7 @@ function WorkspaceResourceDisplayContent({
icon={
}
title={resource.title}
@@ -3457,54 +3466,45 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
: 'Only the workspace owner can manage this workspace’s usage limits.'
return (
-
-
-
-
Usage Limit Reached
+ }
+ >
+
+ {data.message}
+ {canManageBilling ? (
+
+ {buttonLabel}
+
+ ) : (
+
+ {unavailableMessage}
+ {hostContext &&
+ usageGate.isSuccess &&
+ usageGate.data.isExceeded &&
+ usageGate.data.scope === 'member' && (
+
+ )}
+
+ )}
-
- {data.message}
-
- {canManageBilling ? (
-
- {buttonLabel}
- {hosted ? : }
-
- ) : (
-
- {unavailableMessage}
- {hostContext &&
- usageGate.isSuccess &&
- usageGate.data.isExceeded &&
- usageGate.data.scope === 'member' && (
-
- )}
-
- )}
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index bda17642585..b4761b515db 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -1109,7 +1109,7 @@ function MessageContentInner({
return (
-
+
{segments.map((segment, i) => {
switch (segment.type) {
case 'text':
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index a7496f90db6..c96e9a74d6c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -17,6 +17,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { inter } from '@/app/_styles/fonts/inter/inter'
import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -814,7 +815,7 @@ export function MothershipChat({
onContextRemove={onContextRemove}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
>
-
+
{isLoading && !hasMessages ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 76300944348..ce483e4606b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -18,6 +18,7 @@ import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachm
import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
+import { inter } from '@/app/_styles/fonts/inter/inter'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
AnimatedPlaceholderEffect,
@@ -558,6 +559,7 @@ const UserInputImpl = forwardRef(function UserI
}}
className={cn(
'relative z-10 mx-auto w-full max-w-chat cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
+ inter.className,
isInitialView && 'shadow-ambient'
)}
onDragEnter={handleDragEnter}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
index d807f00a5be..f4d05d73e26 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
@@ -3,15 +3,17 @@
import { useMemo } from 'react'
import { cn } from '@sim/emcn'
import { escapeRegExp } from '@sim/utils/string'
+import { inter } from '@/app/_styles/fonts/inter/inter'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
+import { ResourceMention } from '@/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { getIntegrationMatcher } from '@/blocks/integration-matcher'
const USER_MESSAGE_CLASSES =
- 'whitespace-pre-wrap [overflow-wrap:anywhere] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
+ 'whitespace-pre-wrap [overflow-wrap:anywhere] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
const COMPACT_CLASSES =
- 'truncate text-small leading-[20px] font-[family-name:var(--font-inter)] text-[var(--text-primary)] tracking-[0] antialiased'
+ 'truncate text-small leading-[20px] text-[var(--text-primary)] tracking-[0] antialiased'
interface UserMessageContentProps {
content: string
@@ -105,13 +107,15 @@ function computeIntegrationRanges(text: string, taken: MentionRange[]): MentionR
function MentionHighlight({ context }: { context: ChatMessageContext }) {
return (
-
-
- {context.label}
-
+
+ }
+ />
)
}
@@ -123,7 +127,7 @@ export function UserMessageContent({
compact = false,
}: UserMessageContentProps) {
const trimmed = content.trim()
- const classes = cn(compact ? COMPACT_CLASSES : USER_MESSAGE_CLASSES, className)
+ const classes = cn(inter.className, compact ? COMPACT_CLASSES : USER_MESSAGE_CLASSES, className)
const ranges = useMemo(() => computeMentionRanges(content, contexts ?? []), [content, contexts])
diff --git a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx
index 3f60d94d8d5..940da286618 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx
@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'
-import { inter } from '@/app/_styles/fonts/inter/inter'
/**
* Redirects rather than 404s when Chat is disabled: this path is baked into
@@ -20,9 +19,5 @@ export default async function HomeLayout({
redirect(`/workspace/${workspaceId}`)
}
- return (
-
- {children}
-
- )
+ return {children}
}
diff --git a/apps/sim/hooks/queries/link-preview.test.tsx b/apps/sim/hooks/queries/link-preview.test.tsx
new file mode 100644
index 00000000000..4517287b9f0
--- /dev/null
+++ b/apps/sim/hooks/queries/link-preview.test.tsx
@@ -0,0 +1,62 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
+vi.mock('@/lib/api/client/request', () => ({ requestJson: requestMock }))
+
+import { linkPreviewKeys, useLinkPreview } from '@/hooks/queries/link-preview'
+
+const URL = 'https://example.com/guide'
+const complete = { title: 'Guide', description: null, siteName: null }
+let client: QueryClient
+let container: HTMLDivElement
+let root: Root
+
+function Preview() {
+ useLinkPreview(URL)
+ return null
+}
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ requestMock.mockReset().mockResolvedValue({ preview: complete })
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ client.clear()
+})
+
+describe('link preview freshness', () => {
+ it.each([true, false])(
+ 'retries a one-minute-old preview only when its image is retryable: %s',
+ async (retryable) => {
+ client.setQueryData(
+ linkPreviewKeys.detail(URL),
+ { preview: { ...complete, ...(retryable ? { imageRetryable: true } : {}) } },
+ { updatedAt: Date.now() - 61_000 }
+ )
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+ expect(requestMock).toHaveBeenCalledTimes(retryable ? 1 : 0)
+ if (retryable) {
+ expect(requestMock).toHaveBeenCalledWith(expect.anything(), {
+ query: { url: URL },
+ signal: expect.any(AbortSignal),
+ })
+ }
+ }
+ )
+})
diff --git a/apps/sim/hooks/queries/link-preview.ts b/apps/sim/hooks/queries/link-preview.ts
index 9e7ecd8f35d..5c53e8315a4 100644
--- a/apps/sim/hooks/queries/link-preview.ts
+++ b/apps/sim/hooks/queries/link-preview.ts
@@ -4,6 +4,7 @@ import { getLinkPreviewContract, type LinkPreviewResponse } from '@/lib/api/cont
/** Previews are near-immutable page metadata; the server also caches for 24h. */
export const LINK_PREVIEW_STALE_TIME = 60 * 60 * 1000
+export const LINK_PREVIEW_RETRY_STALE_TIME = 60 * 1000
export const linkPreviewKeys = {
all: ['link-preview'] as const,
@@ -17,17 +18,19 @@ async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise fetchLinkPreview(url as string, signal),
enabled: Boolean(url),
- staleTime: LINK_PREVIEW_STALE_TIME,
+ staleTime: (query) =>
+ query.state.data?.preview?.imageRetryable
+ ? LINK_PREVIEW_RETRY_STALE_TIME
+ : LINK_PREVIEW_STALE_TIME,
retry: false,
})
}
diff --git a/apps/sim/lib/api/contracts/link-preview.ts b/apps/sim/lib/api/contracts/link-preview.ts
index 48b705c19e4..856aa358374 100644
--- a/apps/sim/lib/api/contracts/link-preview.ts
+++ b/apps/sim/lib/api/contracts/link-preview.ts
@@ -16,6 +16,17 @@ export const linkPreviewResponseSchema = z.object({
title: z.string().nullable(),
description: z.string().nullable(),
siteName: z.string().nullable(),
+ image: z
+ .string()
+ .max(180_000)
+ .regex(/^data:image\/webp;base64,[A-Za-z0-9+/=]+$/)
+ .optional(),
+ imageRetryable: z
+ .literal(true)
+ .describe(
+ 'The optional image was deferred or temporarily unavailable; retry on later intent.'
+ )
+ .optional(),
})
.nullable(),
})
diff --git a/apps/sim/lib/core/errors/retryable-infrastructure.test.ts b/apps/sim/lib/core/errors/retryable-infrastructure.test.ts
index f2424908058..7784fef58db 100644
--- a/apps/sim/lib/core/errors/retryable-infrastructure.test.ts
+++ b/apps/sim/lib/core/errors/retryable-infrastructure.test.ts
@@ -41,6 +41,17 @@ describe('isRetryableInfrastructureError', () => {
})
})
+ it.each(['DNS_TIMEOUT', 'EAI_AGAIN'])('recognizes transient DNS errors: %s', (code) => {
+ expect(
+ isRetryableInfrastructureError(new Error('DNS lookup failed', { cause: errorWithCode(code) }))
+ ).toBe(true)
+ })
+
+ it('does not retry permanent DNS or destination-policy failures', () => {
+ expect(isRetryableInfrastructureError(errorWithCode('ENOTFOUND'))).toBe(false)
+ expect(isRetryableInfrastructureError(new Error('Destination blocked'))).toBe(false)
+ })
+
it('does not classify semantic SQL errors as retryable', () => {
expect(isRetryableInfrastructureError(errorWithCode('42703'))).toBe(false)
expect(isRetryableInfrastructureError(new Error('workflow not found'))).toBe(false)
diff --git a/apps/sim/lib/core/errors/retryable-infrastructure.ts b/apps/sim/lib/core/errors/retryable-infrastructure.ts
index 30ef8103986..8513969dd8b 100644
--- a/apps/sim/lib/core/errors/retryable-infrastructure.ts
+++ b/apps/sim/lib/core/errors/retryable-infrastructure.ts
@@ -16,10 +16,13 @@ const RETRYABLE_DB_ERROR_CODES = new Set([
])
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
+ 'DNS_TIMEOUT',
+ 'EAI_AGAIN',
'ETIMEDOUT',
'ECONNRESET',
'ECONNREFUSED',
'EPIPE',
+ 'ERR_STREAM_PREMATURE_CLOSE',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
diff --git a/apps/sim/lib/core/security/egress/validate.ts b/apps/sim/lib/core/security/egress/validate.ts
index 6fd33b5a85e..54dfde0b72c 100644
--- a/apps/sim/lib/core/security/egress/validate.ts
+++ b/apps/sim/lib/core/security/egress/validate.ts
@@ -38,6 +38,8 @@ export interface EgressValidationSuccess {
export interface EgressValidationFailure {
readonly isValid: false
readonly error: string
+ /** Retains resolver failure codes so callers can distinguish an outage from a policy denial. */
+ readonly cause?: unknown
}
export type EgressValidationResult = EgressValidationSuccess | EgressValidationFailure
@@ -45,6 +47,8 @@ export type EgressValidationResult = EgressValidationSuccess | EgressValidationF
export interface EgressValidationOptions {
/** Omit destination-derived values from logs when the URL contains protected context. */
logDetails?: boolean
+ /** Cancels DNS validation before a guarded connection can begin. */
+ signal?: AbortSignal
}
type EgressDenial = Extract
@@ -77,6 +81,7 @@ export async function validateEgressUrl(
profile: EgressProfile,
options: EgressValidationOptions = {}
): Promise {
+ options.signal?.throwIfAborted()
if (!url || typeof url !== 'string') {
return { isValid: false, error: `${paramName} is required and must be a string` }
}
@@ -113,15 +118,16 @@ export async function validateEgressUrl(
let addresses: string[]
try {
- addresses = (await resolveHostAddresses(host)).addresses
+ addresses = (await resolveHostAddresses(host, { signal: options.signal })).addresses
} catch (error) {
+ options.signal?.throwIfAborted()
logger.warn(
'DNS lookup failed',
options.logDetails === false
? { profile, paramName }
: { profile, paramName, host, error: toError(error).message }
)
- return { isValid: false, error: `${paramName} hostname could not be resolved` }
+ return { isValid: false, error: `${paramName} hostname could not be resolved`, cause: error }
}
// Refused records are filtered rather than failing the whole host: pinning to a
diff --git a/apps/sim/lib/core/security/input-validation.server.test.ts b/apps/sim/lib/core/security/input-validation.server.test.ts
index 5e85382bf2d..826d3910245 100644
--- a/apps/sim/lib/core/security/input-validation.server.test.ts
+++ b/apps/sim/lib/core/security/input-validation.server.test.ts
@@ -26,7 +26,10 @@ vi.mock('@/lib/core/config/env-flags', () => ({
getProxyUrl: () => undefined,
}))
-import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
+import {
+ secureFetchWithValidation,
+ validateUrlWithDNS,
+} from '@/lib/core/security/input-validation.server'
/**
* Shapes a resolver answer the way `resolveHostAddresses` does, including its
@@ -125,6 +128,16 @@ describe('validateUrlWithDNS address classification', () => {
).toBe(false)
})
+ it('retains resolver causes for retry classification without admitting the request', async () => {
+ const cause = Object.assign(new Error('Temporary DNS failure'), { code: 'EAI_AGAIN' })
+ mockResolve.mockRejectedValue(cause)
+ await expect(
+ secureFetchWithValidation('https://example.com/preview', {
+ profile: 'contentFetch',
+ })
+ ).rejects.toMatchObject({ message: 'url hostname could not be resolved', cause })
+ })
+
it('can conceal credential-derived host details in validation logs', async () => {
mockResolve.mockRejectedValue(new Error('DNS failure with credential-host-canary'))
@@ -141,4 +154,25 @@ describe('validateUrlWithDNS address classification', () => {
})
expect(JSON.stringify(mockWarn.mock.calls)).not.toContain('credential-host-canary')
})
+
+ it('forwards fetch cancellation through DNS preflight without treating it as a resolver failure', async () => {
+ const controller = new AbortController()
+ mockResolve.mockImplementationOnce(
+ (_host, options) =>
+ new Promise((_, reject) => {
+ options.signal.addEventListener('abort', () => reject(options.signal.reason), {
+ once: true,
+ })
+ })
+ )
+ const pending = secureFetchWithValidation('https://example.com/preview', {
+ profile: 'contentFetch',
+ signal: controller.signal,
+ })
+ const rejection = expect(pending).rejects.toThrow('Preview deadline')
+ controller.abort(new Error('Preview deadline'))
+ await rejection
+ expect(mockResolve).toHaveBeenCalledWith('example.com', { signal: controller.signal })
+ expect(mockWarn).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts
index 0bf028fa4ba..77160997596 100644
--- a/apps/sim/lib/core/security/input-validation.server.ts
+++ b/apps/sim/lib/core/security/input-validation.server.ts
@@ -26,11 +26,15 @@ import { describeEgressDenial, type EgressProfile } from '@/lib/core/security/eg
import {
checkEgressUrl,
checkResolvedEgress,
+ type EgressValidationOptions,
validateEgressUrl,
} from '@/lib/core/security/egress/validate'
import type { HttpRedirectPolicy } from '@/lib/core/security/http-redirect-policy'
import type { ValidationResult } from '@/lib/core/security/input-validation'
-import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
+import {
+ createPrematureStreamCloseError,
+ nodeReadableToWebStream,
+} from '@/lib/core/utils/node-stream'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const logger = createLogger('InputValidation')
@@ -40,7 +44,13 @@ const logger = createLogger('InputValidation')
*/
export type AsyncValidationResult =
| { isValid: true; resolvedIP: string; originalHostname: string; error?: undefined }
- | { isValid: false; error: string; resolvedIP?: undefined; originalHostname?: undefined }
+ | {
+ isValid: false
+ error: string
+ cause?: unknown
+ resolvedIP?: undefined
+ originalHostname?: undefined
+ }
/**
* Validates a URL, resolves its DNS, and returns the address to pin.
@@ -59,12 +69,12 @@ export async function validateUrlWithDNS(
url: string | null | undefined,
paramName: string,
profile: EgressProfile,
- options: { logDetails?: boolean } = {}
+ options: EgressValidationOptions = {}
): Promise {
const result = await validateEgressUrl(url, paramName, profile, options)
return result.isValid
? { isValid: true, resolvedIP: result.resolvedIP, originalHostname: result.originalHostname }
- : { isValid: false, error: result.error }
+ : { isValid: false, error: result.error, cause: result.cause }
}
/**
@@ -911,7 +921,7 @@ async function undiciRequestAsResponse(
signal?.addEventListener('abort', onAbort, { once: true })
body.once('error', (error) => decoder.destroy(error))
body.once('close', () => {
- if (!body.readableEnded) decoder.destroy(new Error('Response body closed before completing'))
+ if (!body.readableEnded) decoder.destroy(createPrematureStreamCloseError())
})
decoder.once('close', () => {
signal?.removeEventListener('abort', onAbort)
@@ -1210,10 +1220,13 @@ export async function secureFetchWithPinnedIP(
}
validateUrlWithDNS(redirectUrl, 'redirectUrl', options.profile, {
logDetails: options.logUrlValidationDetails,
+ signal: options.signal,
})
.then((validation) => {
if (!validation.isValid) {
- settledReject(new Error(`Redirect blocked: ${validation.error}`))
+ settledReject(
+ new Error(`Redirect blocked: ${validation.error}`, { cause: validation.cause })
+ )
return
}
const redirectPolicy = options.redirectPolicy
@@ -1411,13 +1424,12 @@ export async function secureFetchWithPinnedIP(
})
nodeRes.once('error', fail)
nodeRes.once('close', () => {
- if (!bodySettled) fail(new Error('Response body closed before completing'))
+ if (!bodySettled) fail(createPrematureStreamCloseError())
})
if (decoder) {
res.once('error', (error) => decoder.destroy(error))
res.once('close', () => {
- if (!res.readableEnded)
- decoder.destroy(new Error('Response body closed before completing'))
+ if (!res.readableEnded) decoder.destroy(createPrematureStreamCloseError())
})
res.pipe(decoder)
}
@@ -1514,7 +1526,11 @@ export async function secureFetchWithPinnedIP(
req.on('error', settledReject)
req.on('timeout', () => {
destroyRequest()
- settledReject(new Error(`Request timed out after ${requestOptions.timeout}ms`))
+ settledReject(
+ Object.assign(new Error(`Request timed out after ${requestOptions.timeout}ms`), {
+ code: 'ETIMEDOUT',
+ })
+ )
})
send = () => {
req.end(options.body)
@@ -1555,9 +1571,10 @@ export async function secureFetchWithValidation(
): Promise {
const validation = await validateUrlWithDNS(url, paramName, options.profile, {
logDetails: options.logUrlValidationDetails,
+ signal: options.signal,
})
if (!validation.isValid) {
- throw new Error(validation.error)
+ throw new Error(validation.error, { cause: validation.cause })
}
return secureFetchWithPinnedIP(url, validation.resolvedIP, options)
}
diff --git a/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts b/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts
index b8ddd9ad206..ad1a5798bf6 100644
--- a/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts
+++ b/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts
@@ -6,6 +6,7 @@
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
+import { resolveHostAddresses } from '@sim/security/dns'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/security/dns', () => ({
@@ -58,6 +59,20 @@ async function startRecordingServer(hops: RecordedHop[]): Promise {
}
describe('secureFetchWithPinnedIP redirect replay', () => {
+ it('preserves transient DNS failure causes on redirects', async () => {
+ const cause = Object.assign(new Error('Temporary DNS failure'), { code: 'EAI_AGAIN' })
+ vi.mocked(resolveHostAddresses).mockRejectedValueOnce(cause)
+ const origin = await startServer((_req, res) => {
+ res.writeHead(302, { location: 'https://example.com/next' })
+ res.end()
+ })
+ await expect(
+ secureFetchWithPinnedIP(origin, '127.0.0.1', {
+ profile: 'contentFetch',
+ })
+ ).rejects.toMatchObject({ cause })
+ })
+
it('rejects a redirect target before following it', async () => {
const hops: RecordedHop[] = []
const target = await startRecordingServer(hops)
diff --git a/apps/sim/lib/core/security/secure-fetch-request-framing.server.test.ts b/apps/sim/lib/core/security/secure-fetch-request-framing.server.test.ts
index 7d977c64e29..3701841767e 100644
--- a/apps/sim/lib/core/security/secure-fetch-request-framing.server.test.ts
+++ b/apps/sim/lib/core/security/secure-fetch-request-framing.server.test.ts
@@ -194,6 +194,16 @@ describe('secureFetchWithPinnedIP request framing', () => {
expect(receivedHost).toBe(url.host)
})
+ it('exposes a retryable code when the request times out before headers', async () => {
+ const origin = await startServer((req) => req.resume())
+ await expect(
+ secureFetchWithPinnedIP(origin, '127.0.0.1', {
+ timeout: 20,
+ profile: 'configuredEndpoint',
+ })
+ ).rejects.toMatchObject({ code: 'ETIMEDOUT' })
+ })
+
it('cancels a framed request while waiting for response headers', async () => {
const controller = new AbortController()
const origin = await startServer((req) => {
diff --git a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts
index c86966085f3..c9e75b91f11 100644
--- a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts
+++ b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts
@@ -414,7 +414,7 @@ describe('pinned fetch response decoding', () => {
controller.abort(reason)
if (mode === 'guarded') await expect(reading).rejects.toBe(reason)
- else await expect(reading).rejects.toThrow(/closed before completing/)
+ else await expect(reading).rejects.toMatchObject({ code: 'ERR_STREAM_PREMATURE_CLOSE' })
expect(decoder.destroyed).toBe(true)
} finally {
decoder.destroy()
diff --git a/apps/sim/lib/core/utils/node-stream.test.ts b/apps/sim/lib/core/utils/node-stream.test.ts
new file mode 100644
index 00000000000..71061f15ac3
--- /dev/null
+++ b/apps/sim/lib/core/utils/node-stream.test.ts
@@ -0,0 +1,33 @@
+/** @vitest-environment node */
+import { PassThrough } from 'node:stream'
+import { describe, expect, it } from 'vitest'
+import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
+import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
+
+describe('nodeReadableToWebStream', () => {
+ it('marks an errorless premature close as retryable instead of completing a partial body', async () => {
+ const source = new PassThrough()
+ const reading = new Response(nodeReadableToWebStream(source)).text()
+ const rejected = expect(reading).rejects.toMatchObject({ code: 'ERR_STREAM_PREMATURE_CLOSE' })
+ source.write('partial')
+ source.destroy()
+ await rejected
+ expect(await reading.catch(isRetryableInfrastructureError)).toBe(true)
+ })
+
+ it('preserves complete responses', async () => {
+ const source = new PassThrough()
+ const reading = new Response(nodeReadableToWebStream(source)).text()
+ source.end('complete')
+ await expect(reading).resolves.toBe('complete')
+ })
+
+ it('preserves the original source error', async () => {
+ const source = new PassThrough()
+ const reading = new Response(nodeReadableToWebStream(source)).text()
+ const error = new Error('Source failed')
+ const rejected = expect(reading).rejects.toBe(error)
+ source.destroy(error)
+ await rejected
+ })
+})
diff --git a/apps/sim/lib/core/utils/node-stream.ts b/apps/sim/lib/core/utils/node-stream.ts
index 1288a2280d7..12723820f34 100644
--- a/apps/sim/lib/core/utils/node-stream.ts
+++ b/apps/sim/lib/core/utils/node-stream.ts
@@ -1,5 +1,12 @@
import type { Readable } from 'node:stream'
+/** Preserve Node's transient stream-close code when an upstream closes without an error event. */
+export function createPrematureStreamCloseError() {
+ return Object.assign(new Error('Stream closed before completing'), {
+ code: 'ERR_STREAM_PREMATURE_CLOSE',
+ })
+}
+
/**
* Bridges a Node `Readable` into a WHATWG `ReadableStream` suitable for a `Response`
* body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an unhandled
@@ -47,7 +54,7 @@ export function nodeReadableToWebStream(nodeStream: Readable): ReadableStream ({ fetchMock: vi.fn() }))
+vi.mock('@/lib/core/security/input-validation.server', () => ({
+ secureFetchWithValidation: fetchMock,
+}))
+
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { fetchLinkPreview } from '@/lib/link-preview/fetch-preview'
+
+const PAGE = 'https://example.com/docs/guide'
+const IMAGE = 'https://images.example.com/card.png'
+function page(image = IMAGE) {
+ return new Response(
+ ``,
+ { headers: { 'content-type': 'text/html' } }
+ )
+}
+
+describe('public link preview images', () => {
+ beforeEach(() => fetchMock.mockReset())
+
+ it.each(['Text/HTML; charset=UTF-8', 'Application/XHTML+XML ; charset=utf-8'])(
+ 'accepts case-insensitive HTML media types: %s',
+ async (contentType) => {
+ fetchMock.mockResolvedValueOnce(
+ new Response('Guide ', { headers: { 'content-type': contentType } })
+ )
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: null,
+ siteName: null,
+ })
+ }
+ )
+
+ it('normalizes a raster to a bounded thumbnail and guards both requests and redirects', async () => {
+ const input = await sharp({
+ create: { width: 1200, height: 630, channels: 3, background: '#5577aa' },
+ })
+ .png()
+ .toBuffer()
+ fetchMock
+ .mockResolvedValueOnce(page())
+ .mockResolvedValueOnce(
+ new Response(new Uint8Array(input), { headers: { 'content-type': 'image/png' } })
+ )
+ const result = await fetchLinkPreview(PAGE)
+ expect(result?.title).toBe('Guide')
+ expect(result?.image).toMatch(/^data:image\/webp;base64,/)
+ const buffer = Buffer.from(result!.image!.split(',')[1], 'base64')
+ expect(buffer.length).toBeLessThanOrEqual(128 * 1024)
+ expect(await sharp(buffer).metadata()).toMatchObject({
+ format: 'webp',
+ width: 640,
+ height: 336,
+ })
+ for (const [, options] of fetchMock.mock.calls) {
+ expect(options).toMatchObject({
+ profile: 'contentFetch',
+ maxRedirects: 3,
+ signal: expect.any(AbortSignal),
+ })
+ expect(() => options.assertRedirectTarget('http://example.com')).toThrow()
+ expect(() => options.assertRedirectTarget('https://user:password@example.com')).toThrow()
+ }
+ expect(fetchMock.mock.calls[0][1].maxResponseBytes).toBe(1024 * 1024)
+ expect(fetchMock.mock.calls[1][1].maxResponseBytes).toBe(2 * 1024 * 1024)
+ })
+
+ it.each(['http://example.com/image.png', 'data:image/png;base64,AA==', 'file:///tmp/image.png'])(
+ 'skips unsafe image schemes: %s',
+ async (image) => {
+ fetchMock.mockResolvedValueOnce(page(image))
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it('resolves relative images against the final redirected page', async () => {
+ fetchMock
+ .mockImplementationOnce(async (_url, options) => {
+ options.assertRedirectTarget('https://example.com/new/page')
+ return page('../card.png')
+ })
+ .mockResolvedValueOnce(new Response('', { status: 404 }))
+ await fetchLinkPreview(PAGE)
+ expect(fetchMock.mock.calls[1][0]).toBe('https://example.com/card.png')
+ })
+
+ it.each([
+ new Error('Private IP blocked'),
+ new Error('Redirect blocked'),
+ new PayloadSizeLimitError({ label: 'response body', maxBytes: 2 * 1024 * 1024 }),
+ ])('does not retry permanent image failures: %s', async (error) => {
+ fetchMock.mockResolvedValueOnce(page()).mockRejectedValueOnce(error)
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ })
+ })
+
+ it.each(['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'DNS_TIMEOUT'])(
+ 'retries transient fetch failures through their cause chain: %s',
+ async (code) => {
+ const cause = Object.assign(new Error('Upstream unavailable'), { code })
+ fetchMock
+ .mockResolvedValueOnce(page())
+ .mockRejectedValueOnce(new Error('Fetch failed', { cause }))
+ expect(await fetchLinkPreview(PAGE)).toMatchObject({ title: 'Guide', imageRetryable: true })
+ }
+ )
+
+ it('does not retry a malformed image reference', async () => {
+ fetchMock.mockResolvedValueOnce(page('https://['))
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('retries an image whose body closes before completing', async () => {
+ const interrupted = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([137, 80, 78, 71]))
+ controller.error(
+ Object.assign(new Error('Stream closed before completing'), {
+ code: 'ERR_STREAM_PREMATURE_CLOSE',
+ })
+ )
+ },
+ })
+ fetchMock
+ .mockResolvedValueOnce(page())
+ .mockResolvedValueOnce(
+ new Response(interrupted, { headers: { 'content-type': 'image/png' } })
+ )
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ imageRetryable: true,
+ })
+ })
+
+ it('does not retry malformed raster data', async () => {
+ fetchMock
+ .mockResolvedValueOnce(page())
+ .mockResolvedValueOnce(
+ new Response('not a PNG', { headers: { 'content-type': 'image/png' } })
+ )
+ expect(await fetchLinkPreview(PAGE)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ })
+ })
+
+ it.each([
+ { status: 404, contentType: 'image/png', retryable: undefined },
+ { status: 429, contentType: 'image/png', retryable: true },
+ { status: 503, contentType: 'image/png', retryable: true },
+ { status: 200, contentType: 'image/svg+xml', retryable: undefined },
+ ])(
+ 'cancels rejected image bodies: $status $contentType',
+ async ({ status, contentType, retryable }) => {
+ const cancel = vi.fn()
+ const response = new Response(new ReadableStream({ cancel }), {
+ status,
+ headers: { 'content-type': contentType },
+ })
+ const read = vi.spyOn(response, 'arrayBuffer')
+ fetchMock.mockResolvedValueOnce(page()).mockResolvedValueOnce(response)
+ const result = await fetchLinkPreview(PAGE)
+ expect(result?.imageRetryable).toBe(retryable)
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(read).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each([
+ { status: 404, contentType: 'text/html' },
+ { status: 503, contentType: 'text/html' },
+ { status: 200, contentType: 'application/octet-stream' },
+ { status: 200, contentType: 'text/html-invalid' },
+ ])('cancels rejected page bodies: $status $contentType', async ({ status, contentType }) => {
+ const cancel = vi.fn()
+ const response = new Response(new ReadableStream({ cancel }), {
+ status,
+ headers: { 'content-type': contentType },
+ })
+ const read = vi.spyOn(response, 'text')
+ fetchMock.mockResolvedValueOnce(response)
+ expect(await fetchLinkPreview(PAGE)).toBeNull()
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(read).not.toHaveBeenCalled()
+ })
+
+ it('rejects SVG even when served with a raster content type', async () => {
+ fetchMock
+ .mockResolvedValueOnce(page())
+ .mockResolvedValueOnce(
+ new Response(
+ '',
+ { headers: { 'content-type': 'image/png' } }
+ )
+ )
+ expect((await fetchLinkPreview(PAGE))?.image).toBeUndefined()
+ })
+
+ it('propagates caller cancellation instead of publishing an incomplete cached preview', async () => {
+ const controller = new AbortController()
+ fetchMock.mockResolvedValueOnce(page()).mockImplementationOnce(async (_url, options) => {
+ controller.abort()
+ options.signal.throwIfAborted()
+ })
+ await expect(fetchLinkPreview(PAGE, controller.signal)).rejects.toThrow()
+ })
+
+ it('retains page metadata when only the optional image exhausts the deadline', async () => {
+ const deadline = new AbortController()
+ const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal)
+ fetchMock.mockResolvedValueOnce(page()).mockImplementationOnce(async (_url, options) => {
+ deadline.abort(new DOMException('Preview deadline exceeded', 'TimeoutError'))
+ options.signal.throwIfAborted()
+ })
+ try {
+ expect(await fetchLinkPreview(PAGE, new AbortController().signal)).toEqual({
+ title: 'Guide',
+ description: 'A useful guide',
+ siteName: null,
+ imageRetryable: true,
+ })
+ } finally {
+ timeout.mockRestore()
+ }
+ })
+
+ it('rejects credentials before making a request', async () => {
+ await expect(fetchLinkPreview('https://user:password@example.com')).rejects.toThrow()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('admits only two image requests at once and releases capacity without queueing the rest', async () => {
+ const releases: Array<() => void> = []
+ fetchMock.mockImplementation(async (url) => {
+ if (url !== IMAGE) return page()
+ return new Promise((resolve) => {
+ releases.push(() => resolve(new Response('', { status: 404 })))
+ })
+ })
+ const pending = Array.from({ length: 60 }, (_, index) => fetchLinkPreview(`${PAGE}?n=${index}`))
+ await vi.waitFor(() => expect(releases).toHaveLength(2))
+ expect(fetchMock.mock.calls.filter(([url]) => url === IMAGE)).toHaveLength(2)
+ releases.forEach((release) => release())
+ const results = await Promise.all(pending)
+ expect(results.filter((result) => result?.imageRetryable)).toHaveLength(58)
+
+ fetchMock.mockResolvedValueOnce(page()).mockResolvedValueOnce(new Response('', { status: 404 }))
+ expect((await fetchLinkPreview(PAGE))?.imageRetryable).toBeUndefined()
+ expect(fetchMock.mock.calls.filter(([url]) => url === IMAGE)).toHaveLength(3)
+ })
+
+ it('marks temporary image-server failures as retryable while preserving text', async () => {
+ fetchMock.mockResolvedValueOnce(page()).mockResolvedValueOnce(new Response('', { status: 503 }))
+ expect(await fetchLinkPreview(PAGE)).toMatchObject({ title: 'Guide', imageRetryable: true })
+ })
+})
diff --git a/apps/sim/lib/link-preview/fetch-preview.ts b/apps/sim/lib/link-preview/fetch-preview.ts
new file mode 100644
index 00000000000..5e5373639c9
--- /dev/null
+++ b/apps/sim/lib/link-preview/fetch-preview.ts
@@ -0,0 +1,142 @@
+import { truncate } from '@sim/utils/string'
+import * as cheerio from 'cheerio'
+import sharp from 'sharp'
+import type { LinkPreview } from '@/lib/api/contracts/link-preview'
+import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
+import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
+
+const FETCH_TIMEOUT_MS = 5000
+const PREVIEW_DEADLINE_MS = 8000
+const MAX_HTML_BYTES = 1024 * 1024
+const MAX_IMAGE_BYTES = 2 * 1024 * 1024
+const MAX_PREVIEW_BYTES = 128 * 1024
+const MAX_IMAGE_PIXELS = 16_000_000
+const RASTER_FORMATS = new Set(['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'])
+const MAX_CONCURRENT_IMAGES = 2
+let activeImages = 0
+
+type PreviewImage = Pick, 'image' | 'imageRetryable'>
+
+/** Public previews never forward credentials or allow a redirect into an insecure scheme. */
+function assertPublicHttps(url: string) {
+ const parsed = new URL(url)
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
+ throw new Error('Preview URLs must use HTTPS without credentials')
+ }
+}
+
+/** Bounded raster thumbnail; the browser never contacts an untrusted og:image URL. */
+async function fetchPreviewImage(url: string, signal?: AbortSignal): Promise {
+ try {
+ assertPublicHttps(url)
+ } catch {
+ return {}
+ }
+ /** Skip optional work at capacity rather than queueing image buffers across requests. */
+ if (activeImages >= MAX_CONCURRENT_IMAGES) return { imageRetryable: true }
+ activeImages += 1
+ try {
+ const response = await secureFetchWithValidation(url, {
+ profile: 'contentFetch',
+ timeout: FETCH_TIMEOUT_MS,
+ maxRedirects: 3,
+ maxResponseBytes: MAX_IMAGE_BYTES,
+ assertRedirectTarget: assertPublicHttps,
+ signal,
+ headers: { Accept: 'image/jpeg,image/png,image/webp,image/avif,image/gif' },
+ })
+ if (response.status < 200 || response.status >= 300) {
+ await response.body?.cancel().catch(() => {})
+ return response.status === 429 || response.status >= 500 ? { imageRetryable: true } : {}
+ }
+ if (
+ !/^image\/(jpeg|png|webp|avif|gif)(;|$)/i.test(response.headers.get('content-type') ?? '')
+ ) {
+ await response.body?.cancel().catch(() => {})
+ return {}
+ }
+ const image = sharp(Buffer.from(await response.arrayBuffer()), {
+ limitInputPixels: MAX_IMAGE_PIXELS,
+ pages: 1,
+ }).timeout({ seconds: 2 })
+ const metadata = await image.metadata()
+ if (!metadata.format || !RASTER_FORMATS.has(metadata.format)) return {}
+ signal?.throwIfAborted()
+ const buffer = await image
+ .rotate()
+ .resize({ width: 640, height: 336, fit: 'inside', withoutEnlargement: true })
+ .webp({ quality: 75 })
+ .toBuffer()
+ signal?.throwIfAborted()
+ if (buffer.length > MAX_PREVIEW_BYTES) return {}
+ return { image: `data:image/webp;base64,${buffer.toString('base64')}` }
+ } catch (error) {
+ signal?.throwIfAborted()
+ return isRetryableInfrastructureError(error) ? { imageRetryable: true } : {}
+ } finally {
+ activeImages -= 1
+ }
+}
+
+/** Public-page metadata and an optional normalized image, each under the content-fetch SSRF policy. */
+export async function fetchLinkPreview(
+ url: string,
+ callerSignal?: AbortSignal
+): Promise {
+ const deadline = AbortSignal.timeout(PREVIEW_DEADLINE_MS)
+ const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline
+ assertPublicHttps(url)
+ let finalUrl = url
+ const response = await secureFetchWithValidation(url, {
+ profile: 'contentFetch',
+ timeout: FETCH_TIMEOUT_MS,
+ maxRedirects: 3,
+ maxResponseBytes: MAX_HTML_BYTES,
+ signal,
+ assertRedirectTarget: (redirectUrl) => {
+ assertPublicHttps(redirectUrl)
+ finalUrl = redirectUrl
+ },
+ headers: {
+ 'User-Agent': 'Simbot/1.0 (+https://sim.ai)',
+ Accept: 'text/html,application/xhtml+xml',
+ },
+ })
+ const contentType = (response.headers.get('content-type') ?? '')
+ .split(';', 1)[0]
+ .trim()
+ .toLowerCase()
+ if (
+ response.status < 200 ||
+ response.status >= 300 ||
+ (contentType !== 'text/html' && contentType !== 'application/xhtml+xml')
+ ) {
+ await response.body?.cancel().catch(() => {})
+ return null
+ }
+ const $ = cheerio.load(await response.text())
+ const meta = (key: string) =>
+ $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')?.trim() || null
+ const title =
+ meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
+ const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
+ const siteName = meta('og:site_name')
+ if (!title && !description && !siteName) return null
+ const imageRef = meta('og:image:secure_url') ?? meta('og:image') ?? meta('twitter:image')
+ let image: PreviewImage = {}
+ if (imageRef) {
+ try {
+ image = await fetchPreviewImage(new URL(imageRef, finalUrl).href, signal)
+ } catch {
+ callerSignal?.throwIfAborted()
+ image = signal.aborted ? { imageRetryable: true } : {}
+ }
+ }
+ callerSignal?.throwIfAborted()
+ return {
+ title: title ? truncate(title, 200) : null,
+ description: description ? truncate(description, 300) : null,
+ siteName: siteName ? truncate(siteName, 200) : null,
+ ...image,
+ }
+}
diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts
index eb92ca8d8c6..ac789561f2c 100644
--- a/apps/sim/vitest.setup.ts
+++ b/apps/sim/vitest.setup.ts
@@ -35,6 +35,11 @@ if (typeof document !== 'undefined') {
setupGlobalFetchMock()
setupGlobalStorageMocks()
+/** next/font is compiled by Next, not evaluated by the unit-test runtime. */
+vi.mock('@/app/_styles/fonts/inter/inter', () => ({
+ inter: { className: 'test-inter-font', variable: 'test-inter-variable' },
+}))
+
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
diff --git a/packages/security/src/dns.test.ts b/packages/security/src/dns.test.ts
index 196c1f38c6c..015f50da22a 100644
--- a/packages/security/src/dns.test.ts
+++ b/packages/security/src/dns.test.ts
@@ -56,6 +56,39 @@ describe('resolveHostAddresses', () => {
await expect(resolveHostAddresses('missing.example')).rejects.toThrow('ENOTFOUND')
})
+ it('does not start DNS work for an already aborted caller', async () => {
+ const controller = new AbortController()
+ controller.abort()
+ await expect(
+ resolveHostAddresses('example.com', { signal: controller.signal })
+ ).rejects.toThrow()
+ expect(mockLookup).not.toHaveBeenCalled()
+ })
+
+ it('cancels a pending lookup at the caller deadline and cleans up its listener and timer', async () => {
+ vi.useFakeTimers()
+ try {
+ let rejectLookup!: (error: Error) => void
+ mockLookup.mockReturnValue(
+ new Promise((_, reject) => {
+ rejectLookup = reject
+ })
+ )
+ const controller = new AbortController()
+ const removeListener = vi.spyOn(controller.signal, 'removeEventListener')
+ const pending = resolveHostAddresses('example.com', { signal: controller.signal })
+ const rejection = expect(pending).rejects.toThrow('Preview deadline')
+ controller.abort(new Error('Preview deadline'))
+ await rejection
+ expect(vi.getTimerCount()).toBe(0)
+ expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
+ rejectLookup(new Error('Late lookup failure'))
+ await Promise.resolve()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('clears the deadline timer once the lookup succeeds', async () => {
// A leaked timer holds the event loop open for the full window and is
// invisible to every other assertion here.
diff --git a/packages/security/src/dns.ts b/packages/security/src/dns.ts
index 020e2b74433..dc3611d07b2 100644
--- a/packages/security/src/dns.ts
+++ b/packages/security/src/dns.ts
@@ -69,20 +69,27 @@ export interface ResolvedHost {
*/
export async function resolveHostAddresses(
host: string,
- options: { timeoutMs?: number } = {}
+ options: { timeoutMs?: number; signal?: AbortSignal } = {}
): Promise {
- const { timeoutMs = DEFAULT_DNS_TIMEOUT_MS } = options
+ const { timeoutMs = DEFAULT_DNS_TIMEOUT_MS, signal } = options
+ signal?.throwIfAborted()
const lookup = dns.lookup(host, { all: true, verbatim: true })
// If the timeout wins the race the lookup stays pending; its eventual
// settlement is swallowed so a late rejection cannot surface as an unhandled
// one.
lookup.catch(() => {})
let timer: NodeJS.Timeout | undefined
+ let onAbort: (() => void) | undefined
try {
const resolved = await Promise.race([
lookup,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new DnsTimeoutError(host)), timeoutMs)
+ if (signal) {
+ onAbort = () => reject(signal.reason)
+ signal.addEventListener('abort', onAbort, { once: true })
+ if (signal.aborted) onAbort()
+ }
}),
])
if (resolved.length === 0) {
@@ -102,5 +109,6 @@ export async function resolveHostAddresses(
}
} finally {
clearTimeout(timer)
+ if (onAbort) signal?.removeEventListener('abort', onAbort)
}
}