Skip to content

Commit 4dda745

Browse files
committed
MAESTRO: Address PR #486 review comments from CodeRabbit and Greptile
- TerminalView: Guard duplicate PTY spawns with in-flight ref; fix missing useCallback deps (onTabPidChange, onTabStateChange, sessionSshRemoteConfig); prevent repeated loading message writes per tab; pass sessionSshRemoteConfig through to spawnTerminalTab IPC - TabBar/TerminalTabItem: Add tabIndex, role, aria-selected, onFocus, onBlur, onKeyDown for full keyboard accessibility - TabBar auto-scroll: include activeTerminalTabId and inputMode so terminal tabs scroll into view on selection - TabBar unread filter: gate terminal tabs by inputMode === 'terminal' to prevent leaking them into AI-mode filtered view - TerminalSearchBar: add type/aria-label to icon buttons; clear terminal engine search state on bar close or empty query; guard Enter navigation when query is empty - TerminalTabRenameModal: sync value with currentName/isOpen via useEffect to fix stale state when modal reopens for a new tab - useMainKeyboardHandler: scope Ctrl-passthrough to macOS only to avoid breaking Ctrl+F/W/K on Windows/Linux; always preventDefault on closeTab shortcut in terminal mode regardless of tab count - tabStore: validate close before kill to prevent orphaned PTY when closeTerminalTabHelper refuses (last-tab guard) - useDebouncedPersistence: validate and normalize activeTerminalTabId against cleaned terminal tabs before persisting - terminalTabHelpers: always overwrite exitCode on state transitions to clear stale values from prior process runs
1 parent b8afbb7 commit 4dda745

8 files changed

Lines changed: 92 additions & 25 deletions

File tree

src/renderer/components/TabBar.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,15 +1782,29 @@ const TerminalTabItem = memo(function TerminalTabItem({
17821782
<div
17831783
ref={setTabRef}
17841784
data-tab-id={tab.id}
1785+
tabIndex={0}
1786+
role="tab"
1787+
aria-selected={isActive}
17851788
className={`
17861789
relative flex items-center gap-1.5 px-3 py-1.5 cursor-pointer
1787-
transition-all duration-150 select-none shrink-0
1790+
transition-all duration-150 select-none shrink-0 outline-none
17881791
${isDragging ? 'opacity-50' : ''}
17891792
${isDragOver ? 'ring-2 ring-inset' : ''}
17901793
`}
17911794
style={tabStyle}
17921795
title={tab.cwd ? `${tab.shellType}${tab.cwd}` : tab.shellType}
17931796
onClick={handleTabSelect}
1797+
onFocus={handleMouseEnter}
1798+
onBlur={() => {
1799+
handleMouseLeave();
1800+
setOverlayOpen(false);
1801+
}}
1802+
onKeyDown={(e) => {
1803+
if (e.key === 'Enter' || e.key === ' ') {
1804+
e.preventDefault();
1805+
handleTabSelect();
1806+
}
1807+
}}
17941808
onDoubleClick={handleDoubleClick}
17951809
onMouseDown={handleMouseDown}
17961810
onMouseEnter={handleMouseEnter}
@@ -2056,8 +2070,11 @@ function TabBarInner({
20562070
requestAnimationFrame(() => {
20572071
requestAnimationFrame(() => {
20582072
const container = tabBarRef.current;
2059-
// When a file tab is active, scroll to it; otherwise scroll to the active AI tab
2060-
const targetTabId = activeFileTabId || activeTabId;
2073+
// Scroll to the currently active tab across AI/file/terminal modes
2074+
const targetTabId =
2075+
inputMode === 'terminal'
2076+
? activeTerminalTabId || activeTabId
2077+
: activeFileTabId || activeTabId;
20612078
const tabElement = container?.querySelector(
20622079
`[data-tab-id="${targetTabId}"]`
20632080
) as HTMLElement | null;
@@ -2083,7 +2100,7 @@ function TabBarInner({
20832100
}
20842101
});
20852102
});
2086-
}, [activeTabId, activeFileTabId, activeTabName, showUnreadOnly]);
2103+
}, [activeTabId, activeFileTabId, activeTerminalTabId, inputMode, activeTabName, showUnreadOnly]);
20872104

20882105
// Can always close tabs - closing the last one creates a fresh new tab
20892106
const canClose = true;
@@ -2108,10 +2125,10 @@ function TabBarInner({
21082125
if (ut.type === 'file') {
21092126
return ut.id === activeFileTabId;
21102127
}
2111-
// Terminal tabs: only show if active
2112-
return ut.id === activeTerminalTabId;
2128+
// Terminal tabs: only show if active in terminal mode
2129+
return inputMode === 'terminal' && ut.id === activeTerminalTabId;
21132130
});
2114-
}, [unifiedTabs, showUnreadOnly, activeTabId, activeFileTabId, activeTerminalTabId]);
2131+
}, [unifiedTabs, showUnreadOnly, activeTabId, activeFileTabId, activeTerminalTabId, inputMode]);
21152132

