Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
deploymentShapeMock,
deploymentShapeMockFns,
} from '@sim/testing/mocks/deployment-shape.mock'
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import {
organizationProviderMock,
organizationProviderMockFns,
Expand Down Expand Up @@ -57,9 +58,7 @@ vi.mock('@/hooks/queries/skills', () => ({
}),
}))
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
vi.mock('@/blocks/integration-matcher', () => ({
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
}))
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
vi.mock(
'@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown',
async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
deploymentShapeMock,
deploymentShapeMockFns,
} from '@sim/testing/mocks/deployment-shape.mock'
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import {
kbConnectorsQueriesMock,
kbConnectorsQueriesMockFns,
Expand Down Expand Up @@ -46,7 +47,7 @@ vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () =>
useFeatureFlag: (name: string) => (name === 'mothership-plan-mode' ? mocks.plan : false),
}))
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
vi.mock('@/blocks/integration-matcher', () => ({ mentionifyIntegrations: (text: string) => text }))
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
vi.mock('next/navigation', () => nextNavigationMock)
vi.mock('@tanstack/react-query', () => reactQueryMock)
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,15 @@ const MARKDOWN_COMPONENTS = {
</div>
)
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
a({
children,
href,
'data-footnote-ref': footnoteRef,
}: {
children?: React.ReactNode
href?: string
'data-footnote-ref'?: boolean
}) {
if (href?.startsWith(SOURCE_LINK_PREFIX)) {
return (
<SourceReference index={Number(href.slice(SOURCE_LINK_PREFIX.length))}>
Expand All @@ -393,7 +401,13 @@ const MARKDOWN_COMPONENTS = {
)
}
return (
<a href={href} className={PROSE_LINK_CLASS} target='_blank' rel='noopener noreferrer'>
<a
href={href}
data-footnote-ref={footnoteRef}
className={PROSE_LINK_CLASS}
target='_blank'
rel='noopener noreferrer'
>
{children}
</a>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function getChatFindText(markdown: string): string {
return node.value
if (node.type === 'image' || node.type === 'imageReference') return '\uffff'
if (node.type === 'break') return '\n'
if (node.type === 'footnoteDefinition') return ''
if (!('children' in node)) return ''
return node.children
.map(text)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @vitest-environment jsdom
*/
import { act, useRef } from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content'
import { useChatFind } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find'
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'

type ChatFind = ReturnType<typeof useChatFind>

interface HarnessProps {
chatId?: string
messages: ChatMessage[]
onRender: (find: ChatFind) => void
}

// double-cast-allowed: the hook only calls scrollToIndex on the virtualizer
const virtualizer = { scrollToIndex: () => {} } as unknown as Virtualizer<HTMLDivElement, Element>

function Harness({ chatId, messages, onRender }: HarnessProps) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollElementRef = useRef<HTMLDivElement>(null)
const find = useChatFind({
chatId,
messages,
hiddenUserByIndex: [],
containerRef,
scrollElementRef,
virtualizer,
})
onRender(find)
return (
<div ref={containerRef} tabIndex={-1} onKeyDown={find.onKeyDown}>
<div ref={scrollElementRef}>
{messages.map((message, index) => (
<div
key={message.id}
data-index={index}
dangerouslySetInnerHTML={{
__html: renderToStaticMarkup(<ChatContent content={message.content} />),
}}
/>
))}
</div>
</div>
)
}

const cleanups: (() => void)[] = []

function renderFind(props: Omit<HarnessProps, 'onRender'>) {
let find: ChatFind | undefined
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
const render = (next: Omit<HarnessProps, 'onRender'>) =>
act(() => root.render(<Harness {...next} onRender={(value) => (find = value)} />))
cleanups.push(() => {
act(() => root.unmount())
host.remove()
})
render(props)
return {
get current() {
if (!find) throw new Error('Hook was not rendered')
return find
},
rerender: render,
search(query: string) {
act(() => {
host.firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'f', metaKey: true, bubbles: true })
)
})
act(() => find?.onQueryChange(query))
},
}
}

function assistant(id: string, content: string): ChatMessage {
return { id, role: 'assistant', content }
}

class TestHighlight extends Set<Range> {}

beforeEach(() => {
vi.stubGlobal('Highlight', TestHighlight)
vi.stubGlobal('CSS', { highlights: new Map<string, TestHighlight>() })
Range.prototype.getBoundingClientRect = () => new DOMRect()
})

afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup()
vi.unstubAllGlobals()
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
})

