From 7a1a6b44e152e54ec201303d787a9be942b0c4bc Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 09:56:32 +0800 Subject: [PATCH 1/3] fix(web-ui): read every branch label from one refreshed source Branch names were read from several independent copies, so a switch made in the integrated terminal, by the agent, or by another surface left most of the UI stale until the window was refocused. - Poll the shared Git state every 2s while a repository has subscribers, pausing while the document is hidden and in peer-device mode. A single timer in GitStateManager serves every consumer instead of each surface keeping one. - Let a completed checkout write the shared current branch directly and broadcast it, so a manual switch moves every surface at once rather than waiting for the next poll or a Git round trip. The reconciling refresh still runs afterwards, so Git remains the source of truth. - Read the branch from that shared value in the welcome panel, the chat empty state, the Git graph header, the branch list and the branch picker, instead of from each surface's own fetch. - Force refreshBasic(), which the infinite TTL of the basic layer had turned into a silent no-op for explicit callers. - Delete the unreferenced BranchSelectModal surface together with its locale keys, since it was the last consumer of the old pattern. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../panels/BranchSelectModal.appearance.ts | 14 - .../components/panels/BranchSelectModal.scss | 289 -------------- .../panels/BranchSelectModal.test.tsx | 89 ----- .../components/panels/BranchSelectModal.tsx | 355 ------------------ .../src/app/scenes/git/views/BranchesView.tsx | 39 +- .../flow_chat/components/ChatEmptyState.tsx | 48 +-- .../components/WelcomePanel.test.tsx | 78 ++-- .../src/flow_chat/components/WelcomePanel.tsx | 77 ++-- .../registry/defaultAppearanceRegistry.ts | 2 - src/web-ui/src/locales/en-US/panels/git.json | 16 - src/web-ui/src/locales/zh-CN/panels/git.json | 16 - src/web-ui/src/locales/zh-TW/panels/git.json | 16 - .../git/components/BranchQuickSwitch.tsx | 25 +- .../GitBranchHistoryView.tsx | 23 +- .../components/GitGraphView/GitGraphView.tsx | 39 +- src/web-ui/src/tools/git/hooks/useGitState.ts | 6 +- .../tools/git/state/GitStateManager.test.ts | 128 +++++++ .../src/tools/git/state/GitStateManager.ts | 166 +++++++- 18 files changed, 495 insertions(+), 931 deletions(-) delete mode 100644 src/web-ui/src/app/components/panels/BranchSelectModal.appearance.ts delete mode 100644 src/web-ui/src/app/components/panels/BranchSelectModal.scss delete mode 100644 src/web-ui/src/app/components/panels/BranchSelectModal.test.tsx delete mode 100644 src/web-ui/src/app/components/panels/BranchSelectModal.tsx 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..b783290c0f 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -465,3 +465,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..8be407fae5 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,84 @@ 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.pollTimer !== null) return; + + this.pollTimer = setInterval(() => { + this.pollTick(); + }, POLL_INTERVAL_MS); + + // Pause polling while the tab is hidden — the user isn't looking at the + // stale branch label, and `window focus` already does a catch-up refresh + // when they come back. This mirrors the visibility-gating used elsewhere. + const handleVisibility = (): void => { + if (document.hidden) { + this.stopPollingTimerOnly(); + } else { + if (this.subscribers.size > 0 && this.pollTimer === null) { + // 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.pollTimer = setInterval(() => { + this.pollTick(); + }, POLL_INTERVAL_MS); + } + } + }; + document.addEventListener('visibilitychange', handleVisibility); + this.pollVisibilityCleanup = () => { + document.removeEventListener('visibilitychange', handleVisibility); + }; + } + + 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 }); + }); + } + } // ------------------------------------------------------------------------- From 073638ebddaf9f4bcaf2f0b703989761537f8700 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 09:56:35 +0800 Subject: [PATCH 2/3] fix(mobile-web): make a failed file card retryable and a notice readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file card carried its failure reason only in `title`, which no touch host shows, and offered no way to ask again — even though the host resolves file metadata on demand, so a file can appear after the message that names it, and a target switch can fail the request. Show the reason in the card and add a retry that re-runs the lookup. The chat notice floats over the transcript while the shared status surface is a 10% tint, so the text behind it bled through the message. Composite the tone tint onto an opaque raised surface instead. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../src/components/ChatMarkdown.tsx | 23 +++++++++-- .../src/styles/components/chat.scss | 11 +++++ .../src/styles/components/markdown.scss | 27 +++++++++++- .../tests/mobile-ui-components.test.mjs | 41 +++++++++++++++++++ .../MobileOutputArtifacts.test.tsx | 20 +++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) 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'); + }); }); From 6b540909de0c4ac94bb2d70fdc837c5c23799e89 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 19 Sep 2026 10:11:25 +0800 Subject: [PATCH 3/3] fix(web-ui): keep git polling paused while hidden --- .../tools/git/state/GitStateManager.test.ts | 46 ++++++++++++++++++ .../src/tools/git/state/GitStateManager.ts | 48 ++++++++++--------- 2 files changed, 72 insertions(+), 22 deletions(-) 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 b783290c0f..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'], diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index 8be407fae5..d3199d9da0 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -1006,33 +1006,37 @@ export class GitStateManager { */ private ensurePollingStarted(): void { if (typeof window === 'undefined') return; - if (this.pollTimer !== null) return; - this.pollTimer = setInterval(() => { - this.pollTick(); - }, POLL_INTERVAL_MS); - - // Pause polling while the tab is hidden — the user isn't looking at the - // stale branch label, and `window focus` already does a catch-up refresh - // when they come back. This mirrors the visibility-gating used elsewhere. - const handleVisibility = (): void => { - if (document.hidden) { - this.stopPollingTimerOnly(); - } else { - if (this.subscribers.size > 0 && this.pollTimer === null) { + 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.pollTimer = setInterval(() => { - this.pollTick(); - }, POLL_INTERVAL_MS); + this.startPollingTimer(); } - } - }; - document.addEventListener('visibilitychange', handleVisibility); - this.pollVisibilityCleanup = () => { - document.removeEventListener('visibilitychange', handleVisibility); - }; + }; + 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 {