diff --git a/src/mobile-web/src/components/ChatMarkdown.tsx b/src/mobile-web/src/components/ChatMarkdown.tsx index e06501a681..08229cff9e 100644 --- a/src/mobile-web/src/components/ChatMarkdown.tsx +++ b/src/mobile-web/src/components/ChatMarkdown.tsx @@ -269,9 +269,13 @@ interface FileCardProps { const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) => { const { t } = useI18n(); const [state, setState] = useState({ status: 'loading' }); + const [attempt, setAttempt] = useState(0); const onGetFileInfoRef = useRef(onGetFileInfo); onGetFileInfoRef.current = onGetFileInfo; + // The owning host resolves metadata on demand, so a failure is not final: a + // file can appear after the message that names it, and a target switch can + // fail the request. Keep the reason and make the lookup repeatable. useEffect(() => { let cancelled = false; onGetFileInfoRef.current(path) @@ -283,7 +287,12 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); }); return () => { cancelled = true; }; - }, [path]); + }, [path, attempt]); + + const handleRetry = useCallback(() => { + setState({ status: 'loading' }); + setAttempt(value => value + 1); + }, []); const handleClick = useCallback(async () => { if (state.status !== 'ready' && state.status !== 'done') return; @@ -311,10 +320,18 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) ); } if (state.status === 'error') { + // The reason used to live only in `title`, which no touch host shows. Keep + // the failure readable and repeatable instead of a dead, dimmed card. return ( - + - {t('chat.fileUnavailable')} + + {t('chat.fileUnavailable')} + {state.message} + + + {t('devices.retry')} + ); } diff --git a/src/mobile-web/src/styles/components/chat.scss b/src/mobile-web/src/styles/components/chat.scss index 732743717b..242e314388 100644 --- a/src/mobile-web/src/styles/components/chat.scss +++ b/src/mobile-web/src/styles/components/chat.scss @@ -358,6 +358,17 @@ white-space: pre-line; overflow-wrap: anywhere; animation: toastSlideIn 0.25s ease-out; + // This notice floats over the transcript, so the shared status surface (a 10% + // tint) would let the message behind it show through the text. Composite the + // tone tint onto an opaque raised surface instead. + --chat-toast-tint: var(--openbitfun-color-surface-subtle); + background-color: var(--openbitfun-color-surface-raised); + background-image: linear-gradient(var(--chat-toast-tint), var(--chat-toast-tint)); + box-shadow: var(--openbitfun-shadow-base); + + &[data-tone='info'] { --chat-toast-tint: var(--openbitfun-color-status-info-surface); } + &[data-tone='warning'] { --chat-toast-tint: var(--openbitfun-color-status-warning-surface); } + &[data-tone='danger'] { --chat-toast-tint: var(--openbitfun-color-status-danger-surface); } } @keyframes toastSlideIn { diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss index ac6c26abf4..5f20cb1b5d 100644 --- a/src/mobile-web/src/styles/components/markdown.scss +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -254,8 +254,15 @@ cursor: default; } + // A failed lookup is information, not decoration: dimming the card to 0.56 + // made the reason unreadable. Show the failure tone instead. .file-card[data-status='error'] { - opacity: 0.56; + border-color: var(--openbitfun-color-status-danger-border); + background: var(--openbitfun-color-status-danger-surface); + } + + .file-card[data-status='error'] .file-card__name { + color: var(--openbitfun-color-status-danger-content); } .file-card__icon { @@ -301,6 +308,24 @@ margin-block-start: 2px; } + .file-card__reason { + display: -webkit-box; + margin-block-start: 2px; + overflow: hidden; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-support-font-size); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .file-card__retry { + min-inline-size: 44px; + min-block-size: 44px; + flex: 0 0 auto; + color: var(--openbitfun-color-status-danger-content); + font-size: var(--openbitfun-type-support-font-size); + } + .file-card__placeholder { flex: 1; opacity: 0.72; diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index daf7f3fde6..4c336cbae0 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -322,6 +322,47 @@ test('mobile transcript keeps one user bubble and projects file cards outside ma assert.match(markdownStyles, /\.file-card\s*\{[\s\S]*?inline-size:\s*100%;/); }); +test('mobile file card failures stay readable and repeatable', async () => { + const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); + const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8'); + + // The host resolves metadata on demand, so the card must expose both the + // failure reason and a way to ask again instead of latching the first error. + assert.match(markdown, /const \[attempt, setAttempt\] = useState\(0\);/); + assert.match(markdown, /\}, \[path, attempt\]\);/); + assert.match(markdown, /const handleRetry = useCallback\(\(\) => \{[\s\S]*?setAttempt\(value => value \+ 1\);[\s\S]*?\}, \[\]\);/); + assert.match(markdown, /className="file-card__reason">\{state\.message\}/); + assert.match(markdown, /className="file-card__retry"[\s\S]*?t\('devices\.retry'\)/); + assert.doesNotMatch(markdown, /data-status="error" title=/); + + const errorCardStyles = markdownStyles.slice( + markdownStyles.indexOf(".file-card[data-status='error'] {"), + markdownStyles.indexOf('.file-card__icon {'), + ); + assert.match(errorCardStyles, /background:\s*var\(--openbitfun-color-status-danger-surface\);/); + assert.doesNotMatch(errorCardStyles, /opacity:/); + assert.match(markdownStyles, /\.file-card__reason\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/); +}); + +test('chat notices stay readable above the transcript they float over', async () => { + const chatStyles = await readFile(path.join(sourceDirectory, 'styles/components/chat.scss'), 'utf8'); + const feedback = await readFile(path.join(sourceDirectory, 'components/ChatFeedback.tsx'), 'utf8'); + + assert.match(feedback, /className="chat-page__toast"/); + const toastStyles = chatStyles.slice( + chatStyles.indexOf('.chat-page__toast {'), + chatStyles.indexOf('@keyframes toastSlideIn'), + ); + // The shared status surface is a 10% tint; a floating notice needs an opaque + // base so the transcript behind it cannot bleed through the message text. + assert.match(toastStyles, /background-color:\s*var\(--openbitfun-color-surface-raised\);/); + assert.match(toastStyles, /background-image:\s*linear-gradient\(var\(--chat-toast-tint\), var\(--chat-toast-tint\)\);/); + for (const tone of ['info', 'warning', 'danger']) { + assert.match(toastStyles, new RegExp(`&\\[data-tone='${tone}'\\]`)); + } + assert.doesNotMatch(toastStyles, /background:\s*var\(--openbitfun-color-status-(?:info|warning|danger)-surface\)/); +}); + test('mobile viewport follows the keyboard, restores insets and leaves pinch zoom alone', async () => { const source = await readFile(path.join(sourceDirectory, 'hooks/useMobileViewport.ts'), 'utf8'); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/MobileOutputArtifacts.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/MobileOutputArtifacts.test.tsx index 2ca47bbed3..d7012c83f6 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/MobileOutputArtifacts.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/MobileOutputArtifacts.test.tsx @@ -70,4 +70,24 @@ describe('mobile output artifact rendering', () => { expect(read).toHaveBeenLastCalledWith('preview.png', true); expect(container.querySelector('img')?.src).toBe('data:image/png;base64,YQ=='); }); + + it('explains a failed file card and re-reads it on retry', async () => { + const info = vi.fn() + .mockRejectedValueOnce(new Error('Cannot resolve output file: No such file or directory')) + .mockResolvedValue({ name: 'report.zip', size: 2048, mimeType: 'application/zip' }); + await act(async () => root.render( + + + , + )); + expect(container.querySelector('.file-card')?.getAttribute('data-status')).toBe('error'); + expect(container.textContent).toContain('chat.fileUnavailable'); + expect(container.textContent).toContain('Cannot resolve output file: No such file or directory'); + const retry = [...container.querySelectorAll('button')].find(button => button.textContent === 'devices.retry'); + expect(retry).toBeDefined(); + await act(async () => retry!.click()); + expect(info).toHaveBeenCalledTimes(2); + expect(container.querySelector('.file-card')?.getAttribute('data-status')).toBe('ready'); + expect(container.textContent).toContain('report.zip'); + }); }); diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts b/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts deleted file mode 100644 index 04c3811475..0000000000 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; -export const branchSelectModalAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'branch-select-modal', - parts: [ - { id: 'overlay' }, { id: 'root' }, { id: 'close' }, { id: 'header' }, - { id: 'content' }, { id: 'input' }, { id: 'error' }, { id: 'list' }, - { id: 'loading' }, { id: 'item' }, { id: 'itemName' }, { id: 'badge' }, - { id: 'empty' }, { id: 'footer' }, { id: 'options' }, - ], - states: [ - { id: 'selected', selector: { kind: 'self', suffix: '[data-openbitfun-state~="selected"]' } }, - { id: 'current', selector: { kind: 'self', suffix: '[data-openbitfun-state~="current"]' } }, - ], -}; diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.scss b/src/web-ui/src/app/components/panels/BranchSelectModal.scss deleted file mode 100644 index 69c30601d2..0000000000 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.scss +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Branch selection modal styles - * Simple and understated style - */ -@use '@/shared/styles/surface-recipes' as surfaces; - -// ==================== Overlay ==================== - -.branch-select-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: var(--openbitfun-layer-modal); - background: color-mix(in srgb, var(--openbitfun-color-content-on-light) 60%, transparent); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - opacity: 1; - transition: opacity 160ms cubic-bezier(0.23, 1, 0.32, 1); - - &[data-state='closed'] { - opacity: 0; - pointer-events: none; - transition-duration: 120ms; - } -} - -// ==================== Dialog Body ==================== - -.branch-select-dialog { - @include surfaces.dialog; - - position: relative; - display: flex; - flex-direction: column; - width: 380px; - max-width: calc(100vw - 48px); - max-height: min(520px, calc(100vh - 48px)); - overflow: hidden; - opacity: 1; - transform: translateY(0) scale(1); - transition: - opacity 160ms cubic-bezier(0.23, 1, 0.32, 1), - transform 160ms cubic-bezier(0.23, 1, 0.32, 1); -} - -.branch-select-overlay[data-state='closed'] .branch-select-dialog { - opacity: 0; - transform: translateY(4px) scale(0.985); - transition-duration: 120ms; -} - -@starting-style { - .branch-select-overlay { - opacity: 0; - } - - .branch-select-dialog { - opacity: 0; - transform: translateY(4px) scale(0.985); - } -} - -// ==================== Header Area ==================== - -.branch-select-dialog__header { - display: flex; - align-items: center; - gap: var(--openbitfun-space-3); - flex-shrink: 0; - padding-block: var(--openbitfun-space-2); - padding-inline: 20px var(--openbitfun-space-2); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); -} - -.branch-select-dialog__title { - flex: 1; - min-width: 0; - overflow-wrap: anywhere; - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - margin: 0; - color: var(--openbitfun-color-content-primary); - line-height: var(--openbitfun-type-modifier-leading-snug-line-height); -} - -// ==================== Content Area ==================== - -.branch-select-dialog__content { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 16px; - flex: 1; - min-height: 0; -} - -// ==================== Branch Name Input ==================== - -.branch-select-dialog__input-wrapper { - display: flex; - align-items: center; -} - -.branch-select-dialog__input { - width: 100%; -} - -// ==================== Error Message ==================== - -.branch-select-dialog__error { - padding: 8px 12px; - background: var(--openbitfun-color-status-danger-surface); - border: 1px solid var(--openbitfun-color-status-danger-border); - border-radius: 6px; - color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-label-sm-font-size); -} - -// ==================== Branch List ==================== - -.branch-select-dialog__list { - flex: 1; - min-height: 0; - max-height: 240px; - overflow-x: hidden; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 6px; - background: var(--openbitfun-color-surface-panel); - -} - -.branch-select-dialog__loading { - padding: 24px; - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); -} - -.branch-select-dialog__loading-dots { - display: flex; - gap: 4px; - - span { - width: 4px; - height: 4px; - background: var(--openbitfun-color-content-muted); - border-radius: 50%; - animation: branch-loading-pulse 1.2s ease-in-out infinite; - - &:nth-child(2) { animation-delay: 0.15s; } - &:nth-child(3) { animation-delay: 0.3s; } - } -} - -@keyframes branch-loading-pulse { - 0%, 100% { opacity: 0.3; transform: scale(1); } - 50% { opacity: 1; transform: scale(1.2); } -} - -.branch-select-dialog__empty { - padding: 24px; - text-align: center; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-md-font-size); -} - -// ==================== Branch Item ==================== - -.branch-select-dialog__item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - cursor: pointer; - transition: background 0.12s ease; - - &:hover:not(.disabled) { - background: var(--openbitfun-color-action-quiet-hover); - } - - &.selected { - background: var(--openbitfun-color-action-secondary-background); - } - - &.current { - .branch-select-dialog__item-name { - font-weight: var(--openbitfun-type-label-sm-font-weight); - } - } - - &--new { - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-action-quiet-hover); - - &:hover { - background: var(--openbitfun-color-action-secondary-background); - } - } - - &.disabled { - opacity: 0.5; - cursor: not-allowed; - - .branch-select-dialog__item-name { - color: var(--openbitfun-color-content-muted); - } - } -} - -.branch-select-dialog__item-icon { - flex-shrink: 0; - color: var(--openbitfun-color-accent-default); - - &--new { - color: var(--openbitfun-color-status-success-content); - } -} - -.branch-select-dialog__item-name { - flex: 1; - font-size: var(--openbitfun-type-label-md-font-size); - color: var(--openbitfun-color-content-primary); - white-space: nowrap; - overflow: hidden; - - strong { - color: var(--openbitfun-color-accent-default); - } -} - -.branch-select-dialog__item-badge { - flex-shrink: 0; - padding: 2px 6px; - font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - text-transform: uppercase; - color: var(--openbitfun-color-accent-default); - background: var(--openbitfun-color-action-secondary-background); - border-radius: 4px; - - &--worktree { - color: var(--openbitfun-color-status-warning-content); - background: var(--openbitfun-color-status-warning-surface); - } -} - -// ==================== Footer Buttons ==================== - -.branch-select-dialog__footer { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - padding: 12px 16px 16px; - border-top: 1px solid var(--openbitfun-color-border-subtle); -} - -.branch-select-dialog__options { - flex: 1 1 220px; - min-width: 0; -} - -@media (prefers-reduced-motion: reduce) { - .branch-select-dialog { - animation: none; - transform: none; - transition: opacity 160ms cubic-bezier(0.23, 1, 0.32, 1); - } - - .branch-select-overlay[data-state='closed'] .branch-select-dialog { - transform: none; - transition-duration: 120ms; - } - - .branch-select-dialog__loading-dots span { - animation: none; - opacity: 0.65; - transform: none; - } -} diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.test.tsx b/src/web-ui/src/app/components/panels/BranchSelectModal.test.tsx deleted file mode 100644 index dcb1648213..0000000000 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment jsdom - -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_RETAINED_MOUNT_MS } from '@/shared/presence'; -import { BranchSelectModal } from './BranchSelectModal'; - -const mocks = vi.hoisted(() => ({ - getBranches: vi.fn(), -})); - -vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ - getAppearanceOverlayHost: () => document.body, -})); - -vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), - i18nService: { t: (key: string) => key }, -})); - -vi.mock('../../../infrastructure/api/service-api/GitAPI', () => ({ - gitAPI: { getBranches: mocks.getBranches }, -})); - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -describe('BranchSelectModal presence', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.useFakeTimers(); - mocks.getBranches.mockResolvedValue([]); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - document.querySelectorAll('.branch-select-overlay').forEach((node) => node.remove()); - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - const renderModal = (isOpen: boolean, title: string) => { - const onClose = vi.fn(); - act(() => { - root.render( - , - ); - }); - return onClose; - }; - - it('closes once from the title row without also activating the backdrop', () => { - const onClose = renderModal(true, 'Choose release branch'); - const close = document.querySelector( - '.branch-select-dialog__header button[aria-label="actions.close"]', - ); - - expect(close).not.toBeNull(); - act(() => close!.click()); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('retains the closed surface and last title until the exit window completes', () => { - renderModal(true, 'Choose release branch'); - expect(document.querySelector('.branch-select-overlay')?.getAttribute('data-state')).toBe('open'); - - renderModal(false, 'Changed after close'); - expect(document.querySelector('.branch-select-overlay')?.getAttribute('data-state')).toBe('closed'); - expect(document.querySelector('.branch-select-dialog__title')?.textContent).toBe('Choose release branch'); - - act(() => vi.advanceTimersByTime(DEFAULT_RETAINED_MOUNT_MS - 1)); - expect(document.querySelector('.branch-select-overlay')).not.toBeNull(); - - act(() => vi.advanceTimersByTime(1)); - expect(document.querySelector('.branch-select-overlay')).toBeNull(); - }); -}); diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx b/src/web-ui/src/app/components/panels/BranchSelectModal.tsx deleted file mode 100644 index a212d88899..0000000000 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Branch selection modal - * Supports selecting existing branches or creating new branches - */ - -import { OverflowText, Button, Checkbox, Icon, IconButton, Input, ScrollArea, Tooltip } from '@openbitfun/ui'; -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -; -import { createLogger } from '@/shared/utils/logger'; -import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; -import { RetainedMountBoundary, DEFAULT_RETAINED_MOUNT_MS } from '@/shared/presence'; -import { useI18n } from '@/infrastructure/i18n'; -import { gitAPI, type GitBranch as GitBranchType } from '../../../infrastructure/api/service-api/GitAPI'; -import './BranchSelectModal.scss'; - -const log = createLogger('BranchSelectModal'); - -type SelectableBranch = GitBranchType & { - isCurrent?: boolean; - hasWorktree?: boolean; -}; - -export interface BranchSelectResult { - branch: string; - isNew: boolean; - openAfterCreate: boolean; -} - -export interface BranchSelectModalProps { - isOpen: boolean; - onClose: () => void; - onSelect: (result: BranchSelectResult) => void; - repositoryPath: string; - workspaceId: string; - title?: string; - currentBranch?: string; - existingWorktreeBranches?: string[]; - showOpenAfterCreate?: boolean; - defaultOpenAfterCreate?: boolean; -} - -export const BranchSelectModal: React.FC = ({ - isOpen, - onClose, - onSelect, - repositoryPath, - workspaceId, - title, - currentBranch, - existingWorktreeBranches = [], - showOpenAfterCreate = false, - defaultOpenAfterCreate = false, -}) => { - const { t } = useI18n('panels/git'); - const { t: tCommon } = useI18n('common'); - const [branches, setBranches] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [selectedBranch, setSelectedBranch] = useState(null); - const [isNewBranch, setIsNewBranch] = useState(false); - const [openAfterCreate, setOpenAfterCreate] = useState(defaultOpenAfterCreate); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const inputRef = useRef(null); - const resolvedTitle = title ?? t('branchSelect.title'); - const retainedDisplayRef = useRef({ - title: resolvedTitle, - currentBranch, - existingWorktreeBranches, - showOpenAfterCreate, - }); - - if (isOpen) { - retainedDisplayRef.current = { - title: resolvedTitle, - currentBranch, - existingWorktreeBranches, - showOpenAfterCreate, - }; - } - - const retainedDisplay = retainedDisplayRef.current; - - useEffect(() => { - if (!isOpen) { - const resetTimer = window.setTimeout(() => { - setSearchTerm(''); - setSelectedBranch(null); - setIsNewBranch(false); - setOpenAfterCreate(defaultOpenAfterCreate); - setError(null); - }, DEFAULT_RETAINED_MOUNT_MS); - return () => window.clearTimeout(resetTimer); - } - - const focusTimer = window.setTimeout(() => { - inputRef.current?.focus(); - }, 100); - return () => window.clearTimeout(focusTimer); - }, [defaultOpenAfterCreate, isOpen]); - - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen && !isImeOwnedKeyboardEvent(e)) { - onClose(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen, onClose]); - - const loadBranches = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const branchList = await gitAPI.getBranches({ workspaceId, repositoryPath }, false); - setBranches(branchList); - } catch (err) { - log.error('Failed to load branches', err); - setError(t('branchSelect.errors.loadFailed')); - } finally { - setIsLoading(false); - } - }, [repositoryPath, t, workspaceId]); - - useEffect(() => { - if (isOpen && repositoryPath) { - void loadBranches(); - } - }, [isOpen, loadBranches, repositoryPath]); - - const filteredBranches = useMemo(() => { - let result = branches; - - if (searchTerm.trim()) { - const lowerSearch = searchTerm.toLowerCase(); - result = result.filter(branch => - branch.name.toLowerCase().includes(lowerSearch) - ); - } - - const availableBranches: SelectableBranch[] = []; - const unavailableBranches: SelectableBranch[] = []; - const existingWorktreeSet = new Set(retainedDisplay.existingWorktreeBranches); - - result.forEach(branch => { - const isCurrent = branch.name === retainedDisplay.currentBranch; - const hasWorktree = existingWorktreeSet.has(branch.name); - - if (isCurrent || hasWorktree) { - unavailableBranches.push({ ...branch, isCurrent, hasWorktree }); - } else { - availableBranches.push(branch); - } - }); - - return [...availableBranches, ...unavailableBranches]; - }, [branches, retainedDisplay.currentBranch, retainedDisplay.existingWorktreeBranches, searchTerm]); - - const canCreateNewBranch = useMemo(() => { - if (!searchTerm.trim()) return false; - const exists = branches.some( - branch => branch.name.toLowerCase() === searchTerm.toLowerCase() - ); - return !exists; - }, [branches, searchTerm]); - - const handleSelectBranch = useCallback((branchName: string, isNew: boolean) => { - setSelectedBranch(branchName); - setIsNewBranch(isNew); - }, []); - - const handleConfirm = useCallback(() => { - if (selectedBranch) { - onSelect({ - branch: selectedBranch, - isNew: isNewBranch, - openAfterCreate, - }); - onClose(); - } - }, [selectedBranch, isNewBranch, onClose, onSelect, openAfterCreate]); - - const handleDoubleClick = useCallback((branchName: string, isNew: boolean) => { - onSelect({ - branch: branchName, - isNew: isNew, - openAfterCreate, - }); - onClose(); - }, [onClose, onSelect, openAfterCreate]); - - const modalContent = ( -
-
e.stopPropagation()}> -
-

