mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- OpenCode CLI now uses `run` batch subcommand with positional prompts 🚀 - Dropped `-p` prompt flag for OpenCode JSON mode compatibility 🧩 - Added `noPromptSeparator` for OpenCode positional prompt handling 🧠 - Implemented “Close All Tabs” action for faster session cleanup 🧹 - Implemented “Close Other Tabs” to focus instantly on one tab 🎯 - Implemented “Close Tabs to Left” for browser-style tab management ⬅️ - Implemented “Close Tabs to Right” to prune clutter in one click ➡️ - Wired bulk tab-close handlers through App, Modals, MainPanel, TabBar 🔌 - Enhanced tab context menu with bulk close options and smart disabling 📋 - Updated tests to reflect OpenCode batch mode and UI copy tooltip 🧪
This commit is contained in:
@@ -474,12 +474,14 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
*/
|
||||
buildInitialArgs: (prompt: string, options?: { images?: string[] }) => {
|
||||
// OpenCode arg order from process.ts IPC handler:
|
||||
// 1. base args: [] (empty for OpenCode)
|
||||
// 2. jsonOutputArgs: ['--format', 'json']
|
||||
// 3. Optional: --model provider/model (from OPENCODE_MODEL env var)
|
||||
// 4. promptArgs: ['-p', prompt] (YOLO mode with auto-approve)
|
||||
// 1. batchModePrefix: ['run'] (subcommand for batch mode)
|
||||
// 2. base args: [] (empty for OpenCode)
|
||||
// 3. jsonOutputArgs: ['--format', 'json']
|
||||
// 4. Optional: --model provider/model (from OPENCODE_MODEL env var)
|
||||
// 5. prompt as positional argument (noPromptSeparator: true)
|
||||
|
||||
const args = [
|
||||
'run', // batchModePrefix
|
||||
'--format', 'json',
|
||||
];
|
||||
|
||||
@@ -501,11 +503,12 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
throw new Error('OpenCode should not support stream-json input - capability misconfigured');
|
||||
}
|
||||
|
||||
// OpenCode uses -p flag for prompt (enables YOLO mode with auto-approve)
|
||||
return [...args, '-p', prompt];
|
||||
// OpenCode uses 'run' subcommand for batch mode with prompt as positional arg
|
||||
return [...args, prompt];
|
||||
},
|
||||
buildResumeArgs: (sessionId: string, prompt: string) => {
|
||||
const args = [
|
||||
'run', // batchModePrefix
|
||||
'--format', 'json',
|
||||
];
|
||||
|
||||
@@ -515,8 +518,8 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// -p flag for YOLO mode, --session for resume
|
||||
args.push('--session', sessionId, '-p', prompt);
|
||||
// --session for resume, prompt as positional arg
|
||||
args.push('--session', sessionId, prompt);
|
||||
return args;
|
||||
},
|
||||
parseSessionId: (output: string) => {
|
||||
|
||||
@@ -1115,10 +1115,10 @@ describe('agent-detector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCode YOLO mode configuration', () => {
|
||||
it('should use promptArgs with -p flag for YOLO mode (not batchModePrefix with run)', async () => {
|
||||
// This test ensures we never regress to using 'run' subcommand which does NOT auto-approve permissions
|
||||
// The -p flag is required for YOLO mode (auto-approve all permissions)
|
||||
describe('OpenCode batch mode configuration', () => {
|
||||
it('should use batchModePrefix with run subcommand for batch mode (YOLO mode)', async () => {
|
||||
// OpenCode uses 'run' subcommand for batch mode which auto-approves all permissions
|
||||
// The -p flag is for TUI mode only and doesn't work with --format json
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (args[0] === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
@@ -1131,19 +1131,14 @@ describe('agent-detector', () => {
|
||||
|
||||
expect(opencode).toBeDefined();
|
||||
|
||||
// CRITICAL: OpenCode must NOT use batchModePrefix with 'run' - it doesn't auto-approve permissions
|
||||
expect(opencode?.batchModePrefix).toBeUndefined();
|
||||
// OpenCode uses batchModePrefix: ['run'] for batch mode
|
||||
expect(opencode?.batchModePrefix).toEqual(['run']);
|
||||
|
||||
// CRITICAL: OpenCode MUST use promptArgs with -p flag for YOLO mode
|
||||
expect(opencode?.promptArgs).toBeDefined();
|
||||
expect(typeof opencode?.promptArgs).toBe('function');
|
||||
|
||||
// Verify promptArgs generates correct -p flag
|
||||
const promptArgsResult = opencode?.promptArgs?.('test prompt');
|
||||
expect(promptArgsResult).toEqual(['-p', 'test prompt']);
|
||||
// promptArgs should NOT be defined - prompt is passed as positional arg
|
||||
expect(opencode?.promptArgs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT have noPromptSeparator since promptArgs handles prompt formatting', async () => {
|
||||
it('should have noPromptSeparator true since prompt is positional arg', async () => {
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (args[0] === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
@@ -1154,9 +1149,9 @@ describe('agent-detector', () => {
|
||||
const agents = await detector.detectAgents();
|
||||
const opencode = agents.find(a => a.id === 'opencode');
|
||||
|
||||
// When using promptArgs, noPromptSeparator should be undefined or not needed
|
||||
// since the prompt is passed via the -p flag, not as a positional argument
|
||||
expect(opencode?.noPromptSeparator).toBeUndefined();
|
||||
// OpenCode uses noPromptSeparator: true since prompt is positional
|
||||
// (yargs handles positional args without needing '--' separator)
|
||||
expect(opencode?.noPromptSeparator).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct jsonOutputArgs for JSON streaming', async () => {
|
||||
|
||||
@@ -1638,14 +1638,14 @@ describe('Lightbox Functionality', () => {
|
||||
});
|
||||
|
||||
// Verify copy button is present
|
||||
const copyButton = screen.getByTitle('Copy image to clipboard');
|
||||
const copyButton = screen.getByTitle('Copy image to clipboard (⌘C)');
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
|
||||
// Click it - the actual clipboard copy may fail but we're testing the button renders/clicks
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// The button should still be there
|
||||
expect(screen.getByTitle('Copy image to clipboard')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Copy image to clipboard (⌘C)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes lightbox when clicking overlay background', async () => {
|
||||
|
||||
@@ -120,17 +120,17 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
|
||||
name: 'OpenCode',
|
||||
binaryName: 'opencode',
|
||||
command: 'opencode',
|
||||
args: [], // Base args (none for OpenCode)
|
||||
args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand)
|
||||
// OpenCode CLI argument builders
|
||||
// Batch mode uses -p flag: opencode -p "prompt" --format json [--model provider/model] [--session <id>] [--agent plan]
|
||||
// The -p flag runs in non-interactive mode and auto-approves all permissions (YOLO mode).
|
||||
// Note: The 'run' subcommand does NOT auto-approve - only -p does.
|
||||
// Batch mode: opencode run --format json [--model provider/model] [--session <id>] [--agent plan] -- "prompt"
|
||||
// The 'run' subcommand auto-approves all permissions (YOLO mode is implicit).
|
||||
batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode
|
||||
jsonOutputArgs: ['--format', 'json'], // JSON output format
|
||||
resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID
|
||||
readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode
|
||||
modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b')
|
||||
promptArgs: (prompt: string) => ['-p', prompt], // -p flag enables non-interactive mode with auto-approve (YOLO mode)
|
||||
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode -p "prompt" -f /path/to/image.png
|
||||
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt"
|
||||
noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args
|
||||
// Agent-specific configuration options shown in UI
|
||||
configOptions: [
|
||||
{
|
||||
|
||||
@@ -4070,6 +4070,85 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
}));
|
||||
}, [defaultSaveToHistory, defaultShowThinking]);
|
||||
|
||||
/**
|
||||
* Close all tabs in the active session.
|
||||
* Creates a fresh new tab after closing all existing ones.
|
||||
*/
|
||||
const handleCloseAllTabs = useCallback(() => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionIdRef.current) return s;
|
||||
// Close all tabs by iterating through them
|
||||
let updatedSession = s;
|
||||
const tabIds = s.aiTabs.map(t => t.id);
|
||||
for (const tabId of tabIds) {
|
||||
const tab = updatedSession.aiTabs.find(t => t.id === tabId);
|
||||
const result = closeTab(updatedSession, tabId, false, { skipHistory: tab ? hasActiveWizard(tab) : false });
|
||||
if (result) {
|
||||
updatedSession = result.session;
|
||||
}
|
||||
}
|
||||
return updatedSession;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close all tabs except the active tab.
|
||||
*/
|
||||
const handleCloseOtherTabs = useCallback(() => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionIdRef.current) return s;
|
||||
let updatedSession = s;
|
||||
const tabsToClose = s.aiTabs.filter(t => t.id !== s.activeTabId);
|
||||
for (const tab of tabsToClose) {
|
||||
const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) });
|
||||
if (result) {
|
||||
updatedSession = result.session;
|
||||
}
|
||||
}
|
||||
return updatedSession;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close all tabs to the left of the active tab.
|
||||
*/
|
||||
const handleCloseTabsLeft = useCallback(() => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionIdRef.current) return s;
|
||||
const activeIndex = s.aiTabs.findIndex(t => t.id === s.activeTabId);
|
||||
if (activeIndex <= 0) return s; // Nothing to close
|
||||
let updatedSession = s;
|
||||
const tabsToClose = s.aiTabs.slice(0, activeIndex);
|
||||
for (const tab of tabsToClose) {
|
||||
const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) });
|
||||
if (result) {
|
||||
updatedSession = result.session;
|
||||
}
|
||||
}
|
||||
return updatedSession;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close all tabs to the right of the active tab.
|
||||
*/
|
||||
const handleCloseTabsRight = useCallback(() => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionIdRef.current) return s;
|
||||
const activeIndex = s.aiTabs.findIndex(t => t.id === s.activeTabId);
|
||||
if (activeIndex < 0 || activeIndex >= s.aiTabs.length - 1) return s; // Nothing to close
|
||||
let updatedSession = s;
|
||||
const tabsToClose = s.aiTabs.slice(activeIndex + 1);
|
||||
for (const tab of tabsToClose) {
|
||||
const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) });
|
||||
if (result) {
|
||||
updatedSession = result.session;
|
||||
}
|
||||
}
|
||||
return updatedSession;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveQueuedItem = useCallback((itemId: string) => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionIdRef.current) return s;
|
||||
@@ -9068,7 +9147,10 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
setEditAgentSession, setEditAgentModalOpen,
|
||||
|
||||
// Auto Run state for keyboard handler
|
||||
activeBatchRunState
|
||||
activeBatchRunState,
|
||||
|
||||
// Bulk tab close handlers
|
||||
handleCloseAllTabs, handleCloseOtherTabs, handleCloseTabsLeft, handleCloseTabsRight
|
||||
|
||||
};
|
||||
|
||||
@@ -9461,6 +9543,10 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
onQuickActionsToggleReadOnlyMode={handleQuickActionsToggleReadOnlyMode}
|
||||
onQuickActionsToggleTabShowThinking={handleQuickActionsToggleTabShowThinking}
|
||||
onQuickActionsOpenTabSwitcher={handleQuickActionsOpenTabSwitcher}
|
||||
onCloseAllTabs={handleCloseAllTabs}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCloseTabsLeft={handleCloseTabsLeft}
|
||||
onCloseTabsRight={handleCloseTabsRight}
|
||||
setPlaygroundOpen={setPlaygroundOpen}
|
||||
onQuickActionsRefreshGitFileState={handleQuickActionsRefreshGitFileState}
|
||||
onQuickActionsDebugReleaseQueuedItem={handleQuickActionsDebugReleaseQueuedItem}
|
||||
@@ -10279,6 +10365,10 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
onToggleUnreadFilter={toggleUnreadFilter}
|
||||
onOpenTabSearch={() => setTabSwitcherOpen(true)}
|
||||
onCloseAllTabs={handleCloseAllTabs}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCloseTabsLeft={handleCloseTabsLeft}
|
||||
onCloseTabsRight={handleCloseTabsRight}
|
||||
onToggleTabSaveToHistory={() => {
|
||||
if (!activeSession) return;
|
||||
const activeTab = getActiveTab(activeSession);
|
||||
|
||||
@@ -757,6 +757,11 @@ export interface AppUtilityModalsProps {
|
||||
onToggleReadOnlyMode: () => void;
|
||||
onToggleTabShowThinking: () => void;
|
||||
onOpenTabSwitcher: () => void;
|
||||
// Bulk tab close operations
|
||||
onCloseAllTabs?: () => void;
|
||||
onCloseOtherTabs?: () => void;
|
||||
onCloseTabsLeft?: () => void;
|
||||
onCloseTabsRight?: () => void;
|
||||
setPlaygroundOpen?: (open: boolean) => void;
|
||||
onRefreshGitFileState: () => Promise<void>;
|
||||
onDebugReleaseQueuedItem: () => void;
|
||||
@@ -932,6 +937,11 @@ export function AppUtilityModals({
|
||||
onToggleReadOnlyMode,
|
||||
onToggleTabShowThinking,
|
||||
onOpenTabSwitcher,
|
||||
// Bulk tab close operations
|
||||
onCloseAllTabs,
|
||||
onCloseOtherTabs,
|
||||
onCloseTabsLeft,
|
||||
onCloseTabsRight,
|
||||
setPlaygroundOpen,
|
||||
onRefreshGitFileState,
|
||||
onDebugReleaseQueuedItem,
|
||||
@@ -1081,6 +1091,10 @@ export function AppUtilityModals({
|
||||
onToggleReadOnlyMode={onToggleReadOnlyMode}
|
||||
onToggleTabShowThinking={onToggleTabShowThinking}
|
||||
onOpenTabSwitcher={onOpenTabSwitcher}
|
||||
onCloseAllTabs={onCloseAllTabs}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCloseTabsLeft={onCloseTabsLeft}
|
||||
onCloseTabsRight={onCloseTabsRight}
|
||||
setPlaygroundOpen={setPlaygroundOpen}
|
||||
onRefreshGitFileState={onRefreshGitFileState}
|
||||
onDebugReleaseQueuedItem={onDebugReleaseQueuedItem}
|
||||
@@ -1788,6 +1802,11 @@ export interface AppModalsProps {
|
||||
onQuickActionsToggleReadOnlyMode: () => void;
|
||||
onQuickActionsToggleTabShowThinking: () => void;
|
||||
onQuickActionsOpenTabSwitcher: () => void;
|
||||
// Bulk tab close operations (for QuickActionsModal)
|
||||
onCloseAllTabs?: () => void;
|
||||
onCloseOtherTabs?: () => void;
|
||||
onCloseTabsLeft?: () => void;
|
||||
onCloseTabsRight?: () => void;
|
||||
setPlaygroundOpen?: (open: boolean) => void;
|
||||
onQuickActionsRefreshGitFileState: () => Promise<void>;
|
||||
onQuickActionsDebugReleaseQueuedItem: () => void;
|
||||
@@ -2075,6 +2094,11 @@ export function AppModals(props: AppModalsProps) {
|
||||
onQuickActionsToggleReadOnlyMode,
|
||||
onQuickActionsToggleTabShowThinking,
|
||||
onQuickActionsOpenTabSwitcher,
|
||||
// Bulk tab close operations
|
||||
onCloseAllTabs,
|
||||
onCloseOtherTabs,
|
||||
onCloseTabsLeft,
|
||||
onCloseTabsRight,
|
||||
setPlaygroundOpen,
|
||||
onQuickActionsRefreshGitFileState,
|
||||
onQuickActionsDebugReleaseQueuedItem,
|
||||
@@ -2373,6 +2397,10 @@ export function AppModals(props: AppModalsProps) {
|
||||
onToggleReadOnlyMode={onQuickActionsToggleReadOnlyMode}
|
||||
onToggleTabShowThinking={onQuickActionsToggleTabShowThinking}
|
||||
onOpenTabSwitcher={onQuickActionsOpenTabSwitcher}
|
||||
onCloseAllTabs={onCloseAllTabs}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCloseTabsLeft={onCloseTabsLeft}
|
||||
onCloseTabsRight={onCloseTabsRight}
|
||||
setPlaygroundOpen={setPlaygroundOpen}
|
||||
onRefreshGitFileState={onQuickActionsRefreshGitFileState}
|
||||
onDebugReleaseQueuedItem={onQuickActionsDebugReleaseQueuedItem}
|
||||
|
||||
@@ -155,6 +155,11 @@ interface MainPanelProps {
|
||||
showUnreadOnly?: boolean;
|
||||
onToggleUnreadFilter?: () => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
// Bulk tab close operations
|
||||
onCloseAllTabs?: () => void;
|
||||
onCloseOtherTabs?: () => void;
|
||||
onCloseTabsLeft?: () => void;
|
||||
onCloseTabsRight?: () => void;
|
||||
// Scroll position persistence
|
||||
onScrollPositionChange?: (scrollTop: number) => void;
|
||||
// Scroll bottom state change handler (for hasUnread logic)
|
||||
@@ -321,7 +326,7 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
const [configuredContextWindow, setConfiguredContextWindow] = useState(0);
|
||||
|
||||
// Extract tab handlers from props
|
||||
const { onTabSelect, onTabClose, onNewTab, onRequestTabRename, onTabReorder, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch } = props;
|
||||
const { onTabSelect, onTabClose, onNewTab, onRequestTabRename, onTabReorder, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch, onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight } = props;
|
||||
|
||||
// Get the active tab for header display
|
||||
// The header should show the active tab's data (UUID, name, cost, context), not session-level data
|
||||
@@ -1008,6 +1013,10 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
onToggleUnreadFilter={onToggleUnreadFilter}
|
||||
onOpenTabSearch={onOpenTabSearch}
|
||||
onCloseAllTabs={onCloseAllTabs}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCloseTabsLeft={onCloseTabsLeft}
|
||||
onCloseTabsRight={onCloseTabsRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ interface TabBarProps {
|
||||
showUnreadOnly?: boolean;
|
||||
onToggleUnreadFilter?: () => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
/** Handler to close all tabs */
|
||||
onCloseAllTabs?: () => void;
|
||||
/** Handler to close all tabs except active */
|
||||
onCloseOtherTabs?: () => void;
|
||||
/** Handler to close tabs to the left of active tab */
|
||||
onCloseTabsLeft?: () => void;
|
||||
/** Handler to close tabs to the right of active tab */
|
||||
onCloseTabsRight?: () => void;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
@@ -74,6 +82,18 @@ interface TabProps {
|
||||
shortcutHint?: number | null;
|
||||
registerRef?: (el: HTMLDivElement | null) => void;
|
||||
hasDraft?: boolean;
|
||||
/** Handler to close all tabs */
|
||||
onCloseAllTabs?: () => void;
|
||||
/** Handler to close other tabs (all except this one) */
|
||||
onCloseOtherTabs?: () => void;
|
||||
/** Handler to close tabs to the left of this tab */
|
||||
onCloseTabsLeft?: () => void;
|
||||
/** Handler to close tabs to the right of this tab */
|
||||
onCloseTabsRight?: () => void;
|
||||
/** Total number of tabs */
|
||||
totalTabs?: number;
|
||||
/** Tab index in the full list (0-based) */
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +169,13 @@ function Tab({
|
||||
isLastTab,
|
||||
shortcutHint,
|
||||
registerRef,
|
||||
hasDraft
|
||||
hasDraft,
|
||||
onCloseAllTabs,
|
||||
onCloseOtherTabs,
|
||||
onCloseTabsLeft,
|
||||
onCloseTabsRight,
|
||||
totalTabs,
|
||||
tabIndex
|
||||
}: TabProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
@@ -290,6 +316,30 @@ function Tab({
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseTabClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseOtherTabsClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onCloseOtherTabs?.();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseTabsLeftClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onCloseTabsLeft?.();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseTabsRightClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onCloseTabsRight?.();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const displayName = getTabDisplayName(tab);
|
||||
|
||||
// Browser-style tab: all tabs have borders, active tab "connects" to content
|
||||
@@ -626,6 +676,67 @@ function Tab({
|
||||
Move to Last Position
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tab Close Actions Section - divider and close options */}
|
||||
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
|
||||
|
||||
{/* Close Tab */}
|
||||
<button
|
||||
onClick={handleCloseTabClick}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={totalTabs === 1}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Tab
|
||||
</button>
|
||||
|
||||
{/* Close Other Tabs */}
|
||||
{onCloseOtherTabs && (
|
||||
<button
|
||||
onClick={handleCloseOtherTabsClick}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={totalTabs === 1}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Other Tabs
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close Tabs to Left */}
|
||||
{onCloseTabsLeft && (
|
||||
<button
|
||||
onClick={handleCloseTabsLeftClick}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
tabIndex === 0 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={tabIndex === 0}
|
||||
>
|
||||
<ChevronsLeft className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Tabs to Left
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close Tabs to Right */}
|
||||
{onCloseTabsRight && (
|
||||
<button
|
||||
onClick={handleCloseTabsRightClick}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
tabIndex === (totalTabs ?? 1) - 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={tabIndex === (totalTabs ?? 1) - 1}
|
||||
>
|
||||
<ChevronsRight className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Tabs to Right
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -660,7 +771,11 @@ function TabBarInner({
|
||||
ghCliAvailable,
|
||||
showUnreadOnly: showUnreadOnlyProp,
|
||||
onToggleUnreadFilter,
|
||||
onOpenTabSearch
|
||||
onOpenTabSearch,
|
||||
onCloseAllTabs,
|
||||
onCloseOtherTabs,
|
||||
onCloseTabsLeft,
|
||||
onCloseTabsRight
|
||||
}: TabBarProps) {
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
|
||||
@@ -891,6 +1006,12 @@ function TabBarInner({
|
||||
tabRefs.current.delete(tab.id);
|
||||
}
|
||||
}}
|
||||
onCloseAllTabs={onCloseAllTabs}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCloseTabsLeft={onCloseTabsLeft}
|
||||
onCloseTabsRight={onCloseTabsRight}
|
||||
totalTabs={tabs.length}
|
||||
tabIndex={originalIndex}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user