diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 9484a22271e..6ddf920ea42 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -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, @@ -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 () => { diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 0561cfcc0e9..f7da9cd270f 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -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, @@ -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', () => ({ 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 a7c79cc6fde..2eb61214768 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 @@ -366,7 +366,15 @@ const MARKDOWN_COMPONENTS = { ) }, - 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 ( @@ -393,7 +401,13 @@ const MARKDOWN_COMPONENTS = { ) } return ( - + {children} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts index 125d10ca61d..41fe11f36cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts @@ -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) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.test.tsx new file mode 100644 index 00000000000..4fcf14cabf3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.test.tsx @@ -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 + +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 + +function Harness({ chatId, messages, onRender }: HarnessProps) { + const containerRef = useRef(null) + const scrollElementRef = useRef(null) + const find = useChatFind({ + chatId, + messages, + hiddenUserByIndex: [], + containerRef, + scrollElementRef, + virtualizer, + }) + onRender(find) + return ( +
+
+ {messages.map((message, index) => ( +
), + }} + /> + ))} +
+
+ ) +} + +const cleanups: (() => void)[] = [] + +function renderFind(props: Omit) { + let find: ChatFind | undefined + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + const render = (next: Omit) => + act(() => root.render( (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 {} + +beforeEach(() => { + vi.stubGlobal('Highlight', TestHighlight) + vi.stubGlobal('CSS', { highlights: new Map() }) + 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((resolve) => requestAnimationFrame(() => resolve()))) + + const highlighted = CSS.highlights.get('chat-find') + expect(find.current.count).toBe(1) + expect(highlighted?.size).toBe(find.current.count) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts index fe6eb35545a..5611a35e080 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts @@ -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 { @@ -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() : '' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index e0abea2236c..b3f1316c96a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -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 { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.test.ts new file mode 100644 index 00000000000..49ef5a9902c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.test.ts @@ -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', + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts index 7a85720f5dd..2f56cc6c6a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts @@ -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] @@ -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) + const merged: MentionRange[] = [] + for (const range of ranges) { + if (range.start >= (merged[merged.length - 1]?.end ?? 0)) merged.push(range) + } + return merged } /** diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index a1a3e947d8d..c118f2c6922 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -383,6 +383,7 @@ export { inputValidationMock, inputValidationMockFns, } from './input-validation.mock' +export { integrationMatcherMock } from './integration-matcher.mock' export { integrationsAvailabilityMock, integrationsAvailabilityMockFns, diff --git a/packages/testing/src/mocks/integration-matcher.mock.ts b/packages/testing/src/mocks/integration-matcher.mock.ts new file mode 100644 index 00000000000..6c54be43429 --- /dev/null +++ b/packages/testing/src/mocks/integration-matcher.mock.ts @@ -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), +}