From eab049fc05541d3e8e0125f4a44f500da712e4b7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 12:43:27 -0700 Subject: [PATCH] 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 715532c9643..16b8da24302 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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow' @@ -1479,49 +1478,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 */}