From dde1e81fd52eba80a47d6f01c230c8ad273f5e49 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 6 Jan 2026 13:48:02 -0800 Subject: [PATCH] feat(terminal): added terminal context menu --- .../chunk-context-menu/chunk-context-menu.tsx | 18 +- .../document-context-menu.tsx | 18 +- .../knowledge-base-context-menu.tsx | 18 +- .../log-row-context-menu.tsx | 18 +- .../components/terminal/components/index.ts | 2 + .../components/log-row-context-menu.tsx | 145 ++++++++++++++ .../components/output-context-menu.tsx | 119 ++++++++++++ .../components/terminal/terminal.tsx | 177 +++++++++++++++--- .../emcn/components/popover/popover.tsx | 18 +- 9 files changed, 486 insertions(+), 47 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index ebdf27f537..ae8f0eb9a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -1,6 +1,12 @@ 'use client' -import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' interface ChunkContextMenuProps { isOpen: boolean @@ -102,6 +108,7 @@ export function ChunkContextMenu({ {hasChunk ? ( <> + {/* Navigation */} {!isMultiSelect && onOpenInNewTab && ( { @@ -112,6 +119,9 @@ export function ChunkContextMenu({ Open in new tab )} + {!isMultiSelect && onOpenInNewTab && } + + {/* Edit and copy actions */} {!isMultiSelect && onEdit && ( { @@ -132,6 +142,9 @@ export function ChunkContextMenu({ Copy content )} + {!isMultiSelect && (onEdit || onCopyContent) && } + + {/* State toggle */} {onToggleEnabled && ( )} + + {/* Destructive action */} + {onToggleEnabled && onDelete && } {onDelete && ( {hasDocument ? ( <> + {/* Navigation */} {!isMultiSelect && onOpenInNewTab && ( { @@ -117,6 +124,9 @@ export function DocumentContextMenu({ Open in new tab )} + {!isMultiSelect && onOpenInNewTab && } + + {/* Edit and view actions */} {!isMultiSelect && onRename && ( { @@ -137,6 +147,9 @@ export function DocumentContextMenu({ View tags )} + {!isMultiSelect && (onRename || (hasTags && onViewTags)) && } + + {/* State toggle */} {onToggleEnabled && ( )} + + {/* Destructive action */} + {onToggleEnabled && onDelete && } {onDelete && ( + {/* Navigation */} {showOpenInNewTab && onOpenInNewTab && ( { @@ -114,6 +121,9 @@ export function KnowledgeBaseContextMenu({ Open in new tab )} + {showOpenInNewTab && onOpenInNewTab && } + + {/* View and copy actions */} {showViewTags && onViewTags && ( { @@ -134,6 +144,9 @@ export function KnowledgeBaseContextMenu({ Copy ID )} + {((showViewTags && onViewTags) || onCopyId) && } + + {/* Edit action */} {showEdit && onEdit && ( )} + + {/* Destructive action */} + {showEdit && onEdit && showDelete && onDelete && } {showDelete && onDelete && ( - {/* Copy Execution ID */} + {/* Copy action */} { @@ -61,7 +67,8 @@ export function LogRowContextMenu({ Copy Execution ID - {/* Open Workflow */} + {/* Navigation */} + { @@ -72,7 +79,8 @@ export function LogRowContextMenu({ Open Workflow - {/* Filter by Workflow - only show when not already filtered by this workflow */} + {/* Filter actions */} + {!isFilteredByThisWorkflow && ( )} - - {/* Clear All Filters - show when any filters are active */} {hasActiveFilters && ( { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index 33166dd5e4..cedf50337a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1 +1,3 @@ +export { LogRowContextMenu } from './log-row-context-menu' +export { OutputContextMenu } from './output-context-menu' export { PrettierOutput } from './prettier-output' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx new file mode 100644 index 0000000000..7c009f5058 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx @@ -0,0 +1,145 @@ +'use client' + +import type { RefObject } from 'react' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' +import type { ConsoleEntry } from '@/stores/terminal' + +interface ContextMenuPosition { + x: number + y: number +} + +interface TerminalFilters { + blockIds: Set + statuses: Set<'error' | 'info'> + runIds: Set +} + +interface LogRowContextMenuProps { + isOpen: boolean + position: ContextMenuPosition + menuRef: RefObject + onClose: () => void + entry: ConsoleEntry | null + filters: TerminalFilters + onFilterByBlock: (blockId: string) => void + onFilterByStatus: (status: 'error' | 'info') => void + onFilterByRunId: (runId: string) => void + onClearFilters: () => void + onClearConsole: () => void + hasActiveFilters: boolean +} + +/** + * Context menu for terminal log rows (left side). + * Displays filtering options based on the selected row's properties. + */ +export function LogRowContextMenu({ + isOpen, + position, + menuRef, + onClose, + entry, + filters, + onFilterByBlock, + onFilterByStatus, + onFilterByRunId, + onClearFilters, + onClearConsole, + hasActiveFilters, +}: LogRowContextMenuProps) { + const hasRunId = entry?.executionId != null + + const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false + const entryStatus = entry?.success ? 'info' : 'error' + const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false + const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false + + return ( + + + + {/* Clear filters at top when active */} + {hasActiveFilters && ( + <> + { + onClearFilters() + onClose() + }} + > + Clear All Filters + + {entry && } + + )} + + {/* Filter actions */} + {entry && ( + <> + { + onFilterByBlock(entry.blockId) + onClose() + }} + > + Filter by Block + + { + onFilterByStatus(entryStatus) + onClose() + }} + > + Filter by Status + + {hasRunId && ( + { + onFilterByRunId(entry.executionId!) + onClose() + }} + > + Filter by Run ID + + )} + + )} + + {/* Destructive action */} + {(entry || hasActiveFilters) && } + { + onClearConsole() + onClose() + }} + > + Clear Console + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx new file mode 100644 index 0000000000..8746a5bc39 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx @@ -0,0 +1,119 @@ +'use client' + +import type { RefObject } from 'react' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' + +interface ContextMenuPosition { + x: number + y: number +} + +interface OutputContextMenuProps { + isOpen: boolean + position: ContextMenuPosition + menuRef: RefObject + onClose: () => void + onCopySelection: () => void + onCopyAll: () => void + onSearch: () => void + wrapText: boolean + onToggleWrap: () => void + openOnRun: boolean + onToggleOpenOnRun: () => void + onClearConsole: () => void + hasSelection: boolean +} + +/** + * Context menu for terminal output panel (right side). + * Displays copy, search, and display options for the code viewer. + */ +export function OutputContextMenu({ + isOpen, + position, + menuRef, + onClose, + onCopySelection, + onCopyAll, + onSearch, + wrapText, + onToggleWrap, + openOnRun, + onToggleOpenOnRun, + onClearConsole, + hasSelection, +}: OutputContextMenuProps) { + return ( + + + + {/* Copy and search actions */} + { + onCopySelection() + onClose() + }} + > + Copy Selection + + { + onCopyAll() + onClose() + }} + > + Copy All + + { + onSearch() + onClose() + }} + > + Search + + + {/* Display settings - toggles don't close menu */} + + + Wrap Text + + + Open on Run + + + {/* Destructive action */} + + { + onClearConsole() + onClose() + }} + > + Clear Console + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index a70657ea01..b63ce2d3b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -38,11 +38,16 @@ import { import { getEnv, isTruthy } from '@/lib/core/config/env' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' +import { + LogRowContextMenu, + OutputContextMenu, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, useTerminalFilters, useTerminalResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { getBlock } from '@/blocks' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' @@ -365,6 +370,28 @@ export function Terminal() { hasActiveFilters, } = useTerminalFilters() + // Context menu state + const [hasSelection, setHasSelection] = useState(false) + const [contextMenuEntry, setContextMenuEntry] = useState(null) + const [storedSelectionText, setStoredSelectionText] = useState('') + + // Context menu hooks + const { + isOpen: isLogRowMenuOpen, + position: logRowMenuPosition, + menuRef: logRowMenuRef, + handleContextMenu: handleLogRowContextMenu, + closeMenu: closeLogRowMenu, + } = useContextMenu() + + const { + isOpen: isOutputMenuOpen, + position: outputMenuPosition, + menuRef: outputMenuRef, + handleContextMenu: handleOutputContextMenu, + closeMenu: closeOutputMenu, + } = useContextMenu() + /** * Expands the terminal to its last meaningful height, with safeguards: * - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}. @@ -511,15 +538,11 @@ export function Terminal() { const handleRowClick = useCallback((entry: ConsoleEntry) => { setSelectedEntry((prev) => { const isDeselecting = prev?.id === entry.id - // Re-enable auto-select when deselecting, disable when selecting setAutoSelectEnabled(isDeselecting) return isDeselecting ? null : entry }) }, []) - /** - * Handle header click - toggle between expanded and collapsed - */ const handleHeaderClick = useCallback(() => { if (isExpanded) { setIsToggling(true) @@ -529,16 +552,10 @@ export function Terminal() { } }, [expandToLastHeight, isExpanded, setTerminalHeight]) - /** - * Handle transition end - reset toggling state - */ const handleTransitionEnd = useCallback(() => { setIsToggling(false) }, []) - /** - * Handle copy output to clipboard - */ const handleCopy = useCallback(() => { if (!selectedEntry) return @@ -560,9 +577,6 @@ export function Terminal() { } }, [activeWorkflowId, clearWorkflowConsole]) - /** - * Activates output search and focuses the search input. - */ const activateOutputSearch = useCallback(() => { setIsOutputSearchActive(true) setTimeout(() => { @@ -570,9 +584,6 @@ export function Terminal() { }, 0) }, []) - /** - * Closes output search and clears the query. - */ const closeOutputSearch = useCallback(() => { setIsOutputSearchActive(false) setOutputSearchQuery('') @@ -604,9 +615,6 @@ export function Terminal() { setCurrentMatchIndex(0) }, []) - /** - * Handle clear console for current workflow via mouse interaction. - */ const handleClearConsole = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -615,10 +623,6 @@ export function Terminal() { [clearCurrentWorkflowConsole] ) - /** - * Handle export of console entries for the current workflow via mouse interaction. - * Mirrors the visibility and interaction behavior of the clear console action. - */ const handleExportConsole = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -629,9 +633,60 @@ export function Terminal() { [activeWorkflowId, exportConsoleCSV] ) - /** - * Handle training button click - toggle training state or open modal - */ + const handleCopySelection = useCallback(() => { + if (storedSelectionText) { + navigator.clipboard.writeText(storedSelectionText) + setShowCopySuccess(true) + } + }, [storedSelectionText]) + + const handleOutputPanelContextMenu = useCallback( + (e: React.MouseEvent) => { + const selection = window.getSelection() + const selectionText = selection?.toString() || '' + setStoredSelectionText(selectionText) + setHasSelection(selectionText.length > 0) + handleOutputContextMenu(e) + }, + [handleOutputContextMenu] + ) + + const handleRowContextMenu = useCallback( + (e: React.MouseEvent, entry: ConsoleEntry) => { + setContextMenuEntry(entry) + handleLogRowContextMenu(e) + }, + [handleLogRowContextMenu] + ) + + const handleFilterByBlock = useCallback( + (blockId: string) => { + toggleBlock(blockId) + closeLogRowMenu() + }, + [toggleBlock, closeLogRowMenu] + ) + + const handleFilterByStatus = useCallback( + (status: 'error' | 'info') => { + toggleStatus(status) + closeLogRowMenu() + }, + [toggleStatus, closeLogRowMenu] + ) + + const handleFilterByRunId = useCallback( + (runId: string) => { + toggleRunId(runId) + closeLogRowMenu() + }, + [toggleRunId, closeLogRowMenu] + ) + + const handleClearConsoleFromMenu = useCallback(() => { + clearCurrentWorkflowConsole() + }, [clearCurrentWorkflowConsole]) + const handleTrainingClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -644,9 +699,6 @@ export function Terminal() { [isTraining, stopTraining, toggleTrainingModal] ) - /** - * Whether training controls should be visible - */ const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls /** @@ -721,6 +773,23 @@ export function Terminal() { } }, [showCopySuccess]) + /** + * Track text selection state for context menu. + * Skip updates when the context menu is open to prevent the selection + * state from changing mid-click (which would disable the copy button). + */ + useEffect(() => { + const handleSelectionChange = () => { + if (isOutputMenuOpen) return + + const selection = window.getSelection() + setHasSelection(Boolean(selection && selection.toString().length > 0)) + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => document.removeEventListener('selectionchange', handleSelectionChange) + }, [isOutputMenuOpen]) + /** * Auto-select the latest entry when new logs arrive * Re-enables auto-selection when all entries are cleared @@ -1311,6 +1380,7 @@ export function Terminal() { isSelected && 'bg-[var(--surface-6)] dark:bg-[var(--surface-4)]' )} onClick={() => handleRowClick(entry)} + onContextMenu={(e) => handleRowContextMenu(e, entry)} > {/* Block */}
{/* Status */} -
+
{statusInfo ? ( {statusInfo.label} @@ -1719,7 +1795,10 @@ export function Terminal() { )} {/* Content */} -
+
{shouldShowCodeDisplay ? ( + + {/* Log Row Context Menu */} + { + clearFilters() + closeLogRowMenu() + }} + onClearConsole={handleClearConsoleFromMenu} + hasActiveFilters={hasActiveFilters} + /> + + {/* Output Panel Context Menu */} + setWrapText(!wrapText)} + openOnRun={openOnRun} + onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} + onClearConsole={handleClearConsoleFromMenu} + hasSelection={hasSelection} + /> ) } diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index c82e651b8c..0aa8237cee 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -119,10 +119,8 @@ const STYLES = { 'hover:bg-[var(--border-1)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]', }, secondary: { - active: - 'bg-[var(--brand-secondary)] text-[var(--text-inverse)] [&_svg]:text-[var(--text-inverse)]', - hover: - 'hover:bg-[var(--brand-secondary)] hover:text-[var(--text-inverse)] dark:hover:text-[var(--text-inverse)] hover:[&_svg]:text-[var(--text-inverse)] dark:hover:[&_svg]:text-[var(--text-inverse)]', + active: 'bg-[var(--brand-secondary)] text-white [&_svg]:text-white', + hover: 'hover:bg-[var(--brand-secondary)] hover:text-white hover:[&_svg]:text-white', }, inverted: { active: @@ -474,14 +472,20 @@ const PopoverScrollArea = React.forwardRef { - /** Whether this item is currently active/selected */ + /** + * Whether this item has active/highlighted background styling. + * Use for keyboard navigation focus or persistent highlight states. + */ active?: boolean /** Only show when not inside any folder */ rootOnly?: boolean /** Whether this item is disabled */ disabled?: boolean /** - * Show checkmark when active + * Show a checkmark to indicate selection/checked state. + * Unlike `active`, this only shows the checkmark without background highlight, + * following the pattern where hover provides interaction feedback + * and checkmarks indicate current value. * @default false */ showCheck?: boolean @@ -528,7 +532,7 @@ const PopoverItem = React.forwardRef( {...props} > {children} - {showCheck && active && } + {showCheck && }
) }