Skip to content

Commit 2aed9b5

Browse files
authored
feat(chat): add shared find menu (#8351)
* feat(chat): add shared find menu * fix(chat): preserve find focus and scroll ownership * fix(chat): resume pending send scroll after find closes
1 parent bccc722 commit 2aed9b5

13 files changed

Lines changed: 559 additions & 154 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
FindBar,
3+
type FindBarProps,
4+
} from '@/app/workspace/[workspaceId]/components/find-bar/find-bar'
5+
export { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut'
Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,43 @@
11
'use client'
22

33
import type React from 'react'
4-
import { useEffect } from 'react'
4+
import { useCallback, useEffect } from 'react'
55

66
interface UseFindShortcutOptions {
77
/**
8-
* Whether this surface currently owns Cmd/Ctrl+F. Every find surface binds its own listener, so
9-
* exactly one owner may be enabled at a time — the surfaces arbitrate by mounting (the Files list
10-
* disables itself while a file is open, and the file editor enables itself only where the document
11-
* is the page), by an embed flag (the table grid), or by DOM containment (the browser session).
12-
* Two enabled owners mounted at once would race, and first-registered would win.
8+
* Whether this surface owns Cmd/Ctrl+F. Full-page owners must be mutually exclusive;
9+
* scoped owners arbitrate through containment and descendant event handling.
1310
*/
1411
enabled: boolean
1512
/** The find bar's input, focused and selected once the bar opens. */
1613
inputRef: React.RefObject<HTMLInputElement | null>
14+
/** Limits a split-pane surface to shortcuts originating inside it. */
15+
containerRef?: React.RefObject<HTMLElement | null>
1716
onOpen: () => void
1817
}
1918

2019
/**
2120
* Binds Cmd/Ctrl+F to open a find bar, overriding the browser's own find.
2221
*
23-
* Listens on the document rather than a container so the shortcut answers before anything inside the
24-
* surface has been focused — a file that has only been opened, never clicked into, still responds.
22+
* Full-page surfaces listen on the document so an opened file responds before being focused.
23+
* Scoped surfaces attach the returned React key handler so descendant controls can consume it first.
2524
* A press another surface already consumed is left alone (`defaultPrevented`), and any chord with a
2625
* further modifier falls through to the browser, so Cmd+Shift+F and Cmd+Alt+F keep their meanings.
2726
*/
28-
export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOptions): void {
29-
useEffect(() => {
30-
if (!enabled) return
31-
const handleFindShortcut = (event: KeyboardEvent) => {
27+
export function useFindShortcut({
28+
enabled,
29+
inputRef,
30+
containerRef,
31+
onOpen,
32+
}: UseFindShortcutOptions): React.KeyboardEventHandler<HTMLElement> {
33+
const handleFindShortcut = useCallback(
34+
(event: KeyboardEvent | React.KeyboardEvent<HTMLElement>) => {
35+
if (!enabled) return
36+
if (
37+
containerRef &&
38+
(!(event.target instanceof Node) || !containerRef.current?.contains(event.target))
39+
)
40+
return
3241
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
3342
if (event.key.toLowerCase() !== 'f') return
3443
if (event.defaultPrevented) return
@@ -39,8 +48,13 @@ export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOp
3948
inputRef.current?.focus()
4049
inputRef.current?.select()
4150
})
42-
}
51+
},
52+
[enabled, inputRef, containerRef, onOpen]
53+
)
54+
useEffect(() => {
55+
if (!enabled || containerRef) return
4356
document.addEventListener('keydown', handleFindShortcut)
4457
return () => document.removeEventListener('keydown', handleFindShortcut)
45-
}, [enabled, inputRef, onOpen])
58+
}, [enabled, containerRef, handleFindShortcut])
59+
return handleFindShortcut
4660
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ interface SourceReferenceProps {
182182
function SourceReference({ index, children }: SourceReferenceProps) {
183183
const source = useContext(SourceRefsContext)[index]
184184
if (!source) return <>{children}</>
185-
return <SourceChip source={source} />
185+
return (
186+
<span data-chat-find-boundary>
187+
<SourceChip source={source} />
188+
</span>
189+
)
186190
}
187191

188192
const WORKSPACE_LINK_PREFIX = '#sim-workspace-ref-'
@@ -196,7 +200,9 @@ function WorkspaceReference({ index, children }: SourceReferenceProps) {
196200
const { resources, onSelect } = useContext(WorkspaceRefsContext)
197201
const resource = resources[index]
198202
return resource ? (
199-
<WorkspaceResourceDisplay data={resource} onSelect={onSelect} />
203+
<span data-chat-find-boundary>
204+
<WorkspaceResourceDisplay data={resource} onSelect={onSelect} />
205+
</span>
200206
) : (
201207
<>{children}</>
202208
)
@@ -342,7 +348,10 @@ const MARKDOWN_COMPONENTS = {
342348

343349
return (
344350
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--border)]'>
345-
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
351+
<div
352+
data-chat-find-ignore
353+
className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'
354+
>
346355
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
347356
<CopyCodeButton
348357
code={codeString}
@@ -762,6 +771,7 @@ function ChatContentInner({
762771
return (
763772
<div
764773
key={`inline-${i}`}
774+
data-chat-find-content
765775
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
766776
>
767777
<Streamdown
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
assistantMessageHasRenderableContent,
33
getOrchestratorMessageText,
4+
getOrchestratorMessageTextSegments,
45
MessageContent,
56
} from './message-content'
67
export type { MessagePhase } from './utils'

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -624,21 +624,23 @@ export function parseBlocks(blocks: ContentBlock[], isStreaming = false): Messag
624624
)
625625
}
626626

627-
function joinRenderableText(parts: string[]): string {
628-
return parts.filter(Boolean).join('\n\n')
627+
/** Returns independently rendered text segments, excluding agent groups and other UI segments. */
628+
export function getOrchestratorMessageTextSegments(
629+
blocks: ContentBlock[],
630+
fallbackContent: string
631+
): string[] {
632+
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
633+
if (parsed.length === 0) return [fallbackContent]
634+
635+
return parsed.map((segment) => (segment.type === 'text' ? segment.content : '')).filter(Boolean)
629636
}
630637

631638
/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */
632639
export function getOrchestratorMessageText(
633640
blocks: ContentBlock[],
634641
fallbackContent: string
635642
): string {
636-
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
637-
if (parsed.length === 0) return fallbackContent
638-
639-
return joinRenderableText(
640-
parsed.map((segment) => (segment.type === 'text' ? segment.content : ''))
641-
)
643+
return getOrchestratorMessageTextSegments(blocks, fallbackContent).join('\n\n')
642644
}
643645

644646
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getChatFindText } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text'
3+
4+
describe('chat find text', () => {
5+
it('searches visible Markdown across inline formatting without indexing link destinations', () => {
6+
expect(getChatFindText('A **formatted** [answer](https://example.com/hidden).')).toBe(
7+
'A formatted answer.'
8+
)
9+
})
10+
11+
it('keeps separate blocks and inline images from creating joined words', () => {
12+
expect(getChatFindText('first\n\nsecond\n\nleft![image](image.png)right')).toBe(
13+
'first\nsecond\nleft\uffffright'
14+
)
15+
})
16+
17+
it('preserves code and decodes escaped prose without indexing reference definitions', () => {
18+
expect(
19+
getChatFindText('Use `a_b` and a\\_b &amp; c.\n\n```ts\na_b()\n```\n\n[ref]: /hidden')
20+
).toBe('Use a_b and a_b & c.\na_b()')
21+
})
22+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Nodes } from 'mdast'
2+
import remarkGfm from 'remark-gfm'
3+
import remarkParse from 'remark-parse'
4+
import { unified } from 'unified'
5+
6+
const parser = unified().use(remarkParse).use(remarkGfm)
7+
const INLINE_CONTAINERS = new Set([
8+
'paragraph',
9+
'heading',
10+
'strong',
11+
'emphasis',
12+
'delete',
13+
'link',
14+
'linkReference',
15+
'tableCell',
16+
])
17+
18+
/** Projects Markdown onto searchable display text, excluding invisible destinations and metadata. */
19+
export function getChatFindText(markdown: string): string {
20+
function text(node: Nodes): string {
21+
if (node.type === 'text' || node.type === 'inlineCode' || node.type === 'code')
22+
return node.value
23+
if (node.type === 'image' || node.type === 'imageReference') return '\uffff'
24+
if (node.type === 'break') return '\n'
25+
if (!('children' in node)) return ''
26+
return node.children
27+
.map(text)
28+
.filter(Boolean)
29+
.join(INLINE_CONTAINERS.has(node.type) ? '' : '\n')
30+
}
31+
return text(parser.parse(markdown))
32+
}

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

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/rea
1818
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
1919
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
2020
import { inter } from '@/app/_styles/fonts/inter/inter'
21+
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar'
2122
import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions'
2223
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
2324
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -41,6 +42,7 @@ import {
4142
toCopyableMarkdown,
4243
} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
4344
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
45+
import { useChatFind } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find'
4446
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
4547
import {
4648
UserInput,
@@ -217,7 +219,7 @@ const UserMessageRow = memo(function UserMessageRow({
217219
className={attachmentWidthClassName}
218220
/>
219221
)}
220-
<div className={bubbleClassName}>
222+
<div className={bubbleClassName} data-chat-find-content>
221223
<UserMessageContent content={content} contexts={contexts} />
222224
</div>
223225
</div>
@@ -386,8 +388,8 @@ export function MothershipChat({
386388
*/
387389
const messages = useDeferredValue(messagesProp)
388390
const [lastRowAnimating, setLastRowAnimating] = useState(false)
391+
const containerRef = useRef<HTMLDivElement>(null)
389392
const scrollElementRef = useRef<HTMLDivElement | null>(null)
390-
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
391393
const sizerRef = useRef<HTMLDivElement | null>(null)
392394
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
393395
const sizerFloorAppliedRef = useRef(0)
@@ -525,13 +527,6 @@ export function MothershipChat({
525527
sizerFloorAppliedRef.current = floor
526528
sizer.style.minHeight = `${floor}px`
527529
})
528-
const setScrollElement = useCallback(
529-
(el: HTMLDivElement | null) => {
530-
scrollElementRef.current = el
531-
autoScrollRef(el)
532-
},
533-
[autoScrollRef]
534-
)
535530

536531
const hasMessages = messages.length > 0
537532

@@ -695,6 +690,23 @@ export function MothershipChat({
695690
useAnimationFrameWithResizeObserver: true,
696691
})
697692

693+
const find = useChatFind({
694+
chatId,
695+
messages,
696+
hiddenUserByIndex: interactionPairing.hiddenUserByIndex,
697+
containerRef,
698+
scrollElementRef,
699+
virtualizer,
700+
})
701+
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating, find.isOpen)
702+
const setScrollElement = useCallback(
703+
(el: HTMLDivElement | null) => {
704+
scrollElementRef.current = el
705+
autoScrollRef(el)
706+
},
707+
[autoScrollRef]
708+
)
709+
698710
/**
699711
* Instance property — silently ignored if passed as a `useVirtualizer`
700712
* option. Skips scroll compensation for the streaming last row: it starts
@@ -784,7 +796,7 @@ export function MothershipChat({
784796
}, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer])
785797

786798
/**
787-
* The user's OWN send always snaps the viewport to their message: sending IS the intent
799+
* With find closed, the user's own send snaps the viewport to their message: sending is the intent
788800
* to watch the reply, and the streaming sticky-scroll only engages when already pinned
789801
* to the bottom — from a scrolled-up position a fresh turn would stream out of view
790802
* (verified live, three-for-three, during the revamp browser pass).
@@ -795,11 +807,11 @@ export function MothershipChat({
795807
const scrolledForUserMsgRef = useRef<string | undefined>(undefined)
796808
useLayoutEffect(() => {
797809
if (!lastUserMessageId || scrolledForUserMsgRef.current === lastUserMessageId) return
798-
if (isSending && initialScrollBlocked) return
810+
if (find.isOpen || (isSending && initialScrollBlocked)) return
799811
scrolledForUserMsgRef.current = lastUserMessageId
800812
if (!isSending) return
801813
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
802-
}, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer])
814+
}, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer, find.isOpen])
803815

804816
const virtualItems = virtualizer.getVirtualItems()
805817

@@ -813,7 +825,32 @@ export function MothershipChat({
813825
onViewSources={onViewSources}
814826
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
815827
>
816-
<div className={cn('flex h-full min-h-0 flex-col', inter.className, className)}>
828+
<div
829+
ref={containerRef}
830+
onKeyDown={find.onKeyDown}
831+
tabIndex={-1}
832+
className={cn(
833+
'relative flex h-full min-h-0 flex-col [&::highlight(chat-find)]:bg-[var(--highlight-match-bg)] [&::highlight(chat-find)]:text-[var(--highlight-match-text)] [&::highlight(chat-find-active)]:bg-[var(--brand-secondary)] [&::highlight(chat-find-active)]:text-[var(--color-black)]',
834+
inter.className,
835+
className
836+
)}
837+
>
838+
{find.isOpen && (
839+
<FindBar
840+
ariaLabel='Find in chat'
841+
query={find.query}
842+
onQueryChange={find.onQueryChange}
843+
onNext={find.next}
844+
onPrev={find.prev}
845+
onClose={find.close}
846+
count={find.count}
847+
currentIndex={find.currentIndex}
848+
truncated={find.truncated}
849+
isLoading={find.isStale}
850+
canNavigate={!find.isStale}
851+
inputRef={find.inputRef}
852+
/>
853+
)}
817854
<div ref={setScrollElement} className={styles.scrollContainer} onCopy={handleCopy}>
818855
{isLoading && !hasMessages ? (
819856
<MothershipChatSkeleton layout={layout} />

0 commit comments

Comments
 (0)