From 376c41f87933e0cbd76878c5f1783daa0e25eb36 Mon Sep 17 00:00:00 2001 From: ArrogHie Date: Thu, 30 Jul 2026 11:12:31 +0800 Subject: [PATCH] feat: add branch switcher popover to chat input workspace strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add clickable branch chip in ChatInputWorkspaceStrip that opens a BranchQuickSwitch popover for quick branch switching and creation. BranchQuickSwitch enhancements: - Add onCreateSuccess prop and branch creation capability - Show 'Create branch' option when search term does not match existing branches (calls gitService.createBranch, which runs git checkout -b) - Add isCreating/creatingBranch state with loading spinner - Update keyboard navigation: Enter triggers creation when search term is new, otherwise switches to selected branch - Add --create item variant with accent color styling ChatInputWorkspaceStrip integration: - Branch chip becomes clickable (role=button, onClick, Enter/Space keys) when repository is available - Render BranchQuickSwitch through createPortal to document.body to avoid disrupting the absolutely-positioned strip layout - Add --branch-clickable modifier with pointer cursor and hover styles - Update branch tooltip to 'Click to switch or create branch' i18n (en-US/zh-CN/zh-TW): - quickSwitch.searchPlaceholder: 'Search or create branch...' - quickSwitch.createBranchLabel: 'Create branch' / '创建分支' / '建立分支' - quickSwitch.errors.createFailed: 'Failed to create branch' / ... - workspaceStrip.branchTooltip: 'Click to switch or create branch' / ... Tests: - Mock BranchQuickSwitch, add click interaction test - Add non-repo guard test (chip not clickable when no repository) - Query document instead of container for portaled element --- .pnpm-store/v11/index.db | Bin 0 -> 8192 bytes .../components/BranchQuickSwitch.scss | 26 +++- .../NavPanel/components/BranchQuickSwitch.tsx | 143 ++++++++++++++---- .../components/ChatInputWorkspaceStrip.scss | 15 ++ .../ChatInputWorkspaceStrip.test.tsx | 57 +++++++ .../components/ChatInputWorkspaceStrip.tsx | 50 +++++- src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/en-US/panels/git.json | 6 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/panels/git.json | 6 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/panels/git.json | 6 +- 12 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 .pnpm-store/v11/index.db diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000000000000000000000000000000000..d583c087b2ea7237a82fd6abbd7a82897e4249b9 GIT binary patch literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;Y;~cx0O=t zfB*y_009U<00Izz00bZafgB3lKCO>xt!CY>pipIV>wEYDQ#G?7q-|A44BRz*ko}y78W!h}e%vF6aP~>|v kx0l=(WA#c7>G359KmY;|fB*y_009U<00Izz00dHje+G3k*8l(j literal 0 HcmV?d00001 diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss index 0440886e9..ddefbc677 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss @@ -103,10 +103,34 @@ opacity: 0.7; pointer-events: none; } + + &--create { + border-bottom: 1px solid var(--border-subtle); + + .branch-quick-switch__item-icon--create { + color: var(--color-accent-500); + } + + .branch-quick-switch__item-name strong { + color: var(--color-accent-500); + font-weight: 600; + } + + &:hover, + &.branch-quick-switch__item--selected { + background: color-mix(in srgb, var(--color-accent-500) 8%, transparent); + } + } + + &:active:not(.branch-quick-switch__item--current) { + transform: scale(0.985); + } } .branch-quick-switch__item-icon { flex-shrink: 0; color: var(--color-text-primary); } -.branch-quick-switch__item-name { flex: 1; font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.branch-quick-switch__item-name { flex: 1; font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + strong { font-weight: 500; } +} .branch-quick-switch__item-check { flex-shrink: 0; color: var(--color-text-primary); } .branch-quick-switch__spinner { diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx index 94e53d864..1a135271f 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx @@ -1,11 +1,12 @@ /** * Branch quick switch overlay. - * Shown when clicking the branch badge in NavPanel Git item. - * Supports search and checkout. + * Shown when clicking the branch badge in NavPanel Git item + * or the branch chip in ChatInputWorkspaceStrip. + * Supports search, checkout, and creating new branches. */ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { GitBranch, Check, Loader2 } from 'lucide-react'; +import { GitBranch, Check, Loader2, Plus } from 'lucide-react'; import { type GitBranch as GitBranchType } from '../../../../infrastructure/api/service-api/GitAPI'; import { useI18n } from '@/infrastructure/i18n'; import { gitService, gitEventService } from '../../../../tools/git/services'; @@ -23,6 +24,7 @@ export interface BranchQuickSwitchProps { currentBranch: string; anchorRef: React.RefObject; onSwitchSuccess?: (branchName: string) => void; + onCreateSuccess?: (branchName: string) => void; } export const BranchQuickSwitch: React.FC = ({ @@ -31,7 +33,8 @@ export const BranchQuickSwitch: React.FC = ({ repositoryPath, currentBranch, anchorRef, - onSwitchSuccess + onSwitchSuccess, + onCreateSuccess }) => { const { t } = useI18n('panels/git'); const [branches, setBranches] = useState([]); @@ -39,6 +42,8 @@ export const BranchQuickSwitch: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [switchingBranch, setSwitchingBranch] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [creatingBranch, setCreatingBranch] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -157,10 +162,19 @@ export const BranchQuickSwitch: React.FC = ({ }); }, [branches, searchTerm]); - useEffect(() => { setSelectedIndex(0); }, [filteredBranches.length]); + const canCreateNewBranch = useMemo(() => { + const trimmed = searchTerm.trim(); + if (!trimmed) return false; + return !branches.some(b => b.name.toLowerCase() === trimmed.toLowerCase()); + }, [branches, searchTerm]); + + const branchStartIndex = canCreateNewBranch ? 1 : 0; + const totalItems = filteredBranches.length + (canCreateNewBranch ? 1 : 0); + + useEffect(() => { setSelectedIndex(0); }, [filteredBranches.length, canCreateNewBranch]); const handleSwitchBranch = useCallback(async (branchName: string) => { - if (branchName === currentBranch || isSwitching) return; + if (branchName === currentBranch || isSwitching || isCreating) return; setIsSwitching(true); setSwitchingBranch(branchName); try { @@ -192,14 +206,49 @@ export const BranchQuickSwitch: React.FC = ({ setIsSwitching(false); setSwitchingBranch(null); } - }, [repositoryPath, currentBranch, isSwitching, onSwitchSuccess, onClose, t]); + }, [repositoryPath, currentBranch, isSwitching, isCreating, onSwitchSuccess, onClose, t]); + + const handleCreateBranch = useCallback(async (branchName: string) => { + if (isCreating || isSwitching) return; + const trimmed = branchName.trim(); + if (!trimmed) return; + setIsCreating(true); + setCreatingBranch(trimmed); + try { + const result = await gitService.createBranch(repositoryPath, trimmed, currentBranch || undefined); + if (result.success) { + notificationService.success( + t('quickSwitch.notifications.switchSuccess', { branch: trimmed }), + { duration: 3000 } + ); + gitEventService.emit('branch:changed', { + repositoryPath, + branch: { name: trimmed, current: true, remote: false, ahead: 0, behind: 0 }, + timestamp: new Date(), + }); + onCreateSuccess?.(trimmed); + onClose(); + } else { + const errorMessage = result.error + ? t('quickSwitch.errors.switchFailedWithMessage', { error: result.error }) + : t('quickSwitch.errors.createFailed'); + notificationService.error(errorMessage, { title: t('quickSwitch.errors.title'), duration: 5000 }); + } + } catch (error) { + log.error('Failed to create branch', error); + notificationService.error(t('quickSwitch.errors.unexpected'), { duration: 5000 }); + } finally { + setIsCreating(false); + setCreatingBranch(null); + } + }, [repositoryPath, currentBranch, isCreating, isSwitching, onCreateSuccess, onClose, t]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (filteredBranches.length === 0) return; + if (totalItems === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); - setSelectedIndex(prev => prev < filteredBranches.length - 1 ? prev + 1 : prev); + setSelectedIndex(prev => prev < totalItems - 1 ? prev + 1 : prev); break; case 'ArrowUp': e.preventDefault(); @@ -207,20 +256,25 @@ export const BranchQuickSwitch: React.FC = ({ break; case 'Enter': { e.preventDefault(); - const sel = filteredBranches[selectedIndex]; - if (sel && !sel.current) handleSwitchBranch(sel.name); + if (canCreateNewBranch && selectedIndex === 0) { + handleCreateBranch(searchTerm.trim()); + } else { + const branchIndex = selectedIndex - branchStartIndex; + const sel = filteredBranches[branchIndex]; + if (sel && !sel.current) handleSwitchBranch(sel.name); + } break; } } - }, [filteredBranches, selectedIndex, handleSwitchBranch]); + }, [totalItems, canCreateNewBranch, selectedIndex, branchStartIndex, searchTerm, filteredBranches, handleSwitchBranch, handleCreateBranch]); useEffect(() => { - if (listRef.current && filteredBranches.length > 0) { + if (listRef.current && totalItems > 0) { const items = listRef.current.querySelectorAll('.branch-quick-switch__item'); const selectedItem = items[selectedIndex] as HTMLElement; if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest' }); } - }, [selectedIndex, filteredBranches.length]); + }, [selectedIndex, totalItems]); if (!isOpen) return null; @@ -247,29 +301,52 @@ export const BranchQuickSwitch: React.FC = ({ {t('quickSwitch.loading')} - ) : filteredBranches.length === 0 ? ( + ) : totalItems === 0 ? (
{searchTerm ? t('empty.noMatchingBranches') : t('empty.noBranches')}
) : ( - filteredBranches.map((branch, index) => ( -
handleSwitchBranch(branch.name)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {branch.name} - {branch.current && } - {switchingBranch === branch.name && } -
- )) + <> + {canCreateNewBranch && ( +
handleCreateBranch(searchTerm.trim())} + onMouseEnter={() => setSelectedIndex(0)} + > + + + {t('quickSwitch.createBranchLabel')} {searchTerm.trim()} + + {creatingBranch === searchTerm.trim() && } +
+ )} + {filteredBranches.map((branch, index) => { + const itemIndex = index + branchStartIndex; + return ( +
handleSwitchBranch(branch.name)} + onMouseEnter={() => setSelectedIndex(itemIndex)} + > + + {branch.name} + {branch.current && } + {switchingBranch === branch.name && } +
+ ); + })} + )} diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 87ef2a8f9..0dd52e71a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -403,6 +403,21 @@ gap: 3px; } + &--branch-clickable { + cursor: pointer; + transition: background-color $motion-fast $easing-standard, color $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + color: var(--color-text-secondary); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 1px; + } + } + /* Checkbox-style chip: reads as a control without competing with the branch. */ &--worktree { align-items: center; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index bd37689c7..e8dcefd3a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -38,6 +38,11 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({ useGitState: mocks.useGitState, })); +vi.mock('@/app/components/NavPanel/components/BranchQuickSwitch', () => ({ + BranchQuickSwitch: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
: null, +})); + describe('ChatInputWorkspaceStrip git refresh behavior', () => { let container: HTMLDivElement; let root: Root; @@ -316,4 +321,56 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull(); }); + + it('opens branch switch panel when branch chip is clicked', async () => { + await act(async () => { + root.render( + + ); + }); + + const branchChip = container.querySelector('[data-testid="chat-input-branch-chip"]'); + expect(branchChip).not.toBeNull(); + expect(branchChip?.getAttribute('role')).toBe('button'); + expect(branchChip?.tabIndex).toBe(0); + expect(container.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull(); + + await act(async () => { + branchChip?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(document.querySelector('[data-testid="branch-quick-switch-mock"]')).not.toBeNull(); + + await act(async () => { + document + .querySelector('[data-testid="branch-quick-switch-mock"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(document.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull(); + }); + + it('does not expose branch click when repository is unavailable', async () => { + mocks.useGitState.mockReturnValue({ + currentBranch: '', + isRepository: false, + }); + + await act(async () => { + root.render( + + ); + }); + + const branchChip = container.querySelector('[data-testid="chat-input-branch-chip"]'); + expect(branchChip?.getAttribute('role')).toBeNull(); + expect(branchChip?.tabIndex).toBe(-1); + expect(container.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 74d295929..f50395483 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -3,6 +3,7 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Activity, @@ -21,6 +22,7 @@ import { Tooltip, IconButton } from '@/component-library'; import { useGitState } from '@/tools/git/hooks/useGitState'; import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; +import { BranchQuickSwitch } from '@/app/components/NavPanel/components/BranchQuickSwitch'; import './ChatInputWorkspaceStrip.scss'; export interface ChatInputWorkspaceStripProps { @@ -85,6 +87,8 @@ export const ChatInputWorkspaceStrip: React.FC = ( const { t: tWorktrees } = useI18n('worktrees'); const permissionRootRef = useRef(null); const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); + const branchChipRef = useRef(null); + const [branchPanelOpen, setBranchPanelOpen] = useState(false); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); @@ -160,12 +164,14 @@ export const ChatInputWorkspaceStrip: React.FC = ( }; }, [permissionMenuOpen]); + const isBranchClickable = isRepository && !!currentBranch?.trim(); + const branchTooltipContent = useMemo( () => - isRepository && currentBranch?.trim() - ? currentBranch.trim() + isBranchClickable + ? t('workspaceStrip.branchTooltip') : t('workspaceStrip.branchTooltipUnavailable'), - [currentBranch, isRepository, t], + [isBranchClickable, t], ); if (!label && !showRightActions) { @@ -238,7 +244,30 @@ export const ChatInputWorkspaceStrip: React.FC = ( {' / '} - + setBranchPanelOpen(true) : undefined} + onKeyDown={ + isBranchClickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setBranchPanelOpen(true); + } + } + : undefined + } + > = ( ) : null}
) : null} + + {isBranchClickable && branchPanelOpen && createPortal( + setBranchPanelOpen(false)} + repositoryPath={trimmedPath} + currentBranch={branchLabel} + anchorRef={branchChipRef as React.RefObject} + onSwitchSuccess={() => setBranchPanelOpen(false)} + onCreateSuccess={() => setBranchPanelOpen(false)} + />, + document.body + )} ); }; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 4efd58db2..20909ca5e 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -566,6 +566,7 @@ } }, "workspaceStrip": { + "branchTooltip": "Click to switch or create branch", "branchTooltipUnavailable": "Not a git repository or no current branch" }, "context": { diff --git a/src/web-ui/src/locales/en-US/panels/git.json b/src/web-ui/src/locales/en-US/panels/git.json index 0a6aa6721..5ba528de2 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -188,8 +188,9 @@ "current": "Current" }, "quickSwitch": { - "searchPlaceholder": "Search branches...", + "searchPlaceholder": "Search or create branch...", "loading": "Loading...", + "createBranchLabel": "Create branch", "notifications": { "switchSuccess": "Switched to branch {{branch}}" }, @@ -199,7 +200,8 @@ "switchFailedWithMessage": "Failed to switch branch: {{error}}", "localChanges": "Please commit or stash local changes first", "indexConflict": "Please resolve the current index conflicts first", - "unexpected": "Unexpected error while switching branches" + "unexpected": "Unexpected error while switching branches", + "createFailed": "Failed to create branch" } }, "branchSelect": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 40669ed69..de409006b 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -566,6 +566,7 @@ } }, "workspaceStrip": { + "branchTooltip": "点击切换或创建分支", "branchTooltipUnavailable": "非 Git 仓库或当前无分支" }, "context": { diff --git a/src/web-ui/src/locales/zh-CN/panels/git.json b/src/web-ui/src/locales/zh-CN/panels/git.json index 7d37a0a8b..b49f81c2e 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -188,8 +188,9 @@ "current": "当前" }, "quickSwitch": { - "searchPlaceholder": "搜索分支...", + "searchPlaceholder": "搜索或新建分支...", "loading": "加载中...", + "createBranchLabel": "创建分支", "notifications": { "switchSuccess": "已切换到分支 {{branch}}" }, @@ -199,7 +200,8 @@ "switchFailedWithMessage": "Failed to switch branch: {{error}}", "localChanges": "Please commit or stash local changes first", "indexConflict": "Please resolve the current index conflicts first", - "unexpected": "Unexpected error while switching branches" + "unexpected": "Unexpected error while switching branches", + "createFailed": "创建分支失败" } }, "branchSelect": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index c4da9e8ea..b2381cb4c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -566,6 +566,7 @@ } }, "workspaceStrip": { + "branchTooltip": "點擊切換或建立分支", "branchTooltipUnavailable": "非 Git 存放庫或目前無分支" }, "context": { diff --git a/src/web-ui/src/locales/zh-TW/panels/git.json b/src/web-ui/src/locales/zh-TW/panels/git.json index 08163ff2e..0a52fd55a 100644 --- a/src/web-ui/src/locales/zh-TW/panels/git.json +++ b/src/web-ui/src/locales/zh-TW/panels/git.json @@ -188,8 +188,9 @@ "current": "目前" }, "quickSwitch": { - "searchPlaceholder": "搜尋分支...", + "searchPlaceholder": "搜尋或建立分支...", "loading": "載入中...", + "createBranchLabel": "建立分支", "notifications": { "switchSuccess": "已切換到分支 {{branch}}" }, @@ -199,7 +200,8 @@ "switchFailedWithMessage": "Failed to switch branch: {{error}}", "localChanges": "Please commit or stash local changes first", "indexConflict": "Please resolve the current index conflicts first", - "unexpected": "Unexpected error while switching branches" + "unexpected": "Unexpected error while switching branches", + "createFailed": "建立分支失敗" } }, "branchSelect": {