diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 220c15a51..65f6e04eb 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { Plus, X } from 'lucide-react'; +import { PinOff, Plus, X } from 'lucide-react'; import { useToast } from '../hooks/use-toast'; import { useTheme } from '../hooks/useTheme'; import { TerminalPane, type TerminalPaneHandle } from './TerminalPane'; @@ -62,6 +62,9 @@ interface Props { onRenameTask?: (project: Project, task: Task, newName: string) => Promise; } +// Cross-panel signal emitted from TaskTerminalPanel when a pinned terminal is selected. +const FOCUS_PINNED_TERMINAL_EVENT = 'emdash:focus-pinned-terminal'; + function ConversationTabButton({ conversation, activeConversationId, @@ -142,13 +145,64 @@ function ConversationTabButton({ ); } +function PinnedTerminalTabButton({ + label, + isActive, + onClick, + onUnpin, +}: { + label: string; + isActive: boolean; + onClick: () => void; + onUnpin: () => void; +}) { + return ( + + ); +} + const ChatInterface: React.FC = ({ task, project, projectName: _projectName, projectPath, projectRemoteConnectionId, - projectRemotePath: _projectRemotePath, + projectRemotePath, defaultBranch, className, initialAgent, @@ -175,6 +229,7 @@ const ChatInterface: React.FC = ({ const [conversationsLoaded, setConversationsLoaded] = useState(false); const [showCreateChatModal, setShowCreateChatModal] = useState(false); const [busyByConversationId, setBusyByConversationId] = useState>({}); + const [activePinnedTerminalKey, setActivePinnedTerminalKey] = useState(null); const lockedAgentWriteRef = useRef(null); const tabsContainerRef = useRef(null); const [tabsOverflow, setTabsOverflow] = useState(false); @@ -285,8 +340,65 @@ const ChatInterface: React.FC = ({ }), [conversations] ); - const { activeTerminalId } = useTaskTerminals(task.id, task.path); + const sidebarTaskKey = `${task.id}::${task.path}`; + const sidebarTaskTerminals = useTaskTerminals(sidebarTaskKey, task.path); + const sidebarGlobalKey = task.path + ? `global::${task.path}` + : projectPath + ? `global::${projectPath}` + : 'global::home'; + const sidebarGlobalTerminals = useTaskTerminals(sidebarGlobalKey, projectPath || undefined); + const pinnedTerminals = useMemo(() => { + const taskPins = sidebarTaskTerminals.terminals + .filter((terminal) => terminal.pinned) + .map((terminal) => ({ + key: `task::${terminal.id}`, + terminalId: terminal.id, + tabLabel: `${terminal.title} (worktree)`, + cwd: terminal.cwd, + scope: 'task' as const, + })); + const globalPins = sidebarGlobalTerminals.terminals + .filter((terminal) => terminal.pinned) + .map((terminal) => ({ + key: `global::${terminal.id}`, + terminalId: terminal.id, + tabLabel: `${terminal.title} (project)`, + cwd: terminal.cwd, + scope: 'global' as const, + })); + return [...taskPins, ...globalPins]; + }, [sidebarTaskTerminals.terminals, sidebarGlobalTerminals.terminals]); + const activePinnedTerminal = useMemo( + () => pinnedTerminals.find((terminal) => terminal.key === activePinnedTerminalKey) ?? null, + [pinnedTerminals, activePinnedTerminalKey] + ); + const numberedTabs = useMemo( + () => [ + ...sortedConversations.map((conversation) => ({ + type: 'conversation' as const, + conversationId: conversation.id, + })), + ...pinnedTerminals.map((terminal) => ({ + type: 'pinned' as const, + terminalKey: terminal.key, + })), + ], + [sortedConversations, pinnedTerminals] + ); + const effectiveRemoteProjectPath = useMemo( + () => workspaceRemotePath || projectRemotePath || undefined, + [workspaceRemotePath, projectRemotePath] + ); + const activeTerminalPaneId = activePinnedTerminal?.terminalId ?? terminalId; + const activeTerminalPaneCwd = useMemo(() => { + if (!activePinnedTerminal) return effectiveCwd; + if (activePinnedTerminal.scope === 'task') { + return effectiveRemoteProjectPath || activePinnedTerminal.cwd || task.path; + } + return activePinnedTerminal.cwd || projectPath || task.path; + }, [activePinnedTerminal, effectiveCwd, effectiveRemoteProjectPath, task.path, projectPath]); // Wire comment injection to pendingInjectionManager useCommentInjection(task.id, task.path); @@ -312,6 +424,12 @@ const ChatInterface: React.FC = ({ onTaskInterfaceReady(); }, [task.id, onTaskInterfaceReady]); + useEffect(() => { + if (!activePinnedTerminalKey) return; + if (pinnedTerminals.some((terminal) => terminal.key === activePinnedTerminalKey)) return; + setActivePinnedTerminalKey(null); + }, [activePinnedTerminalKey, pinnedTerminals]); + const syncConversations = useCallback(async () => { setConversationsLoaded(false); const loadedConversations = await rpc.db.getConversations(task.id); @@ -472,7 +590,7 @@ const ChatInterface: React.FC = ({ handleSearchQueryChange, stepSearch, } = useTerminalSearch({ - terminalId, + terminalId: activeTerminalPaneId, containerRef: terminalPanelRef, enabled: true, onCloseFocus: () => terminalRef.current?.focus(), @@ -495,13 +613,13 @@ const ChatInterface: React.FC = ({ if (!conversationsLoaded) return; // Small delay to ensure terminal is mounted and attached const timer = setTimeout(() => { - const session = terminalSessionRegistry.getSession(terminalId); + const session = terminalSessionRegistry.getSession(activeTerminalPaneId); if (session) { session.focus(); } }, 100); return () => clearTimeout(timer); - }, [task.id, terminalId, conversationsLoaded]); + }, [task.id, activeTerminalPaneId, conversationsLoaded]); // Focus terminal when this task becomes active (for already-mounted terminals) useEffect(() => { @@ -519,7 +637,7 @@ const ChatInterface: React.FC = ({ timer = setTimeout(() => { timer = null; if (!mounted) return; - const session = terminalSessionRegistry.getSession(terminalId); + const session = terminalSessionRegistry.getSession(activeTerminalPaneId); if (session) session.focus(); }, 0); }; @@ -529,9 +647,10 @@ const ChatInterface: React.FC = ({ if (timer !== null) clearTimeout(timer); window.removeEventListener('focus', handleWindowFocus); }; - }, [terminalId]); + }, [activeTerminalPaneId]); useEffect(() => { + if (activePinnedTerminal) return; const meta = agentMeta[agent]; if (!meta?.terminalOnly || !meta.autoStartCommand) return; @@ -568,7 +687,7 @@ const ChatInterface: React.FC = ({ } catch {} clearTimeout(t); }; - }, [agent, terminalId]); + }, [agent, terminalId, activePinnedTerminal]); useEffect(() => { setCliStartError(null); @@ -709,6 +828,7 @@ const ChatInterface: React.FC = ({ conversationId, }); setActiveConversationId(conversationId); + setActivePinnedTerminalKey(null); // Update provider based on conversation const conv = conversations.find((c) => c.id === conversationId); @@ -719,6 +839,54 @@ const ChatInterface: React.FC = ({ [task.id, conversations] ); + const handleSelectPinnedTerminal = useCallback( + (terminalKey: string) => { + const current = pinnedTerminals.find((terminal) => terminal.key === terminalKey); + if (!current) return; + if (activePinnedTerminalKey === terminalKey) { + terminalSessionRegistry.getSession(current.terminalId)?.focus(); + return; + } + setActivePinnedTerminalKey(terminalKey); + }, + [activePinnedTerminalKey, pinnedTerminals] + ); + + const handleUnpinPinnedTerminal = useCallback( + (terminalKey: string) => { + const terminal = pinnedTerminals.find((item) => item.key === terminalKey); + if (!terminal) return; + if (terminal.scope === 'task') { + sidebarTaskTerminals.setPinned(terminal.terminalId, false); + } else { + sidebarGlobalTerminals.setPinned(terminal.terminalId, false); + } + if (activePinnedTerminalKey === terminalKey) { + setActivePinnedTerminalKey(null); + } + }, + [pinnedTerminals, sidebarTaskTerminals, sidebarGlobalTerminals, activePinnedTerminalKey] + ); + + useEffect(() => { + const onFocusPinned = (event: Event) => { + const detail = ( + event as CustomEvent<{ + taskId?: string | null; + scope?: 'task' | 'global'; + terminalId?: string; + }> + ).detail; + if (!detail?.terminalId || !detail.scope) return; + if (detail.taskId !== task.id) return; + handleSelectPinnedTerminal(`${detail.scope}::${detail.terminalId}`); + }; + + window.addEventListener(FOCUS_PINNED_TERMINAL_EVENT, onFocusPinned as EventListener); + return () => + window.removeEventListener(FOCUS_PINNED_TERMINAL_EVENT, onFocusPinned as EventListener); + }, [task.id, handleSelectPinnedTerminal]); + const handleCloseChat = useCallback( async (conversationId: string) => { if (conversations.length <= 1) { @@ -905,19 +1073,24 @@ const ChatInterface: React.FC = ({ const customEvent = event as CustomEvent<{ tabIndex: number }>; const tabIndex = customEvent.detail?.tabIndex; if (typeof tabIndex !== 'number') return; - if (tabIndex < 0 || tabIndex >= sortedConversations.length) return; + if (tabIndex < 0 || tabIndex >= numberedTabs.length) return; - const selectedConversation = sortedConversations[tabIndex]; - if (selectedConversation) { - handleSwitchChat(selectedConversation.id); + const selectedTab = numberedTabs[tabIndex]; + if (!selectedTab) return; + + if (selectedTab.type === 'conversation') { + handleSwitchChat(selectedTab.conversationId); + return; } + + handleSelectPinnedTerminal(selectedTab.terminalKey); }; window.addEventListener('emdash:select-agent-tab', handleAgentTabSelection); return () => { window.removeEventListener('emdash:select-agent-tab', handleAgentTabSelection); }; - }, [sortedConversations, handleSwitchChat]); + }, [numberedTabs, handleSwitchChat, handleSelectPinnedTerminal]); // Close active chat tab on Cmd+W useEffect(() => { @@ -1178,7 +1351,7 @@ const ChatInterface: React.FC = ({ = ({ /> ); })} + {pinnedTerminals.map((terminal) => ( + handleSelectPinnedTerminal(terminal.key)} + onUnpin={() => handleUnpinPinnedTerminal(terminal.key)} + /> + ))}