11'use client'
22
3- import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
3+ import {
4+ type MouseEvent as ReactMouseEvent ,
5+ useCallback ,
6+ useEffect ,
7+ useMemo ,
8+ useRef ,
9+ useState ,
10+ } from 'react'
411import { cn , TabStrip , type TabStripItem } from '@sim/emcn'
512import { Loader , TerminalWindow } from '@sim/emcn/icons'
613import { createLogger } from '@sim/logger'
@@ -24,6 +31,8 @@ import {
2431 writeToTerminal ,
2532} from '@/lib/terminal/transport'
2633import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
34+ import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
35+ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
2736import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
2837
2938const logger = createLogger ( 'TerminalSession' )
@@ -294,6 +303,16 @@ export function TerminalSession() {
294303 [ tabs , activeTerminalId ]
295304 )
296305
306+ const [ contextTerminalId , setContextTerminalId ] = useState < string | null > ( null )
307+ const {
308+ isOpen : isContextMenuOpen ,
309+ position : contextMenuPosition ,
310+ menuRef : contextMenuRef ,
311+ handleContextMenu,
312+ closeMenu : closeContextMenu ,
313+ } = useContextMenu ( )
314+ const contextTab = tabs . find ( ( tab ) => tab . terminalId === contextTerminalId )
315+
297316 const handleNew = useCallback ( ( ) => {
298317 void openTerminal ( )
299318 } , [ ] )
@@ -304,17 +323,51 @@ export function TerminalSession() {
304323 void closeTerminal ( terminalId )
305324 } , [ ] )
306325
326+ // A duplicate is a new shell in the same directory, not a copy of the
327+ // session: scrollback and whatever is running belong to the original pty.
328+ const handleDuplicate = useCallback ( ( cwd : string | null ) => {
329+ void openTerminal ( cwd ?? undefined )
330+ } , [ ] )
331+
332+ const openTabContextMenu = useCallback (
333+ ( event : ReactMouseEvent < HTMLDivElement > , terminalId : string ) => {
334+ window . getSelection ( ) ?. removeAllRanges ( )
335+ setContextTerminalId ( terminalId )
336+ handleContextMenu ( event )
337+ } ,
338+ [ handleContextMenu ]
339+ )
340+
307341 return (
308342 < div className = 'flex h-full flex-col overflow-hidden bg-[var(--bg)]' >
309343 { tabs . length > 0 && (
310344 < TabStrip
311345 tabs = { items }
312346 onSelect = { handleSwitch }
313347 onNew = { handleNew }
348+ onTabContextMenu = { openTabContextMenu }
314349 maxTabs = { MAX_TERMINALS }
315350 newTabLabel = 'New terminal'
316351 { ...( tabs . length > 1 ? { onClose : handleClose } : { } ) }
317- />
352+ >
353+ < ContextMenu
354+ isOpen = { isContextMenuOpen && Boolean ( contextTab ) }
355+ position = { contextMenuPosition }
356+ menuRef = { contextMenuRef }
357+ onClose = { closeContextMenu }
358+ onDuplicate = { contextTab ? ( ) => handleDuplicate ( contextTab . cwd ) : undefined }
359+ // The last terminal has no close affordance in the strip either:
360+ // closing it would leave the panel with nothing to show.
361+ { ...( contextTab && tabs . length > 1
362+ ? { onCloseTab : ( ) => handleClose ( contextTab . terminalId ) , showCloseTab : true }
363+ : { } ) }
364+ onDelete = { ( ) => { } }
365+ showRename = { false }
366+ showDuplicate = { Boolean ( contextTab ) }
367+ disableDuplicate = { tabs . length >= MAX_TERMINALS }
368+ showDelete = { false }
369+ />
370+ </ TabStrip >
318371 ) }
319372
320373 < div className = 'relative min-h-0 flex-1' >
0 commit comments