Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
'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. */
inputRef: React.RefObject<HTMLInputElement | null>
/** Limits a split-pane surface to shortcuts originating inside it. */
containerRef?: React.RefObject<HTMLElement | null>
onOpen: () => void
}

/**
* 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.
*/
export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOptions): void {
useEffect(() => {
if (!enabled) return
const handleFindShortcut = (event: KeyboardEvent) => {
export function useFindShortcut({
enabled,
inputRef,
containerRef,
onOpen,
}: UseFindShortcutOptions): React.KeyboardEventHandler<HTMLElement> {
const handleFindShortcut = useCallback(
(event: KeyboardEvent | React.KeyboardEvent<HTMLElement>) => {
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
Expand All @@ -39,8 +48,13 @@ export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOp
inputRef.current?.focus()
inputRef.current?.select()
})
}
},
[enabled, inputRef, containerRef, onOpen]
)
useEffect(() => {
if (!enabled || containerRef) return
document.addEventListener('keydown', handleFindShortcut)
return () => document.removeEventListener('keydown', handleFindShortcut)
}, [enabled, inputRef, onOpen])
}, [enabled, containerRef, handleFindShortcut])
return handleFindShortcut
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,11 @@ interface SourceReferenceProps {
function SourceReference({ index, children }: SourceReferenceProps) {
const source = useContext(SourceRefsContext)[index]
if (!source) return <>{children}</>
return <SourceChip source={source} />
return (
<span data-chat-find-boundary>
<SourceChip source={source} />
</span>
)
}

const WORKSPACE_LINK_PREFIX = '#sim-workspace-ref-'
Expand All @@ -196,7 +200,9 @@ function WorkspaceReference({ index, children }: SourceReferenceProps) {
const { resources, onSelect } = useContext(WorkspaceRefsContext)
const resource = resources[index]
return resource ? (
<WorkspaceResourceDisplay data={resource} onSelect={onSelect} />
<span data-chat-find-boundary>
<WorkspaceResourceDisplay data={resource} onSelect={onSelect} />
</span>
) : (
<>{children}</>
)
Expand Down Expand Up @@ -342,7 +348,10 @@ const MARKDOWN_COMPONENTS = {

return (
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--border)]'>
<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)]'>
<div
data-chat-find-ignore
className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'
>
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
Expand Down Expand Up @@ -762,6 +771,7 @@ function ChatContentInner({
return (
<div
key={`inline-${i}`}
data-chat-find-content
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
assistantMessageHasRenderableContent,
getOrchestratorMessageText,
getOrchestratorMessageTextSegments,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
Original file line number Diff line number Diff line change
Expand Up @@ -624,21 +624,23 @@ export function parseBlocks(blocks: ContentBlock[], isStreaming = false): Messag
)
}

function joinRenderableText(parts: string[]): string {
return parts.filter(Boolean).join('\n\n')
/** Returns independently rendered text segments, excluding agent groups and other UI segments. */
export function getOrchestratorMessageTextSegments(
blocks: ContentBlock[],
fallbackContent: string
): string[] {
const parsed = blocks.length > 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. */
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[] {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 &amp; c.\n\n```ts\na_b()\n```\n\n[ref]: /hidden')
).toBe('Use a_b and a_b & c.\na_b()')
})
})
Original file line number Diff line number Diff line change
@@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -217,7 +219,7 @@ const UserMessageRow = memo(function UserMessageRow({
className={attachmentWidthClassName}
/>
)}
<div className={bubbleClassName}>
<div className={bubbleClassName} data-chat-find-content>
<UserMessageContent content={content} contexts={contexts} />
</div>
</div>
Expand Down Expand Up @@ -386,8 +388,8 @@ export function MothershipChat({
*/
const messages = useDeferredValue(messagesProp)
const [lastRowAnimating, setLastRowAnimating] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const scrollElementRef = useRef<HTMLDivElement | null>(null)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
const sizerRef = useRef<HTMLDivElement | null>(null)
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
const sizerFloorAppliedRef = useRef(0)
Expand Down Expand Up @@ -525,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

Expand Down Expand Up @@ -695,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
Expand Down Expand Up @@ -784,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).
Expand All @@ -795,11 +807,11 @@ export function MothershipChat({
const scrolledForUserMsgRef = useRef<string | undefined>(undefined)
useLayoutEffect(() => {
if (!lastUserMessageId || scrolledForUserMsgRef.current === lastUserMessageId) return
if (isSending && initialScrollBlocked) return
if (find.isOpen || (isSending && initialScrollBlocked)) return
scrolledForUserMsgRef.current = lastUserMessageId
if (!isSending) return
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
}, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer])
}, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer, find.isOpen])

const virtualItems = virtualizer.getVirtualItems()

Expand All @@ -813,7 +825,32 @@ export function MothershipChat({
onViewSources={onViewSources}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
>
<div className={cn('flex h-full min-h-0 flex-col', inter.className, className)}>
<div
ref={containerRef}
onKeyDown={find.onKeyDown}
tabIndex={-1}
className={cn(
'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)]',
inter.className,
className
)}
>
{find.isOpen && (
<FindBar
ariaLabel='Find in chat'
query={find.query}
onQueryChange={find.onQueryChange}
onNext={find.next}
onPrev={find.prev}
onClose={find.close}
count={find.count}
currentIndex={find.currentIndex}
truncated={find.truncated}
isLoading={find.isStale}
canNavigate={!find.isStale}
inputRef={find.inputRef}
/>
)}
<div ref={setScrollElement} className={styles.scrollContainer} onCopy={handleCopy}>
{isLoading && !hasMessages ? (
<MothershipChatSkeleton layout={layout} />
Expand Down
Loading
Loading