Skip to content

Commit 2671951

Browse files
authored
improvement(chat): unify message styling and source previews (#8241)
* improvement(chat): unify message styling and source previews * fix(chat): bound preview work and preserve readable tables * fix(chat): preserve link contrast and bound preview retries * fix(chat): restore Inter and refine table overflow
1 parent 491740a commit 2671951

51 files changed

Lines changed: 1388 additions & 458 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-reply.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ export function HeroChatReply({ content, onOpenWorkflowResource }: HeroChatReply
2424
<>
2525
{paragraph.slice(0, resourceIndex)}
2626
<ResourceMention
27-
icon={
28-
<Workflow className='relative top-0.5 size-[12px] shrink-0 text-[var(--text-icon)]' />
29-
}
27+
icon={<Workflow className='size-[12px] shrink-0 text-[var(--text-icon)]' />}
3028
title={WORKFLOW_TITLE}
3129
onSelect={onOpenWorkflowResource}
3230
/>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/** @vitest-environment node */
2+
import { NextRequest } from 'next/server'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({
6+
fetch: vi.fn(),
7+
get: vi.fn(),
8+
set: vi.fn(),
9+
}))
10+
vi.mock('@/lib/auth', () => ({ getSession: async () => ({ user: { id: 'test-user' } }) }))
11+
vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({
12+
enforceUserRateLimit: async () => null,
13+
}))
14+
vi.mock('@/lib/api/server', () => ({
15+
parseRequest: async () => ({
16+
success: true,
17+
data: { query: { url: 'https://example.com/guide' } },
18+
}),
19+
}))
20+
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => mocks }))
21+
vi.mock('@/lib/core/network/context.server', () => ({
22+
runWithOutboundOrganization: (_organization: null, run: () => unknown) => run(),
23+
}))
24+
vi.mock('@/lib/core/utils/with-route-handler', () => ({
25+
withRouteHandler: (handler: unknown) => handler,
26+
}))
27+
vi.mock('@/lib/link-preview/fetch-preview', () => ({ fetchLinkPreview: mocks.fetch }))
28+
29+
import { GET } from '@/app/api/link-preview/route'
30+
31+
const complete = { title: 'Guide', description: null, siteName: null }
32+
33+
describe('link preview cache lifetime', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
mocks.get.mockResolvedValue(null)
37+
mocks.set.mockResolvedValue('OK')
38+
})
39+
40+
it.each([
41+
{ preview: { ...complete, imageRetryable: true }, ttl: 60 },
42+
{ preview: complete, ttl: 24 * 60 * 60 },
43+
{ preview: null, ttl: 60 * 60 },
44+
])('caches $preview for $ttl seconds', async ({ preview, ttl }) => {
45+
mocks.fetch.mockResolvedValue(preview)
46+
const response = await GET(new NextRequest('https://example.com/api/link-preview'))
47+
expect(await response.json()).toEqual({ preview })
48+
expect(mocks.set).toHaveBeenCalledWith(expect.any(String), JSON.stringify(preview), 'EX', ttl)
49+
})
50+
})

‎apps/sim/app/api/link-preview/route.ts‎

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { createHash } from 'crypto'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
4-
import { truncate } from '@sim/utils/string'
5-
import * as cheerio from 'cheerio'
64
import type { NextRequest } from 'next/server'
75
import { NextResponse } from 'next/server'
86
import type { LinkPreview } from '@/lib/api/contracts/link-preview'
@@ -12,66 +10,15 @@ import { getSession } from '@/lib/auth'
1210
import { getRedisClient } from '@/lib/core/config/redis'
1311
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
1412
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
15-
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
1613
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { fetchLinkPreview } from '@/lib/link-preview/fetch-preview'
1715

1816
const logger = createLogger('LinkPreviewAPI')
1917

