diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-reply.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-reply.tsx index a559859d115..5f0ee48c889 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-reply.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-reply.tsx @@ -24,9 +24,7 @@ export function HeroChatReply({ content, onOpenWorkflowResource }: HeroChatReply <> {paragraph.slice(0, resourceIndex)} - } + icon={} title={WORKFLOW_TITLE} onSelect={onOpenWorkflowResource} /> diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts new file mode 100644 index 00000000000..2071d1e94ad --- /dev/null +++ b/apps/sim/app/api/link-preview/route.test.ts @@ -0,0 +1,50 @@ +/** @vitest-environment node */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), + get: vi.fn(), + set: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: async () => ({ user: { id: 'test-user' } }) })) +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceUserRateLimit: async () => null, +})) +vi.mock('@/lib/api/server', () => ({ + parseRequest: async () => ({ + success: true, + data: { query: { url: 'https://example.com/guide' } }, + }), +})) +vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => mocks })) +vi.mock('@/lib/core/network/context.server', () => ({ + runWithOutboundOrganization: (_organization: null, run: () => unknown) => run(), +})) +vi.mock('@/lib/core/utils/with-route-handler', () => ({ + withRouteHandler: (handler: unknown) => handler, +})) +vi.mock('@/lib/link-preview/fetch-preview', () => ({ fetchLinkPreview: mocks.fetch })) + +import { GET } from '@/app/api/link-preview/route' + +const complete = { title: 'Guide', description: null, siteName: null } + +describe('link preview cache lifetime', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.get.mockResolvedValue(null) + mocks.set.mockResolvedValue('OK') + }) + + it.each([ + { preview: { ...complete, imageRetryable: true }, ttl: 60 }, + { preview: complete, ttl: 24 * 60 * 60 }, + { preview: null, ttl: 60 * 60 }, + ])('caches $preview for $ttl seconds', async ({ preview, ttl }) => { + mocks.fetch.mockResolvedValue(preview) + const response = await GET(new NextRequest('https://example.com/api/link-preview')) + expect(await response.json()).toEqual({ preview }) + expect(mocks.set).toHaveBeenCalledWith(expect.any(String), JSON.stringify(preview), 'EX', ttl) + }) +}) diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 27e810a593a..a7d165a4f76 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -1,8 +1,6 @@ import { createHash } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { truncate } from '@sim/utils/string' -import * as cheerio from 'cheerio' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { LinkPreview } from '@/lib/api/contracts/link-preview' @@ -12,66 +10,15 @@ import { getSession } from '@/lib/auth' import { getRedisClient } from '@/lib/core/config/redis' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchLinkPreview } from '@/lib/link-preview/fetch-preview' const logger = createLogger('LinkPreviewAPI') -const FETCH_TIMEOUT_MS = 5000 -const MAX_RESPONSE_BYTES = 256 * 1024 -const MAX_REDIRECTS = 3 -const TITLE_MAX_CHARS = 200 -const DESCRIPTION_MAX_CHARS = 300 const CACHE_TTL_SECONDS = 24 * 60 * 60 const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 -const CACHE_KEY_PREFIX = 'link-preview:v1:' - -/** - * Parses preview metadata from the fetched document (already capped at - * MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity - * decoding. - */ -function parsePreview(html: string): LinkPreview { - const $ = cheerio.load(html) - - const meta = (key: string): string | null => { - const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content') - return value?.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 - return { - title: title ? truncate(title, TITLE_MAX_CHARS) : null, - description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null, - siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null, - } -} - -async function fetchPreview(url: string): Promise { - const response = await secureFetchWithValidation(url, { - // The URL is harvested from a rendered link rather than authored as a - // destination, so it gets no reach into a private network. - profile: 'contentFetch', - timeout: FETCH_TIMEOUT_MS, - maxRedirects: MAX_REDIRECTS, - maxResponseBytes: MAX_RESPONSE_BYTES, - headers: { - 'User-Agent': 'Simbot/1.0 (+https://sim.ai)', - Accept: 'text/html,application/xhtml+xml', - }, - }) - if (response.status < 200 || response.status >= 300) return null - const contentType = response.headers.get('content-type') ?? '' - if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) { - return null - } - return parsePreview(await response.text()) -} +const RETRYABLE_CACHE_TTL_SECONDS = 60 +const CACHE_KEY_PREFIX = 'link-preview:v2:' export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -106,8 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let preview: LinkPreview = null try { /** Link previews have no organization owner and use a shared URL cache. */ - preview = await runWithOutboundOrganization(null, () => fetchPreview(url)) + preview = await runWithOutboundOrganization(null, () => fetchLinkPreview(url, request.signal)) } catch (error) { + if (request.signal.aborted) return new NextResponse(null, { status: 499 }) logger.info('Link preview fetch failed; returning null preview', { host: new URL(url).hostname, error: getErrorMessage(error, 'unknown error').replaceAll(url, '[url]'), @@ -115,7 +63,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (redis) { - const ttl = preview ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS + const ttl = preview?.imageRetryable + ? RETRYABLE_CACHE_TTL_SECONDS + : preview + ? CACHE_TTL_SECONDS + : NEGATIVE_CACHE_TTL_SECONDS try { await redis.set(cacheKey, JSON.stringify(preview), 'EX', ttl) } catch (error) { diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 0193d2ae948..dedee3901c3 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -10,6 +10,7 @@ import { isAssistantImageType, } from '@/lib/uploads/shared/assistant-images' import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' +import { inter } from '@/app/_styles/fonts/inter/inter' import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list' @@ -268,6 +269,7 @@ export function Composer({ onDragOver={files.handleDragOver} onDrop={files.handleDrop} className={cn( + inter.className, 'relative z-10 mx-auto w-full max-w-chat', !imagesOnly && 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx index 9d20f497ede..fc5fc324d71 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx @@ -31,6 +31,7 @@ vi.mock('next/navigation', () => ({ usePathname: () => '/workspace/workspace-1/files', useRouter: () => ({ push: vi.fn() }), })) +vi.mock('@/app/_styles/fonts/inter/inter', () => ({ inter: { variable: 'test-inter-variable' } })) vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) })) vi.mock('@/hooks/queries/workspace-files', () => ({ useUploadWorkspaceFile: () => ({ mutateAsync: uploadFile }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 70b9b576b35..7fde7d1a97d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -25,6 +25,7 @@ import { } from '@/lib/mothership/chat/selection-context' import type { FileDownloadSource } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { inter } from '@/app/_styles/fonts/inter/inter' import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar' import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict' import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' @@ -118,8 +119,10 @@ function warnRichMarkdownPasteLimit(reason?: 'paste' | 'formatting') { * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder → * live swap never reflows. Shared as one constant to keep them in lockstep. */ -const EDITOR_SURFACE_CLASS = - 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white' +const EDITOR_SURFACE_CLASS = cn( + 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white', + inter.variable +) /** ProseMirror block positions do not correspond to markdown source line numbers. */ function buildEditorSelectionContext( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx index 10d16ddd7f0..e62f5e15d8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx @@ -1,14 +1,13 @@ -import { cn, Lightbox } from '@sim/emcn' +import { ChipTag, cn, Lightbox, OverflowText } from '@sim/emcn' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types' function FileAttachmentPill(props: { mediaType: string; filename: string }) { const Icon = getDocumentIcon(props.mediaType, props.filename) return ( -
- - {props.filename} -
+ + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx index 724f22c1929..a4d7e4fe0c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx @@ -59,7 +59,7 @@ export function ActivityDisclosure({ {collapsible && ( -
+
{children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx index 9e03319e5cd..53472fe2c34 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx @@ -80,7 +80,7 @@ describe('flat expanded activity layout', () => { expect(iconSlot(row).classList).toContain(ICON_SLOT) } const list = rows[0].closest('.flex-col')! - expect(list.classList).toContain('gap-1.5') + expect(list.classList).toContain('gap-2') let node: Element | null = rows[0] while (node && node !== container) { expect(hasIndent(node), node.className).toBe(false) @@ -134,16 +134,15 @@ describe('flat expanded activity layout', () => { }, }, ]) - const blocks = container.querySelector('.flex-col.gap-3')! + const blocks = container.querySelector('.flex-col.gap-2')! expect(blocks.contains(statuses()[0])).toBe(true) - expect(blocks.classList).toContain('gap-3') - expect(blocks.classList).not.toContain('gap-1.5') + expect(blocks.classList).toContain('gap-2') expect(statuses()).toHaveLength(1) expand() const rows = statuses().slice(1) expect(rows).toHaveLength(3) for (const row of rows) { - expect(row.closest('.flex-col')!.classList).toContain('gap-1.5') + expect(row.closest('.flex-col')!.classList).toContain('gap-2') expect(iconSlot(row).classList).toContain(ICON_SLOT) } }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx index bfb3ab1811b..90e1bc2c061 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx @@ -85,8 +85,7 @@ export function ActivityViewport({ className={cn( 'pr-2', !unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto', - scrollFadeClass, - (edges.top || edges.bottom) && 'py-1' + scrollFadeClass )} {...scrollFadeAttributes(edges)} > diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index a6339072e4b..e2d7b289290 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -280,7 +280,7 @@ export function AgentGroupView({ liveToolId={liveCall?.id} /> ) : ( -
{items.map(renderItem)}
+
{items.map(renderItem)}
) const headerText = error ? agentLabel @@ -308,7 +308,7 @@ export function AgentGroupView({ ) return ( -
+
{isMainAgent ? ( activity ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx index 67aabae5498..15d8939d4fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -49,5 +49,5 @@ export function MainAgentActivity({ ) }) - return
{activity}
+ return
{activity}
} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx index a046cb97652..0b86d12ac7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx @@ -9,15 +9,15 @@ import { scrollFadeClass, useScrollEdges, } from '@sim/emcn' -import { - externalLinkHostname, - 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 { + externalLinkHostname, + 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' interface SearchActivityResultsProps { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx index 9b2ad1686cc..78277ac0102 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -242,7 +242,7 @@ export function ToolActivityGroup({ isStreaming={working && autoScrollActivity} unbounded={entries.some(({ sources }) => (sources?.length ?? 0) > 0)} > -
+
{entries.map(({ tool, sources }, index) => ( {tools.length === 1 ? null : tool.id === headerTool.id ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index dc3537b8e1e..875df53d49b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -24,22 +24,26 @@ import '@sim/emcn/components/code/code.css' import { Checkbox, CopyCodeButton, + chipFilledFillTokens, cn, Lightbox, languages, highlight as prismHighlight, + scrollFadeAttributes, + scrollFadeXClass, + useScrollEdges, } from '@sim/emcn' import { extractTextContent } from '@/lib/core/utils/react-node-text' import { inlineChatImageUrl, isInlineFileReference, } from '@/lib/mothership/chat/inline-image-reference' +import { inter } from '@/app/_styles/fonts/inter/inter' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize' import { ExternalLink, - externalLinkHostname, LinkSourcesContext, - PROSE_LINK_CLASS, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link' import { HighlightedLines } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines' import { remarkPlainText } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text' @@ -47,6 +51,10 @@ import { SourceChip, sourceLabel, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' +import { + externalLinkHostname, + PROSE_LINK_CLASS, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link' import { type ContentSegment, type CredentialSubmissionPayload, @@ -59,7 +67,6 @@ import { import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url' import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types' import { useSmoothText } from '@/hooks/use-smooth-text' -import { sanitizeChatDisplayContent } from './chat-sanitize' const LANG_ALIASES: Record = { js: 'javascript', @@ -81,13 +88,13 @@ const MARKDOWN_REMARK_PLUGINS = [ const PROSE_CLASSES = cn( 'prose prose-base dark:prose-invert max-w-none', - 'font-[family-name:var(--font-inter)] antialiased break-words tracking-[0]', + 'antialiased break-words tracking-[0]', 'prose-headings:font-semibold prose-headings:tracking-[0] prose-headings:text-[var(--text-primary)]', 'prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0', 'prose-p:text-base prose-p:leading-[25px] prose-p:text-[var(--text-primary)]', 'prose-li:text-base prose-li:leading-[25px] prose-li:text-[var(--text-primary)]', 'prose-li:my-1', - 'prose-ul:my-4 prose-ol:my-4', + 'prose-ul:my-4 prose-ol:my-4 [&_li>ul]:my-1 [&_li>ol]:my-1', 'prose-strong:font-semibold prose-strong:text-[var(--text-primary)]', 'prose-a:text-[var(--text-primary)] prose-a:no-underline', 'prose-hr:border-[var(--border)] prose-hr:my-6', @@ -263,16 +270,36 @@ function highlight(code: string, language: string): string { return html } +interface MarkdownTableProps { + children?: React.ReactNode +} + +function MarkdownTable({ children }: MarkdownTableProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { axis: 'x' }) + const isOverflowing = edges.left || edges.right + + return ( +
+ + {children} +
+
+ ) +} + const MARKDOWN_COMPONENTS = { - table({ children }: { children?: React.ReactNode }) { - return ( -
- - {children} -
-
- ) - }, + table: MarkdownTable, thead({ children }: { children?: React.ReactNode }) { return {children} }, @@ -345,11 +372,7 @@ const MARKDOWN_COMPONENTS = { } const hostname = externalLinkHostname(href) if (hostname && href) { - return ( - - {children} - - ) + return {children} } if (href?.startsWith('mailto:')) { return ( @@ -366,7 +389,16 @@ const MARKDOWN_COMPONENTS = { }, ul({ children, className }: { children?: React.ReactNode; className?: string }) { if (className?.includes('contains-task-list')) { - return
    {children}
+ return ( +
    li:not(.task-list-item)]:ms-5 [&>li:not(.task-list-item)]:list-disc', + className + )} + > + {children} +
+ ) } return
    {children}
}, @@ -376,7 +408,12 @@ const MARKDOWN_COMPONENTS = { li({ children, className }: { children?: React.ReactNode; className?: string }) { if (className?.includes('task-list-item')) { return ( -
  • +
  • p:only-child]:inline [&>p]:my-0', + className + )} + > {children}
  • ) @@ -389,7 +426,12 @@ const MARKDOWN_COMPONENTS = { }, inlineCode({ children }: { children?: React.ReactNode }) { return ( - + {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) } }