Files
openchamber/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx
T
Bohdan Triapitsyn 63da6e6292 feat(ui): unify overlay animations and trim tooltip delays
Use base-ui's transition-status pattern (data-starting-style /
data-ending-style) for Dialog, DropdownMenu, Select, Tooltip and Popover
so open/close animate consistently at 150ms ease-out without flicker.
Wrap dialog popups in a centered flex container so the scale animation
no longer fights with translate-based positioning, and switch
PendingChangesBar to a real Popover so it actually animates closed.

Convert ScheduledTaskEditorDialog from raw Radix to the shared Dialog
wrapper, give ScheduledTasksDialog a stable min-height to prevent layout
shift mid-animation, and reset NewWorktreeDialog form state on open
instead of close so fields don't empty during the close transition.

Drop tooltip delay from 700ms to 300ms globally and remove all
per-component overrides except the model/variant/agent selectors in the
chat input (600ms).
2026-04-30 22:41:33 +03:00

131 lines
5.7 KiB
TypeScript

import React from 'react';
import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import type { ToolPart } from '@opencode-ai/sdk/v2';
import { Popover } from '@base-ui/react/popover';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import {
type ChangedFile,
type ChangedFileEntry,
FILE_EDIT_TOOLS,
extractChangedFiles,
isGitFile,
toRelativePath,
} from './changedFiles';
import { ChangedFilesList } from './ChangedFilesList';
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { TurnActivityRecord } from './lib/turns/types';
interface TurnChangedFilesDropdownProps {
activityParts: TurnActivityRecord[] | undefined;
}
export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> = React.memo(({ activityParts }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
const changedFiles = React.useMemo<ChangedFile[]>(() => {
// Skip work entirely in git repos — the global PendingChangesBar handles those.
if (isGitRepo !== false) return [];
if (!activityParts || activityParts.length === 0) return [];
const toolParts: ToolPart[] = [];
for (const activity of activityParts) {
const part = activity.part;
if (part.type !== 'tool') continue;
if (!FILE_EDIT_TOOLS.has(part.tool)) continue;
toolParts.push(part);
}
if (toolParts.length === 0) return [];
return extractChangedFiles(toolParts);
}, [activityParts, isGitRepo]);
if (changedFiles.length === 0) return null;
const syncPortalContainer = () => {
const container = triggerButtonRef.current?.closest('[data-slot="dialog-content"], [role="dialog"]') as HTMLElement | null;
setPortalContainer(container || null);
};
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (isGitFile(file)) return;
const absolutePath = file.path.startsWith('/')
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
const editor = runtime?.editor;
if (editor) {
void editor.openFile(absolutePath);
setIsExpanded(false);
return;
}
const store = useUIStore.getState();
if (!store.isMobile) {
store.openContextFile(currentDirectory, absolutePath);
setIsExpanded(false);
return;
}
store.navigateToDiff(toRelativePath(file.path, currentDirectory));
store.setRightSidebarOpen(false);
setIsExpanded(false);
};
const fileCount = changedFiles.length;
const label = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
return (
<Popover.Root open={isExpanded} onOpenChange={setIsExpanded}>
<Tooltip>
<TooltipTrigger asChild>
<Popover.Trigger
render={
<button
ref={triggerButtonRef}
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground/60 hover:text-muted-foreground tabular-nums"
aria-label={`${label} changed in this turn`}
onPointerDownCapture={syncPortalContainer}
onFocusCapture={syncPortalContainer}
>
<RiFileEditLine className="h-3.5 w-3.5" />
<span className="message-footer__label">{label}</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5" />
)}
</button>
}
/>
</TooltipTrigger>
<TooltipContent>{label} changed in this turn</TooltipContent>
</Tooltip>
<Popover.Portal container={portalContainer || undefined}>
<Popover.Positioner side="top" align="start" sideOffset={4} collisionPadding={8}>
<Popover.Popup
style={changedFilesPopoverStyle}
className={`${changedFilesPopoverClassName} transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95`}
>
<ChangedFilesList
files={changedFiles}
currentDirectory={currentDirectory}
onOpenFile={handleOpenFile}
/>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
});
TurnChangedFilesDropdown.displayName = 'TurnChangedFilesDropdown';