From 4da415dfd92533a47948af5ce49dfa149388bd29 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:42:54 -0700 Subject: [PATCH] fix: address review findings --- .../src/components/tab-bar/TabBar.tsx | 250 ++++++++++++------ .../components/tab-bar/TabBarCreateEntry.tsx | 112 ++++++-- .../tab-bar/tab-create-menu-options.test.ts | 72 +++++ .../tab-bar/tab-create-menu-options.ts | 198 ++++++++++++++ 4 files changed, 543 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-menu-options.ts diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 3635dde98b3..be3d5529f11 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -54,6 +54,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { TabCreateEntryArgs } from './tab-create-entry-action' import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' +import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' import { translate } from '@/i18n/i18n' const isWindows = navigator.userAgent.includes('Windows') @@ -304,6 +305,7 @@ function TabBarInner({ // clicks, so it misses webview clicks entirely. Listening for window blur // catches the moment focus leaves the renderer (including into a webview). const [newTabMenuOpen, setNewTabMenuOpen] = useState(false) + const [createMenuQuery, setCreateMenuQuery] = useState('') const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) const pendingNewTabMenuFocusAnimationRef = useRef(null) const pendingNewTabMenuFocusRetryRef = useRef(null) @@ -357,6 +359,109 @@ function TabBarInner({ const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) } + const windowsShellEntries = useMemo(() => { + if (!shouldShowWindowsShellMenu || !onNewTerminalWithShell) { + return undefined + } + const allShells: { + label: string + shell: BuiltInWindowsTerminalShell + }[] = [ + { + label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'), + shell: 'powershell.exe' + }, + { + label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'), + shell: 'cmd.exe' + }, + ...(windowsTerminalCapabilities.gitBashAvailable + ? ([ + { + label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'), + shell: WINDOWS_GIT_BASH_SHELL + } + ] as const) + : []), + ...(windowsTerminalCapabilities.wslAvailable + ? ([ + { + label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'), + shell: 'wsl.exe' + } + ] as const) + : []) + ] + const defaultEntry = + allShells.find((shell) => shell.shell === defaultWindowsShell) ?? allShells[0] + const orderedShells = [ + defaultEntry, + ...allShells.filter((shell) => shell.shell !== defaultEntry.shell) + ] + return orderedShells.map((entry) => ({ label: entry.label, shell: entry.shell })) + }, [ + defaultWindowsShell, + onNewTerminalWithShell, + shouldShowWindowsShellMenu, + windowsTerminalCapabilities.gitBashAvailable, + windowsTerminalCapabilities.wslAvailable + ]) + const createMenuOptions = useMemo( + () => + buildTabCreateMenuOptions({ + terminalOnly, + windowsShellEntries, + hasNewBrowser: !terminalOnly, + hasNewMarkdown: !terminalOnly && Boolean(onNewFileTab), + hasOpenMarkdown: !terminalOnly && Boolean(onOpenFileTab), + hasSimulator: + !terminalOnly && isMacOs && mobileEmulatorEnabled && Boolean(onNewSimulatorTab), + simulatorIsGoTo: workspaceHasSimulatorTab + }), + [ + mobileEmulatorEnabled, + onNewFileTab, + onNewSimulatorTab, + onOpenFileTab, + terminalOnly, + windowsShellEntries, + workspaceHasSimulatorTab + ] + ) + const handleSelectCreateMenuOption = (option: TabCreateMenuOption): void => { + switch (option.kind) { + case 'new-terminal': + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalTab() + break + case 'new-terminal-shell': + if (!onNewTerminalWithShell || !option.shell) { + break + } + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalWithShell( + resolveWindowsShellLaunchTarget( + option.shell, + defaultWindowsPowerShellImplementation, + windowsTerminalCapabilities.pwshAvailable + ) + ) + break + case 'new-browser': + onNewBrowserTab() + break + case 'new-markdown': + onNewFileTab?.() + break + case 'open-markdown': + onOpenFileTab?.() + break + case 'new-simulator': + case 'go-to-simulator': + onNewSimulatorTab?.() + break + } + } const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) const result = launchAgentInNewTab({ @@ -366,7 +471,13 @@ function TabBarInner({ launchSource: 'tab_bar_quick_launch' }) if (!result) { - toast.error(translate("auto.components.tab.bar.TabBar.ab589350e5", "Could not build launch command for {{value0}}.", { value0: option?.label ?? agent })) + toast.error( + translate( + 'auto.components.tab.bar.TabBar.ab589350e5', + 'Could not build launch command for {{value0}}.', + { value0: option?.label ?? agent } + ) + ) return } if (result.tabId) { @@ -406,68 +517,38 @@ function TabBarInner({ const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current const defaultTerminalMenuItems = - shouldShowWindowsShellMenu && onNewTerminalWithShell ? ( - // Why: previously the Windows path nested shell choices under a - // Radix submenu. In practice the submenu frequently failed to open - // on hover/click, and even when it worked the two-step expansion - // hid the fact that multiple shells were available. Inlining all - // shells as flat items — default pinned to the top with the - // Ctrl+T hint — matches the "no popouts, show all options at - // once" rec. Each entry uses a shell-specific icon (ShellIcon) - // so PowerShell / CMD / Git Bash / WSL are distinguishable at a glance. - // Labels use "CMD Prompt" instead of "Command Prompt" to keep - // each row narrow enough that the shortcut hint fits without - // wrapping. - (() => { - const allShells: { - label: string - shell: BuiltInWindowsTerminalShell - }[] = [ - { label: translate("auto.components.tab.bar.TabBar.2148f65e04", "PowerShell"), shell: 'powershell.exe' }, - { label: translate("auto.components.tab.bar.TabBar.1a8af49530", "CMD Prompt"), shell: 'cmd.exe' }, - ...(windowsTerminalCapabilities.gitBashAvailable - ? ([{ label: translate("auto.components.tab.bar.TabBar.efb33546ff", "Git Bash"), shell: WINDOWS_GIT_BASH_SHELL }] as const) - : []), - ...(windowsTerminalCapabilities.wslAvailable - ? ([{ label: translate("auto.components.tab.bar.TabBar.d1afac112b", "WSL"), shell: 'wsl.exe' }] as const) - : []) - ] - const defaultEntry = allShells.find((s) => s.shell === defaultWindowsShell) ?? allShells[0] - const orderedShells = [ - defaultEntry, - ...allShells.filter((s) => s.shell !== defaultEntry.shell) - ] - return orderedShells.map((entry, idx) => { - const isDefault = idx === 0 - return ( - { - // Why: the top-level Windows shell menu models shell - // categories, not concrete executables. When the user - // picked PowerShell 7+ in advanced settings, launching the - // "PowerShell" menu item must preserve that implementation - // instead of forcing inbox powershell.exe. - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalWithShell( - resolveWindowsShellLaunchTarget( - entry.shell, - defaultWindowsPowerShellImplementation, - windowsTerminalCapabilities.pwshAvailable - ) + windowsShellEntries && onNewTerminalWithShell ? ( + windowsShellEntries.map((entry, idx) => { + const isDefault = idx === 0 + return ( + { + // Why: the top-level Windows shell menu models shell + // categories, not concrete executables. When the user + // picked PowerShell 7+ in advanced settings, launching the + // "PowerShell" menu item must preserve that implementation + // instead of forcing inbox powershell.exe. + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalWithShell( + resolveWindowsShellLaunchTarget( + entry.shell, + defaultWindowsPowerShellImplementation, + windowsTerminalCapabilities.pwshAvailable ) - }} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - > - - {translate("auto.components.tab.bar.TabBar.7c1313d237", "New Terminal:")} {entry.label} - {isDefault ? ( - {newTerminalShortcut} - ) : null} - - ) - }) - })() + ) + }} + className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + > + + + {translate('auto.components.tab.bar.TabBar.7c1313d237', 'New Terminal:')}{' '} + {entry.label} + + {isDefault ? {newTerminalShortcut} : null} + + ) + }) ) : ( { @@ -477,7 +558,8 @@ function TabBarInner({ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - {translate("auto.components.tab.bar.TabBar.d364f3c8d4", "New Terminal")}{newTerminalShortcut} + {translate('auto.components.tab.bar.TabBar.d364f3c8d4', 'New Terminal')} + {newTerminalShortcut} ) const newBrowserMenuItem = !terminalOnly ? ( @@ -486,7 +568,8 @@ function TabBarInner({ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - {translate("auto.components.tab.bar.TabBar.4833fb2cbe", "New Browser Tab")}{newBrowserShortcut} + {translate('auto.components.tab.bar.TabBar.4833fb2cbe', 'New Browser Tab')} + {newBrowserShortcut} ) : null const newSimulatorMenuItem = @@ -499,11 +582,16 @@ function TabBarInner({ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - {translate("auto.components.tab.bar.TabBar.b426bb2615", "Go to Mobile Emulator")}{newSimulatorShortcut} + {translate('auto.components.tab.bar.TabBar.b426bb2615', 'Go to Mobile Emulator')} + {newSimulatorShortcut} - {translate("auto.components.tab.bar.TabBar.aea43b5748", "Open the existing emulator tab.")} + {translate( + 'auto.components.tab.bar.TabBar.aea43b5748', + 'Open the existing emulator tab.' + )} + ) : ( - {translate("auto.components.tab.bar.TabBar.fd2b42aaa3", "New Mobile Emulator")}{newSimulatorShortcut} + {translate('auto.components.tab.bar.TabBar.fd2b42aaa3', 'New Mobile Emulator')} + {newSimulatorShortcut} ) ) : null @@ -522,7 +611,8 @@ function TabBarInner({ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - {translate("auto.components.tab.bar.TabBar.3d5d6c960d", "New Markdown")}{newFileShortcut} + {translate('auto.components.tab.bar.TabBar.3d5d6c960d', 'New Markdown')} + {newFileShortcut} ) : null const openMarkdownMenuItem = @@ -532,7 +622,8 @@ function TabBarInner({ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - {translate("auto.components.tab.bar.TabBar.4f327c8b3d", "Open Markdown...")} + {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + ) : null const standardCreateMenuItems = newTabMenuOrder === 'markdown-first' ? ( @@ -562,6 +653,14 @@ function TabBarInner({ return () => window.removeEventListener('blur', dismiss) }, [newTabMenuOpen]) + useEffect(() => { + if (!newTabMenuOpen) { + setCreateMenuQuery('') + } + }, [newTabMenuOpen]) + + const showStaticCreateMenuItems = createMenuQuery.trim().length === 0 + const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) const editorMap = useMemo( () => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])), @@ -952,12 +1051,12 @@ function TabBarInner({ @@ -980,6 +1079,7 @@ function TabBarInner({ worktreeId={worktreeId} groupId={resolvedGroupId} menuOpen={newTabMenuOpen} + menuOptions={createMenuOptions} agentOptions={agentLaunchOptions} onLaunchAgent={launchAgentFromNewTabEntry} onOpenDefaultTerminal={() => { @@ -987,13 +1087,15 @@ function TabBarInner({ onNewTerminalTab() }} onOpenEntry={onOpenEntry} + onQueryChange={setCreateMenuQuery} + onSelectMenuOption={handleSelectCreateMenuOption} onDidOpenEntry={() => setNewTabMenuOpen(false)} /> - + {showStaticCreateMenuItems ? : null} ) : null} - {standardCreateMenuItems} - {showAgentLaunchItems ? ( + {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> void onLaunchAgent?: (agent: TuiAgent) => void onOpenDefaultTerminal?: () => void onOpenEntry?: (args: TabCreateEntryArgs) => Promise + onQueryChange?: (query: string) => void + onSelectMenuOption?: (option: TabCreateMenuOption) => void worktreeId: string } @@ -34,10 +42,13 @@ export default function TabBarCreateEntry({ agentOptions = EMPTY_AGENT_OPTIONS, groupId, menuOpen, + menuOptions = EMPTY_MENU_OPTIONS, onDidOpenEntry, onLaunchAgent, onOpenDefaultTerminal, onOpenEntry, + onQueryChange, + onSelectMenuOption, worktreeId }: TabBarCreateEntryProps): React.JSX.Element { const [query, setQuery] = useState('') @@ -57,12 +68,27 @@ export default function TabBarCreateEntry({ return () => cancelAnimationFrame(focusFrame) }, [menuOpen]) - const options = useMemo(() => getTabEntryOptions(query, fileList), [fileList, query]) + const matchingMenuOptions = useMemo( + () => findMatchingTabCreateMenuOptions(query, menuOptions), + [menuOptions, query] + ) + const options = useMemo(() => { + const entryOptions = getTabEntryOptions(query, fileList) + if (matchingMenuOptions.length === 0) { + return entryOptions + } + // Why: a matched create-menu action should win over a generic new-file fallback. + return entryOptions.filter((option) => option.classification.kind !== 'new-file') + }, [fileList, matchingMenuOptions.length, query]) const matchingAgentOptions = useMemo( () => findMatchingTabAgentLaunchOptions(query, agentOptions), [agentOptions, query] ) + useEffect(() => { + onQueryChange?.(query) + }, [onQueryChange, query]) + if (selectedIndexQuery !== query) { setSelectedIndexQuery(query) if (selectedIndex !== 0) { @@ -84,6 +110,10 @@ export default function TabBarCreateEntry({ const disabled = !onOpenEntry const hasQuery = query.trim().length > 0 const activeOptions: ActiveOption[] = [ + ...matchingMenuOptions.map((option) => ({ + kind: 'menu' as const, + option + })), ...matchingAgentOptions.map((option) => ({ kind: 'agent' as const, option @@ -117,6 +147,11 @@ export default function TabBarCreateEntry({ setError(statusMessage) return } + if (selectedOption.kind === 'menu') { + onSelectMenuOption?.(selectedOption.option) + onDidOpenEntry?.() + return + } if (selectedOption.kind === 'agent') { onLaunchAgent?.(selectedOption.option.agent) onDidOpenEntry?.() @@ -165,7 +200,7 @@ export default function TabBarCreateEntry({ }} onPointerDown={(event) => event.stopPropagation()} > -
+
{error || activeOptions.length > 0 || hasQuery ? ( @@ -215,13 +256,23 @@ type ActiveOption = kind: 'entry' option: ActiveEntryOption } + | { + kind: 'menu' + option: TabCreateMenuOption + } function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption { return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked' } function getActiveOptionId(option: ActiveOption): string { - return option.kind === 'agent' ? `agent:${option.option.agent}` : option.option.id + if (option.kind === 'agent') { + return `agent:${option.option.agent}` + } + if (option.kind === 'menu') { + return `menu:${option.option.id}` + } + return option.option.id } function EntryStatusRow({ @@ -262,11 +313,17 @@ function EntryActionRow({ onClick={onClick} > {presentation.icon} - {presentation.label} -