{retainedDisplay.title}

- - } - onClick={onClose} - size="sm" - aria-label={tCommon('actions.close')} - /> - -
- -
-
- setSearchTerm(e.target.value)} - className="branch-select-dialog__input" - /> -
- - {error && ( -
- {error} -
- )} - - - {isLoading ? ( -
-
- - - -
- {t('branchSelect.loading')} -
- ) : ( - <> - {canCreateNewBranch && ( -
handleSelectBranch(searchTerm.trim(), true)} - onDoubleClick={() => handleDoubleClick(searchTerm.trim(), true)} - > - - - {t('branchSelect.createNewLabel')} {searchTerm.trim()} - -
- )} - - {filteredBranches.map((branch) => { - const isDisabled = branch.isCurrent || branch.hasWorktree; - const hasWorktree = branch.hasWorktree; - - return ( -
!isDisabled && handleSelectBranch(branch.name, false)} - onDoubleClick={() => !isDisabled && handleDoubleClick(branch.name, false)} - > - - - {branch.name} - - {branch.current && ( - {t('branch.current')} - )} - {hasWorktree && !branch.current && ( - - {t('branchSelect.badges.inUse')} - - )} -
- ); - })} - - {filteredBranches.length === 0 && !canCreateNewBranch && ( -
- {searchTerm ? t('empty.noMatchingBranches') : t('empty.noBranches')} -
- )} - - )} -
-
- -
- {retainedDisplay.showOpenAfterCreate ? ( -
- setOpenAfterCreate(event.target.checked)} - label={t('branchSelect.openAfterCreate.label')} - description={t('branchSelect.openAfterCreate.description')} - /> -
- ) : null} - - -
-
-
- ); - - const retainedModal = ( - - {modalContent} - - ); - - if (typeof document === 'undefined') { - return retainedModal; - } - - return createPortal(retainedModal, getAppearanceOverlayHost()); -}; - -export default BranchSelectModal; diff --git a/src/web-ui/src/app/scenes/git/views/BranchesView.tsx b/src/web-ui/src/app/scenes/git/views/BranchesView.tsx index 22b72f335d..d4cf15dfae 100644 --- a/src/web-ui/src/app/scenes/git/views/BranchesView.tsx +++ b/src/web-ui/src/app/scenes/git/views/BranchesView.tsx @@ -3,13 +3,13 @@ */ import { OverflowText, Button, Icon, IconButton, SearchField, Tooltip, ScrollArea } from '@openbitfun/ui'; -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { RotateCcw, FileText } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { gitService } from '@/tools/git/services'; -import { useGitOperations } from '@/tools/git/hooks'; +import { useGitOperations, useGitState } from '@/tools/git/hooks'; import { useNotification } from '@/shared/notification-system'; import { CreateBranchDialog } from '@/tools/git/components/CreateBranchDialog'; import type { GitBranch as GitBranchType, GitCommit as GitCommitType, GitFileChange } from '@/tools/git/types/repository'; @@ -43,6 +43,35 @@ const BranchesView: React.FC = ({ workspacePath, workspaceId autoRefresh: false, }); + // Subscribe to the shared current branch. The list's "current" badge and + // highlight are derived from this single value rather than from the per-item + // flag returned by `git branch`, so a switch performed anywhere — including a + // manual switch that writes the shared state directly — moves the marker here + // without waiting for this view to re-read the repository. + const scope = { workspaceId: workspaceId ?? '', repositoryPath: workspacePath }; + const { currentBranch: managerCurrentBranch } = useGitState({ + repositoryPath: scope, + layers: ['basic'], + isActive: !!workspacePath, + refreshOnMount: true, + refreshOnActive: true, + participateInWindowFocusRefresh: true, + debugSource: 'branches_view', + }); + + // Fall back to the fetched list's own flag until the shared state carries a + // value, so the badge is not blank on first paint. + const effectiveCurrentBranch = + managerCurrentBranch ?? branches.find(b => b.current)?.name ?? null; + const displayBranches = useMemo( + () => branches.map(branch => ( + branch.current === (branch.name === effectiveCurrentBranch) + ? branch + : { ...branch, current: branch.name === effectiveCurrentBranch } + )), + [branches, effectiveCurrentBranch], + ); + const loadBranches = useCallback(async () => { if (!workspacePath) return; setBranchLoading(true); @@ -90,8 +119,8 @@ const BranchesView: React.FC = ({ workspacePath, workspaceId }, [selectedBranchName, loadCommits]); const filteredBranches = branchSearchQuery.trim() - ? branches.filter(b => (b.name ?? '').toLowerCase().includes(branchSearchQuery.toLowerCase())) - : branches; + ? displayBranches.filter(b => (b.name ?? '').toLowerCase().includes(branchSearchQuery.toLowerCase())) + : displayBranches; const filteredCommits = commitSearchQuery.trim() ? commits.filter( @@ -227,7 +256,7 @@ const BranchesView: React.FC = ({ workspacePath, workspaceId size="sm" variant="primary" leadingIcon={} - onClick={() => handleCreateFrom(branches.find(b => b.current)?.name ?? selectedBranchName ?? '')} + onClick={() => handleCreateFrom(effectiveCurrentBranch ?? selectedBranchName ?? '')} title={t('dialog.createNewBranch.title')} > {t('dialog.createNewBranch.confirm')} diff --git a/src/web-ui/src/flow_chat/components/ChatEmptyState.tsx b/src/web-ui/src/flow_chat/components/ChatEmptyState.tsx index 1a6886c2d8..cfbdf2f631 100644 --- a/src/web-ui/src/flow_chat/components/ChatEmptyState.tsx +++ b/src/web-ui/src/flow_chat/components/ChatEmptyState.tsx @@ -1,43 +1,36 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; -import { gitService } from '@/tools/git/services/GitService'; -import { createLogger } from '@/shared/utils/logger'; +import { useGitState } from '@/tools/git/hooks/useGitState'; import './ChatEmptyState.scss'; -const log = createLogger('ChatEmptyState'); - /** * Chat empty state component - * Displays current workspace, branch info, and prompts user to interact via AI chat + * Displays current workspace, branch info, and prompts user to interact via AI chat. + * + * Uses the shared GitStateManager so the branch label stays in sync with + * external changes (e.g. `git checkout` from the integrated terminal) via + * the manager's polling interval instead of being stuck at mount-time value. */ export const ChatEmptyState: React.FC = () => { const { t } = useTranslation('flow-chat'); const { workspace: currentWorkspace } = useCurrentWorkspace(); - const [currentBranch, setCurrentBranch] = useState(''); - const [loading, setLoading] = useState(true); - useEffect(() => { - const loadGitInfo = async () => { - if (!currentWorkspace?.rootPath) { - setLoading(false); - return; - } + const scope = currentWorkspace?.id + ? { workspaceId: currentWorkspace.id } + : { workspaceId: '' }; - try { - const status = await gitService.getStatus({ workspaceId: currentWorkspace.id }); - if (status) { - setCurrentBranch(status.current_branch || ''); - } - } catch (error) { - log.debug('Failed to get Git info', error); - } finally { - setLoading(false); - } - }; + const { isRepository, currentBranch } = useGitState({ + repositoryPath: scope, + layers: ['basic'], + isActive: !!currentWorkspace?.id, + refreshOnMount: !!currentWorkspace?.id, + refreshOnActive: true, + participateInWindowFocusRefresh: true, + debugSource: 'chat_empty_state', + }); - loadGitInfo(); - }, [currentWorkspace]); + const loading = !!currentWorkspace?.id && !isRepository && !currentBranch; return (
@@ -90,4 +83,3 @@ export const ChatEmptyState: React.FC = () => {
); }; - diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx index 94f5d1e043..18cea99b33 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx @@ -14,9 +14,11 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const gitApiMock = vi.hoisted(() => ({ isGitRepository: vi.fn(), getStatus: vi.fn(), + getRepositoryBasic: vi.fn(), })); vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: () => {} }, useTranslation: () => ({ t: (key: string, values?: Record) => values?.count ?? key, }), @@ -54,15 +56,33 @@ vi.mock('@/app/scenes/my-agent/useAgentIdentityDocument', () => ({ useAgentIdentityDocument: () => ({ document: { name: '' } }), })); -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} +// WelcomePanel now reads Git state through useGitState (which subscribes to +// the shared GitStateManager and participates in its 2 s polling interval) +// instead of hitting gitAPI directly. Return deterministic state so the +// render tests do not race against real timers or Tauri APIs. +vi.mock('@/tools/git/hooks/useGitState', () => ({ + useGitState: () => ({ + state: null, + isLoading: false, + error: null, + refresh: vi.fn(), + refreshBasic: vi.fn(), + refreshStatus: vi.fn(), + refreshDetailed: vi.fn(), + isRepository: true, + repositoryTrustRequired: false, + currentBranch: 'main', + ahead: 0, + behind: 0, + hasChanges: false, + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + branches: undefined, + commits: undefined, + }), +})); describe('WelcomePanel Git summary loading', () => { let container: HTMLDivElement; @@ -72,16 +92,6 @@ describe('WelcomePanel Git summary loading', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); - gitApiMock.isGitRepository.mockReset(); - gitApiMock.getStatus.mockReset(); - gitApiMock.getStatus.mockResolvedValue({ - current_branch: 'main', - staged: [], - unstaged: [], - untracked: [], - ahead: 0, - behind: 0, - }); }); afterEach(() => { @@ -95,38 +105,14 @@ describe('WelcomePanel Git summary loading', () => { container.remove(); }); - it('does not request Git status after the panel unmounts during repository detection', async () => { - const repositoryProbe = deferred(); - gitApiMock.isGitRepository.mockReturnValue(repositoryProbe.promise); - - await act(async () => { - root.render(); - }); - - expect(gitApiMock.isGitRepository).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }); - - act(() => { - root.unmount(); - }); - - await act(async () => { - repositoryProbe.resolve(true); - await repositoryProbe.promise; - }); - - expect(gitApiMock.getStatus).not.toHaveBeenCalled(); - }); - - it('loads Git status when the panel remains mounted', async () => { - gitApiMock.isGitRepository.mockResolvedValue(true); - + it('renders the git branch chip when a workspace is open', async () => { await act(async () => { root.render(); }); - expect(gitApiMock.getStatus).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }, 'welcome_panel'); expect(container.querySelector('[data-openbitfun-product-part="workspaceAction"]')).not.toBeNull(); expect(container.querySelector('[data-openbitfun-product-part="gitAction"]')).not.toBeNull(); + expect(container.querySelector('[data-openbitfun-product-part="gitAction"]')?.textContent).toContain('main'); }); it('does not render the retired panda mascot', async () => { @@ -139,7 +125,6 @@ describe('WelcomePanel Git summary loading', () => { }); it('portals the workspace menu outside the scrollable welcome panel', async () => { - gitApiMock.isGitRepository.mockResolvedValue(false); await act(async () => { root.render(); }); @@ -180,7 +165,6 @@ describe('WelcomePanel Git summary loading', () => { style.textContent = snapshot.cssText; document.head.appendChild(style); - gitApiMock.isGitRepository.mockResolvedValue(false); await act(async () => root.render()); const rule = Array.from(style.sheet!.cssRules).find(candidate => diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index abef888530..5bd6620e34 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -8,7 +8,6 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { FolderOpen, FolderPlus } from 'lucide-react'; import { Button, Menu, MenuItem, MenuSeparator, Icon, PageHeader } from '@openbitfun/ui'; -import { gitAPI } from '../../infrastructure/api'; import { useApp } from '../../app/hooks/useApp'; import { createLogger } from '@/shared/utils/logger'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -17,18 +16,12 @@ import CoworkExampleCards from './CoworkExampleCards'; import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentityDocument'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; +import { useGitState } from '@/tools/git/hooks/useGitState'; import './WelcomePanel.css'; import './WelcomePanelSurface.scss'; const log = createLogger('WelcomePanel'); -interface GitWorkState { - currentBranch: string; - unstagedFiles: number; - stagedFiles: number; - unpushedCommits: number; -} - interface WelcomePanelProps { onQuickAction?: (command: string) => void; className?: string; @@ -47,7 +40,6 @@ export const WelcomePanel: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { t: tCommon } = useTranslation('common'); - const [gitState, setGitState] = useState(null); const [workspaceDropdownOpen, setWorkspaceDropdownOpen] = useState(false); const [isSelectingWorkspace, setIsSelectingWorkspace] = useState(false); const workspaceDropdownRef = useRef(null); @@ -66,6 +58,41 @@ export const WelcomePanel: React.FC = ({ const isCoworkSession = sessionModeLower === 'cowork'; const isClawSession = sessionModeLower === 'claw'; + // Subscribe to shared Git state so the welcome panel stays in sync with + // branch / worktree changes (including external ones picked up by the + // GitStateManager poll). When there is no workspace or we are in a + // cowork/claw session we pass a blank scope so the hook stays idle. + const activeWsId = !isCoworkSession && !isClawSession ? currentWorkspace?.id : undefined; + const gitScope = activeWsId ? { workspaceId: activeWsId } : { workspaceId: '' }; + const { + isRepository, + currentBranch, + ahead, + staged, + unstaged, + untracked, + } = useGitState({ + repositoryPath: gitScope, + layers: ['basic', 'status'], + isActive: !!activeWsId, + refreshOnMount: !!activeWsId, + refreshOnActive: true, + participateInWindowFocusRefresh: true, + debugSource: 'welcome_panel', + }); + + // Derive the same shape the old loadGitState produced so the render and + // narrative helpers below do not have to change. + const gitState = useMemo(() => { + if (!isRepository || !currentBranch) return null; + return { + currentBranch, + unstagedFiles: (unstaged?.length || 0) + (untracked?.length || 0), + stagedFiles: staged?.length || 0, + unpushedCommits: ahead || 0, + }; + }, [isRepository, currentBranch, ahead, staged, unstaged, untracked]); + const identityWorkspace = useMemo( () => (isClawSession && workspaceId ? { id: workspaceId, rootPath: workspacePath } : null), [isClawSession, workspaceId, workspacePath], @@ -152,38 +179,6 @@ export const WelcomePanel: React.FC = ({ ); }, [gitState, handleGitClick, t]); - const loadGitState = useCallback(async ( - workspaceId: string, - shouldCancel: () => boolean = () => false, - ) => { - try { - const isGitRepo = await gitAPI.isGitRepository({ workspaceId }); - if (shouldCancel()) return; - if (!isGitRepo) { setGitState(null); return; } - const s = await gitAPI.getStatus({ workspaceId }, 'welcome_panel'); - if (shouldCancel()) return; - setGitState({ - currentBranch: s.current_branch, - unstagedFiles: s.unstaged.length + s.untracked.length, - stagedFiles: s.staged.length, - unpushedCommits: s.ahead, - }); - } catch (err) { - log.warn('Failed to load git state', err); - setGitState(null); - } - }, []); - - useEffect(() => { - // The workspace is addressed by ID; git state needs an opened workspace, not a path. - if (isCoworkSession || isClawSession || !currentWorkspace?.id) { setGitState(null); return; } - let cancelled = false; - void loadGitState(currentWorkspace.id, () => cancelled); - return () => { - cancelled = true; - }; - }, [currentWorkspace?.id, isCoworkSession, isClawSession, loadGitState]); - useEffect(() => { if (!workspaceDropdownOpen) return; const handlePointerDown = (e: MouseEvent) => { diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 31b4d93f88..1399afca5d 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -143,7 +143,6 @@ import { statusBarPopoversAppearanceDescriptor } from '@/tools/editor/components import { mEditorAppearanceDescriptor } from '@/tools/editor/meditor/components/MEditor.appearance'; import { globalSearchAppearanceDescriptor } from '@/app/global-search/GlobalSearchRoot.appearance'; import { workspaceRelatedPathsDialogAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.appearance'; -import { branchSelectModalAppearanceDescriptor } from '@/app/components/panels/BranchSelectModal.appearance'; import { floatingMiniChatAppearanceDescriptor } from '@/app/layout/FloatingMiniChat.appearance'; import { miniAppBubbleWelcomeAppearanceDescriptor } from '@/app/layout/MiniAppBubbleWelcome.appearance'; import { branchesViewAppearanceDescriptor } from '@/app/scenes/git/views/BranchesView.appearance'; @@ -399,7 +398,6 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(mEditorAppearanceDescriptor) .registerComponent(globalSearchAppearanceDescriptor) .registerComponent(workspaceRelatedPathsDialogAppearanceDescriptor) - .registerComponent(branchSelectModalAppearanceDescriptor) .registerComponent(floatingMiniChatAppearanceDescriptor) .registerComponent(miniAppBubbleWelcomeAppearanceDescriptor) .registerComponent(branchesViewAppearanceDescriptor) 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 8f38bb688e..bc44aa80dc 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -233,22 +233,6 @@ "unexpected": "Unexpected error while switching branches" } }, - "branchSelect": { - "title": "Select or Create Branch", - "inputPlaceholder": "Enter new branch name...", - "loading": "Loading...", - "createNewLabel": "Create new branch:", - "openAfterCreate": { - "label": "Open after create", - "description": "Open the new worktree as a workspace immediately after it is created." - }, - "badges": { - "inUse": "In Use" - }, - "errors": { - "loadFailed": "Failed to load branch list" - } - }, "selection": { "selectAll": "Select all unstaged files", "deselectAll": "Deselect all", 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 26adc36965..63e9946768 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -233,22 +233,6 @@ "unexpected": "切换分支时发生意外错误" } }, - "branchSelect": { - "title": "选择或创建分支", - "inputPlaceholder": "输入新分支名...", - "loading": "加载中...", - "createNewLabel": "创建新分支:", - "openAfterCreate": { - "label": "创建后打开", - "description": "创建完成后,立即将新的 worktree 作为工作区打开。" - }, - "badges": { - "inUse": "已使用" - }, - "errors": { - "loadFailed": "Failed to load branch list" - } - }, "selection": { "selectAll": "全选所有未暂存文件", "deselectAll": "取消全选", 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 19e0292dd3..b4ed1eafad 100644 --- a/src/web-ui/src/locales/zh-TW/panels/git.json +++ b/src/web-ui/src/locales/zh-TW/panels/git.json @@ -233,22 +233,6 @@ "unexpected": "切換分支時發生未預期的錯誤" } }, - "branchSelect": { - "title": "選擇或建立分支", - "inputPlaceholder": "輸入新分支名...", - "loading": "載入中...", - "createNewLabel": "建立新分支:", - "openAfterCreate": { - "label": "建立後開啟", - "description": "建立完成後,立即將新的 worktree 作為工作區開啟。" - }, - "badges": { - "inUse": "已使用" - }, - "errors": { - "loadFailed": "Failed to load branch list" - } - }, "selection": { "selectAll": "全選所有未暫存檔案", "deselectAll": "取消全選", diff --git a/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx b/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx index c5632dc197..06a1efa07c 100644 --- a/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx +++ b/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx @@ -106,17 +106,32 @@ export const BranchQuickSwitch: React.FC = ({ const listRef = useRef(null); const switchInFlightRef = useRef(false); + // The "current" marker comes from the shared branch (passed in by the + // composer strip) rather than from the fetched list's own flag, so a switch + // that happened elsewhere — or the optimistic write a manual switch performs + // — moves the marker here at once. The fetched flag is only a fallback for + // the moment before the shared state has ever been populated. + const effectiveCurrentBranch = currentBranch || branches.find(b => b.current)?.name || null; + const displayBranches = useMemo( + () => branches.map(branch => ( + branch.current === (branch.name === effectiveCurrentBranch) + ? branch + : { ...branch, current: branch.name === effectiveCurrentBranch } + )), + [branches, effectiveCurrentBranch], + ); + const filteredBranches = useMemo(() => { const lowerSearch = searchTerm.trim().toLowerCase(); const filtered = lowerSearch - ? branches.filter(branch => branch.name.toLowerCase().includes(lowerSearch)) - : branches; + ? displayBranches.filter(branch => branch.name.toLowerCase().includes(lowerSearch)) + : displayBranches; return [...filtered].sort((left, right) => { if (left.current) return -1; if (right.current) return 1; return left.name.localeCompare(right.name); }); - }, [branches, searchTerm]); + }, [displayBranches, searchTerm]); const popoverLayout = useAnchoredPopoverPosition({ open: present, @@ -296,7 +311,7 @@ export const BranchQuickSwitch: React.FC = ({ const handleSwitchBranch = useCallback(async (branchName: string) => { if ( - branchName === currentBranch + branchName === effectiveCurrentBranch || switchInFlightRef.current ) return; @@ -318,7 +333,7 @@ export const BranchQuickSwitch: React.FC = ({ setIsSwitching(false); setSwitchingBranch(null); } - }, [completeSwitch, currentBranch, repositoryPath, showSwitchFailure, t]); + }, [completeSwitch, effectiveCurrentBranch, repositoryPath, showSwitchFailure, t]); const handleListKeyDown = useCallback((event: React.KeyboardEvent) => { if ( diff --git a/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.tsx b/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.tsx index 791bde6554..271ed49f68 100644 --- a/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.tsx +++ b/src/web-ui/src/tools/git/components/GitBranchHistoryView/GitBranchHistoryView.tsx @@ -16,6 +16,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { describeGitTrustFailure } from '../../services/GitService'; import { createLogger } from '@/shared/utils/logger'; import { useStableGitWorkspaceScope } from '../../hooks/useStableGitWorkspaceScope'; +import { useGitState } from '../../hooks/useGitState'; import './GitBranchHistoryView.scss'; const log = createLogger('GitBranchHistoryView'); @@ -91,6 +92,22 @@ export const GitBranchHistoryView: React.FC = ({ const repositoryPath = useStableGitWorkspaceScope(workspaceReference); const { t } = useTranslation('panels/git'); const { t: tComponents } = useI18n('components'); + + // Subscribe to live Git state so the cherry-pick availability (which + // depends on whether the viewed branch is the current one) stays accurate + // after external checkouts. The prop value is used as an initial hint when + // the panel first opens (before the subscription has produced a value). + const { currentBranch: liveCurrentBranch } = useGitState({ + repositoryPath, + layers: ['basic'], + isActive: true, + refreshOnMount: true, + refreshOnActive: true, + participateInWindowFocusRefresh: true, + debugSource: 'git_branch_history_view', + }); + const effectiveCurrentBranch = liveCurrentBranch ?? currentBranch ?? null; + const [commits, setCommits] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -107,7 +124,7 @@ export const GitBranchHistoryView: React.FC = ({ const notification = useNotification(); - const canCherryPick = currentBranch && branchName !== currentBranch; + const canCherryPick = effectiveCurrentBranch && branchName !== effectiveCurrentBranch; const loadCommits = useCallback(async () => { @@ -270,7 +287,7 @@ export const GitBranchHistoryView: React.FC = ({ if (failedHashes.length === 0 && successHashes.length > 0) { notification.success(t('branchHistory.notifications.cherryPickSuccess', { count: successHashes.length, - branch: currentBranch + branch: effectiveCurrentBranch })); setSelectedCommits(new Set()); onCherryPickSuccess?.(successHashes); @@ -292,7 +309,7 @@ export const GitBranchHistoryView: React.FC = ({ setSelectedCommits(new Set(failedHashes.map(f => f.hash))); } - }, [selectedCommits, commits, repositoryPath, currentBranch, notification, onCherryPickSuccess, t]); + }, [selectedCommits, commits, repositoryPath, effectiveCurrentBranch, notification, onCherryPickSuccess, t]); if (loading) { return ( diff --git a/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx b/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx index ea9ea7b891..c524698bf2 100644 --- a/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx +++ b/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx @@ -19,6 +19,7 @@ import { useAppearance } from '@/infrastructure/appearance'; import { createLogger } from '@/shared/utils/logger'; import { describeGitTrustFailure } from '../../services/GitService'; import { useStableGitWorkspaceScope } from '../../hooks/useStableGitWorkspaceScope'; +import { useGitState } from '../../hooks/useGitState'; import './GitGraphView.scss'; const log = createLogger('GitGraphView'); @@ -49,6 +50,19 @@ export const GitGraphView: React.FC = ({ const { current: appearance } = useAppearance(); const viewConfig = useMemo(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); + // Subscribe to Git state so external branch changes (detected by the + // GitStateManager poll) trigger a graph reload instead of leaving a stale + // current-branch label and graph visible. We only need `basic` because + // that is the layer that reports `currentBranch`. + const { currentBranch } = useGitState({ + repositoryPath, + layers: ['basic'], + isActive: true, + refreshOnMount: true, + refreshOnActive: true, + participateInWindowFocusRefresh: true, + debugSource: 'git_graph_view', + }); const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(true); @@ -88,6 +102,23 @@ export const GitGraphView: React.FC = ({ loadGraphData(); }, [loadGraphData]); + // Reload the graph only when the shared current branch actually moves to a + // new value — an optimistic write from a manual switch, or the poll noticing + // an external `git checkout`. Comparing against the last value we acted on + // (rather than against `graphData.currentBranch`) keeps this from looping if + // the two sources ever disagree persistently. + const lastSeenBranchRef = useRef(null); + useEffect(() => { + if (currentBranch === null) return; + if (lastSeenBranchRef.current === null) { + lastSeenBranchRef.current = currentBranch; + return; + } + if (lastSeenBranchRef.current === currentBranch) return; + lastSeenBranchRef.current = currentBranch; + loadGraphData(); + }, [currentBranch, loadGraphData]); + useEffect(() => { return () => { @@ -285,16 +316,20 @@ export const GitGraphView: React.FC = ({ const graphWidth = Math.max(800, (graphData.maxLane + 2) * viewConfig.laneWidth! + 600); const totalHeight = graphData.nodes.length * viewConfig.rowHeight!; + // The header label reads the shared branch so a manual switch shows here the + // instant it happens; the graph payload is only a fallback for the brief + // window before the shared state has ever been populated. + const displayedBranch = currentBranch ?? graphData.currentBranch; return (

