From 996e2884a27dbf1680a53f272efb5c196b8253da Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 18:44:58 -0700 Subject: [PATCH 1/3] feat(chat): add shared find menu --- .../components/find-bar/index.ts | 5 + .../components/find-bar/use-find-shortcut.ts | 16 +- .../components/chat-content/chat-content.tsx | 16 +- .../home/components/message-content/index.ts | 1 + .../message-content/message-content.tsx | 18 +- .../mothership-chat/chat-find-text.test.ts | 22 ++ .../mothership-chat/chat-find-text.ts | 32 +++ .../mothership-chat/mothership-chat.tsx | 44 +++- .../mothership-chat/use-chat-find.ts | 239 ++++++++++++++++++ .../user-message-content.tsx | 83 +----- .../components/user-message-content/utils.ts | 96 +++++++ apps/sim/hooks/use-auto-scroll.ts | 9 +- ...check-tool-registry-boundary.baseline.json | 64 ++--- 13 files changed, 512 insertions(+), 133 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/find-bar/index.ts new file mode 100644 index 00000000000..c723c1fb6ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/index.ts @@ -0,0 +1,5 @@ +export { + FindBar, + type FindBarProps, +} from '@/app/workspace/[workspaceId]/components/find-bar/find-bar' +export { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut' diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts index f0bd842f6c9..70940a6ffc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts @@ -14,6 +14,8 @@ interface UseFindShortcutOptions { enabled: boolean /** The find bar's input, focused and selected once the bar opens. */ inputRef: React.RefObject + /** Limits a split-pane surface to shortcuts originating inside it. */ + containerRef?: React.RefObject onOpen: () => void } @@ -25,10 +27,16 @@ interface UseFindShortcutOptions { * A press another surface already consumed is left alone (`defaultPrevented`), and any chord with a * further modifier falls through to the browser, so Cmd+Shift+F and Cmd+Alt+F keep their meanings. */ -export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOptions): void { +export function useFindShortcut({ + enabled, + inputRef, + containerRef, + onOpen, +}: UseFindShortcutOptions): void { useEffect(() => { if (!enabled) return const handleFindShortcut = (event: KeyboardEvent) => { + if (containerRef && !containerRef.current?.contains(event.target as Node)) return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (event.key.toLowerCase() !== 'f') return if (event.defaultPrevented) return @@ -40,7 +48,7 @@ export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOp inputRef.current?.select() }) } - document.addEventListener('keydown', handleFindShortcut) - return () => document.removeEventListener('keydown', handleFindShortcut) - }, [enabled, inputRef, onOpen]) + document.addEventListener('keydown', handleFindShortcut, Boolean(containerRef)) + return () => document.removeEventListener('keydown', handleFindShortcut, Boolean(containerRef)) + }, [enabled, inputRef, containerRef, onOpen]) } 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 34aa6832aed..a7c79cc6fde 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 @@ -182,7 +182,11 @@ interface SourceReferenceProps { function SourceReference({ index, children }: SourceReferenceProps) { const source = useContext(SourceRefsContext)[index] if (!source) return <>{children} - return + return ( + + + + ) } const WORKSPACE_LINK_PREFIX = '#sim-workspace-ref-' @@ -196,7 +200,9 @@ function WorkspaceReference({ index, children }: SourceReferenceProps) { const { resources, onSelect } = useContext(WorkspaceRefsContext) const resource = resources[index] return resource ? ( - + + + ) : ( <>{children} ) @@ -342,7 +348,10 @@ const MARKDOWN_COMPONENTS = { return (
-
+
{language || 'code'} :first-child]:mt-0 [&>:last-child]:mb-0')} > 0 ? parseBlocks(blocks) : [] + if (parsed.length === 0) return [fallbackContent] + + return parsed.map((segment) => (segment.type === 'text' ? segment.content : '')).filter(Boolean) } /** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */ @@ -633,12 +640,7 @@ export function getOrchestratorMessageText( blocks: ContentBlock[], fallbackContent: string ): string { - const parsed = blocks.length > 0 ? parseBlocks(blocks) : [] - if (parsed.length === 0) return fallbackContent - - return joinRenderableText( - parsed.map((segment) => (segment.type === 'text' ? segment.content : '')) - ) + return getOrchestratorMessageTextSegments(blocks, fallbackContent).join('\n\n') } function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.test.ts new file mode 100644 index 00000000000..b6c5fe12f78 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { getChatFindText } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text' + +describe('chat find text', () => { + it('searches visible Markdown across inline formatting without indexing link destinations', () => { + expect(getChatFindText('A **formatted** [answer](https://example.com/hidden).')).toBe( + 'A formatted answer.' + ) + }) + + it('keeps separate blocks and inline images from creating joined words', () => { + expect(getChatFindText('first\n\nsecond\n\nleft![image](image.png)right')).toBe( + 'first\nsecond\nleft\uffffright' + ) + }) + + it('preserves code and decodes escaped prose without indexing reference definitions', () => { + expect( + getChatFindText('Use `a_b` and a\\_b & c.\n\n```ts\na_b()\n```\n\n[ref]: /hidden') + ).toBe('Use a_b and a_b & c.\na_b()') + }) +}) 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 new file mode 100644 index 00000000000..125d10ca61d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text.ts @@ -0,0 +1,32 @@ +import type { Nodes } from 'mdast' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' + +const parser = unified().use(remarkParse).use(remarkGfm) +const INLINE_CONTAINERS = new Set([ + 'paragraph', + 'heading', + 'strong', + 'emphasis', + 'delete', + 'link', + 'linkReference', + 'tableCell', +]) + +/** Projects Markdown onto searchable display text, excluding invisible destinations and metadata. */ +export function getChatFindText(markdown: string): string { + function text(node: Nodes): string { + if (node.type === 'text' || node.type === 'inlineCode' || node.type === 'code') + return node.value + if (node.type === 'image' || node.type === 'imageReference') return '\uffff' + if (node.type === 'break') return '\n' + if (!('children' in node)) return '' + return node.children + .map(text) + .filter(Boolean) + .join(INLINE_CONTAINERS.has(node.type) ? '' : '\n') + } + return text(parser.parse(markdown)) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index ad8d5436977..bc4059b2c13 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -18,6 +18,7 @@ import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/rea import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { inter } from '@/app/_styles/fonts/inter/inter' +import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar' import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -41,6 +42,7 @@ import { toCopyableMarkdown, } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown' import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor' +import { useChatFind } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -217,7 +219,7 @@ const UserMessageRow = memo(function UserMessageRow({ className={attachmentWidthClassName} /> )} -
+
@@ -386,8 +388,11 @@ export function MothershipChat({ */ const messages = useDeferredValue(messagesProp) const [lastRowAnimating, setLastRowAnimating] = useState(false) + const containerRef = useRef(null) const scrollElementRef = useRef(null) - const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) + const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll( + isStreamActive || lastRowAnimating + ) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) const sizerFloorAppliedRef = useRef(0) @@ -802,6 +807,15 @@ export function MothershipChat({ }, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer]) const virtualItems = virtualizer.getVirtualItems() + const find = useChatFind({ + chatId, + messages, + hiddenUserByIndex: interactionPairing.hiddenUserByIndex, + containerRef, + scrollElementRef, + virtualizer, + detachAutoScroll, + }) return ( -
+
+ {find.isOpen && ( + + )}
{isLoading && !hasMessages ? ( 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 new file mode 100644 index 00000000000..e5eb5b0d06d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts @@ -0,0 +1,239 @@ +'use client' + +import { + type RefObject, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { forEachSearchOccurrence } from '@sim/utils/string' +import type { Virtualizer } from '@tanstack/react-virtual' +import { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar' +import { getOrchestratorMessageTextSegments } from '@/app/workspace/[workspaceId]/home/components/message-content' +import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize' +import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { getChatFindText } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/chat-find-text' +import { getUserMessageText } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils' +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]' +const TEXT_BLOCK = 'p, h1, h2, h3, h4, h5, h6, pre, li, td, th' + +interface UseChatFindProps { + chatId?: string + messages: ChatMessage[] + hiddenUserByIndex: Array + containerRef: RefObject + scrollElementRef: RefObject + virtualizer: Virtualizer + detachAutoScroll: () => void +} + +function getMessageSearchText(message: ChatMessage): string { + if (message.role === 'user') return getUserMessageText(message.content, message.contexts) + return getOrchestratorMessageTextSegments(message.contentBlocks ?? [], message.content) + .map((content) => + getChatFindText( + parseSpecialTags(sanitizeChatDisplayContent(content), false) + .segments.map((segment) => + segment.type === 'text' ? segment.content : segment.type === 'thinking' ? '' : '\uffff' + ) + .join('') + ) + ) + .join('\uffff') +} + +/** Finds text without rewriting React-owned message nodes or mounting the entire transcript. */ +function findRanges(root: Element, query: string): Range[] { + const nodes: { node: Text; start: number; offset: number; length: number }[] = [] + let text = '' + let previousBlock: Element | null = null + function visit(node: Node) { + if (node instanceof Element) { + if (node.matches('img, [data-chat-find-boundary]')) { + text += '\uffff' + return + } + if (node.matches(EXCLUDED_CONTENT)) return + if (node.tagName === 'BR') { + text += '\n' + return + } + } + if (node instanceof Text) { + const block = node.parentElement?.closest(TEXT_BLOCK) ?? null + if (!block && !node.data.trim()) return + const offset = node.previousSibling?.nodeName === 'BR' && node.data.startsWith('\n') ? 1 : 0 + if (nodes.length && block !== previousBlock) text += '\n' + previousBlock = block + nodes.push({ node, start: text.length, offset, length: node.length - offset }) + text += node.data.slice(offset) + return + } + for (const child of node.childNodes) visit(child) + } + visit(root) + const ranges: Range[] = [] + let nodeIndex = 0 + forEachSearchOccurrence(text, query, (start, end) => { + if (ranges.length >= MATCH_LIMIT) return + while (nodeIndex + 1 < nodes.length && nodes[nodeIndex + 1].start <= start) nodeIndex++ + const first = nodes[nodeIndex] + let endIndex = nodeIndex + while (endIndex + 1 < nodes.length && nodes[endIndex + 1].start < end) endIndex++ + const last = nodes[endIndex] + if (!first || !last || start >= first.start + first.length || end > last.start + last.length) + return + const range = document.createRange() + range.setStart(first.node, start - first.start + first.offset) + range.setEnd(last.node, end - last.start + last.offset) + ranges.push(range) + }) + return ranges +} + +export function useChatFind({ + chatId, + messages, + hiddenUserByIndex, + containerRef, + scrollElementRef, + virtualizer, + detachAutoScroll, +}: UseChatFindProps) { + const inputRef = useRef(null) + const restoreFocusRef = useRef(null) + const [scope, setScope] = useState(chatId) + const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState('') + const [index, setIndex] = useState(0) + if (scope !== chatId) { + setScope(chatId) + setIsOpen(false) + setQuery('') + setIndex(0) + } + const deferredQuery = useDeferredValue(query) + const term = isOpen ? deferredQuery.trim() : '' + const result = useMemo(() => { + const matches: { messageIndex: number; occurrence: number }[] = [] + let truncated = false + if (term) { + for (const [messageIndex, message] of messages.entries()) { + if (hiddenUserByIndex[messageIndex] || message.origin === 'task') continue + const text = getMessageSearchText(message) + let occurrence = 0 + forEachSearchOccurrence(text, term, () => { + if (matches.length < MATCH_LIMIT) matches.push({ messageIndex, occurrence: occurrence++ }) + else truncated = true + }) + if (truncated) break + } + } + return { matches, truncated } + }, [messages, hiddenUserByIndex, term]) + const currentIndex = Math.min(index, Math.max(0, result.matches.length - 1)) + const active = result.matches[currentIndex] + const messageIndex = active?.messageIndex + const occurrence = active?.occurrence + const isStale = query !== deferredQuery + + const open = useCallback(() => { + if (document.activeElement instanceof HTMLElement && !inputRef.current) { + restoreFocusRef.current = document.activeElement + } + setIsOpen(true) + }, []) + useFindShortcut({ enabled: true, inputRef, containerRef, onOpen: open }) + + const close = useCallback(() => { + setIsOpen(false) + setQuery('') + setIndex(0) + const target = restoreFocusRef.current + if (target?.isConnected) target.focus({ preventScroll: true }) + else containerRef.current?.focus({ preventScroll: true }) + }, [containerRef]) + const onQueryChange = useCallback((value: string) => { + setQuery(value) + setIndex(0) + }, []) + const step = useCallback( + (delta: number) => { + if (result.matches.length && !isStale) + setIndex((currentIndex + delta + result.matches.length) % result.matches.length) + }, + [currentIndex, result.matches.length, isStale] + ) + const next = useCallback(() => step(1), [step]) + const prev = useCallback(() => step(-1), [step]) + + useEffect(() => { + const scroller = scrollElementRef.current + if (!scroller || !term || isStale || messageIndex === undefined) return + detachAutoScroll() + virtualizer.scrollToIndex(messageIndex, { align: 'center' }) + let revealed = false + let frame = 0 + const matches = typeof Highlight === 'undefined' ? null : new Highlight() + const selected = typeof Highlight === 'undefined' ? null : new Highlight() + if (matches && selected) { + CSS.highlights.set('chat-find', matches) + CSS.highlights.set('chat-find-active', selected) + } + const paint = () => { + matches?.clear() + selected?.clear() + for (const row of scroller.querySelectorAll('[data-index]')) { + const ranges = Array.from(row.querySelectorAll('[data-chat-find-content]')) + .filter((root) => !root.closest(EXCLUDED_CONTENT)) + .flatMap((root) => findRanges(root, term)) + for (const range of ranges) matches?.add(range) + if (Number(row.dataset.index) !== messageIndex) continue + const range = ranges[occurrence ?? 0] + if (!range) continue + selected?.add(range) + if (revealed) continue + const rect = range.getBoundingClientRect() + if (!rect.height) continue + const bounds = scroller.getBoundingClientRect() + scroller.scrollTop += rect.top - bounds.top - scroller.clientHeight / 2 + rect.height / 2 + revealed = true + } + } + const schedule = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(paint) + } + const observer = new MutationObserver(schedule) + observer.observe(scroller, { childList: true, characterData: true, subtree: true }) + schedule() + return () => { + observer.disconnect() + cancelAnimationFrame(frame) + if (CSS.highlights?.get('chat-find') === matches) CSS.highlights.delete('chat-find') + if (CSS.highlights?.get('chat-find-active') === selected) + CSS.highlights.delete('chat-find-active') + } + }, [term, isStale, messageIndex, occurrence, scrollElementRef, virtualizer, detachAutoScroll]) + + return { + isOpen, + query, + currentIndex, + count: result.matches.length, + truncated: result.truncated, + inputRef, + onQueryChange, + next, + prev, + close, + isStale, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index f4d05d73e26..2c312455098 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -2,12 +2,11 @@ import { useMemo } from 'react' import { cn } from '@sim/emcn' -import { escapeRegExp } from '@sim/utils/string' import { inter } from '@/app/_styles/fonts/inter/inter' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { ResourceMention } from '@/app/workspace/[workspaceId]/home/components/message-content/components/resource-mention' +import { computeMentionRanges } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils' import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' -import { getIntegrationMatcher } from '@/blocks/integration-matcher' const USER_MESSAGE_CLASSES = 'whitespace-pre-wrap [overflow-wrap:anywhere] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased' @@ -25,86 +24,6 @@ interface UserMessageContentProps { compact?: boolean } -interface MentionRange { - start: number - end: number - context: ChatMessageContext -} - -/** - * Backfills a renderable `blockType` onto integration contexts that are - * missing one (or carry one the registry no longer knows) by resolving the - * label through the integration matcher. Messages persisted before - * `blockType` was included in the save mapping would otherwise render a - * mention pill with no icon. - */ -function withResolvedBlockType(ctx: ChatMessageContext): ChatMessageContext { - if (ctx.kind !== 'integration' || !ctx.label) return ctx - const info = getIntegrationMatcher().byName.get(ctx.label.toLowerCase()) - if (!info) return ctx - return { ...ctx, blockType: info.blockType } -} - -function computeMentionRanges(text: string, contexts: ChatMessageContext[]): MentionRange[] { - const ranges: MentionRange[] = [] - - for (const rawCtx of contexts) { - if (!rawCtx.label) continue - const ctx = withResolvedBlockType(rawCtx) - const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@' - const token = `${prefix}${ctx.label}` - const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g') - let match: RegExpExecArray | null - while ((match = pattern.exec(text)) !== null) { - const leadingSpace = match[1] - const tokenStart = match.index + leadingSpace.length - const tokenEnd = tokenStart + token.length - ranges.push({ start: tokenStart, end: tokenEnd, context: ctx }) - } - } - - for (const range of computeIntegrationRanges(text, ranges)) { - ranges.push(range) - } - - ranges.sort((a, b) => a.start - b.start) - return ranges -} - -/** - * Scans the raw text for explicit, token-starting `@IntegrationName` mentions - * (any casing) and decorates them even when no matching context was stored — - * e.g. a message submitted before the input's auto-mention pass ran, or one - * authored outside the chat input. Ranges already claimed by stored contexts - * are skipped so the two sources never double-decorate. - */ -function computeIntegrationRanges(text: string, taken: MentionRange[]): MentionRange[] { - const { regex, byName } = getIntegrationMatcher() - if (!regex || !text) return [] - - regex.lastIndex = 0 - const ranges: MentionRange[] = [] - let match: RegExpExecArray | null - - while ((match = regex.exec(text)) !== null) { - const atIndex = match.index - 1 - if (atIndex < 0 || text[atIndex] !== '@') continue - if (atIndex > 0 && !/\s/.test(text[atIndex - 1])) continue - const info = byName.get(match[0].toLowerCase()) - if (!info) continue - const start = atIndex - const end = match.index + match[0].length - if (taken.some((r) => start < r.end && end > r.start)) continue - ranges.push({ - start, - end, - context: { kind: 'integration', blockType: info.blockType, label: info.name }, - }) - } - - return ranges -} - function MentionHighlight({ context }: { context: ChatMessageContext }) { return ( a.start - b.start) + return ranges +} + +/** + * Scans the raw text for explicit, token-starting `@IntegrationName` mentions + * (any casing) and decorates them even when no matching context was stored — + * e.g. a message submitted before the input's auto-mention pass ran, or one + * authored outside the chat input. Ranges already claimed by stored contexts + * are skipped so the two sources never double-decorate. + */ +function computeIntegrationRanges(text: string, taken: MentionRange[]): MentionRange[] { + const { regex, byName } = getIntegrationMatcher() + if (!regex || !text) return [] + + regex.lastIndex = 0 + const ranges: MentionRange[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(text)) !== null) { + const atIndex = match.index - 1 + if (atIndex < 0 || text[atIndex] !== '@') continue + if (atIndex > 0 && !/\s/.test(text[atIndex - 1])) continue + const info = byName.get(match[0].toLowerCase()) + if (!info) continue + const start = atIndex + const end = match.index + match[0].length + if (taken.some((r) => start < r.end && end > r.start)) continue + ranges.push({ + start, + end, + context: { kind: 'integration', blockType: info.blockType, label: info.name }, + }) + } + + return ranges +} + +/** Returns the labels and prose rendered by a user message, without mention prefixes. */ +export function getUserMessageText(content: string, contexts: ChatMessageContext[] = []): string { + const ranges = computeMentionRanges(content, contexts) + if (!ranges.length) return content.trim() + let text = '' + let end = 0 + for (const range of ranges) { + text += content.slice(end, range.start) + (range.context.label ?? '') + end = range.end + } + return text + content.slice(end) +} diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index d20a68c10f1..c385ec2d7f5 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -251,5 +251,12 @@ export function useAutoScroll(isStreaming: boolean) { } }, [isStreaming]) - return { ref: callbackRef } + const detach = useCallback(() => { + stickyRef.current = false + userDetachedRef.current = true + settleCleanupRef.current?.() + settleCleanupRef.current = null + }, []) + + return { ref: callbackRef, detach } } diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 681943fcff0..50d9498a2f2 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -87,16 +87,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3223, + "modules": 3285, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1589, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 989, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 985, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 685, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 682, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1635, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 994, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 990, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 686, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 683, "apps/sim/triggers/registry.ts": 485, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/lib/auth/index.ts": 276 + "apps/sim/blocks/registry.ts": 334, + "apps/sim/lib/auth/index.ts": 280 } }, "app/workspace/[workspaceId]/error.tsx": { @@ -157,16 +157,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3223, + "modules": 3285, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1589, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 989, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 985, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 685, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 682, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1635, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 994, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 990, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 686, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 683, "apps/sim/triggers/registry.ts": 485, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/lib/auth/index.ts": 276 + "apps/sim/blocks/registry.ts": 334, + "apps/sim/lib/auth/index.ts": 280 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { @@ -516,29 +516,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2234, + "modules": 2280, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2233, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 605, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 557, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2279, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 642, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 594, "apps/sim/triggers/registry.ts": 522, - "apps/sim/blocks/registry.ts": 353, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 264, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 169, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 156 + "apps/sim/blocks/registry.ts": 354, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 267, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 183, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 179 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2215, + "modules": 2262, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1002, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 735, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1047, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 777, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 380, - "apps/sim/blocks/registry.ts": 353, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 176, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 159, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 156 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 382, + "apps/sim/blocks/registry.ts": 354, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 188, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 184, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 178 } }, "app/workspace/layout.tsx": { From 16d35b2c08a03b5f97de9759a839ef3316680104 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 19:04:17 -0700 Subject: [PATCH 2/3] fix(chat): preserve find focus and scroll ownership --- .../components/find-bar/use-find-shortcut.ts | 40 ++++++++------- .../mothership-chat/mothership-chat.tsx | 43 ++++++++-------- .../mothership-chat/use-chat-find.ts | 50 +++++++++++++------ apps/sim/hooks/use-auto-scroll.ts | 21 ++++---- 4 files changed, 90 insertions(+), 64 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts index 70940a6ffc5..5e6e939883d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts @@ -1,15 +1,12 @@ 'use client' import type React from 'react' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' interface UseFindShortcutOptions { /** - * Whether this surface currently owns Cmd/Ctrl+F. Every find surface binds its own listener, so - * exactly one owner may be enabled at a time — the surfaces arbitrate by mounting (the Files list - * disables itself while a file is open, and the file editor enables itself only where the document - * is the page), by an embed flag (the table grid), or by DOM containment (the browser session). - * Two enabled owners mounted at once would race, and first-registered would win. + * Whether this surface owns Cmd/Ctrl+F. Full-page owners must be mutually exclusive; + * scoped owners arbitrate through containment and descendant event handling. */ enabled: boolean /** The find bar's input, focused and selected once the bar opens. */ @@ -22,8 +19,8 @@ interface UseFindShortcutOptions { /** * Binds Cmd/Ctrl+F to open a find bar, overriding the browser's own find. * - * Listens on the document rather than a container so the shortcut answers before anything inside the - * surface has been focused — a file that has only been opened, never clicked into, still responds. + * Full-page surfaces listen on the document so an opened file responds before being focused. + * Scoped surfaces attach the returned React key handler so descendant controls can consume it first. * A press another surface already consumed is left alone (`defaultPrevented`), and any chord with a * further modifier falls through to the browser, so Cmd+Shift+F and Cmd+Alt+F keep their meanings. */ @@ -32,11 +29,15 @@ export function useFindShortcut({ inputRef, containerRef, onOpen, -}: UseFindShortcutOptions): void { - useEffect(() => { - if (!enabled) return - const handleFindShortcut = (event: KeyboardEvent) => { - if (containerRef && !containerRef.current?.contains(event.target as Node)) return +}: UseFindShortcutOptions): React.KeyboardEventHandler { + const handleFindShortcut = useCallback( + (event: KeyboardEvent | React.KeyboardEvent) => { + if (!enabled) return + if ( + containerRef && + (!(event.target instanceof Node) || !containerRef.current?.contains(event.target)) + ) + return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (event.key.toLowerCase() !== 'f') return if (event.defaultPrevented) return @@ -47,8 +48,13 @@ export function useFindShortcut({ inputRef.current?.focus() inputRef.current?.select() }) - } - document.addEventListener('keydown', handleFindShortcut, Boolean(containerRef)) - return () => document.removeEventListener('keydown', handleFindShortcut, Boolean(containerRef)) - }, [enabled, inputRef, containerRef, onOpen]) + }, + [enabled, inputRef, containerRef, onOpen] + ) + useEffect(() => { + if (!enabled || containerRef) return + document.addEventListener('keydown', handleFindShortcut) + return () => document.removeEventListener('keydown', handleFindShortcut) + }, [enabled, containerRef, handleFindShortcut]) + return handleFindShortcut } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index bc4059b2c13..752e225370a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -390,9 +390,6 @@ export function MothershipChat({ const [lastRowAnimating, setLastRowAnimating] = useState(false) const containerRef = useRef(null) const scrollElementRef = useRef(null) - const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll( - isStreamActive || lastRowAnimating - ) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) const sizerFloorAppliedRef = useRef(0) @@ -530,13 +527,6 @@ export function MothershipChat({ sizerFloorAppliedRef.current = floor sizer.style.minHeight = `${floor}px` }) - const setScrollElement = useCallback( - (el: HTMLDivElement | null) => { - scrollElementRef.current = el - autoScrollRef(el) - }, - [autoScrollRef] - ) const hasMessages = messages.length > 0 @@ -700,6 +690,23 @@ export function MothershipChat({ useAnimationFrameWithResizeObserver: true, }) + const find = useChatFind({ + chatId, + messages, + hiddenUserByIndex: interactionPairing.hiddenUserByIndex, + containerRef, + scrollElementRef, + virtualizer, + }) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating, find.isOpen) + const setScrollElement = useCallback( + (el: HTMLDivElement | null) => { + scrollElementRef.current = el + autoScrollRef(el) + }, + [autoScrollRef] + ) + /** * Instance property — silently ignored if passed as a `useVirtualizer` * option. Skips scroll compensation for the streaming last row: it starts @@ -789,7 +796,7 @@ export function MothershipChat({ }, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer]) /** - * The user's OWN send always snaps the viewport to their message: sending IS the intent + * With find closed, the user's own send snaps the viewport to their message: sending is the intent * to watch the reply, and the streaming sticky-scroll only engages when already pinned * to the bottom — from a scrolled-up position a fresh turn would stream out of view * (verified live, three-for-three, during the revamp browser pass). @@ -802,20 +809,11 @@ export function MothershipChat({ if (!lastUserMessageId || scrolledForUserMsgRef.current === lastUserMessageId) return if (isSending && initialScrollBlocked) return scrolledForUserMsgRef.current = lastUserMessageId - if (!isSending) return + if (!isSending || find.isOpen) return virtualizer.scrollToIndex(lastIndex, { align: 'end' }) - }, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer]) + }, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer, find.isOpen]) const virtualItems = virtualizer.getVirtualItems() - const find = useChatFind({ - chatId, - messages, - hiddenUserByIndex: interactionPairing.hiddenUserByIndex, - containerRef, - scrollElementRef, - virtualizer, - detachAutoScroll, - }) return (
scrollElementRef: RefObject virtualizer: Virtualizer - detachAutoScroll: () => void } -function getMessageSearchText(message: ChatMessage): string { +function getMessageSearchText(message: ChatMessage, cache: LRUCache): string { if (message.role === 'user') return getUserMessageText(message.content, message.contexts) return getOrchestratorMessageTextSegments(message.contentBlocks ?? [], message.content) - .map((content) => - getChatFindText( + .map((content) => { + const cached = cache.get(content) + if (cached !== undefined) return cached + const text = getChatFindText( parseSpecialTags(sanitizeChatDisplayContent(content), false) .segments.map((segment) => segment.type === 'text' ? segment.content : segment.type === 'thinking' ? '' : '\uffff' ) .join('') ) - ) + cache.set(content, text) + return text + }) .join('\uffff') } @@ -105,10 +110,17 @@ export function useChatFind({ containerRef, scrollElementRef, virtualizer, - detachAutoScroll, }: UseChatFindProps) { const inputRef = useRef(null) const restoreFocusRef = useRef(null) + const [textCache] = useState( + () => + new LRUCache({ + max: 10_000, + maxSize: 8 * 1024 * 1024, + sizeCalculation: (value, key) => Math.max(1, (value.length + key.length) * 2), + }) + ) const [scope, setScope] = useState(chatId) const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') @@ -127,7 +139,7 @@ export function useChatFind({ if (term) { for (const [messageIndex, message] of messages.entries()) { if (hiddenUserByIndex[messageIndex] || message.origin === 'task') continue - const text = getMessageSearchText(message) + const text = getMessageSearchText(message, textCache) let occurrence = 0 forEachSearchOccurrence(text, term, () => { if (matches.length < MATCH_LIMIT) matches.push({ messageIndex, occurrence: occurrence++ }) @@ -137,7 +149,7 @@ export function useChatFind({ } } return { matches, truncated } - }, [messages, hiddenUserByIndex, term]) + }, [messages, hiddenUserByIndex, term, textCache]) const currentIndex = Math.min(index, Math.max(0, result.matches.length - 1)) const active = result.matches[currentIndex] const messageIndex = active?.messageIndex @@ -150,16 +162,24 @@ export function useChatFind({ } setIsOpen(true) }, []) - useFindShortcut({ enabled: true, inputRef, containerRef, onOpen: open }) + const onKeyDown = useFindShortcut({ enabled: true, inputRef, containerRef, onOpen: open }) - const close = useCallback(() => { - setIsOpen(false) - setQuery('') - setIndex(0) + const restoreFocus = useCallback(() => { const target = restoreFocusRef.current + restoreFocusRef.current = null if (target?.isConnected) target.focus({ preventScroll: true }) else containerRef.current?.focus({ preventScroll: true }) }, [containerRef]) + const close = useCallback(() => { + setIsOpen(false) + setQuery('') + setIndex(0) + restoreFocus() + }, [restoreFocus]) + useLayoutEffect(() => { + if (restoreFocusRef.current && document.activeElement === document.body) restoreFocus() + else restoreFocusRef.current = null + }, [chatId, restoreFocus]) const onQueryChange = useCallback((value: string) => { setQuery(value) setIndex(0) @@ -177,7 +197,6 @@ export function useChatFind({ useEffect(() => { const scroller = scrollElementRef.current if (!scroller || !term || isStale || messageIndex === undefined) return - detachAutoScroll() virtualizer.scrollToIndex(messageIndex, { align: 'center' }) let revealed = false let frame = 0 @@ -221,7 +240,7 @@ export function useChatFind({ if (CSS.highlights?.get('chat-find-active') === selected) CSS.highlights.delete('chat-find-active') } - }, [term, isStale, messageIndex, occurrence, scrollElementRef, virtualizer, detachAutoScroll]) + }, [term, isStale, messageIndex, occurrence, scrollElementRef, virtualizer]) return { isOpen, @@ -231,6 +250,7 @@ export function useChatFind({ truncated: result.truncated, inputRef, onQueryChange, + onKeyDown, next, prev, close, diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index c385ec2d7f5..08dee542944 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -40,9 +40,10 @@ const POST_STOP_SETTLE_WINDOW = 800 * of the bottom to re-engage. Each streaming start re-seeds stickiness from the * current scroll position, so a user who scrolled up beforehand stays put. * + * Yields scroll ownership while paused, including between streams. * Returns `ref`, the callback ref for the scroll container. */ -export function useAutoScroll(isStreaming: boolean) { +export function useAutoScroll(isStreaming: boolean, paused = false) { const containerRef = useRef(null) const stickyRef = useRef(true) const userDetachedRef = useRef(false) @@ -76,6 +77,13 @@ export function useAutoScroll(isStreaming: boolean) { useEffect(() => () => settleCleanupRef.current?.(), []) useEffect(() => { + if (paused) { + stickyRef.current = false + userDetachedRef.current = true + settleCleanupRef.current?.() + settleCleanupRef.current = null + return + } if (!isStreaming) return const el = containerRef.current if (!el) return @@ -249,14 +257,7 @@ export function useAutoScroll(isStreaming: boolean) { removeGestureGuard() } } - }, [isStreaming]) - - const detach = useCallback(() => { - stickyRef.current = false - userDetachedRef.current = true - settleCleanupRef.current?.() - settleCleanupRef.current = null - }, []) + }, [isStreaming, paused]) - return { ref: callbackRef, detach } + return { ref: callbackRef } } From f904f0500e613bea3ab7e082bdbd34babd4e650e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 19:15:10 -0700 Subject: [PATCH 3/3] fix(chat): resume pending send scroll after find closes --- .../home/components/mothership-chat/mothership-chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 752e225370a..f922fbc9d7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -807,9 +807,9 @@ export function MothershipChat({ const scrolledForUserMsgRef = useRef(undefined) useLayoutEffect(() => { if (!lastUserMessageId || scrolledForUserMsgRef.current === lastUserMessageId) return - if (isSending && initialScrollBlocked) return + if (find.isOpen || (isSending && initialScrollBlocked)) return scrolledForUserMsgRef.current = lastUserMessageId - if (!isSending || find.isOpen) return + if (!isSending) return virtualizer.scrollToIndex(lastIndex, { align: 'end' }) }, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer, find.isOpen])