import React, { useState, useEffect, useCallback, useRef } from "react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import type { SupportedLanguages } from "@pierre/diffs"; import ChatInterface from "./components/ChatInterface"; import ConversationDrawer from "./components/ConversationDrawer"; import CommandPalette from "./components/CommandPalette"; import ModelsModal from "./components/ModelsModal"; import { Conversation, ConversationWithState, ConversationListUpdate } from "./types"; import { api } from "./services/api"; // Worker pool configuration for @pierre/diffs syntax highlighting // Workers run tokenization off the main thread for better performance with large diffs const diffsPoolOptions = { workerFactory: () => new Worker("/diffs-worker.js"), }; // Languages to preload in the highlighter (matches PatchTool.tsx langMap) const diffsHighlighterOptions = { langs: [ "typescript", "tsx", "javascript", "jsx", "python", "ruby", "go", "rust", "java", "c", "cpp", "csharp", "php", "swift", "kotlin", "scala", "bash", "sql", "html", "css", "scss", "json", "xml", "yaml", "toml", "markdown", ] as SupportedLanguages[], }; // Check if a slug is a generated ID (format: cXXXX where X is alphanumeric) function isGeneratedId(slug: string | null): boolean { if (!slug) return true; return /^c[a-z0-9]+$/i.test(slug); } // Get slug from the current URL path (expects /c/ format) function getSlugFromPath(): string | null { const path = window.location.pathname; // Check for /c/ format if (path.startsWith("/c/")) { const slug = path.slice(3); // Remove "/c/" prefix if (slug) { return slug; } } return null; } // Capture the initial slug from URL BEFORE React renders, so it won't be affected // by the useEffect that updates the URL based on current conversation. const initialSlugFromUrl = getSlugFromPath(); // Update the URL to reflect the current conversation slug function updateUrlWithSlug(conversation: Conversation | undefined) { const currentSlug = getSlugFromPath(); const newSlug = conversation?.slug && !isGeneratedId(conversation.slug) ? conversation.slug : null; if (currentSlug !== newSlug) { if (newSlug) { window.history.replaceState({}, "", `/c/${newSlug}`); } else { window.history.replaceState({}, "", "/"); } } } function updatePageTitle(conversation: Conversation | undefined) { const hostname = window.__SHELLEY_INIT__?.hostname; const parts: string[] = []; if (conversation?.slug && !isGeneratedId(conversation.slug)) { parts.push(conversation.slug); } if (hostname) { parts.push(hostname); } parts.push("Shelley Agent"); document.title = parts.join(" - "); } function App() { const [conversations, setConversations] = useState([]); const [currentConversationId, setCurrentConversationId] = useState(null); // Track viewed conversation separately (needed for subagents which aren't in main list) const [viewedConversation, setViewedConversation] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerCollapsed, setDrawerCollapsed] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [diffViewerTrigger, setDiffViewerTrigger] = useState(0); const [modelsModalOpen, setModelsModalOpen] = useState(false); const [modelsRefreshTrigger, setModelsRefreshTrigger] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [subagentUpdate, setSubagentUpdate] = useState(null); const [subagentStateUpdate, setSubagentStateUpdate] = useState<{ conversation_id: string; working: boolean; } | null>(null); const initialSlugResolved = useRef(false); // Resolve initial slug from URL - uses the captured initialSlugFromUrl // Returns the conversation if found, null otherwise const resolveInitialSlug = useCallback( async (convs: Conversation[]): Promise => { if (initialSlugResolved.current) return null; initialSlugResolved.current = true; const urlSlug = initialSlugFromUrl; if (!urlSlug) return null; // First check if we already have this conversation in our list const existingConv = convs.find((c) => c.slug === urlSlug); if (existingConv) return existingConv; // Otherwise, try to fetch by slug (may be a subagent) try { const conv = await api.getConversationBySlug(urlSlug); if (conv) return conv; } catch (err) { console.error("Failed to resolve slug:", err); } // Slug not found, clear the URL window.history.replaceState({}, "", "/"); return null; }, [], ); // Load conversations on mount useEffect(() => { loadConversations(); }, []); // Global keyboard shortcut for command palette (Cmd+K on macOS, Ctrl+K elsewhere) useEffect(() => { const isMac = navigator.platform.toUpperCase().includes("MAC"); const handleKeyDown = (e: KeyboardEvent) => { // On macOS use Cmd+K, on other platforms use Ctrl+K // This preserves native Ctrl+K (kill to end of line) on macOS const modifierPressed = isMac ? e.metaKey : e.ctrlKey; if (modifierPressed && e.key === "k") { e.preventDefault(); setCommandPaletteOpen((prev) => !prev); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); // Handle popstate events (browser back/forward and SubagentTool navigation) useEffect(() => { const handlePopState = async () => { const slug = getSlugFromPath(); if (!slug) return; // Try to find in existing conversations first const existingConv = conversations.find((c) => c.slug === slug); if (existingConv) { setCurrentConversationId(existingConv.conversation_id); setViewedConversation(existingConv); return; } // Otherwise fetch by slug (may be a subagent) try { const conv = await api.getConversationBySlug(slug); if (conv) { setCurrentConversationId(conv.conversation_id); setViewedConversation(conv); } } catch (err) { console.error("Failed to navigate to conversation:", err); } }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [conversations]); // Handle conversation list updates from the message stream const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => { if (update.type === "update" && update.conversation) { // Handle subagent conversations separately if (update.conversation.parent_conversation_id) { setSubagentUpdate(update.conversation); return; } setConversations((prev) => { // Check if this conversation already exists const existingIndex = prev.findIndex( (c) => c.conversation_id === update.conversation!.conversation_id, ); if (existingIndex >= 0) { // Update existing conversation in place, preserving working state // (working state is updated separately via conversation_state) const updated = [...prev]; updated[existingIndex] = { ...update.conversation!, working: prev[existingIndex].working, }; return updated; } else { // Add new conversation at the top (not working by default) return [{ ...update.conversation!, working: false }, ...prev]; } }); } else if (update.type === "delete" && update.conversation_id) { setConversations((prev) => prev.filter((c) => c.conversation_id !== update.conversation_id)); } }, []); // Handle conversation state updates (working state changes) const handleConversationStateUpdate = useCallback( (state: { conversation_id: string; working: boolean }) => { // Check if this is a top-level conversation setConversations((prev) => { const found = prev.find((conv) => conv.conversation_id === state.conversation_id); if (found) { return prev.map((conv) => conv.conversation_id === state.conversation_id ? { ...conv, working: state.working } : conv, ); } // Not a top-level conversation, might be a subagent // Pass the state update to the drawer setSubagentStateUpdate(state); return prev; }); }, [], ); // Update page title and URL when conversation changes useEffect(() => { // Use viewedConversation if it matches (handles subagents), otherwise look up from list const currentConv = viewedConversation?.conversation_id === currentConversationId ? viewedConversation : conversations.find((conv) => conv.conversation_id === currentConversationId); if (currentConv) { updatePageTitle(currentConv); updateUrlWithSlug(currentConv); } }, [currentConversationId, viewedConversation, conversations]); const loadConversations = async () => { try { setLoading(true); setError(null); const convs = await api.getConversations(); setConversations(convs); // Try to resolve conversation from URL slug first const slugConv = await resolveInitialSlug(convs); if (slugConv) { setCurrentConversationId(slugConv.conversation_id); setViewedConversation(slugConv); } else if (!currentConversationId && convs.length > 0) { // If we have conversations and no current one selected, select the first setCurrentConversationId(convs[0].conversation_id); setViewedConversation(convs[0]); } // If no conversations exist, leave currentConversationId as null // The UI will show the welcome screen and create conversation on first message } catch (err) { console.error("Failed to load conversations:", err); setError("Failed to load conversations. Please refresh the page."); } finally { setLoading(false); } }; const refreshConversations = async () => { try { const convs = await api.getConversations(); setConversations(convs); } catch (err) { console.error("Failed to refresh conversations:", err); } }; const startNewConversation = () => { // Save the current conversation's cwd to localStorage so the new conversation picks it up if (currentConversation?.cwd) { localStorage.setItem("shelley_selected_cwd", currentConversation.cwd); } // Clear the current conversation - a new one will be created when the user sends their first message setCurrentConversationId(null); setViewedConversation(null); // Clear URL when starting new conversation window.history.replaceState({}, "", "/"); setDrawerOpen(false); }; const selectConversation = (conversation: Conversation) => { setCurrentConversationId(conversation.conversation_id); setViewedConversation(conversation); setDrawerOpen(false); }; const toggleDrawerCollapsed = () => { setDrawerCollapsed((prev) => !prev); }; const updateConversation = (updatedConversation: Conversation) => { // Skip subagent conversations for the main list if (updatedConversation.parent_conversation_id) { return; } setConversations((prev) => prev.map((conv) => conv.conversation_id === updatedConversation.conversation_id ? { ...updatedConversation, working: conv.working } : conv, ), ); }; const handleConversationArchived = (conversationId: string) => { setConversations((prev) => prev.filter((conv) => conv.conversation_id !== conversationId)); // If the archived conversation was current, switch to another or clear if (currentConversationId === conversationId) { const remaining = conversations.filter((conv) => conv.conversation_id !== conversationId); setCurrentConversationId(remaining.length > 0 ? remaining[0].conversation_id : null); } }; const handleConversationUnarchived = (conversation: Conversation) => { // Add the unarchived conversation back to the list (not working by default) setConversations((prev) => [{ ...conversation, working: false }, ...prev]); }; const handleConversationRenamed = (conversation: Conversation) => { // Update the conversation in the list with the new slug, preserving working state setConversations((prev) => prev.map((c) => c.conversation_id === conversation.conversation_id ? { ...conversation, working: c.working } : c, ), ); }; if (loading && conversations.length === 0) { return (