20-
const FETCH_TIMEOUT_MS = 5000
21-
const MAX_RESPONSE_BYTES = 256 * 1024
22-
const MAX_REDIRECTS = 3
23-
const TITLE_MAX_CHARS = 200
24-
const DESCRIPTION_MAX_CHARS = 300
2518
const CACHE_TTL_SECONDS = 24 * 60 * 60
2619
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
27-
const CACHE_KEY_PREFIX = 'link-preview:v1:'
28-
29-
/**
30-
* Parses preview metadata from the fetched document (already capped at
31-
* MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity
32-
* decoding.
33-
*/
34-
function parsePreview(html: string): LinkPreview {
35-
const $ = cheerio.load(html)
36-
37-
const meta = (key: string): string | null => {
38-
const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')
39-
return value?.trim() || null
40-
}
41-
42-
const title =
43-
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
44-
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
45-
const siteName = meta('og:site_name')
46-
47-
if (!title && !description && !siteName) return null
48-
return {
49-
title: title ? truncate(title, TITLE_MAX_CHARS) : null,
50-
description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null,
51-
siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null,
52-
}
53-
}
54-
55-
async function fetchPreview(url: string): Promise<LinkPreview> {
56-
const response = await secureFetchWithValidation(url, {
57-
// The URL is harvested from a rendered link rather than authored as a
58-
// destination, so it gets no reach into a private network.
59-
profile: 'contentFetch',
60-
timeout: FETCH_TIMEOUT_MS,
61-
maxRedirects: MAX_REDIRECTS,
62-
maxResponseBytes: MAX_RESPONSE_BYTES,
63-
headers: {
64-
'User-Agent': 'Simbot/1.0 (+https://sim.ai)',
65-
Accept: 'text/html,application/xhtml+xml',
66-
},
67-
})
68-
if (response.status < 200 || response.status >= 300) return null
69-
const contentType = response.headers.get('content-type') ?? ''
70-
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
71-
return null
72-
}
73-
return parsePreview(await response.text())
74-
}
20+
const RETRYABLE_CACHE_TTL_SECONDS = 60
21+
const CACHE_KEY_PREFIX = 'link-preview:v2:'
7522

