diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000..d583c087b2 Binary files /dev/null and b/.pnpm-store/v11/index.db differ 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 9f03f5f7bf..a0d9083e44 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss @@ -106,13 +106,33 @@ 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 94e53d864c..1a135271f3 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 87ef2a8f97..0dd52e71a3 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 bd37689c73..e8dcefd3a3 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 9dfe5d0b05..1b5ac4097d 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 { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import './ChatInputWorkspaceStrip.scss'; @@ -96,6 +98,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(); @@ -172,12 +176,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) { @@ -250,7 +256,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 6e385b5b60..6db86d3fae 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -569,6 +569,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 0a6aa67219..5ba528de28 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 af94ab9f62..3e1577ec21 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -569,6 +569,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 7d37a0a8b6..b49f81c2e3 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 8275585599..d508fc72b6 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -569,6 +569,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 08163ff2e0..0a52fd55a8 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": {