diff --git a/.gitignore b/.gitignore index 41baf87a..ef9e3309 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ backend/dist/ frontend/demo-recordings/ frontend/test-results/ frontend/playwright-report/ -frontend/playwright/.cache/ \ No newline at end of file +frontend/playwright/.cache/ +.env diff --git a/backend/deno.lock b/backend/deno.lock index d44f095c..37736359 100644 --- a/backend/deno.lock +++ b/backend/deno.lock @@ -47,7 +47,7 @@ "npm:@hono/node-server@1", "npm:@logtape/logtape@1", "npm:@logtape/pretty@1", - "npm:@types/node@20", + "npm:@types/node@^24.3.0", "npm:@typescript-eslint/eslint-plugin@^8.44.0", "npm:@typescript-eslint/parser@^8.44.0", "npm:commander@14", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58319aed..6ff6ba99 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "dayjs": "^1.11.13", + "lucide-react": "^0.544.0", "react": "^19.1.0", "react-dom": "^19.1.1", "react-router-dom": "^7.6.2" @@ -4054,6 +4055,15 @@ "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 69fce558..3a963822 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "dayjs": "^1.11.13", + "lucide-react": "^0.544.0", "react": "^19.1.0", "react-dom": "^19.1.1", "react-router-dom": "^7.6.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46084ea5..de0ba606 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,6 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { Suspense, lazy } from "react"; -import { ProjectSelector } from "./components/ProjectSelector"; -import { ChatPage } from "./components/ChatPage"; +import { SplitView } from "./components/SplitView"; import { SettingsProvider } from "./contexts/SettingsContext"; import { isDevelopment } from "./utils/environment"; @@ -19,8 +18,7 @@ function App() { - } /> - } /> + } /> {DemoPage && (
+ {!isHistoryView && !isLoadedConversation && ( + + )} {isHistoryView && (
-
+      
         {message.content}
       
@@ -127,7 +138,7 @@ export function SystemMessageComponent({ label={getLabel()} details={details} badge={"subtype" in message ? message.subtype : undefined} - icon={} + icon={} colorScheme={{ header: "text-blue-800 dark:text-blue-300", content: "text-blue-700 dark:text-blue-300", @@ -150,7 +161,7 @@ export function ToolMessageComponent({ message }: ToolMessageComponentProps) { >
- 🔧 +
{message.content}
@@ -221,7 +232,7 @@ export function ToolResultMessageComponent({ label={message.toolName} details={displayContent} badge={message.toolName === "Edit" ? undefined : message.summary} - icon={} + icon={} colorScheme={{ header: "text-emerald-800 dark:text-emerald-300", content: "text-emerald-700 dark:text-emerald-300", @@ -250,7 +261,7 @@ export function PlanMessageComponent({ message }: PlanMessageComponentProps) {
- 📋 +
Ready to code?
@@ -265,7 +276,7 @@ export function PlanMessageComponent({ message }: PlanMessageComponentProps) { Here is Claude's plan:

-
+          
             {message.plan}
           
@@ -286,7 +297,7 @@ export function ThinkingMessageComponent({ label="Claude's Reasoning" details={message.content} badge="thinking" - icon={💭} + icon={} colorScheme={{ header: "text-purple-700 dark:text-purple-300", content: "text-purple-600 dark:text-purple-400 italic", @@ -306,12 +317,15 @@ export function TodoMessageComponent({ message }: TodoMessageComponentProps) { const getStatusIcon = (status: TodoItem["status"]) => { switch (status) { case "completed": - return { icon: "✅", label: "Completed" }; + return { icon: , label: "Completed" }; case "in_progress": - return { icon: "🔄", label: "In progress" }; + return { + icon: , + label: "In progress", + }; case "pending": default: - return { icon: "⏳", label: "Pending" }; + return { icon: , label: "Pending" }; } }; @@ -338,7 +352,7 @@ export function TodoMessageComponent({ message }: TodoMessageComponentProps) { className="w-4 h-4 bg-amber-500 dark:bg-amber-600 rounded-full flex items-center justify-center text-white text-xs" aria-hidden="true" > - 📋 +
Todo List Updated @@ -353,12 +367,12 @@ export function TodoMessageComponent({ message }: TodoMessageComponentProps) { const statusIcon = getStatusIcon(todo.status); return (
- {statusIcon.icon} - +
{todo.content} @@ -392,7 +406,7 @@ export function LoadingComponent() { Claude
-
+ Thinking...
diff --git a/frontend/src/components/SplitView.tsx b/frontend/src/components/SplitView.tsx new file mode 100644 index 00000000..d84eddd5 --- /dev/null +++ b/frontend/src/components/SplitView.tsx @@ -0,0 +1,854 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + FolderIcon, + ChevronRightIcon, + ChevronDownIcon, + ChevronLeftIcon, + PlusIcon, + MagnifyingGlassIcon, + Bars3Icon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import type { + ProjectInfo, + ConversationSummary, + ChatRequest, + ChatMessage, + PermissionMode, + AllMessage, +} from "../types"; +import { getProjectsUrl, getHistoriesUrl, getChatUrl } from "../config/api"; +import { useClaudeStreaming } from "../hooks/useClaudeStreaming"; +import { useChatState } from "../hooks/chat/useChatState"; +import { usePermissions } from "../hooks/chat/usePermissions"; +import { usePermissionMode } from "../hooks/chat/usePermissionMode"; +import { useAbortController } from "../hooks/chat/useAbortController"; +import { useAutoCachedHistoryLoader } from "../hooks/useCachedHistoryLoader"; +import { useSessionCache } from "../hooks/useSessionCache"; +import { normalizeWindowsPath } from "../utils/pathUtils"; +import type { StreamingContext } from "../hooks/streaming/useMessageProcessor"; +import { SettingsButton } from "./SettingsButton"; +import { SettingsModal } from "./SettingsModal"; +import { HistoryView } from "./HistoryView"; +import { ChatInput } from "./chat/ChatInput"; +import { ChatMessages } from "./chat/ChatMessages"; + +interface ProjectWithSessions extends ProjectInfo { + sessions?: ConversationSummary[]; + expanded?: boolean; + loadingSessions?: boolean; +} + +export function SplitView() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedProject, setSelectedProject] = useState(null); + const [currentView, setCurrentView] = useState< + "welcome" | "chat" | "history" + >("welcome"); + const [searchParams, setSearchParams] = useSearchParams(); + + const getProjectDisplayName = (path: string): string => { + return path.split("/").filter(Boolean).pop() || path; + }; + + const filteredProjects = useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowercaseSearch = searchTerm.toLowerCase(); + return projects.filter( + (project) => + getProjectDisplayName(project.path) + .toLowerCase() + .includes(lowercaseSearch) || + project.path.toLowerCase().includes(lowercaseSearch), + ); + }, [projects, searchTerm]); + + useEffect(() => { + loadProjects(); + }, []); + + const loadProjects = async () => { + try { + setLoading(true); + const response = await fetch(getProjectsUrl()); + if (!response.ok) { + throw new Error(`Failed to load projects: ${response.statusText}`); + } + const data = await response.json(); + setProjects( + data.projects.map((project: ProjectInfo) => ({ + ...project, + expanded: false, + })), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load projects"); + } finally { + setLoading(false); + } + }; + + const loadProjectSessions = async ( + projectIndex: number, + encodedName: string, + ) => { + const updatedProjects = [...projects]; + const project = updatedProjects[projectIndex]; + project.loadingSessions = true; + setProjects(updatedProjects); + + try { + const response = await fetch(getHistoriesUrl(encodedName)); + if (response.ok) { + const data = await response.json(); + const sessions = data.conversations || []; + project.sessions = sessions; + + // Preload the most recent 3 sessions for faster switching + const recentSessions = sessions.slice(0, 3); + for (const session of recentSessions) { + try { + await preloadSession(project.path, session.sessionId, encodedName); + } catch (error) { + console.warn( + "Failed to preload session:", + session.sessionId, + error, + ); + } + } + } + } catch (error) { + console.error("Failed to load sessions:", error); + project.sessions = []; + } finally { + project.loadingSessions = false; + setProjects([...updatedProjects]); + } + }; + + const toggleProjectExpansion = (projectPath: string) => { + const updatedProjects = [...projects]; + const projectIndex = updatedProjects.findIndex( + (p) => p.path === projectPath, + ); + if (projectIndex === -1) return; + + const project = updatedProjects[projectIndex]; + project.expanded = !project.expanded; + + if (project.expanded && !project.sessions && !project.loadingSessions) { + loadProjectSessions(projectIndex, project.encodedName); + } + + setProjects(updatedProjects); + }; + + const handleNewSession = (projectPath: string) => { + setSelectedProject(projectPath); + setCurrentView("chat"); + setIsSidebarOpen(false); // Close sidebar on mobile when selecting a project + // Clear any existing session parameters + setSearchParams({}); + }; + + const handleSessionSelect = (projectPath: string, sessionId: string) => { + setSelectedProject(projectPath); + setCurrentView("chat"); + setIsSidebarOpen(false); // Close sidebar on mobile when selecting a session + // Set session parameter for loading existing conversation + setSearchParams({ sessionId }); + }; + + const handleBackToWelcome = () => { + setCurrentView("welcome"); + setSelectedProject(null); + setSearchParams({}); + }; + + const handleSettingsClick = () => { + setIsSettingsOpen(true); + }; + + const handleSettingsClose = () => { + setIsSettingsOpen(false); + }; + + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; + + // Close sidebar when clicking outside on mobile + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (isSidebarOpen && !target.closest('[data-sidebar]') && !target.closest('[data-sidebar-toggle]')) { + setIsSidebarOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isSidebarOpen]); + + // Chat-related hooks and state + const sessionId = searchParams.get("sessionId"); + const workingDirectory = selectedProject + ? normalizeWindowsPath(selectedProject) + : undefined; + + // Get encoded name for current working directory + const getEncodedName = useCallback(() => { + if (!workingDirectory || !projects.length) { + return null; + } + + const project = projects.find((p) => p.path === workingDirectory); + const normalizedWorking = normalizeWindowsPath(workingDirectory); + const normalizedProject = projects.find( + (p) => normalizeWindowsPath(p.path) === normalizedWorking, + ); + const finalProject = project || normalizedProject; + return finalProject?.encodedName || null; + }, [workingDirectory, projects]); + + const { processStreamLine } = useClaudeStreaming(); + const { abortRequest, createAbortHandler } = useAbortController(); + const { permissionMode, setPermissionMode } = usePermissionMode(); + const { preloadSession, setCachedSession, updateScrollPosition } = + useSessionCache(); + + // Load conversation history if sessionId is provided + const { + messages: historyMessages, + loading: historyLoading, + error: historyError, + sessionId: loadedSessionId, + fromCache: historyFromCache, + scrollPosition: historyScrollPosition, + } = useAutoCachedHistoryLoader( + selectedProject || undefined, + getEncodedName() || undefined, + sessionId || undefined, + ); + + // Initialize chat state with loaded history + const { + messages, + input, + isLoading, + currentSessionId, + currentRequestId, + hasShownInitMessage, + currentAssistantMessage, + setInput, + setCurrentSessionId, + setHasShownInitMessage, + setHasReceivedInit, + setCurrentAssistantMessage, + addMessage, + updateLastMessage, + clearInput, + generateRequestId, + resetRequestState, + startRequest, + } = useChatState({ + initialMessages: historyMessages, + initialSessionId: loadedSessionId || undefined, + }); + + const { + allowedTools, + permissionRequest, + showPermissionRequest, + closePermissionRequest, + allowToolTemporary, + allowToolPermanent, + isPermissionMode, + } = usePermissions({ + onPermissionModeChange: setPermissionMode, + }); + + // Chat message sending functionality + const handlePermissionError = useCallback( + (toolName: string, patterns: string[]) => { + showPermissionRequest(toolName, patterns, ""); + }, + [showPermissionRequest], + ); + + const sendMessage = useCallback( + async ( + messageContent?: string, + tools?: string[], + hideUserMessage = false, + overridePermissionMode?: PermissionMode, + ) => { + const content = messageContent || input.trim(); + if (!content || isLoading || !selectedProject) return; + + const requestId = generateRequestId(); + + if (!hideUserMessage) { + const userMessage: ChatMessage = { + type: "chat", + role: "user", + content: content, + timestamp: Date.now(), + }; + addMessage(userMessage); + } + + if (!messageContent) clearInput(); + startRequest(); + + try { + const response = await fetch(getChatUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: content, + requestId, + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + allowedTools: tools || allowedTools, + workingDirectory: selectedProject, + permissionMode: overridePermissionMode || permissionMode, + } as ChatRequest), + }); + + if (!response.body) throw new Error("No response body"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let localHasReceivedInit = false; + let shouldAbort = false; + + const streamingContext: StreamingContext = { + currentAssistantMessage, + setCurrentAssistantMessage, + addMessage: (msg: AllMessage) => { + addMessage(msg); + // Update cache when new messages are added during streaming + if (currentSessionId && selectedProject) { + const updatedMessages = [...messages, msg]; + setCachedSession( + selectedProject, + currentSessionId, + updatedMessages, + ); + } + }, + updateLastMessage: (content: string) => { + // The streaming context expects us to update with content string + updateLastMessage(content); + // Update cache when messages are updated during streaming + if (currentSessionId && selectedProject) { + const updatedMessages = [...messages]; + const lastMessage = updatedMessages[updatedMessages.length - 1]; + if (lastMessage && 'content' in lastMessage) { + updatedMessages[updatedMessages.length - 1] = { + ...lastMessage, + content + } as AllMessage; + setCachedSession( + selectedProject, + currentSessionId, + updatedMessages, + ); + } + } + }, + onSessionId: (sessionId: string) => { + setCurrentSessionId(sessionId); + // Cache the conversation when we get a session ID + if (selectedProject && messages.length > 0) { + setCachedSession(selectedProject, sessionId, messages); + } + }, + shouldShowInitMessage: () => !hasShownInitMessage, + onInitMessageShown: () => setHasShownInitMessage(true), + get hasReceivedInit() { + return localHasReceivedInit; + }, + setHasReceivedInit: (received: boolean) => { + localHasReceivedInit = received; + setHasReceivedInit(received); + }, + onPermissionError: handlePermissionError, + onAbortRequest: async () => { + shouldAbort = true; + await createAbortHandler(requestId)(); + }, + }; + + while (true) { + const { done, value } = await reader.read(); + if (done || shouldAbort) break; + + const chunk = decoder.decode(value); + const lines = chunk.split("\n").filter((line) => line.trim()); + + for (const line of lines) { + if (shouldAbort) break; + processStreamLine(line, streamingContext); + } + + if (shouldAbort) break; + } + } catch (error) { + console.error("Failed to send message:", error); + addMessage({ + type: "chat", + role: "assistant", + content: "Error: Failed to get response", + timestamp: Date.now(), + }); + } finally { + resetRequestState(); + } + }, + [ + input, + isLoading, + selectedProject, + currentSessionId, + allowedTools, + hasShownInitMessage, + currentAssistantMessage, + permissionMode, + generateRequestId, + clearInput, + startRequest, + addMessage, + updateLastMessage, + setCurrentSessionId, + setHasShownInitMessage, + setHasReceivedInit, + setCurrentAssistantMessage, + resetRequestState, + processStreamLine, + handlePermissionError, + createAbortHandler, + messages, + setCachedSession, + ], + ); + + // Permission handlers (simplified versions from ChatPage) + const handlePermissionAllow = useCallback(() => { + if (!permissionRequest) return; + let updatedAllowedTools = allowedTools; + permissionRequest.patterns.forEach((pattern) => { + updatedAllowedTools = allowToolTemporary(pattern, updatedAllowedTools); + }); + closePermissionRequest(); + if (currentSessionId) { + sendMessage("continue", updatedAllowedTools, true); + } + }, [ + permissionRequest, + currentSessionId, + sendMessage, + allowedTools, + allowToolTemporary, + closePermissionRequest, + ]); + + const handlePermissionAllowPermanent = useCallback(() => { + if (!permissionRequest) return; + let updatedAllowedTools = allowedTools; + permissionRequest.patterns.forEach((pattern) => { + updatedAllowedTools = allowToolPermanent(pattern, updatedAllowedTools); + }); + closePermissionRequest(); + if (currentSessionId) { + sendMessage("continue", updatedAllowedTools, true); + } + }, [ + permissionRequest, + currentSessionId, + sendMessage, + allowedTools, + allowToolPermanent, + closePermissionRequest, + ]); + + const handlePermissionDeny = useCallback(() => { + closePermissionRequest(); + }, [closePermissionRequest]); + + // Handle scroll position changes to update cache + const handleScrollPositionChange = useCallback( + (position: number) => { + if (selectedProject && currentSessionId) { + updateScrollPosition(selectedProject, currentSessionId, position); + } + }, + [selectedProject, currentSessionId, updateScrollPosition], + ); + + // Create permission data for inline permission interface + const permissionData = permissionRequest + ? { + patterns: permissionRequest.patterns, + onAllow: handlePermissionAllow, + onAllowPermanent: handlePermissionAllowPermanent, + onDeny: handlePermissionDeny, + } + : undefined; + + if (loading) { + return ( +
+
+ Loading projects... +
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + return ( +
+
+ {/* Overlay for mobile */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)} + /> + )} + + {/* Side Panel */} +
+ {/* Header */} +
+

+ Projects +

+
+ + {/* Close button for mobile */} + +
+
+ + {/* Search Input */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* Projects List */} +
+
+ {filteredProjects.map((project) => ( +
+
toggleProjectExpansion(project.path)} + > + {project.expanded ? ( + + ) : ( + + )} + + + {getProjectDisplayName(project.path)} + +
+ + {project.expanded && ( +
+ {/* New Session Button */} + + + {/* Sessions */} + {project.loadingSessions ? ( +
+
+
+ Loading sessions... +
+
+ ) : project.sessions && project.sessions.length > 0 ? ( +
+ {project.sessions.map((session) => ( + + ))} +
+ ) : project.sessions ? ( +
+
+ No sessions yet +
+
+ ) : null} +
+ )} +
+ ))} +
+
+
+ + {/* Central Content */} +
+ {/* Mobile Header with Burger Menu */} +
+
+ + {selectedProject && ( +
+

+ {getProjectDisplayName(selectedProject)} +

+ {currentSessionId && ( +
+ Session: {currentSessionId.substring(0, 8)}... +
+ )} +
+ )} + {selectedProject && ( + + )} +
+
+ {currentView === "welcome" && ( +
+
+
+ +
+

+ Welcome to Claude Code Web UI +

+

+ Select a project from the sidebar to start a new conversation, + or choose an existing session to continue where you left off. +

+
+
+ + Click on a project to see its sessions +
+
+ + Click "New Session" to start fresh +
+
+ +
+
+
+
+ )} + + {currentView === "history" && selectedProject && ( + + )} + + {currentView === "chat" && selectedProject && ( +
+ {/* Desktop Chat Header - hidden on mobile */} +
+
+ +
+

+ {getProjectDisplayName(selectedProject)} +

+ {currentSessionId && ( +
+ + Session: {currentSessionId.substring(0, 8)}... + + {historyFromCache && ( + + cached + + )} +
+ )} +
+
+
+ + {historyLoading ? ( +
+
+
+

+ Loading conversation history... +

+
+
+ ) : historyError ? ( +
+
+
+ + + +
+

+ Error Loading Conversation +

+

+ {historyError} +

+
+
+ ) : ( + <> + + sendMessage()} + onAbort={() => + abortRequest( + currentRequestId, + isLoading, + resetRequestState, + ) + } + permissionMode={permissionMode} + onPermissionModeChange={setPermissionMode} + showPermissions={isPermissionMode} + permissionData={permissionData} + planPermissionData={undefined} + /> + + )} +
+ )} +
+
+ + {/* Settings Modal */} + +
+ ); +} diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index 6b1493cb..372c3fc1 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -152,11 +152,11 @@ export function ChatInput({ const getPermissionModeIndicator = (mode: PermissionMode): string => { switch (mode) { case "default": - return "🔧 normal mode"; + return "normal mode"; case "plan": - return "⏸ plan mode"; + return "plan mode"; case "acceptEdits": - return "⏵⏵ accept edits"; + return "accept edits"; } }; @@ -209,7 +209,7 @@ export function ChatInput({ } return ( -
+