21162133
const handleDragStart = useCallback((tabId: string, e: React.DragEvent) => {
21172134
e.dataTransfer.effectAllowed = 'move';

src/renderer/components/TerminalSearchBar.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,21 @@ export const TerminalSearchBar = memo(function TerminalSearchBar({
2727
const { registerLayer, unregisterLayer } = useLayerStack();
2828
const onCloseRef = useRef(onClose);
2929
onCloseRef.current = onClose;
30+
const onSearchRef = useRef(onSearch);
31+
onSearchRef.current = onSearch;
3032

31-
// Auto-focus input when opened
33+
// Auto-focus input when opened; clear search state in terminal engine when closed
3234
useEffect(() => {
3335
if (isOpen) {
3436
const timer = setTimeout(() => {
3537
inputRef.current?.focus();
3638
}, 0);
3739
return () => clearTimeout(timer);
3840
} else {
39-
// Reset query when closed
41+
// Reset local state and clear any active search highlight in the terminal engine
4042
setQuery('');
4143
setHasResults(true);
44+
onSearchRef.current('');
4245
}
4346
}, [isOpen]);
4447

@@ -65,12 +68,14 @@ export const TerminalSearchBar = memo(function TerminalSearchBar({
6568
const found = onSearch(value);
6669
setHasResults(found);
6770
} else {
71+
// Clear search highlight in terminal engine when query is emptied
72+
onSearch('');
6873
setHasResults(true);
6974
}
7075
};
7176

7277
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
73-
if (e.key === 'Enter') {
78+
if (e.key === 'Enter' && query) {
7479
e.preventDefault();
7580
if (e.shiftKey) {
7681
const found = onSearchPrevious();
@@ -125,6 +130,8 @@ export const TerminalSearchBar = memo(function TerminalSearchBar({
125130
</span>
126131
)}
127132
<button
133+
type="button"
134+
aria-label="Previous match"
128135
onClick={handlePrevious}
129136
disabled={!query}
130137
title="Previous match (Shift+Enter)"
@@ -134,6 +141,8 @@ export const TerminalSearchBar = memo(function TerminalSearchBar({
134141
<ChevronUp size={14} />
135142
</button>
136143
<button
144+
type="button"
145+
aria-label="Next match"
137146
onClick={handleNext}
138147
disabled={!query}
139148
title="Next match (Enter)"
@@ -143,6 +152,8 @@ export const TerminalSearchBar = memo(function TerminalSearchBar({
143152
<ChevronDown size={14} />
144153
</button>
145154
<button
155+
type="button"
156+
aria-label="Close search"
146157
onClick={onClose}
147158
title="Close (Escape)"
148159
className="p-0.5 rounded opacity-70 hover:opacity-100"

src/renderer/components/TerminalTabRenameModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { memo, useRef, useState } from 'react';
1+
import React, { memo, useRef, useState, useEffect } from 'react';
22
import type { Theme } from '../types';
33
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
44
import { Modal, ModalFooter } from './ui/Modal';
@@ -22,6 +22,13 @@ export const TerminalTabRenameModal = memo(function TerminalTabRenameModal(
2222
const inputRef = useRef<HTMLInputElement>(null);
2323
const [value, setValue] = useState(currentName ?? '');
2424

25+
// Sync input value when modal reopens for a different tab
26+
useEffect(() => {
27+
if (isOpen) {
28+
setValue(currentName ?? '');
29+
}
30+
}, [isOpen, currentName]);
31+
2532
if (!isOpen) return null;
2633

2734
const handleSave = () => {

src/renderer/components/TerminalView.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, forwardRef, useImperativeHandle, useRef, useEffect, useCallback } from 'react';
1+
import { memo, forwardRef, useImperativeHandle, useRef, useEffect, useCallback, useState } from 'react';
22
import { AlertCircle } from 'lucide-react';
33
import { XTerminal, XTerminalHandle } from './XTerminal';
44
import { TerminalSearchBar } from './TerminalSearchBar';
@@ -58,6 +58,10 @@ export const TerminalView = memo(
5858
const terminalRefs = useRef<Map<string, XTerminalHandle>>(new Map());
5959
// Track previous tab states to detect transitions (for exit message)
6060
const prevTabStatesRef = useRef<Map<string, TerminalTab['state']>>(new Map());
61+
// In-flight spawn guard: set of tabIds currently waiting for a PTY PID
62+
const spawnInFlightRef = useRef<Set<string>>(new Set());
63+
// Track which tabs have already had the loading message written to avoid duplicates
64+
const loadingWrittenRef = useRef<Set<string>>(new Set());
6165

6266
const activeTab = getActiveTerminalTab(session);
6367

@@ -94,8 +98,12 @@ export const TerminalView = memo(
9498
// Shared spawn function — used both on mount and for retry
9599
const spawnPtyForTab = useCallback(
96100
(tab: TerminalTab) => {
97-
const terminalSessionId = getTerminalSessionId(session.id, tab.id);
98101
const tabId = tab.id;
102+
// Guard: skip if a spawn is already in flight for this tab
103+
if (spawnInFlightRef.current.has(tabId)) return;
104+
spawnInFlightRef.current.add(tabId);
105+
106+
const terminalSessionId = getTerminalSessionId(session.id, tabId);
99107

100108
window.maestro.process
101109
.spawnTerminalTab({
@@ -104,6 +112,7 @@ export const TerminalView = memo(
104112
shell: defaultShell || undefined,
105113
shellArgs,
106114
shellEnvVars,
115+
sessionSshRemoteConfig: session.sessionSshRemoteConfig,
107116
})
108117
.then((result) => {
109118
if (result.success) {
@@ -114,9 +123,12 @@ export const TerminalView = memo(
114123
})
115124
.catch(() => {
116125
onTabStateChange(tabId, 'exited', 1);
126+
})
127+
.finally(() => {
128+
spawnInFlightRef.current.delete(tabId);
117129
});
118130
},
119-
[session.id, session.cwd, defaultShell, shellArgs, shellEnvVars]
131+
[session.id, session.cwd, session.sessionSshRemoteConfig, defaultShell, shellArgs, shellEnvVars, onTabPidChange, onTabStateChange]
120132
);
121133

122134
// Spawn PTY when active tab changes and has no PID yet
@@ -125,7 +137,7 @@ export const TerminalView = memo(
125137
return;
126138
}
127139
spawnPtyForTab(activeTab);
128-
}, [activeTab?.id]);
140+
}, [activeTab?.id, spawnPtyForTab]);
129141

130142
// Focus the active terminal when the active tab changes
131143
useEffect(() => {
@@ -260,14 +272,16 @@ export const TerminalView = memo(
260272
ref={(handle) => {
261273
if (handle) {
262274
terminalRefs.current.set(tab.id, handle);
263-
// Write loading indicator when terminal mounts with no PTY yet
264-
if (tab.pid === 0 && tab.state === 'idle') {
275+
// Write loading indicator once per idle cycle — guard prevents duplicate writes on re-renders
276+
if (tab.pid === 0 && tab.state === 'idle' && !loadingWrittenRef.current.has(tab.id)) {
277+
loadingWrittenRef.current.add(tab.id);
265278
setTimeout(() => {
266279
handle.write('\x1b[2mStarting terminal...\x1b[0m');
267280
}, 0);
268281
}
269282
} else {
270283
terminalRefs.current.delete(tab.id);
284+
loadingWrittenRef.current.delete(tab.id);
271285
}
272286
}}
273287
sessionId={terminalSessionId}

src/renderer/hooks/keyboard/useMainKeyboardHandler.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
8484

8585
// CRITICAL: When in terminal mode, let xterm.js handle Ctrl+[A-Z] control sequences.
8686
// These include Ctrl+C (SIGINT), Ctrl+D (EOF), Ctrl+Z (suspend), Ctrl+\ (quit), etc.
87-
// Only intercept Meta (Cmd) key combos from terminal mode — those are Maestro shortcuts.
88-
// Exception: Ctrl+Shift+` always creates a new terminal tab regardless of mode.
87+
// On macOS, Ctrl is used for terminal control sequences; Cmd (Meta) is for Maestro shortcuts.
88+
// On Windows/Linux, Ctrl doubles as the modifier for Maestro shortcuts (Ctrl+F, Ctrl+W, etc.)
89+
// so we only bypass for macOS to avoid breaking cross-platform app shortcuts.
90+
// Exception: Ctrl+Shift+` always creates a new terminal tab regardless of mode/platform.
91+
const isMac = navigator.platform.toUpperCase().includes('MAC');
8992
if (
93+
isMac &&
9094
ctx.activeSession?.inputMode === 'terminal' &&
9195
e.ctrlKey &&
9296
!e.metaKey &&
@@ -843,10 +847,13 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
843847
);
844848

845849
// Cmd+W: Close the active terminal tab (only if more than one exists)
846-
if (ctx.isTabShortcut(e, 'closeTab') && terminalTabs.length > 1 && activeTerminalTabId) {
850+
// Always preventDefault to prevent native window-close when only one tab remains
851+
if (ctx.isTabShortcut(e, 'closeTab')) {
847852
e.preventDefault();
848-
ctx.handleCloseTerminalTab(activeTerminalTabId);
849-
trackShortcut('closeTab');
853+
if (terminalTabs.length > 1 && activeTerminalTabId) {
854+
ctx.handleCloseTerminalTab(activeTerminalTabId);
855+
trackShortcut('closeTab');
856+
}
850857
}
851858

852859
// Cmd+Shift+] — Navigate to next terminal tab

src/renderer/hooks/utils/useDebouncedPersistence.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,21 @@ const prepareSessionForPersistence = (session: Session): Session => {
104104
exitCode: undefined,
105105
}));
106106

107+
// Validate activeTerminalTabId against the cleaned terminal tabs list
108+
const activeTerminalTabExists = cleanedTerminalTabs.some(
109+
(tab) => tab.id === session.activeTerminalTabId
110+
);
111+
const newActiveTerminalTabId = activeTerminalTabExists
112+
? session.activeTerminalTabId
113+
: cleanedTerminalTabs[0]?.id ?? null;
114+
107115
return {
108116
...sessionWithoutRuntimeFields,
109117
aiTabs: truncatedTabs,
110118
activeTabId: newActiveTabId,
111119
// Reset terminal tab runtime state
112120
terminalTabs: cleanedTerminalTabs,
121+
activeTerminalTabId: newActiveTerminalTabId,
113122
// Reset runtime-only session state - processes don't survive app restart
114123
state: 'idle',
115124
busySource: undefined,

src/renderer/stores/tabStore.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,11 @@ export const useTabStore = create<TabStore>()((set) => ({
489489
closeTerminalTab: (tabId) => {
490490
const session = getActiveSession();
491491
if (!session) return;
492-
// Kill the PTY process before removing the tab
493-
window.maestro.process.kill(getTerminalSessionId(session.id, tabId));
492+
// Validate close first — closeTerminalTabHelper refuses to close the last tab
494493
const updatedSession = closeTerminalTabHelper(session, tabId);
494+
if (updatedSession === session) return; // Close was rejected (e.g., last tab)
495+
// Kill the PTY process only after confirming the tab will be removed
496+
window.maestro.process.kill(getTerminalSessionId(session.id, tabId));
495497
updateActiveSession(updatedSession);
496498
},
497499

src/renderer/utils/terminalTabHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export function updateTerminalTabState(
286286
...session,
287287
terminalTabs: terminalTabs.map((tab) =>
288288
tab.id === tabId
289-
? { ...tab, state, ...(exitCode !== undefined ? { exitCode } : {}) }
289+
? { ...tab, state, exitCode }
290290
: tab
291291
),
292292
};

0 commit comments

Comments
 (0)