From 613699a3ac22078bfc6b5894df4726fa9acddadd Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 12:43:27 -0700 Subject: [PATCH 1/4] refactor(ui): share code search overlay controls --- .../code-search-overlay.test.tsx | 130 ++++++++++++++++++ .../code-search-overlay.tsx | 98 +++++++++++++ .../components/trace-view/trace-view.tsx | 72 ++-------- .../components/log-details/log-details.tsx | 64 ++------- .../components/output-panel/output-panel.tsx | 69 ++-------- .../preview-editor/preview-editor.tsx | 58 ++------ 6 files changed, 282 insertions(+), 209 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx new file mode 100644 index 00000000000..09a7deed04d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx @@ -0,0 +1,130 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CodeSearchOverlay, + type CodeSearchOverlayProps, +} from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' + +let host: HTMLDivElement +let root: Root +const inputRef = createRef() + +const callbacks = { + onQueryChange: vi.fn(), + onPrevious: vi.fn(), + onNext: vi.fn(), + onClose: vi.fn(), +} +const parentClick = vi.fn() + +function renderOverlay(props: Partial = {}) { + act(() => + root.render( +
+ +
+ ) + ) + const overlay = host.firstElementChild?.firstElementChild as HTMLDivElement + const input = overlay.querySelector('input') as HTMLInputElement + return { overlay, input } +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() +}) + +describe('CodeSearchOverlay', () => { + it('shares the floating chrome and routes query, navigation, and close actions', () => { + const { overlay, input } = renderOverlay() + expect(overlay.className).toContain('h-[34px]') + expect(overlay.className).toContain('rounded-sm bg-[var(--surface-1)]') + expect(overlay.className).toContain('top-0 right-0') + expect(overlay.getAttribute('role')).toBe('presentation') + expect(inputRef.current).toBe(input) + expect(input.getAttribute('aria-label')).toBe('Search code') + expect(input.value).toBe('error') + expect(overlay.textContent).toContain('2/3') + + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + act(() => { + setter?.call(input, 'failed') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(callbacks.onQueryChange).toHaveBeenCalledWith('failed') + act(() => { + overlay.querySelector('[aria-label="Previous match"]')?.click() + overlay.querySelector('[aria-label="Next match"]')?.click() + overlay.querySelector('[aria-label="Close search"]')?.click() + }) + expect(callbacks.onPrevious).toHaveBeenCalledTimes(1) + expect(callbacks.onNext).toHaveBeenCalledTimes(1) + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('retains the attached terminal edge, marker, wider tally, and disabled navigation', () => { + const { overlay, input } = renderOverlay({ + appearance: 'attached', + inputKind: 'plain', + className: 'top-[30px] right-[8px]', + query: '', + matchCount: 0, + currentMatchIndex: 0, + }) + expect(overlay.className).toContain('rounded-b-[4px] border-t-0 bg-[var(--bg)]') + expect(overlay.getAttribute('data-toolbar-root')).toBe('true') + expect(overlay.getAttribute('data-search-active')).toBe('true') + expect(overlay.hasAttribute('role')).toBe(false) + expect(input.className).toContain('h-[23px] w-[94px] text-caption') + expect(overlay.textContent).toContain('No results') + expect(overlay.querySelector('span.w-\\[58px\\]')).not.toBeNull() + const previous = overlay.querySelector('[aria-label="Previous match"]') + const next = overlay.querySelector('[aria-label="Next match"]') + const close = overlay.querySelector('[aria-label="Close search"]') + expect(previous?.disabled).toBe(true) + expect(next?.disabled).toBe(true) + expect(close?.disabled).toBe(false) + expect(previous?.className).toContain('-m-1.5') + expect(previous?.querySelector('svg')?.getAttribute('class')).toContain('size-[14px]') + act(() => { + previous?.click() + next?.click() + close?.click() + }) + expect(callbacks.onPrevious).not.toHaveBeenCalled() + expect(callbacks.onNext).not.toHaveBeenCalled() + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + }) + + it('shows the compact no-results tally for other code panels', () => { + const { overlay } = renderOverlay({ matchCount: 0, currentMatchIndex: 0 }) + expect(overlay.textContent).toContain('0/0') + expect(overlay.getAttribute('data-toolbar-root')).toBeNull() + expect( + overlay.querySelector('[aria-label="Previous match"]')?.disabled + ).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx new file mode 100644 index 00000000000..67b1e3076b5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx @@ -0,0 +1,98 @@ +import type { ChangeEvent, Ref } from 'react' +import { Button, ChipInput, cn, Input } from '@sim/emcn' +import { ArrowDown, ArrowUp, X } from '@sim/emcn/icons' + +export interface CodeSearchOverlayProps { + /** The attached terminal panel has a joined lower edge and wider result tally. */ + appearance?: 'floating' | 'attached' + /** Position relative to the owning code panel. */ + className: string + /** Logs use the chip field; previews and terminal output use the plain field. */ + inputKind: 'chip' | 'plain' + inputRef: Ref + query: string + onQueryChange: (query: string) => void + matchCount: number + currentMatchIndex: number + onPrevious: () => void + onNext: () => void + onClose: () => void +} + +/** Shared controls for searching a Code.Viewer without owning its search state. */ +export function CodeSearchOverlay({ + appearance = 'floating', + className, + inputKind, + inputRef, + query, + onQueryChange, + matchCount, + currentMatchIndex, + onPrevious, + onNext, + onClose, +}: CodeSearchOverlayProps) { + const attached = appearance === 'attached' + const inputProps = { + ref: inputRef, + type: 'text', + value: query, + onChange: (event: ChangeEvent) => onQueryChange(event.target.value), + placeholder: 'Search...', + 'aria-label': 'Search code', + } as const + const actionProps = { + type: 'button' as const, + variant: 'ghost' as const, + iconPadding: attached ? ('md' as const) : ('sm' as const), + className: attached ? '-m-1.5' : undefined, + } + const iconClass = attached ? 'size-[14px]' : 'size-[12px]' + + return ( +
event.stopPropagation()} + data-toolbar-root={attached ? true : undefined} + data-search-active={attached ? true : undefined} + > + {inputKind === 'chip' ? ( + + ) : ( + + )} + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 + ? `${currentMatchIndex + 1}/${matchCount}` + : attached + ? 'No results' + : '0/0'} + + + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index b9ae284b385..eab5ed975ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -20,19 +20,11 @@ import { Tooltip, useCopyToClipboard, } from '@sim/emcn' -import { - ArrowDown, - ArrowUp, - Check, - ChevronsDownUp, - ChevronsUpDown, - Clipboard, - Search, - X, -} from '@sim/emcn/icons' +import { Check, ChevronsDownUp, ChevronsUpDown, Clipboard, Search } from '@sim/emcn/icons' import { formatDuration } from '@sim/utils/formatting' import { createPortal } from 'react-dom' import type { TraceSpan } from '@/lib/logs/types' +import { CodeSearchOverlay } from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' import { adjustBgForContrast, formatCostAmount, @@ -542,54 +534,18 @@ function DetailCodeSection({ )} {isSearchActive && ( -
e.stopPropagation()} - > - setSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-0.5 w-[94px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} - - - - -
+ )} {typeof document !== 'undefined' && createPortal( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 59164f6d861..dc169dd44f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -15,7 +15,6 @@ import { Badge, Button, Chip, - ChipInput, ChipModalTabs, Code, cn, @@ -35,8 +34,6 @@ import { useCopyToClipboard, } from '@sim/emcn' import { - ArrowDown, - ArrowUp, Check, ChevronUp, Clipboard, @@ -60,6 +57,7 @@ import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-s import type { TraceSpan } from '@/lib/logs/types' import { sendMothershipMessage } from '@/lib/mothership/events' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' +import { CodeSearchOverlay } from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' /** * Deep imports on purpose: importing these back through the parent `logs/components` * barrel forms a parent->child cycle that would keep the barrel edge to the snapshot @@ -209,54 +207,18 @@ export const WorkflowOutputSection = memo( {/* Search Overlay */} {isSearchActive && ( -
e.stopPropagation()} - > - setSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-0.5 w-[94px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} - - - - -
+ )} {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index daa24590d8e..743a822da77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -5,7 +5,6 @@ import { Button, Code, cn, - Input, Popover, PopoverContent, PopoverItem, @@ -13,8 +12,6 @@ import { Tooltip, } from '@sim/emcn' import { - ArrowDown, - ArrowUp, Check, Clipboard, Download, @@ -26,6 +23,7 @@ import { } from '@sim/emcn/icons' import Link from 'next/link' import { AgentStreamThinkingChrome } from '@/components/agent-stream/agent-stream-chrome' +import { CodeSearchOverlay } from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' import { OutputContextMenu, StructuredOutput, @@ -493,58 +491,19 @@ export const OutputPanel = React.memo(function OutputPanel({ {/* Search Overlay */} {isOutputSearchActive && ( -
e.stopPropagation()} - data-toolbar-root - data-search-active='true' - > - setOutputSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-0.5 h-[23px] w-[94px] text-caption' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'} - - - - -
+ )} {/* Content */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index bba86b895e8..debd78b8257 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -16,8 +16,6 @@ import { Tooltip, } from '@sim/emcn' import { - ArrowDown, - ArrowUp, Check, ChevronDown, ChevronUp, @@ -41,6 +39,7 @@ import { isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' +import { CodeSearchOverlay } from '@/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu' import { READONLY_PREVIEW_STYLES } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-readonly-styles' @@ -1454,49 +1453,18 @@ function PreviewEditorContent({ {/* Search Overlay */} {isSearchActive && ( -
e.stopPropagation()} - > - setSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-0.5 h-[23px] w-[94px] text-caption' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} - - - - -
+ )} {/* Context Menu */} From 0f50cbd52df828ae62c2f58b81f7d36d1cdb7858 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 15:17:41 -0700 Subject: [PATCH 2/4] refactor(emcn): own compact code search field chrome --- .../code-search-overlay.test.tsx | 4 ++- .../code-search-overlay.tsx | 6 ++-- .../src/components/chip-input/chip-input.tsx | 35 ++++++++++++++++--- packages/emcn/src/components/index.ts | 2 +- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx index 09a7deed04d..b70d4f2502d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx @@ -98,7 +98,9 @@ describe('CodeSearchOverlay', () => { expect(overlay.getAttribute('data-toolbar-root')).toBe('true') expect(overlay.getAttribute('data-search-active')).toBe('true') expect(overlay.hasAttribute('role')).toBe(false) - expect(input.className).toContain('h-[23px] w-[94px] text-caption') + expect(input.parentElement?.className).toContain('h-[23px]') + expect(input.parentElement?.className).toContain('w-[94px]') + expect(input.className).toContain('text-caption') expect(overlay.textContent).toContain('No results') expect(overlay.querySelector('span.w-\\[58px\\]')).not.toBeNull() const previous = overlay.querySelector('[aria-label="Previous match"]') diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx index 67b1e3076b5..5c741d1b300 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent, Ref } from 'react' -import { Button, ChipInput, cn, Input } from '@sim/emcn' +import { Button, ChipInput, cn } from '@sim/emcn' import { ArrowDown, ArrowUp, X } from '@sim/emcn/icons' export interface CodeSearchOverlayProps { @@ -7,7 +7,7 @@ export interface CodeSearchOverlayProps { appearance?: 'floating' | 'attached' /** Position relative to the owning code panel. */ className: string - /** Logs use the chip field; previews and terminal output use the plain field. */ + /** Logs use the 30px chip field; previews and terminal output use compact search. */ inputKind: 'chip' | 'plain' inputRef: Ref query: string @@ -65,7 +65,7 @@ export function CodeSearchOverlay({ {inputKind === 'chip' ? ( ) : ( - + )} -export interface ChipInputProps extends Omit, 'size'> { +/** The compact search field keeps the existing code-viewer input geometry. */ +export const chipInputVariants = cva('', { + variants: { + appearance: { + chip: '', + compactSearch: + 'h-[23px] items-center rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 transition-colors', + }, + }, + defaultVariants: { appearance: 'chip' }, +}) + +export interface ChipInputProps + extends Omit, 'size'>, + VariantProps { + /** The default chip field or the compact code-search field. */ + appearance?: VariantProps['appearance'] /** Leading icon component (e.g. `Search` from `@sim/emcn/icons`). Rendered at 14px in `--text-icon`, with the chip's 1.5 gap. */ icon?: ChipInputIcon /** Custom leading content, such as a color swatch. Takes precedence over `icon`. */ @@ -54,6 +73,7 @@ export interface ChipInputProps extends Omit( ( { + appearance = 'chip', className, inputClassName, icon: Icon, @@ -69,8 +89,9 @@ export const ChipInput = React.forwardRef(
( type={type} disabled={disabled} className={cn( - '-ml-1 h-full w-full bg-transparent indent-1 disabled:cursor-not-allowed', - chipFieldTextClass, + appearance === 'compactSearch' + ? 'h-full w-full touch-manipulation scroll-pr-1 bg-transparent font-sans text-[var(--text-primary)] text-caption outline-hidden [letter-spacing:inherit] placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' + : cn( + '-ml-1 h-full w-full bg-transparent indent-1 disabled:cursor-not-allowed', + chipFieldTextClass + ), inputClassName )} {...props} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 2436b32f65d..28da4499541 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -64,7 +64,7 @@ export { ChipEmailsInput, type ChipEmailsInputProps, } from './chip-emails-input/chip-emails-input' -export { ChipInput, type ChipInputProps } from './chip-input/chip-input' +export { ChipInput, type ChipInputProps, chipInputVariants } from './chip-input/chip-input' export { type ChipConfirmAction, type ChipConfirmDefaultAction, From ff1f7f22385fd6aa2ca8618d2b3fe850072a9341 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 15:33:14 -0700 Subject: [PATCH 3/4] test(ui): cover floating compact code search --- .../code-search-overlay.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx index b70d4f2502d..49505d3a9d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/code-search-overlay/code-search-overlay.test.tsx @@ -85,6 +85,28 @@ describe('CodeSearchOverlay', () => { expect(parentClick).not.toHaveBeenCalled() }) + it('keeps preview search compact in a floating overlay with a usable input ref', () => { + const { overlay, input } = renderOverlay({ + inputKind: 'plain', + className: 'top-10 right-[8px]', + }) + expect(overlay.getAttribute('role')).toBe('presentation') + expect(overlay.className).toContain('top-10 right-[8px]') + expect(overlay.hasAttribute('data-toolbar-root')).toBe(false) + expect(input.parentElement?.className).toContain('h-[23px]') + expect(inputRef.current).toBe(input) + + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + act(() => { + setter?.call(input, 'preview') + input.dispatchEvent(new Event('input', { bubbles: true })) + overlay.querySelector('[aria-label="Next match"]')?.click() + }) + expect(callbacks.onQueryChange).toHaveBeenCalledWith('preview') + expect(callbacks.onNext).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + it('retains the attached terminal edge, marker, wider tally, and disabled navigation', () => { const { overlay, input } = renderOverlay({ appearance: 'attached', From eecaa1a0932f7b5b3cb717360be558248ce343c2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 15:38:50 -0700 Subject: [PATCH 4/4] refactor(emcn): compose compact search from shared chip surface --- packages/emcn/src/components/chip-input/chip-input.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/emcn/src/components/chip-input/chip-input.tsx b/packages/emcn/src/components/chip-input/chip-input.tsx index d643ee0596a..78aef8658b9 100644 --- a/packages/emcn/src/components/chip-input/chip-input.tsx +++ b/packages/emcn/src/components/chip-input/chip-input.tsx @@ -40,8 +40,7 @@ export const chipInputVariants = cva('', { variants: { appearance: { chip: '', - compactSearch: - 'h-[23px] items-center rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 transition-colors', + compactSearch: `${chipFieldSurfaceClass} h-[23px] items-center rounded-sm px-2 dark:bg-[var(--surface-5)]`, }, }, defaultVariants: { appearance: 'chip' },