Loading...

); } if (error && conversations.length === 0) { return (

{error}

); } const currentConversation = conversations.find( (conv) => conv.conversation_id === currentConversationId, ); // Get the CWD from the current conversation, or fall back to the most recent conversation const mostRecentCwd = currentConversation?.cwd || (conversations.length > 0 ? conversations[0].cwd : null); const handleFirstMessage = async (message: string, model: string, cwd?: string) => { try { const response = await api.sendMessageWithNewConversation({ message, model, cwd }); const newConversationId = response.conversation_id; // Fetch the new conversation details const updatedConvs = await api.getConversations(); setConversations(updatedConvs); setCurrentConversationId(newConversationId); } catch (err) { console.error("Failed to send first message:", err); setError("Failed to send message"); throw err; } }; const handleContinueConversation = async ( sourceConversationId: string, model: string, cwd?: string, ) => { try { const response = await api.continueConversation(sourceConversationId, model, cwd); const newConversationId = response.conversation_id; // Fetch the new conversation details const updatedConvs = await api.getConversations(); setConversations(updatedConvs); setCurrentConversationId(newConversationId); } catch (err) { console.error("Failed to continue conversation:", err); setError("Failed to continue conversation"); throw err; } }; return (
{/* Conversations drawer */} setDrawerOpen(false)} onToggleCollapse={toggleDrawerCollapsed} conversations={conversations} currentConversationId={currentConversationId} viewedConversation={viewedConversation} onSelectConversation={selectConversation} onNewConversation={startNewConversation} onConversationArchived={handleConversationArchived} onConversationUnarchived={handleConversationUnarchived} onConversationRenamed={handleConversationRenamed} subagentUpdate={subagentUpdate} subagentStateUpdate={subagentStateUpdate} /> {/* Main chat interface */}
setDrawerOpen(true)} onNewConversation={startNewConversation} currentConversation={currentConversation} onConversationUpdate={updateConversation} onConversationListUpdate={handleConversationListUpdate} onConversationStateUpdate={handleConversationStateUpdate} onFirstMessage={handleFirstMessage} onContinueConversation={handleContinueConversation} mostRecentCwd={mostRecentCwd} isDrawerCollapsed={drawerCollapsed} onToggleDrawerCollapse={toggleDrawerCollapsed} openDiffViewerTrigger={diffViewerTrigger} modelsRefreshTrigger={modelsRefreshTrigger} onOpenModelsModal={() => setModelsModalOpen(true)} onReconnect={refreshConversations} />
{/* Command Palette */} setCommandPaletteOpen(false)} conversations={conversations} onNewConversation={() => { startNewConversation(); setCommandPaletteOpen(false); }} onSelectConversation={(conversation) => { selectConversation(conversation); setCommandPaletteOpen(false); }} onOpenDiffViewer={() => { setDiffViewerTrigger((prev) => prev + 1); setCommandPaletteOpen(false); }} onOpenModelsModal={() => { setModelsModalOpen(true); setCommandPaletteOpen(false); }} hasCwd={!!(currentConversation?.cwd || mostRecentCwd)} /> setModelsModalOpen(false)} onModelsChanged={() => setModelsRefreshTrigger((prev) => prev + 1)} /> {/* Backdrop for mobile drawer */} {drawerOpen && (
setDrawerOpen(false)} /> )}
); } export default App;