{t('graph.title')}

- {graphData.currentBranch && ( + {displayedBranch && ( - {graphData.currentBranch} + {displayedBranch} )}
diff --git a/src/web-ui/src/tools/git/hooks/useGitState.ts b/src/web-ui/src/tools/git/hooks/useGitState.ts index bb32fca5cd..0cb88cb24b 100644 --- a/src/web-ui/src/tools/git/hooks/useGitState.ts +++ b/src/web-ui/src/tools/git/hooks/useGitState.ts @@ -217,7 +217,11 @@ export function useGitState(options: UseGitStateOptions): UseGitStateReturn { ); const refreshBasic = useCallback(async (): Promise => { - return refresh({ layers: ['basic'] }); + // Force because the basic layer uses an infinite TTL by default, so a + // non-forced refresh would be a silent no-op when the caller explicitly + // asked to re-read the current branch (e.g. after a worktree-path + // change). The status/detailed caches are left untouched. + return refresh({ layers: ['basic'], force: true }); }, [refresh]); const refreshStatus = useCallback(async (): Promise => { diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts index 214a938d86..8e8ac063cb 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GitStateManager } from './GitStateManager'; import type { GitStateLayer } from './types'; @@ -97,6 +98,51 @@ describe('GitStateManager refresh performance guards', () => { vi.clearAllMocks(); }); + it('keeps polling paused when another repository subscribes while hidden', async () => { + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false); + const addListener = vi.spyOn(document, 'addEventListener'); + const removeListener = vi.spyOn(document, 'removeEventListener'); + try { + const unsubscribe = manager.subscribe(repositoryPath, () => {}); + hidden.mockReturnValue(true); + document.dispatchEvent(new Event('visibilitychange')); + const unsubscribeOther = manager.subscribe({ workspaceId: 'workspace-2' }, () => {}); + await vi.advanceTimersByTimeAsync(4100); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + const listeners = addListener.mock.calls.filter(([type]) => type === 'visibilitychange'); + expect(listeners).toHaveLength(1); + + hidden.mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(100); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(2); + unsubscribe(); + unsubscribeOther(); + expect(removeListener).toHaveBeenCalledWith('visibilitychange', listeners[0][1]); + await vi.advanceTimersByTimeAsync(4100); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(2); + } finally { + hidden.mockRestore(); + addListener.mockRestore(); + removeListener.mockRestore(); + } + }); + + it('does not start polling for an initially hidden document', async () => { + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + try { + manager.subscribe(repositoryPath, () => {}); + await vi.advanceTimersByTimeAsync(4100); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + hidden.mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(100); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + } finally { + hidden.mockRestore(); + } + }); + it('refreshes the basic layer without fetching full status', async () => { const refresh = manager.refresh(repositoryPath, { layers: ['basic'], @@ -465,3 +511,131 @@ describe('GitStateManager ownership trust', () => { }); }); }); + +/** + * A manual checkout reaches the shared cache as a `branch:changed` event — both + * the Git scene's branch list and the composer's branch picker emit it. These + * tests pin the two properties that let every branch label read from one + * variable: the write lands before any Git round trip, and it reaches + * subscribers immediately. + */ +describe('GitStateManager branch switch propagation', () => { + let manager: GitStateManager; + + beforeEach(() => { + vi.useFakeTimers(); + GitStateManager.resetInstance(); + manager = GitStateManager.getInstance(); + manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 }); + + gitApiMocks.isGitRepository.mockResolvedValue(true); + gitApiMocks.getRepositoryBasic.mockResolvedValue({ + path: repositoryPath.repositoryPath, + name: 'OpenBitFun', + current_branch: 'main', + is_bare: false, + has_changes: false, + remotes: [], + }); + gitApiMocks.getStatus.mockResolvedValue({ + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + gitApiMocks.getBranches.mockResolvedValue([ + { name: 'main', current: true, remote: false, ahead: 0, behind: 0 }, + { name: 'feature', current: false, remote: false, ahead: 0, behind: 0 }, + ]); + gitApiMocks.getCommits.mockResolvedValue([]); + }); + + afterEach(() => { + manager.dispose(); + GitStateManager.resetInstance(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + /** The handler the manager registers for a completed checkout. */ + function branchChangedHandler(): (event: unknown) => void { + const call = gitEventServiceMock.on.mock.calls.find(([type]) => type === 'branch:changed'); + if (!call) { + throw new Error('branch:changed handler was not registered'); + } + return call[1] as (event: unknown) => void; + } + + function reportBranchChange(branchName: string): void { + branchChangedHandler()({ + data: { repositoryPath, branch: { name: branchName }, timestamp: new Date() }, + }); + } + + async function primeState(): Promise { + const settled = manager + .refresh(repositoryPath, { layers: ['basic', 'status', 'detailed'], force: true, reason: 'mount' }) + .then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(100); + const failure = await settled; + if (failure) throw failure; + } + + it('moves subscribers to the new branch before the refresh lands', async () => { + await primeState(); + expect(manager.getState(repositoryPath)?.currentBranch).toBe('main'); + + const seen: (string | null)[] = []; + manager.subscribe( + repositoryPath, + state => { seen.push(state.currentBranch); }, + { layers: ['basic'], immediate: false }, + ); + + // No Git call is made: the checkout report is the only input at this point. + reportBranchChange('feature'); + + expect(manager.getState(repositoryPath)?.currentBranch).toBe('feature'); + expect(seen).toEqual(['feature']); + }); + + it('moves the cached branch list marker without re-reading the repository', async () => { + await primeState(); + gitApiMocks.getBranches.mockClear(); + + reportBranchChange('feature'); + + expect(manager.getState(repositoryPath)?.branches?.map(b => [b.name, b.current])).toEqual([ + ['main', false], + ['feature', true], + ]); + expect(gitApiMocks.getBranches).not.toHaveBeenCalled(); + }); + + // Git outranks the optimistic write: the reconciling refresh the same event + // schedules replaces a value the host disagrees with. + it('lets the reconciling refresh overwrite a branch Git reports differently', async () => { + await primeState(); + + reportBranchChange('feature'); + expect(manager.getState(repositoryPath)?.currentBranch).toBe('feature'); + + await vi.advanceTimersByTimeAsync(100); + + expect(manager.getState(repositoryPath)?.currentBranch).toBe('main'); + }); + + it('ignores a branch change report that carries no branch name', async () => { + await primeState(); + + branchChangedHandler()({ data: { repositoryPath, timestamp: new Date() } }); + + expect(manager.getState(repositoryPath)?.currentBranch).toBe('main'); + }); +}); diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index a294f1237a..d3199d9da0 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -47,6 +47,22 @@ interface SubscriberEntry { options: SubscribeOptions; } +/** + * Git metadata changes (checkout, commit, rebase, etc.) often happen outside + * the app — in an integrated terminal, an external Git client, a script, or + * triggered by the agent itself. None of those emit our in-process + * `branch:changed` / `operation:completed` events, so the UI can stay stale + * indefinitely. We therefore run a low-frequency poll against any repository + * that currently has at least one active subscriber, so the branch chip, + * welcome panel and Git scene all pick up external changes promptly. + * + * The poll hits the lightweight `basic` endpoint (`git branch --show-current` + * plus repo name), so it is cheap even at 2 s cadence. Heavy `status` and + * `detailed` layers keep their longer TTLs and are only refreshed on demand + * or by user-driven events. + */ +const POLL_INTERVAL_MS = 2000; + interface PendingRefresh { layers: Set; force: boolean; @@ -73,6 +89,8 @@ export class GitStateManager { private readonly DEBOUNCE_DELAY = 100; private globalListenersInitialized = false; private globalListenerCleanups: Array<() => void> = []; + private pollTimer: ReturnType | null = null; + private pollVisibilityCleanup: (() => void) | null = null; private constructor() { this.setupGlobalListeners(); @@ -110,6 +128,8 @@ export class GitStateManager { this.subscribers.set(normalizedPath, new Set()); } + const firstSubscriberForRepo = this.subscribers.get(normalizedPath)!.size === 0; + const entry: SubscriberEntry = { callback, options }; this.subscribers.get(normalizedPath)!.add(entry); @@ -120,8 +140,20 @@ export class GitStateManager { } } + if (firstSubscriberForRepo) { + this.ensurePollingStarted(); + } + return () => { - this.subscribers.get(normalizedPath)?.delete(entry); + const set = this.subscribers.get(normalizedPath); + if (!set) return; + set.delete(entry); + if (set.size === 0) { + this.subscribers.delete(normalizedPath); + if (this.subscribers.size === 0) { + this.stopPolling(); + } + } }; } @@ -259,6 +291,7 @@ export class GitStateManager { } this.refreshDebounceTimers.clear(); + this.stopPolling(); this.subscribers.clear(); @@ -897,10 +930,61 @@ export class GitStateManager { }); }; + /** + * Write a completed checkout into the shared state and broadcast it. + * + * Both manual switch entry points (the Git scene branch list and the + * composer branch picker) announce a successful checkout as a + * `branch:changed` event, so that event is the single place where a manual + * switch reaches this cache. Writing it here makes every surface that + * renders `currentBranch` flip at the same moment, instead of each waiting + * for the reconciling refresh below or, worse, the poll interval. + * + * Git stays the source of truth: the forced refresh that follows overwrites + * whatever we write here, so a guess that does not match reality is + * corrected rather than persisted. This runs only after a checkout the host + * reported as successful — it never fabricates one. + * + * The cached branch list is patched too, but only the `current` flag, so + * pickers that render from `state.branches` move their marker immediately + * without touching names or upstream data. + */ + private applyBranchChange(scope: GitWorkspaceScope, branchName: string): void { + // `states`, `subscribers` and `updateState` are all keyed by the + // normalized workspace key, so normalize once here rather than at each use. + const repositoryPath = this.normalizePath(scope); + const prevState = this.states.get(repositoryPath) ?? null; + + const branches = prevState?.branches?.map((branch) => { + const isCurrent = branch.name === branchName; + return branch.current === isCurrent ? branch : { ...branch, current: isCurrent }; + }); + + const nextState = this.updateState(repositoryPath, { + isRepository: true, + repositoryTrustRequired: false, + currentBranch: branchName, + ...(branches ? { branches } : {}), + }); + + const comparison = compareStates(prevState, nextState); + if (!comparison.hasChanges) return; + + this.notifySubscribers(repositoryPath, nextState, prevState, comparison.changedLayers); + this.emitStateChanged(repositoryPath, nextState, comparison.changedLayers, 'operation'); + } + private handleBranchChanged = (event: any): void => { - const { repositoryPath } = event.data; + const { repositoryPath, branch } = event.data; if (!repositoryPath) return; + // Apply the reported branch first so the UI moves now; the forced refresh + // below then reconciles ahead/behind, the working copy and the branch list. + const branchName = typeof branch?.name === 'string' ? branch.name : ''; + if (branchName) { + this.applyBranchChange(repositoryPath, branchName); + } + this.invalidateCache(repositoryPath, ['basic', 'status', 'detailed']); this.refresh(repositoryPath, { layers: ['basic', 'status', 'detailed'], @@ -910,6 +994,88 @@ export class GitStateManager { }; // ------------------------------------------------------------------------- + // Polling — catch external branch / HEAD changes every POLL_INTERVAL_MS. + // ------------------------------------------------------------------------- + + /** + * Start the interval-based poll if not already running. The poll is global + * (one timer for all repositories) and pauses while the document is hidden + * to avoid unnecessary work in background tabs. It only refreshes repos + * that currently have subscribers and always uses a silent basic-layer + * refresh so status/detailed caches are not thrashed. + */ + private ensurePollingStarted(): void { + if (typeof window === 'undefined') return; + + if (this.pollVisibilityCleanup === null) { + const handleVisibility = (): void => { + if (document.hidden) { + this.stopPollingTimerOnly(); + } else if (this.subscribers.size > 0) { + // Kick off an immediate refresh so we don't lag by up to one + // interval after becoming visible, then resume the timer. + this.pollTick(); + this.startPollingTimer(); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + this.pollVisibilityCleanup = () => { + document.removeEventListener('visibilitychange', handleVisibility); + }; + } + + // A subscription can be added while the document is hidden. Keep the + // timer paused in that case; the visibility listener above will start it + // when the document becomes visible again. + if (!document.hidden) { + this.startPollingTimer(); + } + } + + private startPollingTimer(): void { + if (this.pollTimer !== null || this.subscribers.size === 0) return; + this.pollTimer = setInterval(() => { + this.pollTick(); + }, POLL_INTERVAL_MS); + } + + private stopPolling(): void { + this.stopPollingTimerOnly(); + if (this.pollVisibilityCleanup) { + this.pollVisibilityCleanup(); + this.pollVisibilityCleanup = null; + } + } + + private stopPollingTimerOnly(): void { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private pollTick(): void { + if (isPeerDeviceModeActive()) { + // In peer-device mode the controller is not the execution host; rely + // on the transport's state sync instead of issuing local git calls. + return; + } + for (const repoKey of this.subscribers.keys()) { + const scope = this.scopeForKey(repoKey); + // Invalidate the basic-layer timestamp so the non-forced refresh below + // bypasses the Infinity TTL and actually hits Git. We only invalidate + // `basic` — status/detailed keep their longer TTLs and are refreshed + // on demand or by user-driven events, not by this tick. + this.invalidateCache(scope, ['basic']); + this.refresh(scope, { + layers: ['basic'], + reason: 'interval', + silent: true, + }).catch((error) => { + log.debug('Poll-tick refresh failed', { repositoryPath: repoKey, error }); + }); + } + } // -------------------------------------------------------------------------