diff --git a/backend/app.ts b/backend/app.ts index 7f4c2c1..08a8b8d 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -17,6 +17,10 @@ import { handleHistoriesRequest } from "./handlers/histories.ts"; import { handleConversationRequest } from "./handlers/conversations.ts"; import { handleChatRequest } from "./handlers/chat.ts"; import { handleAbortRequest } from "./handlers/abort.ts"; +import { + handleClaudeProjectsRequest, + handleProjectConversationsRequest +} from "./handlers/claudeProjects.ts"; import { logger } from "./utils/logger.ts"; import { readBinaryFile } from "./utils/fs.ts"; @@ -57,6 +61,12 @@ export function createApp( // API routes app.get("/api/projects", (c) => handleProjectsRequest(c)); + + // New Claude projects discovery endpoints + app.get("/api/claude/projects", (c) => handleClaudeProjectsRequest(c)); + app.get("/api/claude/projects/:encodedProjectName/conversations", (c) => + handleProjectConversationsRequest(c), + ); app.get("/api/projects/:encodedProjectName/histories", (c) => handleHistoriesRequest(c), diff --git a/backend/handlers/claudeProjects.ts b/backend/handlers/claudeProjects.ts new file mode 100644 index 0000000..a8b5ad3 --- /dev/null +++ b/backend/handlers/claudeProjects.ts @@ -0,0 +1,217 @@ +import { Context } from "hono"; +import { readDir, stat } from "../utils/fs.ts"; +import { getHomeDir } from "../utils/os.ts"; +import { logger } from "../utils/logger.ts"; +import { parseAllHistoryFiles } from "../history/parser.ts"; +import { groupConversations } from "../history/grouping.ts"; + +export interface ClaudeProject { + encodedName: string; + displayName: string; + path: string; // The actual file system path + conversationCount: number; + lastModified?: string; +} + +export interface ClaudeProjectsResponse { + projects: ClaudeProject[]; +} + +/** + * Decode the encoded project name back to a readable path + * Example: "C--Users-Windows10-new-Documents-web-game2" → "C:/Users/Windows10_new/Documents/web-game2" + */ +function decodeProjectName(encodedName: string): string { + let decoded = encodedName; + + // Handle drive letter (C--) + decoded = decoded.replace(/^([A-Z])--/, "$1:/"); + + // Replace remaining hyphens with forward slashes for path separators + decoded = decoded.replace(/-/g, "/"); + + // Handle common patterns where underscores were converted to hyphens + // This is heuristic-based and may need adjustment for specific cases + decoded = decoded.replace(/Windows10\/new/g, "Windows10_new"); + decoded = decoded.replace(/web\/game(\d+)/g, "web-game$1"); // web-game2, web-game3, etc. + decoded = decoded.replace(/claude\/code\/webui/g, "claude-code-webui"); // claude-code-webui + + return decoded; +} + +/** + * Get a display-friendly name for the project + */ +function getDisplayName(encodedName: string): string { + // For Windows paths like "C--Users-Windows10-new-Documents-web-game2" + // We need to extract the project name, which could contain hyphens + + // First, handle the drive letter and path structure + let pathPart = encodedName; + + // Remove drive letter pattern (C--) + pathPart = pathPart.replace(/^[A-Z]--/, ""); + + // Split into path segments + const segments = pathPart.split("-").filter(p => p.length > 0); + + // For a typical path like "Users-Windows10-new-Documents-web-game2" + // We want to find the project name after "Documents" or similar directory indicators + const commonDirs = ["Users", "Documents", "Desktop", "Projects", "Development"]; + + // Find the last common directory and take everything after it + let projectStartIndex = -1; + for (let i = segments.length - 1; i >= 0; i--) { + if (commonDirs.some(dir => segments[i].toLowerCase().includes(dir.toLowerCase()))) { + projectStartIndex = i + 1; + break; + } + } + + // If we found a common directory, take everything after it as the project name + if (projectStartIndex > 0 && projectStartIndex < segments.length) { + const projectParts = segments.slice(projectStartIndex); + // Rejoin with hyphens for project names that originally had hyphens + return projectParts.join("-"); + } + + // Fallback: take the last 2 parts if they look like a project name + if (segments.length >= 2) { + const lastTwo = segments.slice(-2); + // If they look like they could be a compound name (like "web-game2") + if (lastTwo.every(part => part.length <= 10)) { + return lastTwo.join("-"); + } + } + + // Final fallback: just take the last part + return segments[segments.length - 1] || encodedName; +} + +/** + * Handles GET /api/claude/projects requests + * Lists all projects from the .claude/projects directory + */ +export async function handleClaudeProjectsRequest(c: Context) { + try { + const homeDir = getHomeDir(); + if (!homeDir) { + return c.json({ error: "Home directory not found" }, 500); + } + + const projectsDir = `${homeDir}/.claude/projects`; + + // Check if the projects directory exists + try { + const dirInfo = await stat(projectsDir); + if (!dirInfo.isDirectory) { + return c.json({ projects: [] }); + } + } catch { + // Directory doesn't exist + return c.json({ projects: [] }); + } + + const projects: ClaudeProject[] = []; + + // Read all directories in .claude/projects + for await (const entry of readDir(projectsDir)) { + if (entry.isDirectory) { + const projectPath = `${projectsDir}/${entry.name}`; + + // Count conversation files and get last modified time + let conversationCount = 0; + let lastModified: Date | undefined; + + try { + for await (const file of readDir(projectPath)) { + if (file.name.endsWith(".jsonl")) { + conversationCount++; + + // Get file stats to find last modified time + const filePath = `${projectPath}/${file.name}`; + const fileInfo = await stat(filePath); + if (!lastModified || fileInfo.mtime > lastModified) { + lastModified = fileInfo.mtime; + } + } + } + } catch (error) { + logger.api.error(`Error reading project directory ${entry.name}: {error}`, { error }); + } + + projects.push({ + encodedName: entry.name, + displayName: getDisplayName(entry.name), + path: decodeProjectName(entry.name), + conversationCount, + lastModified: lastModified?.toISOString(), + }); + } + } + + // Sort projects by last modified time (most recent first) + projects.sort((a, b) => { + if (!a.lastModified) return 1; + if (!b.lastModified) return -1; + return b.lastModified.localeCompare(a.lastModified); + }); + + const response: ClaudeProjectsResponse = { projects }; + return c.json(response); + + } catch (error) { + logger.api.error("Error reading Claude projects: {error}", { error }); + return c.json({ error: "Failed to read Claude projects" }, 500); + } +} + +/** + * Handles GET /api/claude/projects/:encodedProjectName/conversations + * Lists all conversations for a specific project with metadata + */ +export async function handleProjectConversationsRequest(c: Context) { + try { + const encodedProjectName = c.req.param("encodedProjectName"); + + if (!encodedProjectName) { + return c.json({ error: "Project name is required" }, 400); + } + + const homeDir = getHomeDir(); + if (!homeDir) { + return c.json({ error: "Home directory not found" }, 500); + } + + const projectDir = `${homeDir}/.claude/projects/${encodedProjectName}`; + + // Check if the project directory exists + try { + const dirInfo = await stat(projectDir); + if (!dirInfo.isDirectory) { + return c.json({ error: "Project not found" }, 404); + } + } catch { + return c.json({ error: "Project not found" }, 404); + } + + // Parse all history files in the project directory + const historyFiles = await parseAllHistoryFiles(projectDir); + + // Group conversations + const conversations = groupConversations(historyFiles); + + // Sort by last message time (most recent first) + conversations.sort((a, b) => { + if (!a.lastTime) return 1; + if (!b.lastTime) return -1; + return b.lastTime.localeCompare(a.lastTime); + }); + + return c.json({ conversations }); + + } catch (error) { + logger.api.error("Error reading project conversations: {error}", { error }); + return c.json({ error: "Failed to read project conversations" }, 500); + } +} \ No newline at end of file diff --git a/frontend/src/components/ChatPage.tsx b/frontend/src/components/ChatPage.tsx index a005e87..9d794cb 100644 --- a/frontend/src/components/ChatPage.tsx +++ b/frontend/src/components/ChatPage.tsx @@ -1,12 +1,14 @@ import { useEffect, useCallback, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { ChevronLeftIcon } from "@heroicons/react/24/outline"; +import { ChevronLeftIcon, HomeIcon } from "@heroicons/react/24/outline"; +import { getClaudeProjectsUrl, getClaudeProjectConversationsUrl } from "../config/api"; import type { ChatRequest, ChatMessage, ProjectInfo, PermissionMode, } from "../types"; +import type { ConversationSummary } from "../../../shared/types"; import { useClaudeStreaming } from "../hooks/useClaudeStreaming"; import { useChatState } from "../hooks/chat/useChatState"; import { usePermissions } from "../hooks/chat/usePermissions"; @@ -19,10 +21,47 @@ import { HistoryButton } from "./chat/HistoryButton"; import { ChatInput } from "./chat/ChatInput"; import { ChatMessages } from "./chat/ChatMessages"; import { HistoryView } from "./HistoryView"; -import { getChatUrl, getProjectsUrl } from "../config/api"; +import { getChatUrl } from "../config/api"; import { KEYBOARD_SHORTCUTS } from "../utils/constants"; import { normalizeWindowsPath } from "../utils/pathUtils"; import type { StreamingContext } from "../hooks/streaming/useMessageProcessor"; +import ProjectsSidebar from "./sidebar/ProjectsSidebar"; + +function getProjectDisplayName(workingDirectory?: string): string { + if (!workingDirectory) return "Claude Code Web UI"; + + // Split the path into segments + const segments = workingDirectory.replace(/^[A-Z]:[\\/]/, "").split(/[\\/]/).filter(s => s.length > 0); + + // Common directory names to skip + const commonDirs = ["Users", "Documents", "Desktop", "Projects", "Code", "Development"]; + + // Find the last common directory and take everything after it as the project name + let projectStartIndex = -1; + for (let i = segments.length - 1; i >= 0; i--) { + if (commonDirs.some(dir => segments[i].toLowerCase().includes(dir.toLowerCase()))) { + projectStartIndex = i + 1; + break; + } + } + + // If we found a common directory, take everything after it as the project name + if (projectStartIndex > 0 && projectStartIndex < segments.length) { + const projectParts = segments.slice(projectStartIndex); + return projectParts.join("-"); + } + + // Fallback: take the last 2 parts if they look like a project name + if (segments.length >= 2) { + const lastTwo = segments.slice(-2); + if (lastTwo.every(part => part.length <= 10)) { + return lastTwo.join("-"); + } + } + + // Final fallback: just take the last part + return segments[segments.length - 1] || "Claude Code Web UI"; +} export function ChatPage() { const location = useLocation(); @@ -30,6 +69,11 @@ export function ChatPage() { const [searchParams] = useSearchParams(); const [projects, setProjects] = useState([]); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [currentConversation, setCurrentConversation] = useState<{ + title: string; + fullTitle: string; + projectEncodedName: string; + } | null>(null); // Extract and normalize working directory from URL const workingDirectory = (() => { @@ -38,14 +82,19 @@ export function ChatPage() { // URL decode the path const decodedPath = decodeURIComponent(rawPath); + console.log(`[ChatPage] Raw path: ${rawPath}`); + console.log(`[ChatPage] Decoded path: ${decodedPath}`); // Normalize Windows paths (remove leading slash from /C:/... format) - return normalizeWindowsPath(decodedPath); + const normalized = normalizeWindowsPath(decodedPath); + console.log(`[ChatPage] Normalized working directory: ${normalized}`); + return normalized; })(); // Get current view and sessionId from query parameters const currentView = searchParams.get("view"); const sessionId = searchParams.get("sessionId"); + console.log(`[ChatPage] Session ID from URL: ${sessionId}`); const isHistoryView = currentView === "history"; const isLoadedConversation = !!sessionId && !isHistoryView; @@ -57,22 +106,31 @@ export function ChatPage() { // Get encoded name for current working directory const getEncodedName = useCallback(() => { + console.log(`[ChatPage] getEncodedName - workingDirectory: ${workingDirectory}`); + console.log(`[ChatPage] getEncodedName - projects.length: ${projects.length}`); + if (!workingDirectory || !projects.length) { + console.log(`[ChatPage] getEncodedName - returning null (missing data)`); return null; } + console.log(`[ChatPage] getEncodedName - available project paths:`, projects.map(p => p.path)); const project = projects.find((p) => p.path === workingDirectory); + console.log(`[ChatPage] getEncodedName - direct project match: ${project?.encodedName || 'none'}`); // Normalize paths for comparison (handle Windows path issues) const normalizedWorking = normalizeWindowsPath(workingDirectory); const normalizedProject = projects.find( (p) => normalizeWindowsPath(p.path) === normalizedWorking, ); + console.log(`[ChatPage] getEncodedName - normalized project match: ${normalizedProject?.encodedName || 'none'}`); // Use normalized result if exact match fails const finalProject = project || normalizedProject; - return finalProject?.encodedName || null; + const result = finalProject?.encodedName || null; + console.log(`[ChatPage] getEncodedName - returning: ${result}`); + return result; }, [workingDirectory, projects]); // Load conversation history if sessionId is provided @@ -384,11 +442,41 @@ export function ChatPage() { setIsSettingsOpen(false); }, []); + // Handle conversation selection from sidebar + const handleConversationSelect = useCallback(async (projectEncodedName: string, conversationId: string) => { + try { + // Fetch the Claude projects to get the decoded path + const response = await fetch(getClaudeProjectsUrl()); + if (!response.ok) { + throw new Error('Failed to fetch Claude projects'); + } + + const data = await response.json(); + const project = data.projects.find((p: any) => p.encodedName === projectEncodedName); + + if (!project) { + console.error('Project not found for encoded name:', projectEncodedName); + return; + } + + // Use the decoded path from the API + const workingDir = project.path; + console.log(`[ChatPage] Navigating to: ${workingDir} with session: ${conversationId}`); + + // Navigate to the project with the session ID to continue the conversation + const searchParams = new URLSearchParams(); + searchParams.set('sessionId', conversationId); + navigate(`/projects/${encodeURIComponent(workingDir)}?${searchParams.toString()}`); + } catch (error) { + console.error('Error selecting conversation:', error); + } + }, [navigate]); + // Load projects to get encodedName mapping useEffect(() => { const loadProjects = async () => { try { - const response = await fetch(getProjectsUrl()); + const response = await fetch(getClaudeProjectsUrl()); if (response.ok) { const data = await response.json(); setProjects(data.projects || []); @@ -400,14 +488,61 @@ export function ChatPage() { loadProjects(); }, []); + // Load conversation details when sessionId is present + useEffect(() => { + const loadConversationDetails = async () => { + if (!sessionId || !workingDirectory) { + setCurrentConversation(null); + return; + } + + try { + // First, get the Claude projects to find the encoded name for the current working directory + const claudeProjectsResponse = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8080'}/api/claude/projects`); + if (!claudeProjectsResponse.ok) return; + + const claudeProjectsData = await claudeProjectsResponse.json(); + const currentProject = claudeProjectsData.projects.find((p: any) => p.path === workingDirectory); + + if (!currentProject) return; + + // Then get the conversations for this project + const conversationsResponse = await fetch(getClaudeProjectConversationsUrl(currentProject.encodedName)); + if (!conversationsResponse.ok) return; + + const conversationsData = await conversationsResponse.json(); + const conversation = conversationsData.conversations.find((c: ConversationSummary) => c.sessionId === sessionId); + + if (conversation) { + // Crop to first few words for a cleaner header + const cropTitle = (text: string, wordLimit = 4) => { + const words = text.trim().split(/\s+/); + if (words.length <= wordLimit) return text; + return words.slice(0, wordLimit).join(' ') + '...'; + }; + + const fullTitle = conversation.lastMessagePreview || 'Untitled Conversation'; + + setCurrentConversation({ + title: cropTitle(fullTitle), + fullTitle: fullTitle, + projectEncodedName: currentProject.encodedName, + }); + } + } catch (error) { + console.error('Failed to load conversation details:', error); + } + }; + + loadConversationDetails(); + }, [sessionId, workingDirectory]); + const handleBackToChat = useCallback(() => { navigate({ search: "" }); }, [navigate]); const handleBackToHistory = useCallback(() => { - const searchParams = new URLSearchParams(); - searchParams.set("view", "history"); - navigate({ search: searchParams.toString() }); + navigate({ search: "" }); }, [navigate]); const handleBackToProjects = useCallback(() => { @@ -435,9 +570,19 @@ export function ChatPage() { return (
-
- {/* Header */} -
+
+ {/* Claude Projects Sidebar */} + + + {/* Main Content Area */} +
+ {/* Header */} +
{isHistoryView && ( @@ -462,10 +607,11 @@ export function ChatPage() {
{(isHistoryView || sessionId) && ( <> @@ -477,33 +623,18 @@ export function ChatPage() { ›{" "}

{isHistoryView ? "Conversation History" - : "Conversation"} + : currentConversation?.title || "Conversation"}

)}
- {workingDirectory && ( -
- - {sessionId && ( - - Session: {sessionId.substring(0, 8)}... - - )} -
- )}
@@ -586,6 +717,7 @@ export function ChatPage() { {/* Settings Modal */} +
); diff --git a/frontend/src/components/ProjectSelector.tsx b/frontend/src/components/ProjectSelector.tsx index d06bca8..85a0a9f 100644 --- a/frontend/src/components/ProjectSelector.tsx +++ b/frontend/src/components/ProjectSelector.tsx @@ -1,45 +1,47 @@ -import { useState, useEffect } from "react"; +import { useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { FolderIcon } from "@heroicons/react/24/outline"; -import type { ProjectsResponse, ProjectInfo } from "../types"; -import { getProjectsUrl } from "../config/api"; +import { FolderIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; import { SettingsButton } from "./SettingsButton"; import { SettingsModal } from "./SettingsModal"; +import ProjectsSidebar from "./sidebar/ProjectsSidebar"; +import { useClaudeProjects } from "../hooks/useClaudeProjects"; +import { getClaudeProjectConversationsUrl } from "../config/api"; +import type { ClaudeProject } from "../../../shared/types"; export function ProjectSelector() { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const navigate = useNavigate(); + + // Use the same data source as the sidebar + const { projects, loading, error } = useClaudeProjects(); - useEffect(() => { - loadProjects(); - }, []); - - const loadProjects = async () => { + const handleProjectSelect = async (project: ClaudeProject) => { try { - setLoading(true); - const response = await fetch(getProjectsUrl()); + // Fetch the conversations for this project to get the latest one + const response = await fetch(getClaudeProjectConversationsUrl(project.encodedName)); if (!response.ok) { - throw new Error(`Failed to load projects: ${response.statusText}`); + throw new Error('Failed to fetch conversations'); + } + + const data = await response.json(); + + if (data.conversations && data.conversations.length > 0) { + // Navigate to the most recent conversation (conversations are sorted by most recent first) + const latestConversation = data.conversations[0]; + console.log(`[ProjectSelector] Opening latest conversation: ${latestConversation.sessionId} for project ${project.displayName}`); + navigate(`/projects/${encodeURIComponent(project.path)}?sessionId=${latestConversation.sessionId}`); + } else { + // If no conversations found, navigate to project without session (will create new conversation) + console.log(`[ProjectSelector] No conversations found for ${project.displayName}, starting new conversation`); + navigate(`/projects/${encodeURIComponent(project.path)}`); } - const data: ProjectsResponse = await response.json(); - setProjects(data.projects); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load projects"); - } finally { - setLoading(false); + } catch (error) { + console.error('Error loading project conversations:', error); + // Fallback to navigating to project without session + navigate(`/projects/${encodeURIComponent(project.path)}`); } }; - const handleProjectSelect = (projectPath: string) => { - const normalizedPath = projectPath.startsWith("/") - ? projectPath - : `/${projectPath}`; - navigate(`/projects${normalizedPath}`); - }; - const handleSettingsClick = () => { setIsSettingsOpen(true); }; @@ -48,6 +50,41 @@ export function ProjectSelector() { setIsSettingsOpen(false); }; + // Format date for display + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + if (diffHours < 1) { + return 'Just now'; + } else if (diffHours < 24) { + return `${Math.floor(diffHours)}h ago`; + } else if (diffDays < 7) { + return `${Math.floor(diffDays)}d ago`; + } else { + return date.toLocaleDateString(); + } + }; + + // Handle conversation selection from sidebar + const handleConversationSelect = useCallback((projectEncodedName: string, conversationId: string) => { + // Find the project from our loaded projects + const project = projects.find(p => p.encodedName === projectEncodedName); + + if (!project) { + console.error('Project not found for encoded name:', projectEncodedName); + return; + } + + console.log(`[ProjectSelector] Navigating to: ${project.path} with session: ${conversationId}`); + + // Navigate to the project with the session ID to continue the conversation + navigate(`/projects/${encodeURIComponent(project.path)}?sessionId=${conversationId}`); + }, [navigate, projects]); + if (loading) { return (
@@ -68,39 +105,70 @@ export function ProjectSelector() { return (
-
- {/* Header */} -
-

- Select a Project -

- -
+
+ {/* Claude Projects Sidebar */} + + + {/* Main Content Area */} +
+ {/* Header */} +
+

+ Select a Project +

+ +
-
- {projects.length > 0 && ( - <> -

- Recent Projects -

- {projects.map((project) => ( - - ))} - - )} -
+
+ {projects.length > 0 && ( + <> +

+ Recent Projects +

+ {projects.map((project) => ( + + ))} + + )} +
- {/* Settings Modal */} - + {/* Settings Modal */} + +
); diff --git a/frontend/src/components/sidebar/ProjectsSidebar.tsx b/frontend/src/components/sidebar/ProjectsSidebar.tsx new file mode 100644 index 0000000..bb3d1e4 --- /dev/null +++ b/frontend/src/components/sidebar/ProjectsSidebar.tsx @@ -0,0 +1,305 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronDownIcon, ChevronRightIcon, FolderIcon, ChatBubbleLeftIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { useClaudeProjects, useProjectConversations } from '../../hooks/useClaudeProjects'; +import type { ClaudeProject, ConversationSummary } from '../../../../shared/types'; + +interface ProjectsSidebarProps { + onConversationSelect: (projectEncodedName: string, conversationId: string) => void; + activeProjectPath?: string; + activeSessionId?: string; + className?: string; +} + +interface ProjectItemProps { + project: ClaudeProject; + isActive: boolean; + activeSessionId?: string; + onConversationSelect: (projectEncodedName: string, conversationId: string) => void; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + if (diffHours < 1) { + return 'Just now'; + } else if (diffHours < 24) { + return `${Math.floor(diffHours)}h ago`; + } else if (diffDays < 7) { + return `${Math.floor(diffDays)}d ago`; + } else { + return date.toLocaleDateString(); + } +} + +function ConversationItem({ + conversation, + isActive, + onSelect +}: { + conversation: ConversationSummary; + isActive: boolean; + onSelect: () => void; +}) { + return ( +
+
+ +
+
+ {conversation.lastMessagePreview || 'No preview available'} +
+
+ + {conversation.messageCount} messages + + + {formatDate(conversation.lastTime)} + +
+
+
+
+ ); +} + +function ProjectItem({ project, isActive, activeSessionId, onConversationSelect }: ProjectItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [needsLatestConversation, setNeedsLatestConversation] = useState(false); + + const { conversations, loading, error } = useProjectConversations( + isExpanded || needsLatestConversation ? project.encodedName : null + ); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + const handleProjectClick = async () => { + console.log(`[ProjectItem] Click on project: ${project.displayName} (${project.encodedName})`); + console.log(`[ProjectItem] Conversations loaded: ${conversations.length}`); + + // If conversations are already loaded, use them + if (conversations.length > 0) { + const latestConversation = conversations[0]; + console.log(`[ProjectItem] Opening latest conversation: ${latestConversation.sessionId}`); + onConversationSelect(project.encodedName, latestConversation.sessionId); + } else { + // Otherwise, trigger loading and wait for conversations + console.log(`[ProjectItem] Triggering conversation loading...`); + setNeedsLatestConversation(true); + } + }; + + // Effect to handle opening latest conversation once loaded + React.useEffect(() => { + if (needsLatestConversation && conversations.length > 0) { + const latestConversation = conversations[0]; + console.log(`[ProjectItem] useEffect - Opening latest conversation: ${latestConversation.sessionId} for project ${project.displayName}`); + onConversationSelect(project.encodedName, latestConversation.sessionId); + setNeedsLatestConversation(false); + } + }, [needsLatestConversation, conversations, project.encodedName, onConversationSelect]); + + return ( +
+ {/* Project Header - Unified hover area */} +
+ {/* Expand/Collapse button - full height click area */} +
{ + e.stopPropagation(); + toggleExpanded(); + }} + className="flex items-center gap-2 px-4 self-stretch cursor-pointer hover:bg-gray-200/70 dark:hover:bg-gray-600/70 transition-colors" + > + {isExpanded ? ( + + ) : ( + + )} + +
+ + {/* Project details */} +
+
+ {project.displayName} +
+
+ {project.conversationCount} conversations + {project.lastModified && ( + <> • {formatDate(project.lastModified)} + )} +
+
+
+ + {/* Conversations List */} + {isExpanded && ( +
+ {loading && ( +
+ Loading conversations... +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + {!loading && !error && conversations.length === 0 && ( +
+ No conversations found +
+ )} + + {!loading && !error && conversations.map((conversation) => ( + onConversationSelect(project.encodedName, conversation.sessionId)} + /> + ))} +
+ )} +
+ ); +} + +export default function ProjectsSidebar({ onConversationSelect, activeProjectPath, activeSessionId, className = '' }: ProjectsSidebarProps) { + const { projects, loading, error, refetch } = useClaudeProjects(); + const navigate = useNavigate(); + + const handleHeaderClick = () => { + navigate('/'); + }; + + if (loading) { + return ( +
+
+

+ Projects +

+
+ Loading projects... +
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ Projects +

+
+ Error: {error} +
+ +
+
+ ); + } + + return ( +
+
+

+ Projects +

+

+ {projects.length} projects found +

+
+ +
+ {projects.length === 0 ? ( +
+ No projects found. Start a conversation with Claude CLI to see projects here. +
+ ) : ( + projects.map((project) => ( + + )) + )} +
+ + {/* New Chat Button - only show when we have an active project */} + {activeProjectPath && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 2f0999a..e13c3a3 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -4,6 +4,7 @@ export const API_CONFIG = { CHAT: "/api/chat", ABORT: "/api/abort", PROJECTS: "/api/projects", + CLAUDE_PROJECTS: "/api/claude/projects", HISTORIES: "/api/projects", CONVERSATIONS: "/api/projects", }, @@ -42,3 +43,13 @@ export const getConversationUrl = ( ) => { return `${API_CONFIG.ENDPOINTS.CONVERSATIONS}/${encodedProjectName}/histories/${sessionId}`; }; + +// Helper function to get Claude projects URL +export const getClaudeProjectsUrl = () => { + return API_CONFIG.ENDPOINTS.CLAUDE_PROJECTS; +}; + +// Helper function to get Claude project conversations URL +export const getClaudeProjectConversationsUrl = (encodedProjectName: string) => { + return `${API_CONFIG.ENDPOINTS.CLAUDE_PROJECTS}/${encodedProjectName}/conversations`; +}; diff --git a/frontend/src/hooks/useClaudeProjects.ts b/frontend/src/hooks/useClaudeProjects.ts new file mode 100644 index 0000000..cc34fdc --- /dev/null +++ b/frontend/src/hooks/useClaudeProjects.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getClaudeProjectsUrl, getClaudeProjectConversationsUrl } from '../config/api'; +import type { + ClaudeProject, + ClaudeProjectsResponse, + ConversationSummary, + ProjectConversationsResponse +} from '../../../shared/types'; + +export interface UseClaudeProjectsReturn { + projects: ClaudeProject[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export interface UseProjectConversationsReturn { + conversations: ConversationSummary[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + + +/** + * Hook to fetch all Claude projects from .claude/projects directory + */ +export function useClaudeProjects(): UseClaudeProjectsReturn { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchProjects = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch(getClaudeProjectsUrl()); + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.statusText}`); + } + + const data: ClaudeProjectsResponse = await response.json(); + setProjects(data.projects); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch projects'; + setError(message); + console.error('Error fetching Claude projects:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + return { + projects, + loading, + error, + refetch: fetchProjects, + }; +} + +/** + * Hook to fetch conversations for a specific Claude project + */ +export function useProjectConversations(encodedProjectName: string | null): UseProjectConversationsReturn { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchConversations = useCallback(async () => { + if (!encodedProjectName) { + setConversations([]); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await fetch(getClaudeProjectConversationsUrl(encodedProjectName)); + if (!response.ok) { + throw new Error(`Failed to fetch conversations: ${response.statusText}`); + } + + const data: ProjectConversationsResponse = await response.json(); + setConversations(data.conversations); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch conversations'; + setError(message); + console.error('Error fetching project conversations:', err); + } finally { + setLoading(false); + } + }, [encodedProjectName]); + + useEffect(() => { + fetchConversations(); + }, [fetchConversations]); + + return { + conversations, + loading, + error, + refetch: fetchConversations, + }; +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts index 51b8879..5adeddc 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -26,6 +26,23 @@ export interface ProjectsResponse { projects: ProjectInfo[]; } +// Claude projects discovery types +export interface ClaudeProject { + encodedName: string; + displayName: string; + path: string; // The actual file system path + conversationCount: number; + lastModified?: string; +} + +export interface ClaudeProjectsResponse { + projects: ClaudeProject[]; +} + +export interface ProjectConversationsResponse { + conversations: ConversationSummary[]; +} + // Conversation history types export interface ConversationSummary { sessionId: string;