Improve branch switch safety and recent branch status (#3302)
* feat(ui): block branch switches on dirty trees * feat(ui): show unpushed commits in git branch selector * feat(ui): show recent branches in git selector * fix(ui): persist recent branch status * feat(ui): add mobile branch picker * fix(ui): guard mobile branch checkout * fix(i18n): restore Turkish git empty state labels * feat(ui): flag dirty draft directories on the branch selector Replaces the draft dirty-directory banner with an indicator on the branch selector: a warning icon plus a hover tooltip that opens by itself for five seconds when the dirty state first appears, then stays hover-only. The copy states the situation and the options (commit or worktree) without prescribing either. * feat(ui): optional push in the dirty branch switch dialog Commit-and-switch gains an opt-in "Push after commit" checkbox. When the push fails the commit stands but the switch is cancelled with an explicit toast, so the user is never moved off a branch without knowing its push did not happen. Without the checkbox the toast states the commit is local only. * fix(i18n): align dirty-directory copy across locales * fix(a11y): name the unpushed-commit badge in the branch picker The badge showed a bare arrow and number with no accessible name or tooltip. Both the desktop recents list and the mobile picker now carry a localized "N commits not pushed" title and aria-label. * fix(mobile): push before switching dirty branches Honor the dirty-switch dialog's push option on the mobile Changes surface. A failed push leaves the new commit on its source branch, refreshes state, and cancels checkout. Mobile branch selection now also shows the existing dirty switch notice.
This commit is contained in:
@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
selectedDraftDirectory,
|
||||
selectedDraftBranchLabel,
|
||||
selectedDraftBranchIsKnown,
|
||||
selectedDraftDirectoryHasUncommittedChanges,
|
||||
projectRootBranchOption,
|
||||
worktreeBranchOptions,
|
||||
draftBranchItems,
|
||||
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
<MobileDraftTargetTriggers
|
||||
selectedProject={selectedDraftProject}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||
showBranchSelector={shouldShowDraftBranchSelector}
|
||||
theme={currentTheme}
|
||||
onOpenPicker={setMobileDraftPicker}
|
||||
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
|
||||
@@ -169,7 +169,9 @@ and the send path reading the same grammar.
|
||||
recorded before a queued write could resurrect it.
|
||||
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
||||
exist yet (a worktree being created). It must survive not appearing in the
|
||||
branch list, or the selector snaps back to the project root mid-creation.
|
||||
branch list, or the selector snaps back to the project root mid-creation. It
|
||||
also owns the advisory dirty state for the selected directory, clearing it as
|
||||
soon as the target changes so a warning never names a previous branch.
|
||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
@@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
|
||||
/** How long a cached branch list is served before it is refreshed. */
|
||||
const BRANCHES_SWR_TTL_MS = 30_000;
|
||||
@@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
||||
const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
|
||||
@@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) {
|
||||
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!enabled
|
||||
|| !selectedDraftDirectory
|
||||
|| selectedDraftProject?.kind === 'chat'
|
||||
|| newSessionDraft?.pendingWorktreeRequestId
|
||||
|| newSessionDraft?.bootstrapPendingDirectory
|
||||
) {
|
||||
setDirtyDraftDirectory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDirtyDraftDirectory(null);
|
||||
getGitStatus(selectedDraftDirectory, { mode: 'light' })
|
||||
.then((status) => {
|
||||
if (!cancelled && (status.files?.length ?? 0) > 0) {
|
||||
setDirtyDraftDirectory(selectedDraftDirectory);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDirtyDraftDirectory(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
|
||||
|
||||
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
||||
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
||||
return Boolean(
|
||||
@@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
selectedDraftDirectory,
|
||||
selectedDraftBranchLabel,
|
||||
selectedDraftBranchIsKnown,
|
||||
selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory,
|
||||
projectRootBranchOption,
|
||||
worktreeBranchOptions,
|
||||
draftBranchItems,
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import {
|
||||
Select,
|
||||
@@ -44,6 +45,7 @@ export interface DraftTargetProps {
|
||||
selectedDirectory: string | null;
|
||||
selectedBranchLabel: string | null;
|
||||
selectedBranchIsKnown: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
projectRootBranchOption: BranchOption | null;
|
||||
worktreeBranchOptions: readonly BranchOption[];
|
||||
branchItems: readonly BranchOption[];
|
||||
@@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme:
|
||||
}
|
||||
|
||||
/** Desktop: inline project and branch selects. */
|
||||
/** How long the dirty-directory tooltip announces itself before becoming hover-only. */
|
||||
const DIRTY_TOOLTIP_FLASH_MS = 5000;
|
||||
|
||||
/**
|
||||
* Opens the tooltip for a few seconds when the dirty state first appears, so
|
||||
* the warning is seen without hovering, then hands control back to hover.
|
||||
*/
|
||||
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasUncommittedChanges) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [hasUncommittedChanges]);
|
||||
|
||||
return { open, onOpenChange: setOpen };
|
||||
}
|
||||
|
||||
export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
const { t } = useI18n();
|
||||
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
|
||||
const {
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedDirectory,
|
||||
selectedBranchLabel,
|
||||
selectedBranchIsKnown,
|
||||
hasUncommittedChanges,
|
||||
projectRootBranchOption,
|
||||
worktreeBranchOptions,
|
||||
branchItems,
|
||||
@@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
{hasUncommittedChanges ? (
|
||||
<Icon
|
||||
name="alert"
|
||||
className="size-3.5 shrink-0 text-[var(--status-warning)]"
|
||||
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
|
||||
/>
|
||||
) : null}
|
||||
<SelectValue>
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
{hasUncommittedChanges ? (
|
||||
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
|
||||
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
|
||||
/** Mobile: buttons that open the bottom sheets below. */
|
||||
export function MobileDraftTargetTriggers(
|
||||
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
|
||||
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
|
||||
& { onOpenPicker: (picker: 'project' | 'branch') => void },
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
|
||||
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
|
||||
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
|
||||
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{showBranchSelector ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => onOpenPicker('branch')}
|
||||
>
|
||||
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => onOpenPicker('branch')}
|
||||
>
|
||||
{hasUncommittedChanges ? (
|
||||
<Icon
|
||||
name="alert"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
|
||||
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{hasUncommittedChanges ? (
|
||||
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
|
||||
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user