feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569)

* feat: add chat option for user message rendering mode

* feat: add chat option to toggle sticky user header

* feat(ui): overhaul context panel with reusable tabs and embedded session chat

Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead.

* feat: polish context panel and git sidebar tabs

Refined context panel tab behavior and visuals for smoother switching and resizing
Reused the new tabs component in right sidebar and git sidebar with fit layout
Improved git section spacing, selection controls, and bulk revert confirmation flow

* feat: open diff files in editor at changed lines

Add edit actions in diff views to open files at the first changed line
Support per-file open-in-editor from All Files headers and icon-only action in single-file view
Improve file jump UX with load-aware navigation and reduced visual blink during line targeting

* fix: stabilize pill tabs and prevent git commit pathspec failures

Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome
Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen
Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files

* fix: align user message action row spacing and hover behavior

* fix: persist user message view preferences in settings

Save plain-text and sticky-header toggles to settings.json when changed
Restore both chat display preferences from settings.json on startup
Validate and accept both preference fields in the settings API

* fix: improve git and sidebar tab layout on mobile

* fix: refine mobile user message action row spacing

Show mobile user-message actions in a consistent external row for sticky and non-sticky modes
Tune button row height and vertical position to match both mobile variants
Reduce sticky-header gradient tail and tighten assistant gap after user messages

* fix: improve chat action hover zones and mobile top shadow logic

Expand desktop trigger area so user action buttons reveal across the full row
Add sticky-header phantom hover row so inline actions appear from the whole button lane
Hide chat top scroll shadow on mobile only when sticky user headers are enabled

* fix: remove commit message input scrollbar flicker

Added optional scrollbar class support to shared textarea wrapper.
Disabled overlay scrollbar for Git commit message input.
Kept auto-resize behavior while preventing one-line empty-state micro-scroll.

* feat: make model provider groups collapsible in selector

Add collapsible provider headers in the chat model dropdown
Persist expanded/collapsed provider state across sessions
Refine provider header UX with inline chevrons and no hover highlight

* feat: arrange chat settings into a compact two-column layout

Places User Message Rendering next to Mermaid Rendering.
Places Diff Layout next to Diff View Mode.
Reduces right-column spacing to better match other settings sections.

* fix: show worktree branch edit controls in draft sessions

Detect worktree mode from current directory when session metadata is not yet bound
Enable immediate branch rename UI in Git sidebar without session switching

