Skip to content

Commit 52a7a88

Browse files
committed
fix(chat): keep find stable across footnotes and pending chats, and match mention chips next to punctuation
1 parent 5c446f1 commit 52a7a88

6 files changed

Lines changed: 188 additions & 8 deletions

File tree

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,15 @@ const MARKDOWN_COMPONENTS = {
366366
</div>
367367
)
368368
},
369-
a({ children, href }: { children?: React.ReactNode; href?: string }) {
369+
a({
370+
children,
371+
href,
372+
'data-footnote-ref': footnoteRef,
373+
}: {
374+
children?: React.ReactNode
375+
href?: string
376+
'data-footnote-ref'?: boolean
377+
}) {
370378
if (href?.startsWith(SOURCE_LINK_PREFIX)) {
371379
return (
372380
<SourceReference index={Number(href.slice(SOURCE_LINK_PREFIX.length))}>
@@ -393,7 +401,13 @@ const MARKDOWN_COMPONENTS = {
393401
)
394402
}
395403
return (
396-
<a href={href} className={PROSE_LINK_CLASS} target='_blank' rel='noopener noreferrer'>
404+
<a
405+
href={href}
406+
data-footnote-ref={footnoteRef}
407+
className={PROSE_LINK_CLASS}
408+
target='_blank'
409+
rel='noopener noreferrer'
410+
>
397411
{children}
398412
</a>
399413
)

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function getChatFindText(markdown: string): string {
2222
return node.value
2323
if (node.type === 'image' || node.type === 'imageReference') return '\uffff'
2424
if (node.type === 'break') return '\n'
25+
if (node.type === 'footnoteDefinition') return ''
2526
if (!('children' in node)) return ''
2627
return node.children
2728
.map(text)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, useRef } from 'react'
5+
import type { Virtualizer } from '@tanstack/react-virtual'
6+
import { createRoot } from 'react-dom/client'
7+
import { renderToStaticMarkup } from 'react-dom/server'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { ChatContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content'
10+
import { useChatFind } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find'
11+
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
12+
13+
type ChatFind = ReturnType<typeof useChatFind>
14+
15+
interface HarnessProps {
16+
chatId?: string
17+
messages: ChatMessage[]
18+
onRender: (find: ChatFind) => void
19+
}
20+
21+
// double-cast-allowed: the hook only calls scrollToIndex on the virtualizer
22+
const virtualizer = { scrollToIndex: () => {} } as unknown as Virtualizer<HTMLDivElement, Element>
23+
24+
function Harness({ chatId, messages, onRender }: HarnessProps) {
25+
const containerRef = useRef<HTMLDivElement>(null)
26+
const scrollElementRef = useRef<HTMLDivElement>(null)
27+
const find = useChatFind({
28+
chatId,
29+
messages,
30+
hiddenUserByIndex: [],
31+
containerRef,
32+
scrollElementRef,
33+
virtualizer,
34+
})
35+
onRender(find)
36+
return (
37+
<div ref={containerRef} tabIndex={-1} onKeyDown={find.onKeyDown}>
38+
<div ref={scrollElementRef}>
39+
{messages.map((message, index) => (
40+
<div
41+
key={message.id}
42+
data-index={index}
43+
dangerouslySetInnerHTML={{
44+
__html: renderToStaticMarkup(<ChatContent content={message.content} />),
45+
}}
46+
/>
47+
))}
48+
</div>
49+
</div>
50+
)
51+
}
52+
53+
const cleanups: (() => void)[] = []
54+
55+
function renderFind(props: Omit<HarnessProps, 'onRender'>) {
56+
let find: ChatFind | undefined
57+
const host = document.createElement('div')
58+
document.body.append(host)
59+
const root = createRoot(host)
60+
const render = (next: Omit<HarnessProps, 'onRender'>) =>
61+
act(() => root.render(<Harness {...next} onRender={(value) => (find = value)} />))
62+
cleanups.push(() => {
63+
act(() => root.unmount())
64+
host.remove()
65+
})
66+
render(props)
67+
return {
68+
get current() {
69+
if (!find) throw new Error('Hook was not rendered')
70+
return find
71+
},
72+
rerender: render,
73+
search(query: string) {
74+
act(() => {
75+
host.firstElementChild?.dispatchEvent(
76+
new KeyboardEvent('keydown', { key: 'f', metaKey: true, bubbles: true })
77+
)
78+
})
79+
act(() => find?.onQueryChange(query))
80+
},
81+
}
82+
}
83+
84+
function assistant(id: string, content: string): ChatMessage {
85+
return { id, role: 'assistant', content }
86+
}
87+
88+
class TestHighlight extends Set<Range> {}
89+
90+
beforeEach(() => {
91+
vi.stubGlobal('Highlight', TestHighlight)
92+
vi.stubGlobal('CSS', { highlights: new Map<string, TestHighlight>() })
93+
Range.prototype.getBoundingClientRect = () => new DOMRect()
94+
})
95+
96+
afterEach(() => {
97+
for (const cleanup of cleanups.splice(0)) cleanup()
98+
vi.unstubAllGlobals()
99+
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
100+
})
101+
102+
describe('useChatFind', () => {
103+
it('keeps the open search when a pending chat is persisted, and resets on a chat switch', () => {
104+
const messages = [assistant('a', 'The answer is here.')]
105+
const find = renderFind({ messages })
106+
find.search('answer')
107+
expect(find.current).toMatchObject({ isOpen: true, query: 'answer', count: 1 })
108+
109+
find.rerender({ chatId: 'chat-1', messages })
110+
expect(find.current).toMatchObject({ isOpen: true, query: 'answer', count: 1 })
111+
112+
find.rerender({ chatId: 'chat-2', messages })
113+
expect(find.current).toMatchObject({ isOpen: false, query: '', count: 0 })
114+
})
115+
116+
it('counts the same footnoted matches in the index as it highlights in the rendered message', async () => {
117+
const find = renderFind({
118+
chatId: 'chat',
119+
messages: [assistant('a', 'Chapter 1 cites this[^1].\n\n[^1]: Page 1.')],
120+
})
121+
find.search('1')
122+
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
123+
124+
const highlighted = CSS.highlights.get('chat-find')
125+
expect(find.current.count).toBe(1)
126+
expect(highlighted?.size).toBe(find.current.count)
127+
})
128+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
2323

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

2929
interface UseChatFindProps {
@@ -127,9 +127,11 @@ export function useChatFind({
127127
const [index, setIndex] = useState(0)
128128
if (scope !== chatId) {
129129
setScope(chatId)
130-
setIsOpen(false)
131-
setQuery('')
132-
setIndex(0)
130+
if (scope !== undefined) {
131+
setIsOpen(false)
132+
setQuery('')
133+
setIndex(0)
134+
}
133135
}
134136
const deferredQuery = useDeferredValue(query)
135137
const term = isOpen ? deferredQuery.trim() : ''
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { computeMentionRanges } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils'
3+
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
4+
5+
vi.mock('@/blocks/integration-matcher', () => ({
6+
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
7+
}))
8+
9+
function spans(text: string, contexts: ChatMessageContext[]): string[] {
10+
return computeMentionRanges(text, contexts).map((range) => text.slice(range.start, range.end))
11+
}
12+
13+
describe('computeMentionRanges', () => {
14+
const workflow: ChatMessageContext = { kind: 'workflow', label: 'Workflow' }
15+
16+
it('matches a mention followed by punctuation', () => {
17+
expect(spans('Run @Workflow, then stop.', [workflow])).toEqual(['@Workflow'])
18+
expect(spans('(see @Workflow)', [workflow])).toEqual(['@Workflow'])
19+
})
20+
21+
it('matches every repeat of a mention separated by one space', () => {
22+
expect(spans('@Workflow @Workflow', [workflow])).toEqual(['@Workflow', '@Workflow'])
23+
})
24+
25+
it('does not match a mention that is a prefix of a longer name', () => {
26+
expect(spans('@Workflow-2', [workflow])).toEqual([])
27+
})
28+
29+
it('matches slash commands by their slash prefix', () => {
30+
expect(spans('/research the topic', [{ kind: 'slash_command', label: 'research' }])).toEqual([
31+
'/research',
32+
])
33+
})
34+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ export function computeMentionRanges(text: string, contexts: ChatMessageContext[
2828
for (const rawCtx of contexts) {
2929
if (!rawCtx.label) continue
3030
const ctx = withResolvedBlockType(rawCtx)
31-
const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@'
31+
const prefix =
32+
ctx.kind === 'skill' || ctx.kind === 'mcp' || ctx.kind === 'slash_command' ? '/' : '@'
3233
const token = `${prefix}${ctx.label}`
33-
const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g')
34+
const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(?=[\\s.,;:!?)\\]]|$)`, 'g')
3435
let match: RegExpExecArray | null
3536
while ((match = pattern.exec(text)) !== null) {
3637
const leadingSpace = match[1]

0 commit comments

Comments
 (0)