Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
WorktreeList,
} from "./components";
import { ModeCarousel, type SidebarMode } from "./components/ModeCarousel";
import { ModeSwitcher } from "./components/ModeSwitcher";

interface SidebarProps {
workspaces: Workspace[];
Expand Down Expand Up @@ -328,12 +327,6 @@ export function Sidebar({

return (
<div className="flex flex-col h-full w-full select-none text-neutral-300 text-sm">
<ModeSwitcher
modes={modes}
currentMode={currentMode}
onModeSelect={setCurrentMode}
scrollProgress={scrollProgress}
/>
<ModeCarousel
modes={modes}
currentMode={currentMode}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import { type MotionValue, useMotionValue } from "framer-motion";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";

export type SidebarMode = "tabs" | "diff";

interface ModeCarouselProps {
modes: SidebarMode[];
currentMode: SidebarMode;
onModeSelect: (mode: SidebarMode) => void;
children: (mode: SidebarMode, isActive: boolean) => ReactNode;
onScrollProgress: (progress: MotionValue<number>) => void;
isDragging?: boolean;
}
import { useCallback, useRef, useState } from "react";
import { ModeContent } from "./components/ModeContent";
import { ModeHeader } from "./components/ModeHeader";
import { ModeNavigation } from "./components/ModeNavigation";
import { useModeDetection } from "./hooks/useModeDetection";
import { useScrollProgress } from "./hooks/useScrollProgress";
import { useScrollSnap } from "./hooks/useScrollSnap";
import type { ModeCarouselProps } from "./types";

export function ModeCarousel({
modes,
Expand All @@ -26,13 +15,8 @@ export function ModeCarousel({
onScrollProgress,
isDragging = false,
}: ModeCarouselProps) {
const scrollTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isInitialMount = useRef(true);

const currentIndex = modes.findIndex((m) => m === currentMode);
const initialProgress = currentIndex >= 0 ? currentIndex : 0;
const modeProgress = useMotionValue(initialProgress);

const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
null,
);
Expand All @@ -44,143 +28,87 @@ export function ModeCarousel({
}
}, []);

// Track scroll position and update motion value
useEffect(() => {
if (!scrollContainer) return;

let rafId: number | undefined;

const updateProgress = () => {
const scrollLeft = scrollContainer.scrollLeft;
const containerWidth = scrollContainer.offsetWidth;
const progress = scrollLeft / containerWidth;
modeProgress.set(progress);
};

const handleScroll = () => {
// Use requestAnimationFrame for smooth updates
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(updateProgress);
};

scrollContainer.addEventListener("scroll", handleScroll, { passive: true });

// Initial value
updateProgress();

return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
}
};
}, [scrollContainer, modeProgress]);

// Expose scroll progress to parent
useEffect(() => {
onScrollProgress(modeProgress);
}, [onScrollProgress, modeProgress]);

// Scroll to current mode when it changes externally
useEffect(() => {
if (!scrollContainer || currentIndex < 0) return;

const targetScrollX = currentIndex * scrollContainer.offsetWidth;

// Only scroll if we're not already at the target position
if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 10) {
scrollContainer.scrollTo({
left: targetScrollX,
behavior: isInitialMount.current ? "auto" : "smooth",
});
}

// Mark that initial mount is complete
isInitialMount.current = false;
}, [currentIndex, scrollContainer]);

// Detect when user finishes scrolling and update current mode
useEffect(() => {
if (!scrollContainer || isDragging) return;

const handleScroll = () => {
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}

// Wait for scroll to settle (150ms after last scroll event)
scrollTimeoutRef.current = setTimeout(() => {
const scrollLeft = scrollContainer.scrollLeft;
const containerWidth = scrollContainer.offsetWidth;

// Calculate which mode we're closest to
const newIndex = Math.round(scrollLeft / containerWidth);

// Update mode if it changed
if (
newIndex >= 0 &&
newIndex < modes.length &&
modes[newIndex] &&
modes[newIndex] !== currentMode
) {
onModeSelect(modes[newIndex]);
}
}, 150);
};

scrollContainer.addEventListener("scroll", handleScroll);

return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [modes, currentMode, onModeSelect, scrollContainer, isDragging]);
// Track scroll progress
const modeProgress = useScrollProgress({
scrollContainer,
currentIndex,
onScrollProgress,
});

// Handle scroll snapping
useScrollSnap({
scrollContainer,
currentIndex,
isInitialMount,
});

// Detect mode changes from scrolling
useModeDetection({
scrollContainer,
modes,
currentMode,
onModeSelect,
isDragging,
});

// If only one mode or no modes, disable carousel
if (modes.length <= 1) {
return (
<div className="flex-1 overflow-y-auto px-3">
{children(currentMode, true)}
<div className="flex flex-col flex-1 h-full">
<div className="flex-1 overflow-y-auto">
<ModeHeader mode={currentMode} />
<div className="px-3">{children(currentMode, true)}</div>
</div>
</div>
);
}

return (
<div
ref={scrollContainerRef}
className="flex-1 overflow-x-scroll overflow-y-hidden hide-scrollbar"
style={{
scrollSnapType: isDragging ? "none" : "x mandatory",
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
msOverflowStyle: "none",
pointerEvents: isDragging ? "none" : "auto",
}}
>
<div className="flex flex-col flex-1 h-full">
{/* Carousel content */}
<div
className="flex h-full"
style={{ width: `${modes.length * 100}%` }}
ref={scrollContainerRef}
className="flex-1 overflow-x-scroll overflow-y-hidden hide-scrollbar"
style={{
scrollSnapType: isDragging ? "none" : "x mandatory",
scrollSnapStop: "always",
scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
overscrollBehaviorX: "contain",
scrollbarWidth: "none",
msOverflowStyle: "none",
pointerEvents: isDragging ? "none" : "auto",
}}
>
{modes.map((mode) => (
<div
key={mode}
className="overflow-y-auto px-3"
style={{
scrollSnapAlign: "start",
scrollSnapStop: "always",
width: `${100 / modes.length}%`,
}}
>
{children(mode, mode === currentMode)}
</div>
))}
<div
className="flex h-full"
style={{ width: `${modes.length * 100}%` }}
>
{modes.map((mode) => (
<div
key={mode}
style={{
width: `${100 / modes.length}%`,
}}
>
<ModeContent
mode={mode}
isActive={mode === currentMode}
>
{children(mode, mode === currentMode)}
</ModeContent>
</div>
))}
</div>
</div>

{/* Bottom navigation bar */}
<ModeNavigation
modes={modes}
currentMode={currentMode}
onModeSelect={onModeSelect}
scrollProgress={modeProgress}
/>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type MotionValue, motion, useTransform } from "framer-motion";

interface AnimatedBackgroundProps {
progress: MotionValue<number>;
modeCount: number;
}

export function AnimatedBackground({
progress,
modeCount,
}: AnimatedBackgroundProps) {
// Calculate the width of each button (36px = h-9 w-9) + gap (4px = gap-1)
const buttonWidth = 36;
const gap = 4;
const totalButtonWidth = buttonWidth + gap;

// Transform progress (0-1) to translateX position
// For 2 modes: 0 -> 0px, 1 -> 40px (buttonWidth + gap)
Comment on lines +17 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify the progress input range in the comment.

The comment states "Transform progress (0-1)" but the actual input range is [0, modeCount - 1] (e.g., 0-1 for 2 modes, 0-2 for 3 modes), not a normalized 0-1 range.

Apply this diff:

-	// Transform progress (0-1) to translateX position
-	// For 2 modes: 0 -> 0px, 1 -> 40px (buttonWidth + gap)
+	// Transform progress (0 to modeCount-1) to translateX position
+	// For 2 modes: progress 0 -> 0px, progress 1 -> 40px (buttonWidth + gap)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Transform progress (0-1) to translateX position
// For 2 modes: 0 -> 0px, 1 -> 40px (buttonWidth + gap)
// Transform progress (0 to modeCount-1) to translateX position
// For 2 modes: progress 0 -> 0px, progress 1 -> 40px (buttonWidth + gap)
🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/AnimatedBackground.tsx
around lines 17 to 18, the inline comment incorrectly says "Transform progress
(0-1)" even though progress actually ranges from 0 to modeCount - 1 (e.g., 0-1
for 2 modes, 0-2 for 3 modes); update the comment to clearly state the expected
input range is [0, modeCount - 1] and give the concrete mapping (e.g., 0 -> 0px,
modeCount - 1 -> (modeCount - 1) * (buttonWidth + gap)) so readers understand it
is not a normalized 0–1 value.

const translateX = useTransform(
progress,
[0, modeCount - 1],
[0, (modeCount - 1) * totalButtonWidth]
);

return (
<motion.div
className="absolute h-9 rounded-lg bg-neutral-800/60"
style={{
width: buttonWidth,
x: translateX,
}}
initial={false}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AnimatedBackground } from "./AnimatedBackground";

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { ModeHeader } from "../ModeHeader";
import type { SidebarMode } from "../../types";

interface ModeContentProps {
mode: SidebarMode;
isActive: boolean;
children: ReactNode;
}
Comment on lines +5 to +9
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused isActive prop in interface.

The isActive prop is declared in the interface but never destructured or used in the component implementation (line 11). This could indicate incomplete implementation or dead code.

Consider either:

  • Removing the prop if it's not needed
  • Using it for conditional styling or behavior (e.g., to apply active state classes)

If isActive should be used for conditional rendering or styling, I can help implement that logic. Would you like me to suggest an implementation?

🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx
around lines 5 to 9, the interface declares an isActive prop that is never
consumed by the component; remove dead code by deleting isActive from
ModeContentProps (and any callers) OR use it by destructuring isActive in the
component signature and applying it to the rendered element for conditional
styling/behavior (e.g., add an "active" className, toggle styles, or set
aria-current/aria-pressed for accessibility) so the prop has an effect.


export function ModeContent({ mode, children }: ModeContentProps) {
return (
<div
className="overflow-y-auto h-full"
style={{
scrollSnapAlign: "start",
scrollSnapStop: "always",
}}
>
<ModeHeader mode={mode} />
<div className="px-3">{children}</div>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ModeContent } from "./ModeContent";

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { modeLabels } from "../../constants";
import type { SidebarMode } from "../../types";

interface ModeHeaderProps {
mode: SidebarMode;
}

export function ModeHeader({ mode }: ModeHeaderProps) {
return (
<div className="px-3 py-2">
<span className="text-xs font-medium text-neutral-300">
{modeLabels[mode]}
</span>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ModeHeader } from "./ModeHeader";

Loading
Loading