* feat: add beta badge to side panel menu action
This commit is contained in:
Bohdan Triapitsyn
2026-03-02 02:11:33 +02:00
committed by GitHub
parent 73e533a315
commit b4cd16f55b
45 changed files with 3551 additions and 975 deletions
+169 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiEditLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -111,6 +111,60 @@ const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
const toAbsolutePath = (directory: string, filePath: string): string => {
const normalizedDirectory = directory.replace(/\\/g, '/').replace(/\/+$/g, '');
const normalizedFilePath = filePath.replace(/\\/g, '/');
if (isAbsolutePath(normalizedFilePath)) {
return normalizedFilePath;
}
const trimmedFilePath = normalizedFilePath.replace(/^\/+/, '');
return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath;
};
const getFirstChangedModifiedLine = (original: string, modified: string): number => {
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
const sharedLength = Math.min(originalLines.length, modifiedLines.length);
for (let index = 0; index < sharedLength; index += 1) {
if (originalLines[index] !== modifiedLines[index]) {
return index + 1;
}
}
if (modifiedLines.length > originalLines.length) {
return originalLines.length + 1;
}
if (originalLines.length > modifiedLines.length) {
return Math.max(1, modifiedLines.length);
}
return 1;
};
const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => {
if (!patch) {
return null;
}
const match = patch.match(/@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/m);
if (!match) {
return null;
}
const parsed = Number.parseInt(match[1], 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return null;
}
return parsed;
};
const formatDiffTotals = (insertions?: number, deletions?: number) => {
const added = insertions ?? 0;
const removed = deletions ?? 0;
@@ -547,6 +601,9 @@ interface MultiFileDiffEntryProps {
defaultCollapsed?: boolean;
expandRequestPath?: string | null;
expandRequestNonce?: number;
showOpenInEditorAction?: boolean;
isOpeningInEditor?: boolean;
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -561,6 +618,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
defaultCollapsed = false,
expandRequestPath = null,
expandRequestNonce = 0,
showOpenInEditorAction = false,
isOpeningInEditor = false,
onOpenInEditor,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
@@ -763,6 +823,25 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
</div>
<div className="relative flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
{showOpenInEditorAction && onOpenInEditor ? (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
title="Open this file in editor at change"
onClick={(event) => {
event.stopPropagation();
onOpenInEditor(file.path, diffData);
}}
disabled={isOpeningInEditor}
>
{isOpeningInEditor ? (
<RiLoader4Line className="size-3.5 animate-spin" />
) : (
<RiEditLine className="size-3.5" />
)}
</Button>
) : null}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
@@ -819,6 +898,7 @@ interface DiffViewProps {
stackedDefaultCollapsedAll?: boolean;
hideFileSelector?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
}
export const DiffView: React.FC<DiffViewProps> = ({
@@ -826,6 +906,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
stackedDefaultCollapsedAll = false,
hideFileSelector = false,
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
}) => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
@@ -853,6 +934,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const diffViewMode = useUIStore((state) => state.diffViewMode);
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
// Default to wrap on mobile
const diffWrapLines = isMobile || diffWrapLinesStore;
@@ -1283,6 +1365,69 @@ export const DiffView: React.FC<DiffViewProps> = ({
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
}, [selectedCachedDiff]);
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
const openFileInEditorAtChange = React.useCallback(async (filePath: string, cachedDiffData: DiffData | null) => {
if (!effectiveDirectory || !filePath) {
return;
}
setOpeningEditorFilePath(filePath);
try {
let targetLine: number | null = null;
if (cachedDiffData && !cachedDiffData.isBinary && !isImageFile(filePath)) {
targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified);
}
if (targetLine === null) {
try {
const patchResponse = await git.getGitDiff(effectiveDirectory, {
path: filePath,
contextLines: 3,
});
targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff);
} catch {
targetLine = null;
}
}
let diffForNavigation = cachedDiffData;
if (targetLine === null || !diffForNavigation) {
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath });
diffForNavigation = {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
};
setDiff(effectiveDirectory, filePath, diffForNavigation);
}
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
? 1
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
openContextFileAtLine(
effectiveDirectory,
toAbsolutePath(effectiveDirectory, filePath),
resolvedTargetLine,
1,
);
} finally {
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
}
}, [effectiveDirectory, git, openContextFileAtLine, setDiff]);
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
if (!selectedFile) {
return;
}
await openFileInEditorAtChange(selectedFile, selectedDiffData);
}, [openFileInEditorAtChange, selectedDiffData, selectedFile]);
const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile);
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
@@ -1402,6 +1547,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
defaultCollapsed={stackedDefaultCollapsedAll ? true : index >= defaultExpandedCount}
expandRequestPath={stackedExpandTarget}
expandRequestNonce={stackedExpandRequestNonce}
showOpenInEditorAction={showOpenInEditorAction}
isOpeningInEditor={openingEditorFilePath === file.path}
onOpenInEditor={(filePath, diffData) => {
void openFileInEditorAtChange(filePath, diffData);
}}
/>
))}
</div>
@@ -1528,6 +1678,24 @@ export const DiffView: React.FC<DiffViewProps> = ({
<RiTextWrap className="size-4" />
</Button>
)}
{showOpenInEditorAction && selectedFileEntry && !isStackedView && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
onClick={() => {
void openSelectedFileInEditorAtChange();
}}
disabled={isOpeningSelectedInEditor}
title="Open this file at first changed line"
>
{isOpeningSelectedInEditor ? (
<RiLoader4Line className="size-3.5 animate-spin" />
) : (
<RiEditLine className="size-3.5" />
)}
</Button>
)}
{selectedFileEntry && currentLayoutForSelectedFile && (
<DiffViewToggle
mode={currentLayoutForSelectedFile === 'side-by-side' ? 'side-by-side' : 'unified'}
+305 -98
View File
@@ -399,6 +399,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
const showEditorTabsRow = isMobile || mode !== 'editor-only';
const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile;
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const gitStatus = useGitStatus(currentDirectory);
@@ -410,7 +412,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [wrapLines, setWrapLines] = React.useState(isMobile);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('view');
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
@@ -504,6 +506,18 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const copiedPathTimeoutRef = React.useRef<number | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
const [editorViewReadyNonce, setEditorViewReadyNonce] = React.useState(0);
const pendingNavigationRafRef = React.useRef<number | null>(null);
const pendingNavigationCycleRef = React.useRef<{ key: string; attempts: number }>({ key: '', attempts: 0 });
React.useEffect(() => {
return () => {
if (pendingNavigationRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(pendingNavigationRafRef.current);
pendingNavigationRafRef.current = null;
}
};
}, []);
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
@@ -544,6 +558,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
// Session/config for sending comments
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
// Global mouseup to end drag selection
React.useEffect(() => {
@@ -1163,7 +1179,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(node.path);
setLoadedFilePath(null);
const selectedIsImage = isImageFile(node.path);
const isSvg = node.path.toLowerCase().endsWith('.svg');
@@ -1184,6 +1200,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent('');
setDraftContent('');
setLoadedFilePath(node.path);
setFileLoading(false);
return;
}
@@ -1196,6 +1213,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
setDraftContent(content.length > MAX_VIEW_CHARS
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: content);
setLoadedFilePath(node.path);
})
.catch((error) => {
if (isDirectoryReadError(error)) {
@@ -1535,6 +1553,19 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const selectedFilePath = selectedFile?.path ?? '';
const pendingNavigationTargetPath = React.useMemo(
() => normalizePath(pendingFileNavigation?.path ?? ''),
[pendingFileNavigation?.path],
);
const shouldMaskEditorForPendingNavigation = Boolean(
pendingFileNavigation
&& pendingNavigationTargetPath
&& selectedFilePath
&& selectedFilePath === pendingNavigationTargetPath
&& !fileLoading
&& !fileError
&& !isSelectedImage,
);
const displaySelectedPath = React.useMemo(() => {
return getDisplayPath(root, selectedFilePath);
@@ -1580,9 +1611,151 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
}, [canEdit, textViewMode]);
React.useEffect(() => {
setTextViewMode('view');
setTextViewMode('edit');
}, [selectedFile?.path]);
React.useEffect(() => {
if (!pendingFileNavigation || !root) {
return;
}
const scheduleNavigationRetry = () => {
if (typeof window === 'undefined') {
return;
}
if (pendingNavigationRafRef.current !== null) {
return;
}
pendingNavigationRafRef.current = window.requestAnimationFrame(() => {
pendingNavigationRafRef.current = null;
setEditorViewReadyNonce((value) => value + 1);
});
};
const isEditorSyncedWithDraft = (view: EditorView, expectedContent: string): boolean => {
if (view.state.doc.length !== expectedContent.length) {
return false;
}
if (expectedContent.length === 0) {
return true;
}
const sampleSize = Math.min(128, expectedContent.length);
const startSample = view.state.sliceDoc(0, sampleSize);
if (startSample !== expectedContent.slice(0, sampleSize)) {
return false;
}
const endFrom = Math.max(0, expectedContent.length - sampleSize);
const endSample = view.state.sliceDoc(endFrom, expectedContent.length);
return endSample === expectedContent.slice(endFrom);
};
const targetPath = normalizePath(pendingFileNavigation.path);
if (!targetPath) {
setPendingFileNavigation(null);
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
return;
}
const navigationKey = `${targetPath}:${pendingFileNavigation.line}:${pendingFileNavigation.column ?? 1}`;
if (pendingNavigationCycleRef.current.key !== navigationKey) {
pendingNavigationCycleRef.current = { key: navigationKey, attempts: 0 };
}
if (selectedFile?.path !== targetPath) {
if (selectedPath !== targetPath) {
setSelectedPath(root, targetPath);
}
return;
}
if (fileLoading || loadedFilePath !== targetPath) {
return;
}
if (fileError || isSelectedImage) {
setPendingFileNavigation(null);
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
return;
}
if (!canEdit) {
return;
}
if (textViewMode !== 'edit') {
setTextViewMode('edit');
return;
}
const view = editorViewRef.current;
if (!view) {
scheduleNavigationRetry();
return;
}
if (!isEditorSyncedWithDraft(view, draftContent)) {
scheduleNavigationRetry();
return;
}
const targetLineNumber = Math.max(1, Math.min(pendingFileNavigation.line, view.state.doc.lines));
const targetLine = view.state.doc.line(targetLineNumber);
const targetColumn = Math.max(1, pendingFileNavigation.column || 1);
const lineLength = Math.max(0, targetLine.to - targetLine.from);
const clampedColumnOffset = Math.min(lineLength, targetColumn - 1);
const targetPosition = targetLine.from + clampedColumnOffset;
const isAtTarget = view.state.selection.main.head === targetPosition;
const shouldDispatch = !isAtTarget || pendingNavigationCycleRef.current.attempts === 0;
if (shouldDispatch) {
pendingNavigationCycleRef.current.attempts += 1;
view.dispatch({
selection: { anchor: targetPosition },
effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }),
});
view.focus();
scheduleNavigationRetry();
return;
}
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
const syncedView = editorViewRef.current;
if (!syncedView) {
return;
}
syncedView.dispatch({
selection: { anchor: targetPosition },
effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }),
});
syncedView.focus();
});
}
setPendingFileNavigation(null);
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
}, [
canEdit,
draftContent,
editorViewReadyNonce,
fileError,
fileLoading,
isSelectedImage,
loadedFilePath,
pendingFileNavigation,
root,
selectedFile?.path,
selectedPath,
setPendingFileNavigation,
setSelectedPath,
textViewMode,
]);
const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => {
if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') {
return;
@@ -1707,12 +1880,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
.then((src) => {
if (!cancelled) {
setDesktopImageSrc(src);
setLoadedFilePath(selectedFile.path);
}
})
.catch((error) => {
if (!cancelled) {
setDesktopImageSrc('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
setLoadedFilePath(null);
}
})
.finally(() => {
@@ -1858,6 +2033,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
</Dialog>
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
{/* Row 1: Tabs */}
{showEditorTabsRow ? (
<div className="flex min-w-0 items-center px-3 py-1.5">
{isMobile && showMobilePageContent && (
<button
@@ -1997,10 +2173,11 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
)
)}
</div>
) : null}
{/* Row 2: Actions (right-aligned) */}
{selectedFile && (
<div className="flex items-center justify-end gap-1 px-3 pb-1.5">
<div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}>
{canEdit && textViewMode === 'edit' && (
<Button
variant="ghost"
@@ -2168,10 +2345,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
) : fileLoading ? (
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading
</div>
suppressFileLoadingIndicator
? <div className="p-3" />
: (
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading
</div>
)
) : fileError ? (
<div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div>
) : isSelectedImage ? (
@@ -2210,102 +2391,114 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
renderShikiFileView(selectedFile, draftContent)
) : (
<div
className="relative h-full"
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
ref={editorWrapperRef}
data-keyboard-avoid="none"
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
>
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
extensions={editorExtensions}
className="h-full"
blockWidgets={blockWidgets}
onViewReady={(view) => {
editorViewRef.current = view;
window.requestAnimationFrame(() => {
nudgeEditorSelectionAboveKeyboard(view);
});
}}
onViewDestroy={() => {
if (editorViewRef.current) {
editorViewRef.current = null;
}
}}
enableSearch
searchOpen={isSearchOpen}
onSearchOpenChange={setIsSearchOpen}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.button !== 0) {
return false;
}
event.preventDefault();
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
extensions={editorExtensions}
className="h-full"
blockWidgets={blockWidgets}
onViewReady={(view) => {
editorViewRef.current = view;
setEditorViewReadyNonce((value) => value + 1);
window.requestAnimationFrame(() => {
nudgeEditorSelectionAboveKeyboard(view);
});
}}
onViewDestroy={() => {
if (editorViewRef.current) {
editorViewRef.current = null;
}
setEditorViewReadyNonce((value) => value + 1);
}}
enableSearch
searchOpen={isSearchOpen}
onSearchOpenChange={setIsSearchOpen}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.button !== 0) {
return false;
}
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end });
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.buttons !== 1) {
return false;
}
if (!isSelectingRef.current || selectionStartRef.current === null) {
return false;
}
const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.buttons !== 1) {
return false;
}
if (!isSelectingRef.current || selectionStartRef.current === null) {
return false;
}
const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
setIsDragging(true);
return false;
return false;
},
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return false;
},
},
}}
/>
}}
/>
</div>
{shouldMaskEditorForPendingNavigation && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Opening file at change...
</div>
</div>
)}
</div>
)}
</ScrollableOverlay>
@@ -2567,10 +2760,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{fileLoading ? (
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading
</div>
suppressFileLoadingIndicator
? <div className="p-4" />
: (
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading
</div>
)
) : fileError ? (
<div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div>
) : isSelectedImage ? (
@@ -2608,7 +2805,8 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
) : canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, draftContent)
) : (
<div className="h-full">
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
@@ -2626,6 +2824,15 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
}
}}
/>
</div>
{shouldMaskEditorForPendingNavigation && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Opening file at change...
</div>
</div>
)}
</div>
)}
</ScrollableOverlay>
+156 -62
View File
@@ -26,7 +26,7 @@ import {
RiSplitCellsHorizontal,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
DialogContent,
@@ -224,10 +224,44 @@ interface GitViewProps {
export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore();
const worktreeMetadata = currentSessionId
? worktreeMap.get(currentSessionId) ?? undefined
: undefined;
const {
currentSessionId,
worktreeMetadata: worktreeMap,
availableWorktrees,
newSessionDraft,
} = useSessionStore();
const normalizedCurrentDirectory = normalizePath(currentDirectory);
const inferredWorktreeMetadata = React.useMemo(() => {
if (!normalizedCurrentDirectory) {
return undefined;
}
const fromAvailable = availableWorktrees.find(
(metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory
);
if (fromAvailable) {
return fromAvailable;
}
for (const metadata of worktreeMap.values()) {
if (normalizePath(metadata.path) === normalizedCurrentDirectory) {
return metadata;
}
}
return undefined;
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
const worktreeMetadata = React.useMemo(() => {
if (currentSessionId) {
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
}
if (newSessionDraft?.open) {
return inferredWorktreeMetadata;
}
return undefined;
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
@@ -343,6 +377,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
);
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
@@ -406,6 +441,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const actionTabItems = React.useMemo(() => [
{ id: 'commit', label: 'Commit', icon: <RiGitCommitLine className="h-3.5 w-3.5" /> },
{ id: 'branch', label: 'Update', icon: <RiGitMergeLine className="h-3.5 w-3.5" /> },
{ id: 'pr', label: 'PR', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
{ id: 'worktree', label: 'Worktree', icon: <RiSplitCellsHorizontal className="h-3.5 w-3.5" /> },
], []);
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
if (typeof window === 'undefined') {
return 'commit';
@@ -1253,6 +1295,56 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
[currentDirectory, refreshStatusAndBranches, git]
);
const handleRevertAll = React.useCallback(
async (paths: string[]) => {
if (!currentDirectory || paths.length === 0 || isRevertingAll) {
return;
}
const uniquePaths = Array.from(new Set(paths));
setIsRevertingAll(true);
setRevertingPaths((previous) => {
const next = new Set(previous);
uniquePaths.forEach((path) => next.add(path));
return next;
});
const failed: Array<{ path: string; message: string }> = [];
try {
await Promise.all(uniquePaths.map(async (filePath) => {
try {
await git.revertGitFile(currentDirectory, filePath);
} catch (err) {
failed.push({
path: filePath,
message: err instanceof Error ? err.message : 'Failed to revert changes',
});
}
}));
await refreshStatusAndBranches(false);
if (failed.length === 0) {
toast.success(`Reverted ${uniquePaths.length} file${uniquePaths.length === 1 ? '' : 's'}`);
} else if (failed.length === uniquePaths.length) {
toast.error(failed[0]?.message || 'Failed to revert changes');
} else {
const successCount = uniquePaths.length - failed.length;
toast.warning(`Reverted ${successCount} file${successCount === 1 ? '' : 's'}, ${failed.length} failed`);
}
} finally {
setRevertingPaths((previous) => {
const next = new Set(previous);
uniquePaths.forEach((path) => next.delete(path));
return next;
});
setIsRevertingAll(false);
}
},
[currentDirectory, git, isRevertingAll, refreshStatusAndBranches]
);
const handleInsertHighlights = React.useCallback(() => {
if (generatedHighlights.length === 0) return;
const normalizedHighlights = generatedHighlights
@@ -1709,29 +1801,25 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent border-t border-border/40' : 'bg-muted/10')}>
<div className="px-3 py-1.5">
<AnimatedTabs<ActionTab>
value={actionTab}
onValueChange={setActionTab}
size="sm"
collapseLabelsOnSmall
collapseLabelsOnNarrow={isSidebarMode}
tabs={[
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
{ value: 'branch', label: 'Update', icon: RiGitMergeLine },
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
]}
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent' : 'bg-muted/10')}>
<div className={cn(isMobile ? 'h-10 px-1.5' : 'h-8 px-2')}>
<SortableTabsStrip
items={actionTabItems}
activeId={actionTab}
onSelect={(tabID) => setActionTab(tabID as ActionTab)}
layoutMode="fit"
variant="active-pill"
inactiveTabsIconOnly={isSidebarMode && isMobile}
className="h-full"
/>
</div>
<div className="h-px bg-border/40" />
{!isSidebarMode ? <div className="h-px bg-border/40" /> : null}
<ScrollableOverlay
as={ScrollShadow}
ref={actionPanelScrollRef}
outerClassName="flex-1 min-h-0"
className="px-4 py-4"
className={cn('px-4', isSidebarMode ? 'pt-1 pb-4' : 'py-4')}
disableHorizontal
preventOverscroll
>
@@ -1750,6 +1838,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onRevertAll={handleRevertAll}
onViewDiff={(path) => {
if (isSidebarMode && currentDirectory && !isMobile) {
openContextDiff(currentDirectory, path);
@@ -1761,6 +1850,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
}
}}
onRevertFile={handleRevertFile}
isRevertingAll={isRevertingAll}
/>
<CommitSection
@@ -1819,52 +1909,56 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
) : null}
{actionTab === 'worktree' ? (
integrateCommitsProps ? (
<IntegrateCommitsSection
variant="plain"
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
<div className="typography-micro text-muted-foreground">
Available in worktree mode.
<div className="space-y-4">
{integrateCommitsProps ? (
<IntegrateCommitsSection
variant="plain"
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : (
<div className="space-y-1 pt-3">
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
<div className="typography-micro text-muted-foreground">
Available in worktree mode.
</div>
</div>
</div>
)
)}
</div>
) : null}
{actionTab === 'pr' ? (
pullRequestProps ? (
<PullRequestSection
variant="plain"
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
onGeneratedDescription={scrollActionPanelToBottom}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
<div className="typography-micro text-muted-foreground">
Push a non-base branch (with upstream) to create a PR.
<div className="space-y-4">
{pullRequestProps ? (
<PullRequestSection
variant="plain"
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
onGeneratedDescription={scrollActionPanelToBottom}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
<div className="typography-micro text-muted-foreground">
Push a non-base branch (with upstream) to create a PR.
</div>
</div>
</div>
)
)}
</div>
) : null}
</ScrollableOverlay>
</div>
@@ -198,13 +198,13 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{operationCompleted ? (
mode === 'dialog' ? (
<DialogFooter>
<Button variant="default" size="sm" onClick={handleClose}>
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</DialogFooter>
) : (
<div className="flex justify-end">
<Button variant="default" size="sm" onClick={handleClose}>
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</div>
@@ -214,7 +214,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
);
const renderForm = () => (
<>
<div className="space-y-4">
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">Operation</p>
@@ -291,15 +291,18 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
<Command>
<DropdownMenuContent
align="start"
className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-(--radix-dropdown-menu-content-available-height) flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
<CommandInput
ref={searchInputRef}
placeholder="Search branches..."
value={branchSearch}
onValueChange={setBranchSearch}
/>
<CommandList>
<CommandList className="h-full min-h-0" disableHorizontal>
<CommandEmpty>No branches found.</CommandEmpty>
{filteredLocal.length > 0 && (
@@ -349,8 +352,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) : null}
{mode === 'dialog' ? (
<DialogFooter className="gap-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
<DialogFooter className="gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel}>
Cancel
</Button>
<Button
@@ -358,7 +361,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="gap-1.5"
className="h-7 px-2 py-0 gap-1.5"
>
{operation === 'merge' ? (
<>
@@ -374,33 +377,35 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
</Button>
</DialogFooter>
) : (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={isDisabled}>
<div className="flex items-center gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel} disabled={isDisabled}>
Reset
</Button>
<div className="flex-1" />
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
{operation === 'merge' ? 'Merge' : 'Rebase'}
</Button>
</div>
)}
</>
</div>
);
const body = isOperating ? renderOperating() : renderForm();
if (mode === 'inline') {
return (
<div className="space-y-4">
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
<div className="typography-micro text-muted-foreground">
Bring changes from another branch into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
<section className="border-0 bg-transparent rounded-none">
<header className="border-b border-border/40 px-0 py-3">
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
<div className="typography-micro text-muted-foreground">
Bring changes from another branch into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
</div>
</div>
</div>
{body}
</div>
</header>
<div className="pt-3">{body}</div>
</section>
);
}
@@ -411,7 +416,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<Button
variant="outline"
size="sm"
className="h-8 px-2 gap-1.5"
className="h-7 px-2 py-0 gap-1.5"
onClick={handleOpenDialog}
disabled={isDisabled}
>
@@ -49,6 +49,7 @@ interface ChangeRowProps {
onRevert: () => void;
isReverting: boolean;
stats?: { insertions: number; deletions: number };
rowPaddingClassName?: string;
}
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
@@ -59,6 +60,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
onRevert,
isReverting,
stats,
rowPaddingClassName,
}) {
const descriptor = useMemo(() => describeChange(file), [file]);
const indicatorLabel = descriptor.description;
@@ -98,7 +100,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
return (
<div
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
role="button"
tabIndex={0}
onClick={onViewDiff}
@@ -1,6 +1,15 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { ChangeRow } from './ChangeRow';
@@ -15,8 +24,10 @@ interface ChangesSectionProps {
onToggleFile: (path: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onRevertAll?: (paths: string[]) => Promise<void> | void;
onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void;
isRevertingAll?: boolean;
variant?: 'framed' | 'plain';
maxListHeightClassName?: string;
onVisiblePathsChange?: (paths: string[]) => void;
@@ -33,8 +44,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onToggleFile,
onSelectAll,
onClearSelection,
onRevertAll,
onViewDiff,
onRevertFile,
isRevertingAll = false,
variant = 'framed',
maxListHeightClassName,
onVisiblePathsChange,
@@ -42,7 +55,11 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const selectedCount = selectedPaths.size;
const totalCount = changeEntries.length;
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
const hasAnySelected = selectedCount > 0;
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
const isPartiallySelected = hasAnySelected && !areAllSelected;
const rowVirtualizer = useVirtualizer({
count: totalCount,
@@ -82,66 +99,115 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
const headerClassName =
variant === 'framed'
? 'flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40'
: 'flex items-center justify-between gap-2 px-4 py-3 border-b border-border/40';
: 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
const scrollOuterClassName =
variant === 'framed'
? 'flex-1 min-h-0 max-h-[30vh]'
: `flex-1 min-h-0 ${maxListHeightClassName ?? ''}`.trim();
: `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
const rowPaddingClassName = variant === 'plain' ? 'pl-0 pr-2' : 'px-3';
const handleConfirmRevertAll = React.useCallback(async () => {
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
return;
}
await onRevertAll(changeEntries.map((entry) => entry.path));
setConfirmRevertAllOpen(false);
}, [changeEntries, isRevertingAll, onRevertAll]);
return (
<section className={containerClassName}>
<header className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">
{selectedCount}/{totalCount}
</span>
{totalCount > 0 && (
<>
<>
<section className={containerClassName}>
<header className={headerClassName}>
<div className="flex min-w-0 items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
{totalCount > 0 ? (
<button
type="button"
onClick={areAllSelected ? onClearSelection : onSelectAll}
disabled={isRevertingAll}
aria-checked={isPartiallySelected ? 'mixed' : hasAnySelected}
aria-label={areAllSelected ? 'Clear file selection' : 'Select all files'}
className={cn(
'inline-flex h-6 items-center gap-1 rounded px-1.5 text-muted-foreground',
'hover:bg-interactive-hover/55 hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isRevertingAll && 'cursor-not-allowed opacity-50'
)}
>
{hasAnySelected ? (
<RiCheckboxLine className={cn('size-4', isPartiallySelected ? 'text-primary/50' : 'text-primary')} />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
<span className="typography-meta text-muted-foreground">{selectedCount}/{totalCount}</span>
</button>
) : null}
</div>
<div className={cn('flex items-center gap-2', variant === 'plain' && 'pr-1')}>
{totalCount > 0 && onRevertAll ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onSelectAll}
className="h-6 px-2 text-xs text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setConfirmRevertAllOpen(true)}
disabled={isRevertingAll}
>
All
Revert all
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onClearSelection}
disabled={selectedCount === 0}
) : null}
</div>
</header>
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
<ScrollShadow
ref={scrollRef}
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
>
{shouldVirtualize ? (
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
None
</Button>
</>
)}
</div>
</header>
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
<ScrollShadow
ref={scrollRef}
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
>
{shouldVirtualize ? (
<div
className="relative w-full divide-y divide-border/60"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualRows.map((row) => {
const file = changeEntries[row.index];
if (!file) {
return null;
}
{virtualRows.map((row) => {
const file = changeEntries[row.index];
if (!file) {
return null;
}
return (
return (
<div
key={file.path}
ref={rowVirtualizer.measureElement}
data-index={row.index}
className={cn(
'absolute left-0 top-0 w-full',
row.index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
style={{ transform: `translateY(${row.start}px)` }}
>
<ChangeRow
file={file}
checked={selectedPaths.has(file.path)}
stats={diffStats?.[file.path]}
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path) || isRevertingAll}
rowPaddingClassName={rowPaddingClassName}
/>
</div>
);
})}
</div>
) : (
<div role="list" aria-label="Changed files">
{changeEntries.map((file, index) => (
<div
key={file.path}
ref={rowVirtualizer.measureElement}
data-index={row.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${row.start}px)` }}
className={cn(
'relative',
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
>
<ChangeRow
file={file}
@@ -150,31 +216,36 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path)}
isReverting={revertingPaths.has(file.path) || isRevertingAll}
rowPaddingClassName={rowPaddingClassName}
/>
</div>
);
})}
</div>
) : (
<div className="divide-y divide-border/60" role="list" aria-label="Changed files">
{changeEntries.map((file) => (
<ChangeRow
key={file.path}
file={file}
checked={selectedPaths.has(file.path)}
stats={diffStats?.[file.path]}
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path)}
/>
))}
</div>
)}
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
</div>
</section>
))}
</div>
)}
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
</div>
</section>
<Dialog open={confirmRevertAllOpen} onOpenChange={(open) => { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revert all changes?</DialogTitle>
<DialogDescription>
This will discard local changes for {totalCount} file{totalCount === 1 ? '' : 's'} in the list.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
{isRevertingAll ? 'Reverting...' : 'Revert all'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -22,15 +22,17 @@ export const CommitInput: React.FC<CommitInputProps> = ({
}) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
// Auto-resize based on content
React.useEffect(() => {
// Auto-resize based on content (layout phase to avoid mount flicker)
React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to measure scrollHeight accurately
textarea.style.height = `${MIN_HEIGHT}px`;
const newHeight = Math.min(Math.max(textarea.scrollHeight, MIN_HEIGHT), MAX_HEIGHT);
const contentHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(contentHeight, MIN_HEIGHT), MAX_HEIGHT);
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY = contentHeight > MAX_HEIGHT ? 'auto' : 'hidden';
}, [value]);
return (
@@ -44,8 +46,9 @@ export const CommitInput: React.FC<CommitInputProps> = ({
autoCorrect={hasTouchInput ? 'on' : 'off'}
autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
spellCheck={hasTouchInput ? true : false}
scrollbarClassName="hidden"
className={cn(
'rounded-lg bg-transparent resize-none overflow-y-auto',
'rounded-lg bg-transparent resize-none overflow-y-hidden',
disabled && 'opacity-50'
)}
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
@@ -64,11 +64,11 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
const headerClassName =
variant === 'framed'
? 'flex w-full items-center justify-between px-3 py-2'
: 'flex w-full items-center justify-between px-4 py-3 border-b border-border/40';
: 'flex w-full items-center justify-between px-0 py-3 border-b border-border/40';
const contentClassName =
variant === 'framed'
? 'flex flex-col gap-3 p-3 pt-0'
: 'flex flex-col gap-3 px-4 py-3';
: 'flex flex-col gap-3 px-0 py-3';
return (
<Collapsible
@@ -168,14 +168,14 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
{isMobile ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Commit & Push"
>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Push"
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
@@ -184,7 +184,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Commit & Push</p>
<p>Push</p>
</TooltipContent>
</Tooltip>
) : (
@@ -193,17 +193,17 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn"
aria-label="Commit & Push"
aria-label="Push"
>
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
<span className="commit-actions__label">Pushing...</span>
</>
) : (
<>
<RiArrowUpLine className="size-4" />
<span className="commit-actions__label commit-actions__label--long">Commit &amp; Push</span>
<span className="commit-actions__label">Push</span>
</>
)}
</ButtonLarge>
@@ -285,7 +285,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
if (useTwoRowHeader) {
return (
<header className={`@container/git-header border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
<header className={`@container/git-header px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1">
{isWorktreeMode ? (
@@ -320,7 +320,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
}
return (
<header className={`@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
<header className={`@container/git-header flex items-center gap-2 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
{isWorktreeMode ? (
<WorktreeBranchDisplay
@@ -377,7 +377,7 @@ Important:
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Button variant="outline" size="sm" className="h-7 px-2 py-0 gap-1.5">
Target
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
<RiArrowDownSLine className="size-4 opacity-60" />
@@ -416,15 +416,15 @@ Important:
</DropdownMenu>
{ui.kind === 'ready' ? (
<Button size="sm" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
<Button size="sm" className="h-7 px-2 py-0" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
Move
</Button>
) : ui.kind === 'loading' ? (
<Button size="sm" variant="outline" disabled>
<Button size="sm" variant="outline" className="h-7 px-2 py-0" disabled>
Checking
</Button>
) : ui.kind === 'running' ? (
<Button size="sm" variant="outline" disabled>
<Button size="sm" variant="outline" className="h-7 px-2 py-0" disabled>
Moving
</Button>
) : null}
@@ -1483,7 +1483,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={() => {
setIsEditingPr(false);
setEditTitle(pr.title || '');
@@ -1501,7 +1501,7 @@ export const PullRequestSection: React.FC<{
<TooltipTrigger asChild>
<Button
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={() => updatePr(pr)}
disabled={isUpdating || !editTitle.trim()}
aria-label="Save PR title and description"
@@ -1518,7 +1518,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={() => setIsEditingPr(true)}
aria-label="Edit PR title and description"
>
@@ -1536,7 +1536,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={openChecksDialog}
disabled={isLoadingCheckDetails}
aria-label="Open checks details"
@@ -1554,7 +1554,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
onClick={sendFailedChecksToChat}
aria-label="Resolve failed checks with agent"
>
@@ -1570,7 +1570,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={openCommentsDialog}
aria-label="Open PR comments"
>
@@ -1585,7 +1585,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
onClick={sendCommentsToChat}
aria-label="Share comments with agent"
>
@@ -1601,7 +1601,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={() => markReady(pr)}
disabled={isMarkingReady || isMerging || isUpdating || isEditingPr}
aria-label="Mark PR ready for review"
@@ -1622,7 +1622,7 @@ export const PullRequestSection: React.FC<{
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
disabled={isMerging || pr.state !== 'open'}
>
<SelectTrigger size="lg" className="h-8 w-auto min-w-0">
<SelectTrigger size="lg" className="h-7 w-auto min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1635,7 +1635,7 @@ export const PullRequestSection: React.FC<{
<TooltipTrigger asChild>
<Button
size="sm"
className="w-8 px-0"
className="h-7 w-7 px-0"
onClick={() => mergePr(pr)}
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft || isUpdating || isEditingPr}
aria-label="Merge pull request"
@@ -1661,7 +1661,7 @@ export const PullRequestSection: React.FC<{
</div>
</div>
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<Button variant="outline" size="sm" className="h-7 px-2 py-0" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Repo
@@ -1831,6 +1831,7 @@ export const PullRequestSection: React.FC<{
<Button
variant="outline"
size="sm"
className="h-7 px-2 py-0"
onClick={generateDescription}
disabled={isGenerating || isCreating}
>
@@ -1840,7 +1841,7 @@ export const PullRequestSection: React.FC<{
<div className="flex-1" />
<Button
size="sm"
className="min-w-[7.5rem] justify-center gap-2"
className="h-7 min-w-[7.5rem] justify-center gap-2 px-2 py-0"
onClick={createPr}
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || targetBaseBranch.trim() === branch}
>