Skip to content
Open
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
101 changes: 59 additions & 42 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -56,6 +57,7 @@ function App() {
const [isClaudeStreaming, setIsClaudeStreaming] = useState(false);
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
const [previousView, setPreviousView] = useState<View>("welcome");
const [selectedProjectPath, setSelectedProjectPath] = useState<string | null>(null);

// Load projects on mount when in projects view
useEffect(() => {
Expand Down Expand Up @@ -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);
};

/**
Expand Down Expand Up @@ -377,6 +389,7 @@ function App() {
projects={projects}
onProjectClick={handleProjectClick}
onProjectSettings={handleProjectSettings}
onNewSession={handleNewSessionForProject}
loading={loading}
className="animate-fade-in"
/>
Expand Down Expand Up @@ -407,8 +420,10 @@ function App() {
return (
<ClaudeCodeSession
session={selectedSession || undefined}
initialProjectPath={selectedProjectPath || undefined}
onBack={() => {
setSelectedSession(null);
setSelectedProjectPath(null);
handleViewChange("projects");
}}
onStreamingChange={(isStreaming, sessionId) => {
Expand Down Expand Up @@ -449,49 +464,51 @@ function App() {
};

return (
<OutputCacheProvider>
<div className="h-screen bg-background flex flex-col">
{/* Topbar */}
<Topbar
onClaudeClick={() => handleViewChange("editor")}
onSettingsClick={() => handleViewChange("settings")}
onUsageClick={() => handleViewChange("usage-dashboard")}
onMCPClick={() => handleViewChange("mcp")}
onInfoClick={() => setShowNFO(true)}
/>

{/* Main Content */}
<div className="flex-1 overflow-y-auto">
{renderContent()}
<ThemeProvider>
<OutputCacheProvider>
<div className="h-screen bg-background flex flex-col">
{/* Topbar */}
<Topbar
onClaudeClick={() => handleViewChange("editor")}
onSettingsClick={() => handleViewChange("settings")}
onUsageClick={() => handleViewChange("usage-dashboard")}
onMCPClick={() => handleViewChange("mcp")}
onInfoClick={() => setShowNFO(true)}
/>

{/* Main Content */}
<div className="flex-1 overflow-y-auto">
{renderContent()}
</div>

{/* NFO Credits Modal */}
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}

{/* Claude Binary Dialog */}
<ClaudeBinaryDialog
open={showClaudeBinaryDialog}
onOpenChange={setShowClaudeBinaryDialog}
onSuccess={() => {
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 */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>

{/* NFO Credits Modal */}
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}

{/* Claude Binary Dialog */}
<ClaudeBinaryDialog
open={showClaudeBinaryDialog}
onOpenChange={setShowClaudeBinaryDialog}
onSuccess={() => {
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 */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
</OutputCacheProvider>
</OutputCacheProvider>
</ThemeProvider>
);
}

Expand Down
93 changes: 89 additions & 4 deletions src/components/ClaudeCodeSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// 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<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
Expand All @@ -119,6 +123,13 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
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;
Expand Down Expand Up @@ -239,12 +250,84 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
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(() => {
Expand Down Expand Up @@ -1244,6 +1327,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
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) {
Expand Down
4 changes: 3 additions & 1 deletion src/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -28,6 +29,7 @@ export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
onBack,
className,
}) => {
const { theme } = useTheme();
const [content, setContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -143,7 +145,7 @@ export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode={theme}>
<MDEditor
value={content}
onChange={(val) => setContent(val || "")}
Expand Down
22 changes: 21 additions & 1 deletion src/components/ProjectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -68,6 +73,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({
projects,
onProjectClick,
onProjectSettings,
onNewSession,
className,
}) => {
const [currentPage, setCurrentPage] = useState(1);
Expand Down Expand Up @@ -135,6 +141,20 @@ export const ProjectList: React.FC<ProjectListProps> = ({
</div>

<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onNewSession && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onNewSession(project);
}}
title="New Claude Code session"
>
<Plus className="h-4 w-4" />
</Button>
)}
{onProjectSettings && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
Expand Down
31 changes: 31 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant={variant}
size={size}
onClick={toggleTheme}
className="relative overflow-hidden transition-all duration-200 hover:bg-accent/50"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
<div className="relative">
{theme === 'dark' ? (
<Sun className="h-4 w-4 transition-all duration-300 rotate-0 scale-100 text-yellow-500" />
) : (
<Moon className="h-4 w-4 transition-all duration-300 rotate-0 scale-100 text-blue-400" />
)}
</div>
</Button>
);
}
3 changes: 3 additions & 0 deletions src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -213,6 +214,8 @@ export const Topbar: React.FC<TopbarProps> = ({
Settings
</Button>

<ThemeToggle size="sm" />

<Button
variant="ghost"
size="icon"
Expand Down
Loading