Skip to content
Open
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
Expand Up @@ -5,6 +5,7 @@ import { memo } from 'react'
import { cn } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import { ChevronRight } from 'lucide-react'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import {
COMMAND_ITEM_CLASSNAME,
Expand Down Expand Up @@ -89,6 +90,7 @@ export const MemoizedCommandItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
Expand All @@ -106,7 +108,7 @@ export const MemoizedActionItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
Expand All @@ -128,6 +130,7 @@ export const MemoizedActionItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
Expand All @@ -144,7 +147,7 @@ export const MemoizedWorkflowItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
name: string
folderPath?: string[]
isCurrent?: boolean
Expand Down Expand Up @@ -179,6 +182,7 @@ export const MemoizedWorkflowItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query &&
Expand All @@ -196,7 +200,7 @@ export const MemoizedFileItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
name: string
folderPath?: string[]
query?: string
Expand Down Expand Up @@ -229,6 +233,7 @@ export const MemoizedFileItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.name === next.name &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
Expand All @@ -244,7 +249,7 @@ export const MemoizedTaskItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
name: string
query?: string
}) {
Expand All @@ -268,7 +273,7 @@ export const MemoizedWorkspaceItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
name: string
isCurrent?: boolean
query?: string
Expand All @@ -286,6 +291,7 @@ export const MemoizedWorkspaceItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query
Expand All @@ -301,7 +307,7 @@ export const MemoizedPageItem = memo(
query,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
Expand All @@ -323,38 +329,54 @@ export const MemoizedPageItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
prev.query === next.query
)

/**
* Also used for the browse-category rows (`count` set) — those are otherwise
* identical to a plain icon row, so a trailing count + chevron is a prop
* rather than a forked component.
*/
export const MemoizedIconItem = memo(
function IconItem({
value,
onSelect,
name,
icon: Icon,
query,
count,
}: {
value: string
onSelect: () => void
onSelect: (value: string) => void
name: string
icon: ComponentType<{ className?: string }>
query?: string
count?: number
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{count !== undefined && (
<span className='ml-auto flex flex-shrink-0 items-center gap-1.5 text-[var(--text-subtle)] text-small'>
{count}
<ChevronRight className='size-[14px]' />
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.onSelect === next.onSelect &&
prev.name === next.name &&
prev.icon === next.icon &&
prev.query === next.query
prev.query === next.query &&
prev.count === next.count
)
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
export type { RecentRenderItem } from './search-groups'
export {
ActionsGroup,
BlocksGroup,
BrowseGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
RecentsGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { Command } from 'cmdk'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlocksGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups'
import type { SearchBlockItem } from '@/stores/modals/search/types'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

function TestIcon() {
return <svg data-testid='icon' />
}

function block(id: string, name: string): SearchBlockItem {
return { id, name, icon: TestIcon, bgColor: '#000', type: id }
}

let container: HTMLDivElement
let root: Root

function mount(ui: ReactNode) {
act(() => {
root.render(<Command shouldFilter={false}>{ui}</Command>)
})
}

function selectByText(text: string) {
const el = Array.from(container.querySelectorAll('[cmdk-item]')).find((node) =>
node.textContent?.includes(text)
)
if (!el) throw new Error(`row not found: ${text}`)
act(() => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
}

beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

describe('BlocksGroup value-dispatch', () => {
it('routes a click on a row to onSelect with the matching item', () => {
const onSelect = vi.fn()
const items = [block('a', 'Alpha'), block('b', 'Beta')]
mount(<BlocksGroup items={items} onSelect={onSelect} />)

selectByText('Beta')

expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(items[1])
})

it('resolves the CURRENT item after items is replaced with a new array (no stale Map lookups)', () => {
const onSelect = vi.fn()
const first = [block('a', 'Alpha'), block('b', 'Beta')]
mount(<BlocksGroup items={first} onSelect={onSelect} />)

const second = [block('a', 'Alpha Renamed'), block('c', 'Gamma')]
mount(<BlocksGroup items={second} onSelect={onSelect} />)

selectByText('Gamma')

expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(second[1])
})

it('still dispatches correctly after onSelect identity changes between renders', () => {
const first = vi.fn()
const second = vi.fn()
const items = [block('a', 'Alpha'), block('b', 'Beta')]
mount(<BlocksGroup items={items} onSelect={first} />)
mount(<BlocksGroup items={items} onSelect={second} />)

selectByText('Alpha')

expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledWith(items[0])
})
})

describe('BlocksGroup truncation affordance', () => {
it('renders nothing extra when nothing was truncated', () => {
mount(<BlocksGroup items={[block('a', 'Alpha')]} onSelect={vi.fn()} truncatedCount={0} />)
expect(container.textContent).not.toMatch(/more/i)
})

it('surfaces a non-selectable "+N more" row when the cap trimmed real matches', () => {
mount(<BlocksGroup items={[block('a', 'Alpha')]} onSelect={vi.fn()} truncatedCount={12} />)
expect(container.textContent).toContain('+12 more')
// Must not be a cmdk row — it should never be selectable via keyboard/click.
const items = container.querySelectorAll('[cmdk-item]')
expect(items).toHaveLength(1)
})
})
Loading
Loading