App.tsx

  1import React, { useState, useEffect, useCallback, useRef } from "react";
  2import { WorkerPoolContextProvider } from "@pierre/diffs/react";
  3import type { SupportedLanguages } from "@pierre/diffs";
  4import ChatInterface from "./components/ChatInterface";
  5import ConversationDrawer from "./components/ConversationDrawer";
  6import CommandPalette from "./components/CommandPalette";
  7import ModelsModal from "./components/ModelsModal";
  8import { Conversation, ConversationWithState, ConversationListUpdate } from "./types";
  9import { api } from "./services/api";
 10
 11// Worker pool configuration for @pierre/diffs syntax highlighting
 12// Workers run tokenization off the main thread for better performance with large diffs
 13const diffsPoolOptions = {
 14  workerFactory: () => new Worker("/diffs-worker.js"),
 15};
 16
 17// Languages to preload in the highlighter (matches PatchTool.tsx langMap)
 18const diffsHighlighterOptions = {
 19  langs: [
 20    "typescript",
 21    "tsx",
 22    "javascript",
 23    "jsx",
 24    "python",
 25    "ruby",
 26    "go",
 27    "rust",
 28    "java",
 29    "c",
 30    "cpp",
 31    "csharp",
 32    "php",
 33    "swift",
 34    "kotlin",
 35    "scala",
 36    "bash",
 37    "sql",
 38    "html",
 39    "css",
 40    "scss",
 41    "json",
 42    "xml",
 43    "yaml",
 44    "toml",
 45    "markdown",
 46  ] as SupportedLanguages[],
 47};
 48
 49// Check if a slug is a generated ID (format: cXXXX where X is alphanumeric)
 50function isGeneratedId(slug: string | null): boolean {
 51  if (!slug) return true;
 52  return /^c[a-z0-9]+$/i.test(slug);
 53}
 54
 55// Get slug from the current URL path (expects /c/<slug> format)
 56function getSlugFromPath(): string | null {
 57  const path = window.location.pathname;
 58  // Check for /c/<slug> format
 59  if (path.startsWith("/c/")) {
 60    const slug = path.slice(3); // Remove "/c/" prefix
 61    if (slug) {
 62      return slug;
 63    }
 64  }
 65  return null;
 66}
 67
 68// Capture the initial slug from URL BEFORE React renders, so it won't be affected
 69// by the useEffect that updates the URL based on current conversation.
 70const initialSlugFromUrl = getSlugFromPath();
 71
 72// Update the URL to reflect the current conversation slug
 73function updateUrlWithSlug(conversation: Conversation | undefined) {
 74  const currentSlug = getSlugFromPath();
 75  const newSlug =
 76    conversation?.slug && !isGeneratedId(conversation.slug) ? conversation.slug : null;
 77
 78  if (currentSlug !== newSlug) {
 79    if (newSlug) {
 80      window.history.replaceState({}, "", `/c/${newSlug}`);
 81    } else {
 82      window.history.replaceState({}, "", "/");
 83    }
 84  }
 85}
 86
 87function updatePageTitle(conversation: Conversation | undefined) {
 88  const hostname = window.__SHELLEY_INIT__?.hostname;
 89  const parts: string[] = [];
 90
 91  if (conversation?.slug && !isGeneratedId(conversation.slug)) {
 92    parts.push(conversation.slug);
 93  }
 94  if (hostname) {
 95    parts.push(hostname);
 96  }
 97  parts.push("Shelley Agent");
 98
 99  document.title = parts.join(" - ");
100}
101
102function App() {
103  const [conversations, setConversations] = useState<ConversationWithState[]>([]);
104  const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
105  // Track viewed conversation separately (needed for subagents which aren't in main list)
106  const [viewedConversation, setViewedConversation] = useState<Conversation | null>(null);
107  const [drawerOpen, setDrawerOpen] = useState(false);
108  const [drawerCollapsed, setDrawerCollapsed] = useState(false);
109  const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
110  const [diffViewerTrigger, setDiffViewerTrigger] = useState(0);
111  const [modelsModalOpen, setModelsModalOpen] = useState(false);
112  const [modelsRefreshTrigger, setModelsRefreshTrigger] = useState(0);
113  const [loading, setLoading] = useState(true);
114  const [error, setError] = useState<string | null>(null);
115  const [subagentUpdate, setSubagentUpdate] = useState<Conversation | null>(null);
116  const [subagentStateUpdate, setSubagentStateUpdate] = useState<{
117    conversation_id: string;
118    working: boolean;
119  } | null>(null);
120  const initialSlugResolved = useRef(false);
121
122  // Resolve initial slug from URL - uses the captured initialSlugFromUrl
123  // Returns the conversation if found, null otherwise
124  const resolveInitialSlug = useCallback(
125    async (convs: Conversation[]): Promise<Conversation | null> => {
126      if (initialSlugResolved.current) return null;
127      initialSlugResolved.current = true;
128
129      const urlSlug = initialSlugFromUrl;
130      if (!urlSlug) return null;
131
132      // First check if we already have this conversation in our list
133      const existingConv = convs.find((c) => c.slug === urlSlug);
134      if (existingConv) return existingConv;
135
136      // Otherwise, try to fetch by slug (may be a subagent)
137      try {
138        const conv = await api.getConversationBySlug(urlSlug);
139        if (conv) return conv;
140      } catch (err) {
141        console.error("Failed to resolve slug:", err);
142      }
143
144      // Slug not found, clear the URL
145      window.history.replaceState({}, "", "/");
146      return null;
147    },
148    [],
149  );
150
151  // Load conversations on mount
152  useEffect(() => {
153    loadConversations();
154  }, []);
155
156  // Global keyboard shortcut for command palette (Cmd+K on macOS, Ctrl+K elsewhere)
157  useEffect(() => {
158    const isMac = navigator.platform.toUpperCase().includes("MAC");
159    const handleKeyDown = (e: KeyboardEvent) => {
160      // On macOS use Cmd+K, on other platforms use Ctrl+K
161      // This preserves native Ctrl+K (kill to end of line) on macOS
162      const modifierPressed = isMac ? e.metaKey : e.ctrlKey;
163      if (modifierPressed && e.key === "k") {
164        e.preventDefault();
165        setCommandPaletteOpen((prev) => !prev);
166      }
167    };
168    document.addEventListener("keydown", handleKeyDown);
169    return () => document.removeEventListener("keydown", handleKeyDown);
170  }, []);
171
172  // Handle popstate events (browser back/forward and SubagentTool navigation)
173  useEffect(() => {
174    const handlePopState = async () => {
175      const slug = getSlugFromPath();
176      if (!slug) return;
177
178      // Try to find in existing conversations first
179      const existingConv = conversations.find((c) => c.slug === slug);
180      if (existingConv) {
181        setCurrentConversationId(existingConv.conversation_id);
182        setViewedConversation(existingConv);
183        return;
184      }
185
186      // Otherwise fetch by slug (may be a subagent)
187      try {
188        const conv = await api.getConversationBySlug(slug);
189        if (conv) {
190          setCurrentConversationId(conv.conversation_id);
191          setViewedConversation(conv);
192        }
193      } catch (err) {
194        console.error("Failed to navigate to conversation:", err);
195      }
196    };
197
198    window.addEventListener("popstate", handlePopState);
199    return () => window.removeEventListener("popstate", handlePopState);
200  }, [conversations]);
201
202  // Handle conversation list updates from the message stream
203  const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => {
204    if (update.type === "update" && update.conversation) {
205      // Handle subagent conversations separately
206      if (update.conversation.parent_conversation_id) {
207        setSubagentUpdate(update.conversation);
208        return;
209      }
210      setConversations((prev) => {
211        // Check if this conversation already exists
212        const existingIndex = prev.findIndex(
213          (c) => c.conversation_id === update.conversation!.conversation_id,
214        );
215
216        if (existingIndex >= 0) {
217          // Update existing conversation in place, preserving working state
218          // (working state is updated separately via conversation_state)
219          const updated = [...prev];
220          updated[existingIndex] = {
221            ...update.conversation!,
222            working: prev[existingIndex].working,
223          };
224          return updated;
225        } else {
226          // Add new conversation at the top (not working by default)
227          return [{ ...update.conversation!, working: false }, ...prev];
228        }
229      });
230    } else if (update.type === "delete" && update.conversation_id) {
231      setConversations((prev) => prev.filter((c) => c.conversation_id !== update.conversation_id));
232    }
233  }, []);
234
235  // Handle conversation state updates (working state changes)
236  const handleConversationStateUpdate = useCallback(
237    (state: { conversation_id: string; working: boolean }) => {
238      // Check if this is a top-level conversation
239      setConversations((prev) => {
240        const found = prev.find((conv) => conv.conversation_id === state.conversation_id);
241        if (found) {
242          return prev.map((conv) =>
243            conv.conversation_id === state.conversation_id
244              ? { ...conv, working: state.working }
245              : conv,
246          );
247        }
248        // Not a top-level conversation, might be a subagent
249        // Pass the state update to the drawer
250        setSubagentStateUpdate(state);
251        return prev;
252      });
253    },
254    [],
255  );
256
257  // Update page title and URL when conversation changes
258  useEffect(() => {
259    // Use viewedConversation if it matches (handles subagents), otherwise look up from list
260    const currentConv =
261      viewedConversation?.conversation_id === currentConversationId
262        ? viewedConversation
263        : conversations.find((conv) => conv.conversation_id === currentConversationId);
264    if (currentConv) {
265      updatePageTitle(currentConv);
266      updateUrlWithSlug(currentConv);
267    }
268  }, [currentConversationId, viewedConversation, conversations]);
269
270  const loadConversations = async () => {
271    try {
272      setLoading(true);
273      setError(null);
274      const convs = await api.getConversations();
275      setConversations(convs);
276
277      // Try to resolve conversation from URL slug first
278      const slugConv = await resolveInitialSlug(convs);
279      if (slugConv) {
280        setCurrentConversationId(slugConv.conversation_id);
281        setViewedConversation(slugConv);
282      } else if (!currentConversationId && convs.length > 0) {
283        // If we have conversations and no current one selected, select the first
284        setCurrentConversationId(convs[0].conversation_id);
285        setViewedConversation(convs[0]);
286      }
287      // If no conversations exist, leave currentConversationId as null
288      // The UI will show the welcome screen and create conversation on first message
289    } catch (err) {
290      console.error("Failed to load conversations:", err);
291      setError("Failed to load conversations. Please refresh the page.");
292    } finally {
293      setLoading(false);
294    }
295  };
296
297  const refreshConversations = async () => {
298    try {
299      const convs = await api.getConversations();
300      setConversations(convs);
301    } catch (err) {
302      console.error("Failed to refresh conversations:", err);
303    }
304  };
305
306  const startNewConversation = () => {
307    // Save the current conversation's cwd to localStorage so the new conversation picks it up
308    if (currentConversation?.cwd) {
309      localStorage.setItem("shelley_selected_cwd", currentConversation.cwd);
310    }
311    // Clear the current conversation - a new one will be created when the user sends their first message
312    setCurrentConversationId(null);
313    setViewedConversation(null);
314    // Clear URL when starting new conversation
315    window.history.replaceState({}, "", "/");
316    setDrawerOpen(false);
317  };
318
319  const selectConversation = (conversation: Conversation) => {
320    setCurrentConversationId(conversation.conversation_id);
321    setViewedConversation(conversation);
322    setDrawerOpen(false);
323  };
324
325  const toggleDrawerCollapsed = () => {
326    setDrawerCollapsed((prev) => !prev);
327  };
328
329  const updateConversation = (updatedConversation: Conversation) => {
330    // Skip subagent conversations for the main list
331    if (updatedConversation.parent_conversation_id) {
332      return;
333    }
334    setConversations((prev) =>
335      prev.map((conv) =>
336        conv.conversation_id === updatedConversation.conversation_id
337          ? { ...updatedConversation, working: conv.working }
338          : conv,
339      ),
340    );
341  };
342
343  const handleConversationArchived = (conversationId: string) => {
344    setConversations((prev) => prev.filter((conv) => conv.conversation_id !== conversationId));
345    // If the archived conversation was current, switch to another or clear
346    if (currentConversationId === conversationId) {
347      const remaining = conversations.filter((conv) => conv.conversation_id !== conversationId);
348      setCurrentConversationId(remaining.length > 0 ? remaining[0].conversation_id : null);
349    }
350  };
351
352  const handleConversationUnarchived = (conversation: Conversation) => {
353    // Add the unarchived conversation back to the list (not working by default)
354    setConversations((prev) => [{ ...conversation, working: false }, ...prev]);
355  };
356
357  const handleConversationRenamed = (conversation: Conversation) => {
358    // Update the conversation in the list with the new slug, preserving working state
359    setConversations((prev) =>
360      prev.map((c) =>
361        c.conversation_id === conversation.conversation_id
362          ? { ...conversation, working: c.working }
363          : c,
364      ),
365    );
366  };
367
368  if (loading && conversations.length === 0) {
369    return (
370      <div className="loading-container">
371        <div className="loading-content">
372          <div className="spinner" style={{ margin: "0 auto 1rem" }}></div>
373          <p className="text-secondary">Loading...</p>
374        </div>
375      </div>
376    );
377  }
378
379  if (error && conversations.length === 0) {
380    return (
381      <div className="error-container">
382        <div className="error-content">
383          <p className="error-message" style={{ marginBottom: "1rem" }}>
384            {error}
385          </p>
386          <button onClick={loadConversations} className="btn-primary">
387            Retry
388          </button>
389        </div>
390      </div>
391    );
392  }
393
394  const currentConversation = conversations.find(
395    (conv) => conv.conversation_id === currentConversationId,
396  );
397
398  // Get the CWD from the current conversation, or fall back to the most recent conversation
399  const mostRecentCwd =
400    currentConversation?.cwd || (conversations.length > 0 ? conversations[0].cwd : null);
401
402  const handleFirstMessage = async (message: string, model: string, cwd?: string) => {
403    try {
404      const response = await api.sendMessageWithNewConversation({ message, model, cwd });
405      const newConversationId = response.conversation_id;
406
407      // Fetch the new conversation details
408      const updatedConvs = await api.getConversations();
409      setConversations(updatedConvs);
410      setCurrentConversationId(newConversationId);
411    } catch (err) {
412      console.error("Failed to send first message:", err);
413      setError("Failed to send message");
414      throw err;
415    }
416  };
417
418  const handleContinueConversation = async (
419    sourceConversationId: string,
420    model: string,
421    cwd?: string,
422  ) => {
423    try {
424      const response = await api.continueConversation(sourceConversationId, model, cwd);
425      const newConversationId = response.conversation_id;
426
427      // Fetch the new conversation details
428      const updatedConvs = await api.getConversations();
429      setConversations(updatedConvs);
430      setCurrentConversationId(newConversationId);
431    } catch (err) {
432      console.error("Failed to continue conversation:", err);
433      setError("Failed to continue conversation");
434      throw err;
435    }
436  };
437
438  return (
439    <WorkerPoolContextProvider
440      poolOptions={diffsPoolOptions}
441      highlighterOptions={diffsHighlighterOptions}
442    >
443      <div className="app-container">
444        {/* Conversations drawer */}
445        <ConversationDrawer
446          isOpen={drawerOpen}
447          isCollapsed={drawerCollapsed}
448          onClose={() => setDrawerOpen(false)}
449          onToggleCollapse={toggleDrawerCollapsed}
450          conversations={conversations}
451          currentConversationId={currentConversationId}
452          viewedConversation={viewedConversation}
453          onSelectConversation={selectConversation}
454          onNewConversation={startNewConversation}
455          onConversationArchived={handleConversationArchived}
456          onConversationUnarchived={handleConversationUnarchived}
457          onConversationRenamed={handleConversationRenamed}
458          subagentUpdate={subagentUpdate}
459          subagentStateUpdate={subagentStateUpdate}
460        />
461
462        {/* Main chat interface */}
463        <div className="main-content">
464          <ChatInterface
465            conversationId={currentConversationId}
466            onOpenDrawer={() => setDrawerOpen(true)}
467            onNewConversation={startNewConversation}
468            currentConversation={currentConversation}
469            onConversationUpdate={updateConversation}
470            onConversationListUpdate={handleConversationListUpdate}
471            onConversationStateUpdate={handleConversationStateUpdate}
472            onFirstMessage={handleFirstMessage}
473            onContinueConversation={handleContinueConversation}
474            mostRecentCwd={mostRecentCwd}
475            isDrawerCollapsed={drawerCollapsed}
476            onToggleDrawerCollapse={toggleDrawerCollapsed}
477            openDiffViewerTrigger={diffViewerTrigger}
478            modelsRefreshTrigger={modelsRefreshTrigger}
479            onOpenModelsModal={() => setModelsModalOpen(true)}
480            onReconnect={refreshConversations}
481          />
482        </div>
483
484        {/* Command Palette */}
485        <CommandPalette
486          isOpen={commandPaletteOpen}
487          onClose={() => setCommandPaletteOpen(false)}
488          conversations={conversations}
489          onNewConversation={() => {
490            startNewConversation();
491            setCommandPaletteOpen(false);
492          }}
493          onSelectConversation={(conversation) => {
494            selectConversation(conversation);
495            setCommandPaletteOpen(false);
496          }}
497          onOpenDiffViewer={() => {
498            setDiffViewerTrigger((prev) => prev + 1);
499            setCommandPaletteOpen(false);
500          }}
501          onOpenModelsModal={() => {
502            setModelsModalOpen(true);
503            setCommandPaletteOpen(false);
504          }}
505          hasCwd={!!(currentConversation?.cwd || mostRecentCwd)}
506        />
507
508        <ModelsModal
509          isOpen={modelsModalOpen}
510          onClose={() => setModelsModalOpen(false)}
511          onModelsChanged={() => setModelsRefreshTrigger((prev) => prev + 1)}
512        />
513
514        {/* Backdrop for mobile drawer */}
515        {drawerOpen && (
516          <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
517        )}
518      </div>
519    </WorkerPoolContextProvider>
520  );
521}
522
523export default App;