@@ -447,7 +447,8 @@ export function TerminalView() {
447447 // The path check in restoreLayout will handle this
448448
449449 // Save layout for previous project (if there was one and has terminals)
450- if ( prevPath && terminalState . tabs . length > 0 ) {
450+ // BUT don't save if we were mid-restore for that project (would save incomplete state)
451+ if ( prevPath && terminalState . tabs . length > 0 && restoringProjectPathRef . current !== prevPath ) {
451452 saveTerminalLayout ( prevPath ) ;
452453 }
453454
@@ -460,19 +461,25 @@ export function TerminalView() {
460461 return ;
461462 }
462463
464+ // ALWAYS clear existing terminals when switching projects
465+ // This is critical - prevents old project's terminals from "bleeding" into new project
466+ clearTerminalState ( ) ;
467+
463468 // Check for saved layout for this project
464469 const savedLayout = getPersistedTerminalLayout ( currentPath ) ;
465470
466- if ( savedLayout && savedLayout . tabs . length > 0 ) {
467- // Restore the saved layout - try to reconnect to existing sessions
468- // Track which project we're restoring to detect stale restores
469- restoringProjectPathRef . current = currentPath ;
471+ // If no saved layout or no tabs, we're done - terminal starts fresh for this project
472+ if ( ! savedLayout || savedLayout . tabs . length === 0 ) {
473+ console . log ( "[Terminal] No saved layout for project, starting fresh" ) ;
474+ return ;
475+ }
470476
471- // Clear existing terminals first (only client state, sessions stay on server)
472- clearTerminalState ( ) ;
477+ // Restore the saved layout - try to reconnect to existing sessions
478+ // Track which project we're restoring to detect stale restores
479+ restoringProjectPathRef . current = currentPath ;
473480
474- // Create terminals and build layout - try to reconnect or create new
475- const restoreLayout = async ( ) => {
481+ // Create terminals and build layout - try to reconnect or create new
482+ const restoreLayout = async ( ) => {
476483 // Check if we're still restoring the same project (user may have switched)
477484 if ( restoringProjectPathRef . current !== currentPath ) {
478485 console . log ( "[Terminal] Restore cancelled - project changed" ) ;
@@ -643,21 +650,29 @@ export function TerminalView() {
643650 } ;
644651
645652 restoreLayout ( ) ;
646- }
647653 } , [ currentProject ?. path , saveTerminalLayout , getPersistedTerminalLayout , clearTerminalState , addTerminalTab , serverUrl ] ) ;
648654
649655 // Save terminal layout whenever it changes (debounced to prevent excessive writes)
650656 // Also save when tabs become empty so closed terminals stay closed on refresh
651657 const saveLayoutTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
658+ const pendingSavePathRef = useRef < string | null > ( null ) ;
652659 useEffect ( ( ) => {
660+ const projectPath = currentProject ?. path ;
653661 // Don't save while restoring this project's layout
654- if ( currentProject ?. path && restoringProjectPathRef . current !== currentProject . path ) {
662+ if ( projectPath && restoringProjectPathRef . current !== projectPath ) {
655663 // Debounce saves to prevent excessive localStorage writes during rapid changes
656664 if ( saveLayoutTimeoutRef . current ) {
657665 clearTimeout ( saveLayoutTimeoutRef . current ) ;
658666 }
667+ // Capture the project path at schedule time so we save to the correct project
668+ // even if user switches projects before the timeout fires
669+ pendingSavePathRef . current = projectPath ;
659670 saveLayoutTimeoutRef . current = setTimeout ( ( ) => {
660- saveTerminalLayout ( currentProject . path ) ;
671+ // Only save if we're still on the same project
672+ if ( pendingSavePathRef . current === projectPath ) {
673+ saveTerminalLayout ( projectPath ) ;
674+ }
675+ pendingSavePathRef . current = null ;
661676 saveLayoutTimeoutRef . current = null ;
662677 } , 500 ) ; // 500ms debounce
663678 }
@@ -949,41 +964,112 @@ export function TerminalView() {
949964 } ) ;
950965 } , [ ] ) ;
951966
952- // Navigate between terminal panes with Ctrl+Alt+Arrow keys
953- const navigateToTerminal = useCallback ( ( direction : "next" | "prev" ) => {
967+ // Navigate between terminal panes with directional awareness
968+ // Arrow keys navigate in the actual spatial direction within the layout
969+ const navigateToTerminal = useCallback ( ( direction : "up" | "down" | "left" | "right" ) => {
954970 if ( ! activeTab ?. layout ) return ;
955971
956- const terminalIds = getTerminalIds ( activeTab . layout ) ;
957- if ( terminalIds . length <= 1 ) return ;
958-
959- const currentIndex = terminalIds . indexOf ( terminalState . activeSessionId || "" ) ;
960- if ( currentIndex === - 1 ) {
972+ const currentSessionId = terminalState . activeSessionId ;
973+ if ( ! currentSessionId ) {
961974 // If no terminal is active, focus the first one
962- setActiveTerminalSession ( terminalIds [ 0 ] ) ;
975+ const terminalIds = getTerminalIds ( activeTab . layout ) ;
976+ if ( terminalIds . length > 0 ) {
977+ setActiveTerminalSession ( terminalIds [ 0 ] ) ;
978+ }
963979 return ;
964980 }
965981
966- let newIndex : number ;
967- if ( direction === "next" ) {
968- newIndex = ( currentIndex + 1 ) % terminalIds . length ;
969- } else {
970- newIndex = ( currentIndex - 1 + terminalIds . length ) % terminalIds . length ;
971- }
982+ // Find the terminal in the given direction
983+ // The algorithm traverses the layout tree to find spatially adjacent terminals
984+ const findTerminalInDirection = (
985+ layout : TerminalPanelContent ,
986+ targetId : string ,
987+ dir : "up" | "down" | "left" | "right"
988+ ) : string | null => {
989+ // Helper to get all terminal IDs from a layout subtree
990+ const getAllTerminals = ( node : TerminalPanelContent ) : string [ ] => {
991+ if ( node . type === "terminal" ) return [ node . sessionId ] ;
992+ return node . panels . flatMap ( getAllTerminals ) ;
993+ } ;
972994
973- setActiveTerminalSession ( terminalIds [ newIndex ] ) ;
995+ // Helper to find terminal and its path in the tree
996+ type PathEntry = { node : TerminalPanelContent ; index : number ; direction : "horizontal" | "vertical" } ;
997+ const findPath = (
998+ node : TerminalPanelContent ,
999+ target : string ,
1000+ path : PathEntry [ ] = [ ]
1001+ ) : PathEntry [ ] | null => {
1002+ if ( node . type === "terminal" ) {
1003+ return node . sessionId === target ? path : null ;
1004+ }
1005+ for ( let i = 0 ; i < node . panels . length ; i ++ ) {
1006+ const result = findPath ( node . panels [ i ] , target , [
1007+ ...path ,
1008+ { node, index : i , direction : node . direction } ,
1009+ ] ) ;
1010+ if ( result ) return result ;
1011+ }
1012+ return null ;
1013+ } ;
1014+
1015+ const path = findPath ( layout , targetId ) ;
1016+ if ( ! path || path . length === 0 ) return null ;
1017+
1018+ // Determine which split direction we need based on arrow direction
1019+ // left/right navigation works in "horizontal" splits (panels side by side)
1020+ // up/down navigation works in "vertical" splits (panels stacked)
1021+ const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical" ;
1022+ const goingForward = dir === "right" || dir === "down" ;
1023+
1024+ // Walk up the path to find a split in the right direction with an adjacent panel
1025+ for ( let i = path . length - 1 ; i >= 0 ; i -- ) {
1026+ const entry = path [ i ] ;
1027+ if ( entry . direction === neededDirection ) {
1028+ const siblings = entry . node . type === "split" ? entry . node . panels : [ ] ;
1029+ const nextIndex = goingForward ? entry . index + 1 : entry . index - 1 ;
1030+
1031+ if ( nextIndex >= 0 && nextIndex < siblings . length ) {
1032+ // Found an adjacent panel in the right direction
1033+ const adjacentPanel = siblings [ nextIndex ] ;
1034+ const adjacentTerminals = getAllTerminals ( adjacentPanel ) ;
1035+
1036+ if ( adjacentTerminals . length > 0 ) {
1037+ // When moving forward (right/down), pick the first terminal in that subtree
1038+ // When moving backward (left/up), pick the last terminal in that subtree
1039+ return goingForward
1040+ ? adjacentTerminals [ 0 ]
1041+ : adjacentTerminals [ adjacentTerminals . length - 1 ] ;
1042+ }
1043+ }
1044+ }
1045+ }
1046+
1047+ return null ;
1048+ } ;
1049+
1050+ const nextTerminal = findTerminalInDirection ( activeTab . layout , currentSessionId , direction ) ;
1051+ if ( nextTerminal ) {
1052+ setActiveTerminalSession ( nextTerminal ) ;
1053+ }
9741054 } , [ activeTab ?. layout , terminalState . activeSessionId , setActiveTerminalSession ] ) ;
9751055
9761056 // Handle global keyboard shortcuts for pane navigation
9771057 useEffect ( ( ) => {
9781058 const handleKeyDown = ( e : KeyboardEvent ) => {
9791059 // Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
9801060 if ( ( e . ctrlKey || e . metaKey ) && e . altKey && ! e . shiftKey ) {
981- if ( e . key === "ArrowRight" || e . key === "ArrowDown" ) {
1061+ if ( e . key === "ArrowRight" ) {
1062+ e . preventDefault ( ) ;
1063+ navigateToTerminal ( "right" ) ;
1064+ } else if ( e . key === "ArrowLeft" ) {
1065+ e . preventDefault ( ) ;
1066+ navigateToTerminal ( "left" ) ;
1067+ } else if ( e . key === "ArrowDown" ) {
9821068 e . preventDefault ( ) ;
983- navigateToTerminal ( "next " ) ;
984- } else if ( e . key === "ArrowLeft" || e . key === " ArrowUp") {
1069+ navigateToTerminal ( "down " ) ;
1070+ } else if ( e . key === "ArrowUp" ) {
9851071 e . preventDefault ( ) ;
986- navigateToTerminal ( "prev " ) ;
1072+ navigateToTerminal ( "up " ) ;
9871073 }
9881074 }
9891075 } ;
@@ -1019,6 +1105,16 @@ export function TerminalView() {
10191105 onSplitHorizontal = { ( ) => createTerminal ( "horizontal" , content . sessionId ) }
10201106 onSplitVertical = { ( ) => createTerminal ( "vertical" , content . sessionId ) }
10211107 onNewTab = { createTerminalInNewTab }
1108+ onNavigateUp = { ( ) => navigateToTerminal ( "up" ) }
1109+ onNavigateDown = { ( ) => navigateToTerminal ( "down" ) }
1110+ onNavigateLeft = { ( ) => navigateToTerminal ( "left" ) }
1111+ onNavigateRight = { ( ) => navigateToTerminal ( "right" ) }
1112+ onSessionInvalid = { ( ) => {
1113+ // Auto-remove stale session when server says it doesn't exist
1114+ // This handles cases like server restart where sessions are lost
1115+ console . log ( `[Terminal] Session ${ content . sessionId } is invalid, removing from layout` ) ;
1116+ killTerminal ( content . sessionId ) ;
1117+ } }
10221118 isDragging = { activeDragId === content . sessionId }
10231119 isDropTarget = { activeDragId !== null && activeDragId !== content . sessionId }
10241120 fontSize = { terminalFontSize }
@@ -1384,6 +1480,11 @@ export function TerminalView() {
13841480 onSplitHorizontal = { ( ) => createTerminal ( "horizontal" , terminalState . maximizedSessionId ! ) }
13851481 onSplitVertical = { ( ) => createTerminal ( "vertical" , terminalState . maximizedSessionId ! ) }
13861482 onNewTab = { createTerminalInNewTab }
1483+ onSessionInvalid = { ( ) => {
1484+ const sessionId = terminalState . maximizedSessionId ! ;
1485+ console . log ( `[Terminal] Maximized session ${ sessionId } is invalid, removing from layout` ) ;
1486+ killTerminal ( sessionId ) ;
1487+ } }
13871488 isDragging = { false }
13881489 isDropTarget = { false }
13891490 fontSize = { findTerminalFontSize ( terminalState . maximizedSessionId ) }
0 commit comments