7623
export const GET = withRouteHandler(async (request: NextRequest) => {
7724
const session = await getSession()
@@ -106,16 +53,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
10653
let preview: LinkPreview = null
10754
try {
10855
/** Link previews have no organization owner and use a shared URL cache. */
109-
preview = await runWithOutboundOrganization(null, () => fetchPreview(url))
56+
preview = await runWithOutboundOrganization(null, () => fetchLinkPreview(url, request.signal))
11057
} catch (error) {
58+
if (request.signal.aborted) return new NextResponse(null, { status: 499 })
11159
logger.info('Link preview fetch failed; returning null preview', {
11260
host: new URL(url).hostname,
11361
error: getErrorMessage(error, 'unknown error').replaceAll(url, '[url]'),
11462
})
11563
}
11664

11765
if (redis) {
118-
const ttl = preview ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS
66+
const ttl = preview?.imageRetryable
67+
? RETRYABLE_CACHE_TTL_SECONDS
68+
: preview
69+
? CACHE_TTL_SECONDS
70+
: NEGATIVE_CACHE_TTL_SECONDS
11971
try {
12072
await redis.set(cacheKey, JSON.stringify(preview), 'EX', ttl)
12173
} catch (error) {

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isAssistantImageType,
1111
} from '@/lib/uploads/shared/assistant-images'
1212
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
13+
import { inter } from '@/app/_styles/fonts/inter/inter'
1314
import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar'
1415
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
1516
import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list'
@@ -280,6 +281,7 @@ export function Composer({
280281
onDragOver={files.handleDragOver}
281282
onDrop={files.handleDrop}
282283
className={cn(
284+
inter.className,
283285
'relative z-10 mx-auto w-full max-w-chat',
284286
!imagesOnly &&
285287
'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ vi.mock('next/navigation', () => ({
3131
usePathname: () => '/workspace/workspace-1/files',
3232
useRouter: () => ({ push: vi.fn() }),
3333
}))
34+
vi.mock('@/app/_styles/fonts/inter/inter', () => ({ inter: { variable: 'test-inter-variable' } }))
3435
vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) }))
3536
vi.mock('@/hooks/queries/workspace-files', () => ({
3637
useUploadWorkspaceFile: () => ({ mutateAsync: uploadFile }),

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '@/lib/mothership/chat/selection-context'
2626
import type { FileDownloadSource } from '@/lib/uploads/client/download'
2727
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
28+
import { inter } from '@/app/_styles/fonts/inter/inter'
2829
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar'
2930
import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict'
3031
import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
@@ -118,8 +119,10 @@ function warnRichMarkdownPasteLimit(reason?: 'paste' | 'formatting') {
118119
* {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder →
119120
* live swap never reflows. Shared as one constant to keep them in lockstep.
120121
*/
121-
const EDITOR_SURFACE_CLASS =
122-
'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'
122+
const EDITOR_SURFACE_CLASS = cn(
123+
'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',
124+
inter.variable
125+
)
123126

124127
/** ProseMirror block positions do not correspond to markdown source line numbers. */
125128
function buildEditorSelectionContext(

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { cn, Lightbox } from '@sim/emcn'
1+
import { ChipTag, cn, Lightbox, OverflowText } from '@sim/emcn'
22
import { getDocumentIcon } from '@/components/icons/document-icons'
33
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
44

55
function FileAttachmentPill(props: { mediaType: string; filename: string }) {
66
const Icon = getDocumentIcon(props.mediaType, props.filename)
77
return (
8-
<div className='flex max-w-[140px] items-center gap-[5px] rounded-lg bg-[var(--surface-5)] px-[6px] py-[3px]'>
9-
<Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
10-
<span className='truncate text-[var(--text-body)] text-xs'>{props.filename}</span>
11-
</div>
8+
<ChipTag variant='mono' leftIcon={Icon} className='max-w-[140px]'>
9+
<OverflowText label={props.filename} />
10+
</ChipTag>
1211
)
1312
}
1413

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function ActivityDisclosure({
5959
{collapsible && (
6060
<Expandable expanded={expanded}>
6161
<ExpandableContent id={contentId}>
62-
<div className='pt-1.5'>
62+
<div className='pt-2'>
6363
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
6464
{children}
6565
</ActivityViewport>

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('flat expanded activity layout', () => {
8080
expect(iconSlot(row).classList).toContain(ICON_SLOT)
8181
}
8282
const list = rows[0].closest('.flex-col')!
83-
expect(list.classList).toContain('gap-1.5')
83+
expect(list.classList).toContain('gap-2')
8484
let node: Element | null = rows[0]
8585
while (node && node !== container) {
8686
expect(hasIndent(node), node.className).toBe(false)
@@ -134,16 +134,15 @@ describe('flat expanded activity layout', () => {
134134
},
135135
},
136136
])
137-
const blocks = container.querySelector('.flex-col.gap-3')!
137+
const blocks = container.querySelector('.flex-col.gap-2')!
138138
expect(blocks.contains(statuses()[0])).toBe(true)
139-
expect(blocks.classList).toContain('gap-3')
140-
expect(blocks.classList).not.toContain('gap-1.5')
139+
expect(blocks.classList).toContain('gap-2')
141140
expect(statuses()).toHaveLength(1)
142141
expand()
143142
const rows = statuses().slice(1)
144143
expect(rows).toHaveLength(3)
145144
for (const row of rows) {
146-
expect(row.closest('.flex-col')!.classList).toContain('gap-1.5')
145+
expect(row.closest('.flex-col')!.classList).toContain('gap-2')
147146
expect(iconSlot(row).classList).toContain(ICON_SLOT)
148147
}
149148
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ export function ActivityViewport({
8585
className={cn(
8686
'pr-2',
8787
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
88-
scrollFadeClass,
89-
(edges.top || edges.bottom) && 'py-1'
88+
scrollFadeClass
9089
)}
9190
{...scrollFadeAttributes(edges)}
9291
>

0 commit comments

Comments
 (0)