Skip to content

Commit bb028dd

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
Carry code search overlay into the live EMCN stack (#8275)
* refactor(ui): share code search overlay controls * refactor(emcn): own compact code search field chrome * test(ui): cover floating compact code search * refactor(emcn): compose compact search from shared chip surface --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 084ce5a commit bb028dd

8 files changed

Lines changed: 336 additions & 215 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createRef } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import {
8+
CodeSearchOverlay,
9+
type CodeSearchOverlayProps,
10+
} from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay'
11+
12+
let host: HTMLDivElement
13+
let root: Root
14+
const inputRef = createRef<HTMLInputElement>()
15+
16+
const callbacks = {
17+
onQueryChange: vi.fn(),
18+
onPrevious: vi.fn(),
19+
onNext: vi.fn(),
20+
onClose: vi.fn(),
21+
}
22+
const parentClick = vi.fn()
23+
24+
function renderOverlay(props: Partial<CodeSearchOverlayProps> = {}) {
25+
act(() =>
26+
root.render(
27+
<div onClick={parentClick}>
28+
<CodeSearchOverlay
29+
className='top-0 right-0'
30+
inputKind='chip'
31+
inputRef={inputRef}
32+
query='error'
33+
matchCount={3}
34+
currentMatchIndex={1}
35+
{...callbacks}
36+
{...props}
37+
/>
38+
</div>
39+
)
40+
)
41+
const overlay = host.firstElementChild?.firstElementChild as HTMLDivElement
42+
const input = overlay.querySelector('input') as HTMLInputElement
43+
return { overlay, input }
44+
}
45+
46+
beforeEach(() => {
47+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
48+
vi.clearAllMocks()
49+
host = document.createElement('div')
50+
document.body.appendChild(host)
51+
root = createRoot(host)
52+
})
53+
54+
afterEach(() => {
55+
act(() => root.unmount())
56+
host.remove()
57+
})
58+
59+
describe('CodeSearchOverlay', () => {
60+
it('shares the floating chrome and routes query, navigation, and close actions', () => {
61+
const { overlay, input } = renderOverlay()
62+
expect(overlay.className).toContain('h-[34px]')
63+
expect(overlay.className).toContain('rounded-sm bg-[var(--surface-1)]')
64+
expect(overlay.className).toContain('top-0 right-0')
65+
expect(overlay.getAttribute('role')).toBe('presentation')
66+
expect(inputRef.current).toBe(input)
67+
expect(input.getAttribute('aria-label')).toBe('Search code')
68+
expect(input.value).toBe('error')
69+
expect(overlay.textContent).toContain('2/3')
70+
71+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
72+
act(() => {
73+
setter?.call(input, 'failed')
74+
input.dispatchEvent(new Event('input', { bubbles: true }))
75+
})
76+
expect(callbacks.onQueryChange).toHaveBeenCalledWith('failed')
77+
act(() => {
78+
overlay.querySelector<HTMLButtonElement>('[aria-label="Previous match"]')?.click()
79+
overlay.querySelector<HTMLButtonElement>('[aria-label="Next match"]')?.click()
80+
overlay.querySelector<HTMLButtonElement>('[aria-label="Close search"]')?.click()
81+
})
82+
expect(callbacks.onPrevious).toHaveBeenCalledTimes(1)
83+
expect(callbacks.onNext).toHaveBeenCalledTimes(1)
84+
expect(callbacks.onClose).toHaveBeenCalledTimes(1)
85+
expect(parentClick).not.toHaveBeenCalled()
86+
})
87+
88+
it('keeps preview search compact in a floating overlay with a usable input ref', () => {
89+
const { overlay, input } = renderOverlay({
90+
inputKind: 'plain',
91+
className: 'top-10 right-[8px]',
92+
})
93+
expect(overlay.getAttribute('role')).toBe('presentation')
94+
expect(overlay.className).toContain('top-10 right-[8px]')
95+
expect(overlay.hasAttribute('data-toolbar-root')).toBe(false)
96+
expect(input.parentElement?.className).toContain('h-[23px]')
97+
expect(inputRef.current).toBe(input)
98+
99+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
100+
act(() => {
101+
setter?.call(input, 'preview')
102+
input.dispatchEvent(new Event('input', { bubbles: true }))
103+
overlay.querySelector<HTMLButtonElement>('[aria-label="Next match"]')?.click()
104+
})
105+
expect(callbacks.onQueryChange).toHaveBeenCalledWith('preview')
106+
expect(callbacks.onNext).toHaveBeenCalledTimes(1)
107+
expect(parentClick).not.toHaveBeenCalled()
108+
})
109+
110+
it('retains the attached terminal edge, marker, wider tally, and disabled navigation', () => {
111+
const { overlay, input } = renderOverlay({
112+
appearance: 'attached',
113+
inputKind: 'plain',
114+
className: 'top-[30px] right-[8px]',
115+
query: '',
116+
matchCount: 0,
117+
currentMatchIndex: 0,
118+
})
119+
expect(overlay.className).toContain('rounded-b-[4px] border-t-0 bg-[var(--bg)]')
120+
expect(overlay.getAttribute('data-toolbar-root')).toBe('true')
121+
expect(overlay.getAttribute('data-search-active')).toBe('true')
122+
expect(overlay.hasAttribute('role')).toBe(false)
123+
expect(input.parentElement?.className).toContain('h-[23px]')
124+
expect(input.parentElement?.className).toContain('w-[94px]')
125+
expect(input.className).toContain('text-caption')
126+
expect(overlay.textContent).toContain('No results')
127+
expect(overlay.querySelector('span.w-\\[58px\\]')).not.toBeNull()
128+
const previous = overlay.querySelector<HTMLButtonElement>('[aria-label="Previous match"]')
129+
const next = overlay.querySelector<HTMLButtonElement>('[aria-label="Next match"]')
130+
const close = overlay.querySelector<HTMLButtonElement>('[aria-label="Close search"]')
131+
expect(previous?.disabled).toBe(true)
132+
expect(next?.disabled).toBe(true)
133+
expect(close?.disabled).toBe(false)
134+
expect(previous?.className).toContain('-m-1.5')
135+
expect(previous?.querySelector('svg')?.getAttribute('class')).toContain('size-[14px]')
136+
act(() => {
137+
previous?.click()
138+
next?.click()
139+
close?.click()
140+
})
141+
expect(callbacks.onPrevious).not.toHaveBeenCalled()
142+
expect(callbacks.onNext).not.toHaveBeenCalled()
143+
expect(callbacks.onClose).toHaveBeenCalledTimes(1)
144+
})
145+
146+
it('shows the compact no-results tally for other code panels', () => {
147+
const { overlay } = renderOverlay({ matchCount: 0, currentMatchIndex: 0 })
148+
expect(overlay.textContent).toContain('0/0')
149+
expect(overlay.getAttribute('data-toolbar-root')).toBeNull()
150+
expect(
151+
overlay.querySelector<HTMLButtonElement>('[aria-label="Previous match"]')?.disabled
152+
).toBe(true)
153+
})
154+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { ChangeEvent, Ref } from 'react'
2+
import { Button, ChipInput, cn } from '@sim/emcn'
3+
import { ArrowDown, ArrowUp, X } from '@sim/emcn/icons'
4+
5+
export interface CodeSearchOverlayProps {
6+
/** The attached terminal panel has a joined lower edge and wider result tally. */
7+
appearance?: 'floating' | 'attached'
8+
/** Position relative to the owning code panel. */
9+
className: string
10+
/** Logs use the 30px chip field; previews and terminal output use compact search. */
11+
inputKind: 'chip' | 'plain'
12+
inputRef: Ref<HTMLInputElement>
13+
query: string
14+
onQueryChange: (query: string) => void
15+
matchCount: number
16+
currentMatchIndex: number
17+
onPrevious: () => void
18+
onNext: () => void
19+
onClose: () => void
20+
}
21+
22+
/** Shared controls for searching a Code.Viewer without owning its search state. */
23+
export function CodeSearchOverlay({
24+
appearance = 'floating',
25+
className,
26+
inputKind,
27+
inputRef,
28+
query,
29+
onQueryChange,
30+
matchCount,
31+
currentMatchIndex,
32+
onPrevious,
33+
onNext,
34+
onClose,
35+
}: CodeSearchOverlayProps) {
36+
const attached = appearance === 'attached'
37+
const inputProps = {
38+
ref: inputRef,
39+
type: 'text',
40+
value: query,
41+
onChange: (event: ChangeEvent<HTMLInputElement>) => onQueryChange(event.target.value),
42+
placeholder: 'Search...',
43+
'aria-label': 'Search code',
44+
} as const
45+
const actionProps = {
46+
type: 'button' as const,
47+
variant: 'ghost' as const,
48+
iconPadding: attached ? ('md' as const) : ('sm' as const),
49+
className: attached ? '-m-1.5' : undefined,
50+
}
51+
const iconClass = attached ? 'size-[14px]' : 'size-[12px]'
52+
53+
return (
54+
<div
55+
role={attached ? undefined : 'presentation'}
56+
className={cn(
57+
'absolute z-30 flex h-[34px] items-center gap-1.5 border border-[var(--border)] px-1.5 shadow-xs',
58+
attached ? 'rounded-b-[4px] border-t-0 bg-[var(--bg)]' : 'rounded-sm bg-[var(--surface-1)]',
59+
className
60+
)}
61+
onClick={(event) => event.stopPropagation()}
62+
data-toolbar-root={attached ? true : undefined}
63+
data-search-active={attached ? true : undefined}
64+
>
65+
{inputKind === 'chip' ? (
66+
<ChipInput {...inputProps} className='mr-0.5 w-[94px]' />
67+
) : (
68+
<ChipInput {...inputProps} appearance='compactSearch' className='mr-0.5 w-[94px]' />
69+
)}
70+
<span
71+
className={cn(
72+
attached ? 'w-[58px] text-xs' : 'min-w-[45px] text-center text-xs',
73+
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
74+
)}
75+
>
76+
{matchCount > 0
77+
? `${currentMatchIndex + 1}/${matchCount}`
78+
: attached
79+
? 'No results'
80+
: '0/0'}
81+
</span>
82+
<Button
83+
{...actionProps}
84+
onClick={onPrevious}
85+
disabled={matchCount === 0}
86+
aria-label='Previous match'
87+
>
88+
<ArrowUp className={iconClass} />
89+
</Button>
90+
<Button {...actionProps} onClick={onNext} disabled={matchCount === 0} aria-label='Next match'>
91+
<ArrowDown className={iconClass} />
92+
</Button>
93+
<Button {...actionProps} onClick={onClose} aria-label='Close search'>
94+
<X className={iconClass} />
95+
</Button>
96+
</div>
97+
)
98+
}

‎apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx‎

Lines changed: 14 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,11 @@ import {
2020
Tooltip,
2121
useCopyToClipboard,
2222
} from '@sim/emcn'
23-
import {
24-
ArrowDown,
25-
ArrowUp,
26-
Check,
27-
ChevronsDownUp,
28-
ChevronsUpDown,
29-
Clipboard,
30-
Search,
31-
X,
32-
} from '@sim/emcn/icons'
23+
import { Check, ChevronsDownUp, ChevronsUpDown, Clipboard, Search } from '@sim/emcn/icons'
3324
import { formatDuration } from '@sim/utils/formatting'
3425
import { createPortal } from 'react-dom'
3526
import type { TraceSpan } from '@/lib/logs/types'
27+
import { CodeSearchOverlay } from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay'
3628
import {
3729
adjustBgForContrast,
3830
formatCostAmount,
@@ -542,54 +534,18 @@ function DetailCodeSection({
542534
)}
543535
</div>
544536
{isSearchActive && (
545-
<div
546-
role='presentation'
547-
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-1.5 rounded-sm border border-[var(--border)] bg-[var(--surface-1)] px-1.5 shadow-xs'
548-
onClick={(e) => e.stopPropagation()}
549-
>
550-
<ChipInput
551-
ref={searchInputRef}
552-
type='text'
553-
value={searchQuery}
554-
onChange={(e) => setSearchQuery(e.target.value)}
555-
placeholder='Search...'
556-
className='mr-0.5 w-[94px]'
557-
/>
558-
<span
559-
className={cn(
560-
'min-w-[45px] text-center text-xs',
561-
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
562-
)}
563-
>
564-
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
565-
</span>
566-
<Button
567-
variant='ghost'
568-
iconPadding='sm'
569-
onClick={goToPreviousMatch}
570-
disabled={matchCount === 0}
571-
aria-label='Previous match'
572-
>
573-
<ArrowUp className='size-[12px]' />
574-
</Button>
575-
<Button
576-
variant='ghost'
577-
iconPadding='sm'
578-
onClick={goToNextMatch}
579-
disabled={matchCount === 0}
580-
aria-label='Next match'
581-
>
582-
<ArrowDown className='size-[12px]' />
583-
</Button>
584-
<Button
585-
variant='ghost'
586-
iconPadding='sm'
587-
onClick={closeSearch}
588-
aria-label='Close search'
589-
>
590-
<X className='size-[12px]' />
591-
</Button>
592-
</div>
537+
<CodeSearchOverlay
538+
className='top-0 right-0'
539+
inputKind='chip'
540+
inputRef={searchInputRef}
541+
query={searchQuery}
542+
onQueryChange={setSearchQuery}
543+
matchCount={matchCount}
544+
currentMatchIndex={currentMatchIndex}
545+
onPrevious={goToPreviousMatch}
546+
onNext={goToNextMatch}
547+
onClose={closeSearch}
548+
/>
593549
)}
594550
{typeof document !== 'undefined' &&
595551
createPortal(

0 commit comments

Comments
 (0)