Skip to content

Commit c83f4be

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/search-provider-validate-before-tx
# Conflicts: # apps/sim/lib/sim-search/live/member-setup.ts
2 parents 354fe10 + dbeb732 commit c83f4be

101 files changed

Lines changed: 30471 additions & 341 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/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
deploymentShapeMock,
66
deploymentShapeMockFns,
77
} from '@sim/testing/mocks/deployment-shape.mock'
8+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
89
import {
910
organizationProviderMock,
1011
organizationProviderMockFns,
@@ -57,9 +58,7 @@ vi.mock('@/hooks/queries/skills', () => ({
5758
}),
5859
}))
5960
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
60-
vi.mock('@/blocks/integration-matcher', () => ({
61-
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
62-
}))
61+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
6362
vi.mock(
6463
'@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown',
6564
async () => {

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
deploymentShapeMock,
88
deploymentShapeMockFns,
99
} from '@sim/testing/mocks/deployment-shape.mock'
10+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
1011
import {
1112
kbConnectorsQueriesMock,
1213
kbConnectorsQueriesMockFns,
@@ -46,7 +47,7 @@ vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () =>
4647
useFeatureFlag: (name: string) => (name === 'mothership-plan-mode' ? mocks.plan : false),
4748
}))
4849
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
49-
vi.mock('@/blocks/integration-matcher', () => ({ mentionifyIntegrations: (text: string) => text }))
50+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
5051
vi.mock('next/navigation', () => nextNavigationMock)
5152
vi.mock('@tanstack/react-query', () => reactQueryMock)
5253
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({

‎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: 7 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,12 @@ 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+
// A pending chat adopting its id (undefined → id) is the same conversation.
131+
if (scope !== undefined) {
132+
setIsOpen(false)
133+
setQuery('')
134+
setIndex(0)
135+
}
133136
}
134137
const deferredQuery = useDeferredValue(query)
135138
const term = isOpen ? deferredQuery.trim() : ''

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22
* @vitest-environment jsdom
33
*/
44
import { act, type ReactNode } from 'react'
5+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
56
import { createRoot, type Root } from 'react-dom/client'
67
import { describe, expect, it, vi } from 'vitest'
78

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

1413
import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard'
1514
import {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { computeMentionRanges } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils'
4+
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
5+
6+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
7+
8+
function spans(text: string, contexts: ChatMessageContext[]): string[] {
9+
return computeMentionRanges(text, contexts).map((range) => text.slice(range.start, range.end))
10+
}
11+
12+
describe('computeMentionRanges', () => {
13+
const workflow: ChatMessageContext = { kind: 'workflow', label: 'Workflow' }
14+
15+
it('matches a mention followed by punctuation', () => {
16+
expect(spans('Run @Workflow, then stop.', [workflow])).toEqual(['@Workflow'])
17+
expect(spans('(see @Workflow)', [workflow])).toEqual(['@Workflow'])
18+
})
19+
20+
it('treats a period as a boundary only when no name continues after it', () => {
21+
expect(spans('see @Workflow.', [workflow])).toEqual(['@Workflow'])
22+
expect(spans('open @report.pdf', [{ kind: 'file', label: 'report' }])).toEqual([])
23+
})
24+
25+
it('matches every repeat of a mention separated by one space', () => {
26+
expect(spans('@Workflow @Workflow', [workflow])).toEqual(['@Workflow', '@Workflow'])
27+
})
28+
29+
it('does not match a mention that is a prefix of a longer name', () => {
30+
expect(spans('@Workflow-2', [workflow])).toEqual([])
31+
})
32+
33+
it('keeps only the longest of overlapping mentions', () => {
34+
const report: ChatMessageContext = { kind: 'file', label: 'report' }
35+
const reportPdf: ChatMessageContext = { kind: 'file', label: 'report.pdf' }
36+
expect(spans('compare @report.pdf with @report', [report, reportPdf])).toEqual([
37+
'@report.pdf',
38+
'@report',
39+
])
40+
expect(
41+
spans('@Workflow 2 and @Workflow', [workflow, { kind: 'workflow', label: 'Workflow 2' }])
42+
).toEqual(['@Workflow 2', '@Workflow'])
43+
})
44+
45+
it('matches slash commands by their slash prefix', () => {
46+
expect(spans('/research the topic', [{ kind: 'slash_command', label: 'research' }])).toEqual([
47+
'/research',
48+
])
49+
})
50+
})

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@ 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(
35+
`(^|\\s)(${escapeRegExp(token)})(?=[\\s,;:!?)\\]]|\\.(?![\\w-])|$)`,
36+
'g'
37+
)
3438
let match: RegExpExecArray | null
3539
while ((match = pattern.exec(text)) !== null) {
3640
const leadingSpace = match[1]
@@ -44,8 +48,12 @@ export function computeMentionRanges(text: string, contexts: ChatMessageContext[
4448
ranges.push(range)
4549
}
4650

47-
ranges.sort((a, b) => a.start - b.start)
48-
return ranges
51+
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
52+
const merged: MentionRange[] = []
53+
for (const range of ranges) {
54+
if (range.start >= (merged[merged.length - 1]?.end ?? 0)) merged.push(range)
55+
}
56+
return merged
4957
}
5058

5159
/**

0 commit comments

Comments
 (0)