From 9092b56c782721edff37ace047223563ab65ad9c Mon Sep 17 00:00:00 2001 From: eranyam Date: Sun, 13 Jul 2025 23:01:29 +0300 Subject: [PATCH] feat: add comprehensive theme toggle system and enhance UI/UX Theme System: - Add ThemeToggle component with dark/light mode switching - Implement ThemeContext for global theme management - Add theme utilities and CSS custom properties - Integrate theme system into main App and Topbar - Support system preference detection and persistence Project Management UX: - Add "New Session" button to project cards for direct session creation - Users can now start Claude Code sessions without re-selecting directories - Enhance project list with quick session access Chat Session Improvements: - Fix auto-scroll behavior to be more intelligent and user-friendly - Auto-scroll only occurs when user is at bottom of chat - Prevent interrupting users reading previous messages - Resume auto-scroll when user scrolls back to bottom - Maintain scroll position during user interaction --- src/App.tsx | 101 +++++++------ src/components/ClaudeCodeSession.tsx | 93 +++++++++++- src/components/MarkdownEditor.tsx | 4 +- src/components/ProjectList.tsx | 22 ++- src/components/ThemeToggle.tsx | 31 ++++ src/components/Topbar.tsx | 3 + src/components/index.ts | 3 +- src/contexts/ThemeContext.tsx | 99 +++++++++++++ src/lib/theme.ts | 132 +++++++++++++++++ src/styles.css | 205 ++++++++++++++++++++++++++- 10 files changed, 638 insertions(+), 55 deletions(-) create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/contexts/ThemeContext.tsx create mode 100644 src/lib/theme.ts diff --git a/src/App.tsx b/src/App.tsx index 48a46001..577cb34b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { Plus, Loader2, Bot, FolderCode } from "lucide-react"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; import { OutputCacheProvider } from "@/lib/outputCache"; +import { ThemeProvider } from "@/contexts/ThemeContext"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ProjectList } from "@/components/ProjectList"; @@ -56,6 +57,7 @@ function App() { const [isClaudeStreaming, setIsClaudeStreaming] = useState(false); const [projectForSettings, setProjectForSettings] = useState(null); const [previousView, setPreviousView] = useState("welcome"); + const [selectedProjectPath, setSelectedProjectPath] = useState(null); // Load projects on mount when in projects view useEffect(() => { @@ -128,6 +130,16 @@ function App() { const handleNewSession = async () => { handleViewChange("claude-code-session"); setSelectedSession(null); + setSelectedProjectPath(null); + }; + + /** + * Opens a new Claude Code session for a specific project + */ + const handleNewSessionForProject = async (project: Project) => { + handleViewChange("claude-code-session"); + setSelectedSession(null); + setSelectedProjectPath(project.path); }; /** @@ -377,6 +389,7 @@ function App() { projects={projects} onProjectClick={handleProjectClick} onProjectSettings={handleProjectSettings} + onNewSession={handleNewSessionForProject} loading={loading} className="animate-fade-in" /> @@ -407,8 +420,10 @@ function App() { return ( { setSelectedSession(null); + setSelectedProjectPath(null); handleViewChange("projects"); }} onStreamingChange={(isStreaming, sessionId) => { @@ -449,49 +464,51 @@ function App() { }; return ( - -
- {/* Topbar */} - handleViewChange("editor")} - onSettingsClick={() => handleViewChange("settings")} - onUsageClick={() => handleViewChange("usage-dashboard")} - onMCPClick={() => handleViewChange("mcp")} - onInfoClick={() => setShowNFO(true)} - /> - - {/* Main Content */} -
- {renderContent()} + + +
+ {/* Topbar */} + handleViewChange("editor")} + onSettingsClick={() => handleViewChange("settings")} + onUsageClick={() => handleViewChange("usage-dashboard")} + onMCPClick={() => handleViewChange("mcp")} + onInfoClick={() => setShowNFO(true)} + /> + + {/* Main Content */} +
+ {renderContent()} +
+ + {/* NFO Credits Modal */} + {showNFO && setShowNFO(false)} />} + + {/* Claude Binary Dialog */} + { + setToast({ message: "Claude binary path saved successfully", type: "success" }); + // Trigger a refresh of the Claude version check + window.location.reload(); + }} + onError={(message) => setToast({ message, type: "error" })} + /> + + {/* Toast Container */} + + {toast && ( + setToast(null)} + /> + )} +
- - {/* NFO Credits Modal */} - {showNFO && setShowNFO(false)} />} - - {/* Claude Binary Dialog */} - { - setToast({ message: "Claude binary path saved successfully", type: "success" }); - // Trigger a refresh of the Claude version check - window.location.reload(); - }} - onError={(message) => setToast({ message, type: "error" })} - /> - - {/* Toast Container */} - - {toast && ( - setToast(null)} - /> - )} - -
- + + ); } diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 2dc9a9b6..5dbcad04 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -106,6 +106,10 @@ export const ClaudeCodeSession: React.FC = ({ // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); + // Auto-scroll behavior state + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + const isUserScrollingRef = useRef(false); + const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); @@ -119,6 +123,13 @@ export const ClaudeCodeSession: React.FC = ({ queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); + // Reset auto-scroll when starting a new session + useEffect(() => { + if (isFirstPrompt) { + setShouldAutoScroll(true); + } + }, [isFirstPrompt]); + // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { if (session) return session; @@ -239,12 +250,84 @@ export const ClaudeCodeSession: React.FC = ({ onStreamingChange?.(isLoading, claudeSessionId); }, [isLoading, claudeSessionId, onStreamingChange]); - // Auto-scroll to bottom when new messages arrive + // Helper function to check if user is at bottom + const isAtBottom = () => { + const element = parentRef.current; + if (!element) return false; + + // Consider user at bottom if they're within 50px of the bottom + const threshold = 50; + const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - threshold; + return isAtBottom; + }; + + // Helper function to scroll to bottom + const scrollToBottom = () => { + const element = parentRef.current; + if (element) { + element.scrollTop = element.scrollHeight; + } + }; + + // Add scroll event listener to detect user scrolling + useEffect(() => { + const element = parentRef.current; + if (!element) return; + + const handleScroll = () => { + if (isUserScrollingRef.current) { + // User is actively scrolling, check if they're at bottom + const atBottom = isAtBottom(); + setShouldAutoScroll(atBottom); + } + }; + + // Unified scroll handler that includes timeout fallback + let scrollTimeout: NodeJS.Timeout; + const handleScrollWithTimeout = () => { + isUserScrollingRef.current = true; + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + isUserScrollingRef.current = false; + }, 150); + handleScroll(); + }; + + element.addEventListener('scroll', handleScrollWithTimeout); + + return () => { + element.removeEventListener('scroll', handleScrollWithTimeout); + clearTimeout(scrollTimeout); + }; + }, []); + + // Auto-scroll to bottom when new messages arrive (only if should auto-scroll) + useEffect(() => { + if (shouldAutoScroll && displayableMessages.length > 0) { + // Use setTimeout to ensure the DOM has updated + setTimeout(() => { + scrollToBottom(); + }, 10); + } + }, [displayableMessages.length, shouldAutoScroll]); + + // Auto-scroll when loading state changes (to show/hide loader) + useEffect(() => { + if (shouldAutoScroll && isLoading) { + setTimeout(() => { + scrollToBottom(); + }, 10); + } + }, [isLoading, shouldAutoScroll]); + + // Auto-scroll when messages are updated (for streaming content) useEffect(() => { - if (displayableMessages.length > 0) { - rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' }); + if (shouldAutoScroll && messages.length > 0) { + setTimeout(() => { + scrollToBottom(); + }, 10); } - }, [displayableMessages.length, rowVirtualizer]); + }, [messages, shouldAutoScroll]); // Calculate total tokens from messages useEffect(() => { @@ -1244,6 +1327,8 @@ export const ClaudeCodeSession: React.FC = ({ onClick={() => { // Use virtualizer to scroll to the last item if (displayableMessages.length > 0) { + // Re-enable auto-scroll + setShouldAutoScroll(true); // Scroll to bottom of the container const scrollElement = parentRef.current; if (scrollElement) { diff --git a/src/components/MarkdownEditor.tsx b/src/components/MarkdownEditor.tsx index 9ffa0f9a..6e60d34c 100644 --- a/src/components/MarkdownEditor.tsx +++ b/src/components/MarkdownEditor.tsx @@ -4,6 +4,7 @@ import { motion } from "framer-motion"; import { ArrowLeft, Save, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Toast, ToastContainer } from "@/components/ui/toast"; +import { useTheme } from "@/contexts/ThemeContext"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; @@ -28,6 +29,7 @@ export const MarkdownEditor: React.FC = ({ onBack, className, }) => { + const { theme } = useTheme(); const [content, setContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [loading, setLoading] = useState(true); @@ -143,7 +145,7 @@ export const MarkdownEditor: React.FC = ({
) : ( -
+
setContent(val || "")} diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx index 058de960..0f54278b 100644 --- a/src/components/ProjectList.tsx +++ b/src/components/ProjectList.tsx @@ -6,7 +6,8 @@ import { FileText, ChevronRight, Settings, - MoreVertical + MoreVertical, + Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -35,6 +36,10 @@ interface ProjectListProps { * Callback when hooks configuration is clicked */ onProjectSettings?: (project: Project) => void; + /** + * Callback when new session is clicked for a project + */ + onNewSession?: (project: Project) => void; /** * Whether the list is currently loading */ @@ -68,6 +73,7 @@ export const ProjectList: React.FC = ({ projects, onProjectClick, onProjectSettings, + onNewSession, className, }) => { const [currentPage, setCurrentPage] = useState(1); @@ -135,6 +141,20 @@ export const ProjectList: React.FC = ({
+ {onNewSession && ( + + )} {onProjectSettings && ( e.stopPropagation()}> diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..e502466e --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,31 @@ +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTheme } from '@/contexts/ThemeContext'; + +interface ThemeToggleProps { + size?: 'default' | 'sm' | 'lg'; + variant?: 'default' | 'outline' | 'ghost'; +} + +export function ThemeToggle({ size = 'default', variant = 'ghost' }: ThemeToggleProps) { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 33055477..7db3f819 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; +import { ThemeToggle } from "@/components/ThemeToggle"; import { api, type ClaudeVersionStatus } from "@/lib/api"; import { cn } from "@/lib/utils"; @@ -213,6 +214,8 @@ export const Topbar: React.FC = ({ Settings + +