describe('useChatFind', () => {
it('keeps the open search when a pending chat is persisted, and resets on a chat switch', () => {
const messages = [assistant('a', 'The answer is here.')]
const find = renderFind({ messages })
find.search('answer')
expect(find.current).toMatchObject({ isOpen: true, query: 'answer', count: 1 })

find.rerender({ chatId: 'chat-1', messages })
expect(find.current).toMatchObject({ isOpen: true, query: 'answer', count: 1 })

find.rerender({ chatId: 'chat-2', messages })
expect(find.current).toMatchObject({ isOpen: false, query: '', count: 0 })
})

it('counts the same footnoted matches in the index as it highlights in the rendered message', async () => {
const find = renderFind({
chatId: 'chat',
messages: [assistant('a', 'Chapter 1 cites this[^1].\n\n[^1]: Page 1.')],
})
find.search('1')
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))

const highlighted = CSS.highlights.get('chat-find')
expect(find.current.count).toBe(1)
expect(highlighted?.size).toBe(find.current.count)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'

const MATCH_LIMIT = 500
const EXCLUDED_CONTENT =
'button, [aria-hidden="true"], [data-agent-group], [data-chat-activity], [data-interaction-card], [data-chat-find-ignore]'
'button, [aria-hidden="true"], [data-agent-group], [data-chat-activity], [data-interaction-card], [data-chat-find-ignore], [data-footnote-ref], [data-footnotes]'
const TEXT_BLOCK = 'p, h1, h2, h3, h4, h5, h6, pre, li, td, th'

interface UseChatFindProps {
Expand Down Expand Up @@ -127,9 +127,12 @@ export function useChatFind({
const [index, setIndex] = useState(0)
if (scope !== chatId) {
setScope(chatId)
setIsOpen(false)
setQuery('')
setIndex(0)
// A pending chat adopting its id (undefined → id) is the same conversation.
if (scope !== undefined) {
setIsOpen(false)
setQuery('')
setIndex(0)
}
}
const deferredQuery = useDeferredValue(query)
const term = isOpen ? deferredQuery.trim() : ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import { createRoot, type Root } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
vi.mock('@/blocks/integration-matcher', () => ({
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
}))
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)

import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard'
import {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import { describe, expect, it, vi } from 'vitest'
import { computeMentionRanges } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'

vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)

function spans(text: string, contexts: ChatMessageContext[]): string[] {
return computeMentionRanges(text, contexts).map((range) => text.slice(range.start, range.end))
}

describe('computeMentionRanges', () => {
const workflow: ChatMessageContext = { kind: 'workflow', label: 'Workflow' }

it('matches a mention followed by punctuation', () => {
expect(spans('Run @Workflow, then stop.', [workflow])).toEqual(['@Workflow'])
expect(spans('(see @Workflow)', [workflow])).toEqual(['@Workflow'])
})

it('treats a period as a boundary only when no name continues after it', () => {
expect(spans('see @Workflow.', [workflow])).toEqual(['@Workflow'])
expect(spans('open @report.pdf', [{ kind: 'file', label: 'report' }])).toEqual([])
})

it('matches every repeat of a mention separated by one space', () => {
expect(spans('@Workflow @Workflow', [workflow])).toEqual(['@Workflow', '@Workflow'])
})

it('does not match a mention that is a prefix of a longer name', () => {
expect(spans('@Workflow-2', [workflow])).toEqual([])
})

it('keeps only the longest of overlapping mentions', () => {
const report: ChatMessageContext = { kind: 'file', label: 'report' }
const reportPdf: ChatMessageContext = { kind: 'file', label: 'report.pdf' }
expect(spans('compare @report.pdf with @report', [report, reportPdf])).toEqual([
'@report.pdf',
'@report',
])
expect(
spans('@Workflow 2 and @Workflow', [workflow, { kind: 'workflow', label: 'Workflow 2' }])
).toEqual(['@Workflow 2', '@Workflow'])
})

it('matches slash commands by their slash prefix', () => {
expect(spans('/research the topic', [{ kind: 'slash_command', label: 'research' }])).toEqual([
'/research',
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ export function computeMentionRanges(text: string, contexts: ChatMessageContext[
for (const rawCtx of contexts) {
if (!rawCtx.label) continue
const ctx = withResolvedBlockType(rawCtx)
const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@'
const prefix =
ctx.kind === 'skill' || ctx.kind === 'mcp' || ctx.kind === 'slash_command' ? '/' : '@'
const token = `${prefix}${ctx.label}`
const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g')
const pattern = new RegExp(
`(^|\\s)(${escapeRegExp(token)})(?=[\\s,;:!?)\\]]|\\.(?![\\w-])|$)`,
'g'
)
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const leadingSpace = match[1]
Expand All @@ -44,8 +48,12 @@ export function computeMentionRanges(text: string, contexts: ChatMessageContext[
ranges.push(range)
}

ranges.sort((a, b) => a.start - b.start)
return ranges
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
Comment thread
waleedlatif1 marked this conversation as resolved.
const merged: MentionRange[] = []
for (const range of ranges) {
if (range.start >= (merged[merged.length - 1]?.end ?? 0)) merged.push(range)
}
return merged
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/testing/src/mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export {
inputValidationMock,
inputValidationMockFns,
} from './input-validation.mock'
export { integrationMatcherMock } from './integration-matcher.mock'
export {
integrationsAvailabilityMock,
integrationsAvailabilityMockFns,
Expand Down
17 changes: 17 additions & 0 deletions packages/testing/src/mocks/integration-matcher.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { vi } from 'vitest'

/**
* Static mock module for `@/blocks/integration-matcher` that knows no integrations, so tests never
* load the block registry: the matcher has no regex and `mentionifyIntegrations` returns its input.
*
* @example
* ```ts
* import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
*
* vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
* ```
*/
export const integrationMatcherMock = {
getIntegrationMatcher: vi.fn(() => ({ regex: null, byName: new Map() })),
mentionifyIntegrations: vi.fn((text: string) => text),
}
Loading