From dfcb4a9d28becd2db8ba253567d4fad131e052e1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 11:46:17 -0700 Subject: [PATCH 1/4] improvement(chat): unify message styling and source previews --- .../hero-chat-loop/hero-chat-reply.tsx | 4 +- apps/sim/app/api/link-preview/route.ts | 61 +----- .../chat-message-attachments.tsx | 9 +- .../agent-group/activity-disclosure.tsx | 2 +- .../agent-group/activity-layout.test.tsx | 9 +- .../agent-group/activity-viewport.tsx | 3 +- .../agent-group/agent-group-view.tsx | 4 +- .../agent-group/main-agent-activity.tsx | 2 +- .../agent-group/search-activity-results.tsx | 8 +- .../agent-group/tool-activity-group.tsx | 2 +- .../components/chat-content/chat-content.tsx | 90 +++++++-- .../chat-content/chat-presentation.test.tsx | 95 +++++++++ .../chat-content/external-link.test.tsx | 183 ++++++++++-------- .../components/chat-content/external-link.tsx | 151 +++------------ .../components/options/options.tsx | 12 +- .../components/resource-mention.tsx | 9 +- .../components/source-card/source-card.tsx | 2 +- .../source-chip/source-chip.test.tsx | 14 +- .../components/source-chip/source-chip.tsx | 49 ++--- .../components/source-chip/source-icon.tsx | 2 +- .../message-content/components/source-link.ts | 40 ++++ .../components/source-preview.tsx | 154 +++++++++++++++ .../components/special-tags/special-tags.tsx | 94 ++++----- .../message-content/message-content.tsx | 2 +- .../user-message-content.tsx | 21 +- .../workspace/[workspaceId]/home/layout.tsx | 7 +- apps/sim/hooks/queries/link-preview.ts | 3 +- apps/sim/lib/api/contracts/link-preview.ts | 5 + .../lib/link-preview/fetch-preview.test.ts | 133 +++++++++++++ apps/sim/lib/link-preview/fetch-preview.ts | 116 +++++++++++ 30 files changed, 860 insertions(+), 426 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-presentation.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-link.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx create mode 100644 apps/sim/lib/link-preview/fetch-preview.test.ts create mode 100644 apps/sim/lib/link-preview/fetch-preview.ts 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.ts b/apps/sim/app/api/link-preview/route.ts index 27e810a593a..0443129f7ea 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,14 @@ 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 CACHE_KEY_PREFIX = 'link-preview:v2:' export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -106,8 +52,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]'), 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..49cd9ad305f 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 @@ -11,7 +11,7 @@ import { useState, } from 'react' import type { Nodes } from 'hast' -import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown' +import { defaultRehypePlugins, defaultRemarkPlugins, type ExtraProps, Streamdown } from 'streamdown' import 'streamdown/styles.css' // prismjs core must load before its language components — they register on the // global `Prism` it installs (on `window`/`global`); fixes SSR + client order. @@ -24,9 +24,11 @@ import '@sim/emcn/components/code/code.css' import { Checkbox, CopyCodeButton, + chipFilledFillTokens, cn, Lightbox, languages, + OverflowText, highlight as prismHighlight, } from '@sim/emcn' import { extractTextContent } from '@/lib/core/utils/react-node-text' @@ -35,11 +37,10 @@ import { isInlineFileReference, } from '@/lib/mothership/chat/inline-image-reference' 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 +48,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 +64,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 +85,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,11 +267,35 @@ function highlight(code: string, language: string): string { return html } +function hasInteractiveTableContent(node?: Nodes): boolean { + return ( + node?.type === 'element' && + (node.tagName === 'a' || + node.tagName === 'img' || + node.children.some(hasInteractiveTableContent)) + ) +} + +interface TableCellContentProps extends ExtraProps { + children?: React.ReactNode +} + +function TableCellContent({ children, node }: TableCellContentProps) { + if (hasInteractiveTableContent(node)) { + return
{children}
+ } + return ( + + {children} + + ) +} + const MARKDOWN_COMPONENTS = { table({ children }: { children?: React.ReactNode }) { return (
- +
{children}
@@ -276,23 +304,23 @@ const MARKDOWN_COMPONENTS = { thead({ children }: { children?: React.ReactNode }) { return {children} }, - th({ children, style }: ThProps) { + th({ children, style, node }: ThProps & ExtraProps) { return ( - {children} + {children} ) }, - td({ children, style }: TdProps) { + td({ children, style, node }: TdProps & ExtraProps) { return ( - {children} + {children} ) }, @@ -345,11 +373,7 @@ const MARKDOWN_COMPONENTS = { } const hostname = externalLinkHostname(href) if (hostname && href) { - return ( - - {children} - - ) + return {children} } if (href?.startsWith('mailto:')) { return ( @@ -366,7 +390,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 +409,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 +427,12 @@ const MARKDOWN_COMPONENTS = { }, inlineCode({ children }: { children?: React.ReactNode }) { return ( - + {children} ) @@ -403,7 +446,14 @@ const MARKDOWN_COMPONENTS = { }, input({ type, checked }: { type?: string; checked?: boolean }) { if (type === 'checkbox') { - return + return ( + + ) } 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..93c2922888e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-presentation.test.tsx @@ -0,0 +1,95 @@ +/** @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 table formatting and gives interactive cells their own preview without an overflow tooltip', 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-overflow-text] [data-streamdown="strong"]')?.textContent + ).toBe('A long document title') + 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/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..699436c1565 --- /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 hover-hover:text-[var(--brand-secondary)] 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..80e671a4217 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx @@ -0,0 +1,154 @@ +'use client' + +import { type ReactElement, useEffect, useRef, useState } from 'react' +import { OverflowText, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn' +import { ArrowUpRight } from '@sim/emcn/icons' +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/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index d807f00a5be..b7883593da3 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 @@ -4,14 +4,15 @@ import { useMemo } from 'react' import { cn } from '@sim/emcn' import { escapeRegExp } from '@sim/utils/string' 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 +106,15 @@ function computeIntegrationRanges(text: string, taken: MentionRange[]): MentionR function MentionHighlight({ context }: { context: ChatMessageContext }) { return ( - - - {context.label} - + + } + /> ) } 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.ts b/apps/sim/hooks/queries/link-preview.ts index 9e7ecd8f35d..a8893a4b2e5 100644 --- a/apps/sim/hooks/queries/link-preview.ts +++ b/apps/sim/hooks/queries/link-preview.ts @@ -17,8 +17,7 @@ async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise ({ fetchMock: vi.fn() })) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: fetchMock, +})) + +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('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('retains text metadata when the image is blocked by the network guard', async () => { + fetchMock.mockResolvedValueOnce(page()).mockRejectedValueOnce(new Error('Private IP blocked')) + expect(await fetchLinkPreview(PAGE)).toMatchObject({ + title: 'Guide', + description: 'A useful guide', + }) + }) + + 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, + }) + } 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() + }) +}) 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..b4b5250bb80 --- /dev/null +++ b/apps/sim/lib/link-preview/fetch-preview.ts @@ -0,0 +1,116 @@ +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 { 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']) + +/** 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) + 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) return undefined + if ( + !/^image\/(jpeg|png|webp|avif|gif)(;|$)/i.test(response.headers.get('content-type') ?? '') + ) { + return undefined + } + const image = sharp(Buffer.from(await response.arrayBuffer()), { + limitInputPixels: MAX_IMAGE_PIXELS, + pages: 1, + }) + const metadata = await image.metadata() + if (!metadata.format || !RASTER_FORMATS.has(metadata.format)) return undefined + signal?.throwIfAborted() + const buffer = await image + .rotate() + .resize({ width: 640, height: 336, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 75 }) + .timeout({ seconds: 2 }) + .toBuffer() + signal?.throwIfAborted() + if (buffer.length > MAX_PREVIEW_BYTES) return undefined + return `data:image/webp;base64,${buffer.toString('base64')}` + } catch { + signal?.throwIfAborted() + return undefined + } +} + +/** 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', + }, + }) + 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 + 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: string | undefined + if (imageRef) { + try { + image = await fetchPreviewImage(new URL(imageRef, finalUrl).href, signal) + } catch { + callerSignal?.throwIfAborted() + } + } + callerSignal?.throwIfAborted() + return { + title: title ? truncate(title, 200) : null, + description: description ? truncate(description, 300) : null, + siteName: siteName ? truncate(siteName, 200) : null, + ...(image ? { image } : {}), + } +} From 78f7955cc53c957d9382fe7611bee2773033ec2e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 12:04:33 -0700 Subject: [PATCH 2/4] fix(chat): bound preview work and preserve readable tables --- apps/sim/app/api/link-preview/route.test.ts | 50 +++++++++++++++ apps/sim/app/api/link-preview/route.ts | 7 ++- .../editor-lifecycle.test.tsx | 1 + .../rich-markdown-editor.tsx | 7 ++- .../components/chat-content/chat-content.tsx | 19 ++++-- apps/sim/hooks/queries/link-preview.test.tsx | 62 +++++++++++++++++++ apps/sim/hooks/queries/link-preview.ts | 10 ++- apps/sim/lib/api/contracts/link-preview.ts | 6 ++ apps/sim/lib/core/security/egress/validate.ts | 6 +- .../security/input-validation.server.test.ts | 26 +++++++- .../core/security/input-validation.server.ts | 5 +- .../lib/link-preview/fetch-preview.test.ts | 26 ++++++++ apps/sim/lib/link-preview/fetch-preview.ts | 36 +++++++---- packages/security/src/dns.test.ts | 33 ++++++++++ packages/security/src/dns.ts | 12 +++- 15 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 apps/sim/app/api/link-preview/route.test.ts create mode 100644 apps/sim/hooks/queries/link-preview.test.tsx 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 0443129f7ea..a7d165a4f76 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -17,6 +17,7 @@ const logger = createLogger('LinkPreviewAPI') const CACHE_TTL_SECONDS = 24 * 60 * 60 const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 +const RETRYABLE_CACHE_TTL_SECONDS = 60 const CACHE_KEY_PREFIX = 'link-preview:v2:' export const GET = withRouteHandler(async (request: NextRequest) => { @@ -62,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/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/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 49cd9ad305f..ad46915701a 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 @@ -282,12 +282,21 @@ interface TableCellContentProps extends ExtraProps { function TableCellContent({ children, node }: TableCellContentProps) { if (hasInteractiveTableContent(node)) { - return
    {children}
    + return ( +
    + {children} +
    + ) } return ( - - {children} - + + + {children} + + ) } @@ -295,7 +304,7 @@ const MARKDOWN_COMPONENTS = { table({ children }: { children?: React.ReactNode }) { 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 a8893a4b2e5..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, @@ -18,15 +19,18 @@ 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 e927e6148b2..856aa358374 100644 --- a/apps/sim/lib/api/contracts/link-preview.ts +++ b/apps/sim/lib/api/contracts/link-preview.ts @@ -21,6 +21,12 @@ export const linkPreviewResponseSchema = z.object({ .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/security/egress/validate.ts b/apps/sim/lib/core/security/egress/validate.ts index 6fd33b5a85e..0da55d6fd06 100644 --- a/apps/sim/lib/core/security/egress/validate.ts +++ b/apps/sim/lib/core/security/egress/validate.ts @@ -45,6 +45,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 +79,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,8 +116,9 @@ 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 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..c3aa426da15 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 @@ -141,4 +144,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..43c43c63be5 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -26,6 +26,7 @@ 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' @@ -59,7 +60,7 @@ 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 @@ -1210,6 +1211,7 @@ export async function secureFetchWithPinnedIP( } validateUrlWithDNS(redirectUrl, 'redirectUrl', options.profile, { logDetails: options.logUrlValidationDetails, + signal: options.signal, }) .then((validation) => { if (!validation.isValid) { @@ -1555,6 +1557,7 @@ 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) diff --git a/apps/sim/lib/link-preview/fetch-preview.test.ts b/apps/sim/lib/link-preview/fetch-preview.test.ts index 2a19cb35204..74c517538aa 100644 --- a/apps/sim/lib/link-preview/fetch-preview.test.ts +++ b/apps/sim/lib/link-preview/fetch-preview.test.ts @@ -120,6 +120,7 @@ describe('public link preview images', () => { title: 'Guide', description: 'A useful guide', siteName: null, + imageRetryable: true, }) } finally { timeout.mockRestore() @@ -130,4 +131,29 @@ describe('public link preview images', () => { 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 index b4b5250bb80..c586ceef4ce 100644 --- a/apps/sim/lib/link-preview/fetch-preview.ts +++ b/apps/sim/lib/link-preview/fetch-preview.ts @@ -11,6 +11,10 @@ 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) { @@ -21,9 +25,16 @@ function assertPublicHttps(url: string) { } /** Bounded raster thumbnail; the browser never contacts an untrusted og:image URL. */ -async function fetchPreviewImage(url: string, signal?: AbortSignal): Promise { +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, @@ -33,31 +44,33 @@ async function fetchPreviewImage(url: string, signal?: AbortSignal): Promise= 300) return undefined + if (response.status === 429 || response.status >= 500) return { imageRetryable: true } + if (response.status < 200 || response.status >= 300) return {} if ( !/^image\/(jpeg|png|webp|avif|gif)(;|$)/i.test(response.headers.get('content-type') ?? '') ) { - return undefined + 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 undefined + 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 }) - .timeout({ seconds: 2 }) .toBuffer() signal?.throwIfAborted() - if (buffer.length > MAX_PREVIEW_BYTES) return undefined - return `data:image/webp;base64,${buffer.toString('base64')}` + if (buffer.length > MAX_PREVIEW_BYTES) return {} + return { image: `data:image/webp;base64,${buffer.toString('base64')}` } } catch { signal?.throwIfAborted() - return undefined + return { imageRetryable: true } + } finally { + activeImages -= 1 } } @@ -98,12 +111,13 @@ export async function fetchLinkPreview( 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: string | undefined + let image: PreviewImage = {} if (imageRef) { try { image = await fetchPreviewImage(new URL(imageRef, finalUrl).href, signal) } catch { callerSignal?.throwIfAborted() + image = { imageRetryable: true } } } callerSignal?.throwIfAborted() @@ -111,6 +125,6 @@ export async function fetchLinkPreview( title: title ? truncate(title, 200) : null, description: description ? truncate(description, 300) : null, siteName: siteName ? truncate(siteName, 200) : null, - ...(image ? { image } : {}), + ...image, } } 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) } } From 06983cd56247e677d7c618a0919bdf1e72d962f9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 12:23:12 -0700 Subject: [PATCH 3/4] fix(chat): preserve link contrast and bound preview retries --- .../message-content/components/source-link.ts | 2 +- .../components/source-preview.tsx | 2 +- .../errors/retryable-infrastructure.test.ts | 11 +++ .../core/errors/retryable-infrastructure.ts | 2 + apps/sim/lib/core/security/egress/validate.ts | 4 +- .../security/input-validation.server.test.ts | 10 +++ .../core/security/input-validation.server.ts | 22 +++-- .../pinned-redirect-replay.server.test.ts | 15 ++++ ...ecure-fetch-request-framing.server.test.ts | 10 +++ .../lib/link-preview/fetch-preview.test.ts | 85 ++++++++++++++++++- apps/sim/lib/link-preview/fetch-preview.ts | 23 +++-- 11 files changed, 168 insertions(+), 18 deletions(-) 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 index 699436c1565..bc6d82cb86b 100644 --- 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 @@ -24,7 +24,7 @@ export function linkSiteName(url: string, siteName?: string | null): string { * 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 hover-hover:text-[var(--brand-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--text-primary)]' + '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 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 index 80e671a4217..abe9364d575 100644 --- 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 @@ -119,7 +119,7 @@ function SourcePreviewContent({ source }: Pick) { source.snippet?.trim() || (!source.connectorType && preview?.description?.trim()) return (
    -
    +
    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..7d513e3be6a 100644 --- a/apps/sim/lib/core/errors/retryable-infrastructure.ts +++ b/apps/sim/lib/core/errors/retryable-infrastructure.ts @@ -16,6 +16,8 @@ const RETRYABLE_DB_ERROR_CODES = new Set([ ]) const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + 'DNS_TIMEOUT', + 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', diff --git a/apps/sim/lib/core/security/egress/validate.ts b/apps/sim/lib/core/security/egress/validate.ts index 0da55d6fd06..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 @@ -125,7 +127,7 @@ export async function validateEgressUrl( ? { 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 c3aa426da15..826d3910245 100644 --- a/apps/sim/lib/core/security/input-validation.server.test.ts +++ b/apps/sim/lib/core/security/input-validation.server.test.ts @@ -128,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')) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 43c43c63be5..9cc30cd48e9 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -41,7 +41,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. @@ -65,7 +71,7 @@ export async function validateUrlWithDNS( 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 } } /** @@ -1215,7 +1221,9 @@ export async function secureFetchWithPinnedIP( }) .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 @@ -1516,7 +1524,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) @@ -1560,7 +1572,7 @@ export async function secureFetchWithValidation( 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/link-preview/fetch-preview.test.ts b/apps/sim/lib/link-preview/fetch-preview.test.ts index 74c517538aa..14b40d6d1ed 100644 --- a/apps/sim/lib/link-preview/fetch-preview.test.ts +++ b/apps/sim/lib/link-preview/fetch-preview.test.ts @@ -7,6 +7,7 @@ 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' @@ -79,14 +80,92 @@ describe('public link preview images', () => { expect(fetchMock.mock.calls[1][0]).toBe('https://example.com/card.png') }) - it('retains text metadata when the image is blocked by the network guard', async () => { - fetchMock.mockResolvedValueOnce(page()).mockRejectedValueOnce(new Error('Private IP blocked')) - expect(await fetchLinkPreview(PAGE)).toMatchObject({ + 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('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' }, + ])('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()) diff --git a/apps/sim/lib/link-preview/fetch-preview.ts b/apps/sim/lib/link-preview/fetch-preview.ts index c586ceef4ce..812bfa3bd12 100644 --- a/apps/sim/lib/link-preview/fetch-preview.ts +++ b/apps/sim/lib/link-preview/fetch-preview.ts @@ -2,6 +2,7 @@ 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 @@ -44,11 +45,14 @@ async function fetchPreviewImage(url: string, signal?: AbortSignal): Promise
    = 500) return { imageRetryable: true }
    -    if (response.status < 200 || response.status >= 300) return {}
    +    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()), {
    @@ -66,9 +70,9 @@ async function fetchPreviewImage(url: string, signal?: AbortSignal): Promise
     MAX_PREVIEW_BYTES) return {}
         return { image: `data:image/webp;base64,${buffer.toString('base64')}` }
    -  } catch {
    +  } catch (error) {
         signal?.throwIfAborted()
    -    return { imageRetryable: true }
    +    return isRetryableInfrastructureError(error) ? { imageRetryable: true } : {}
       } finally {
         activeImages -= 1
       }
    @@ -98,10 +102,15 @@ export async function fetchLinkPreview(
           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'))
    +  if (
    +    response.status < 200 ||
    +    response.status >= 300 ||
    +    (!contentType.includes('text/html') && !contentType.includes('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
    @@ -117,7 +126,7 @@ export async function fetchLinkPreview(
           image = await fetchPreviewImage(new URL(imageRef, finalUrl).href, signal)
         } catch {
           callerSignal?.throwIfAborted()
    -      image = { imageRetryable: true }
    +      image = signal.aborted ? { imageRetryable: true } : {}
         }
       }
       callerSignal?.throwIfAborted()
    
    From f021795da6aff0b62de0a8ba165359397114b8e0 Mon Sep 17 00:00:00 2001
    From: Waleed Latif 
    Date: Thu, 24 Sep 2026 12:49:45 -0700
    Subject: [PATCH 4/4] fix(chat): restore Inter and refine table overflow
    
    ---
     .../home/components/composer/composer.tsx     |  2 +
     .../components/chat-content/chat-content.tsx  | 72 ++++++++-----------
     .../chat-content/chat-presentation.test.tsx   |  9 +--
     .../message-sources/message-sources.tsx       | 10 ++-
     .../components/source-preview.tsx             |  5 +-
     .../mothership-chat/mothership-chat.tsx       |  3 +-
     .../home/components/user-input/user-input.tsx |  2 +
     .../user-message-content.tsx                  |  3 +-
     .../core/errors/retryable-infrastructure.ts   |  1 +
     .../core/security/input-validation.server.ts  | 12 ++--
     .../secure-fetch-response-cap.server.test.ts  |  2 +-
     apps/sim/lib/core/utils/node-stream.test.ts   | 33 +++++++++
     apps/sim/lib/core/utils/node-stream.ts        |  9 ++-
     .../lib/link-preview/fetch-preview.test.ts    | 39 ++++++++++
     apps/sim/lib/link-preview/fetch-preview.ts    |  7 +-
     apps/sim/vitest.setup.ts                      |  5 ++
     16 files changed, 154 insertions(+), 60 deletions(-)
     create mode 100644 apps/sim/lib/core/utils/node-stream.test.ts
    
    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]/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 ad46915701a..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
    @@ -11,7 +11,7 @@ import {
       useState,
     } from 'react'
     import type { Nodes } from 'hast'
    -import { defaultRehypePlugins, defaultRemarkPlugins, type ExtraProps, Streamdown } from 'streamdown'
    +import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown'
     import 'streamdown/styles.css'
     // prismjs core must load before its language components — they register on the
     // global `Prism` it installs (on `window`/`global`); fixes SSR + client order.
    @@ -28,14 +28,17 @@ import {
       cn,
       Lightbox,
       languages,
    -  OverflowText,
       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 {
    @@ -267,69 +270,56 @@ function highlight(code: string, language: string): string {
       return html
     }
     
    -function hasInteractiveTableContent(node?: Nodes): boolean {
    -  return (
    -    node?.type === 'element' &&
    -    (node.tagName === 'a' ||
    -      node.tagName === 'img' ||
    -      node.children.some(hasInteractiveTableContent))
    -  )
    -}
    -
    -interface TableCellContentProps extends ExtraProps {
    +interface MarkdownTableProps {
       children?: React.ReactNode
     }
     
    -function TableCellContent({ children, node }: TableCellContentProps) {
    -  if (hasInteractiveTableContent(node)) {
    -    return (
    -      
    - {children} -
    - ) - } +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} }, - th({ children, style, node }: ThProps & ExtraProps) { + th({ children, style }: ThProps) { return ( - {children} + {children} ) }, - td({ children, style, node }: TdProps & ExtraProps) { + td({ children, style }: TdProps) { return ( - {children} + {children} ) }, @@ -764,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 index 93c2922888e..dc7728b0eda 100644 --- 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 @@ -40,7 +40,7 @@ async function render(content: string) { } describe('mixed chat presentation', () => { - it('preserves table formatting and gives interactive cells their own preview without an overflow tooltip', async () => { + 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', @@ -56,9 +56,10 @@ describe('mixed chat presentation', () => { 'center', 'right', ]) - expect( - container.querySelector('td [data-overflow-text] [data-streamdown="strong"]')?.textContent - ).toBe('A long document title') + 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' 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/source-preview.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-preview.tsx index abe9364d575..f03debe6891 100644 --- 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 @@ -1,8 +1,9 @@ 'use client' import { type ReactElement, useEffect, useRef, useState } from 'react' -import { OverflowText, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn' +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, @@ -76,7 +77,7 @@ export function SourcePreview({ source, children }: SourcePreviewProps) { ref={content} appearance='tooltip' maxWidth='min(320px, calc(100vw - 2rem))' - className='w-[320px]' + className={cn('w-[320px]', inter.className)} sideOffset={8} aria-label='Source preview' onFocusOutside={(event) => { 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 b7883593da3..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,6 +3,7 @@ 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' @@ -126,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/lib/core/errors/retryable-infrastructure.ts b/apps/sim/lib/core/errors/retryable-infrastructure.ts index 7d513e3be6a..8513969dd8b 100644 --- a/apps/sim/lib/core/errors/retryable-infrastructure.ts +++ b/apps/sim/lib/core/errors/retryable-infrastructure.ts @@ -22,6 +22,7 @@ const RETRYABLE_NETWORK_ERROR_CODES = new Set([ 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', + 'ERR_STREAM_PREMATURE_CLOSE', 'ENETDOWN', 'ENETRESET', 'ENETUNREACH', diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 9cc30cd48e9..77160997596 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -31,7 +31,10 @@ import { } 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') @@ -918,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) @@ -1421,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) } 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 { 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' }, @@ -114,6 +128,30 @@ describe('public link preview images', () => { 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()) @@ -153,6 +191,7 @@ describe('public link preview images', () => { { 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 }), { diff --git a/apps/sim/lib/link-preview/fetch-preview.ts b/apps/sim/lib/link-preview/fetch-preview.ts index 812bfa3bd12..5e5373639c9 100644 --- a/apps/sim/lib/link-preview/fetch-preview.ts +++ b/apps/sim/lib/link-preview/fetch-preview.ts @@ -102,11 +102,14 @@ export async function fetchLinkPreview( Accept: 'text/html,application/xhtml+xml', }, }) - const contentType = response.headers.get('content-type') ?? '' + const contentType = (response.headers.get('content-type') ?? '') + .split(';', 1)[0] + .trim() + .toLowerCase() if ( response.status < 200 || response.status >= 300 || - (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) + (contentType !== 'text/html' && contentType !== 'application/xhtml+xml') ) { await response.body?.cancel().catch(() => {}) return null 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)