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 startNewConversation = () => {
298    // Save the current conversation's cwd to localStorage so the new conversation picks it up
299    if (currentConversation?.cwd) {
300      localStorage.setItem("shelley_selected_cwd", currentConversation.cwd);
301    }
302    // Clear the current conversation - a new one will be created when the user sends their first message
303    setCurrentConversationId(null);
304    setViewedConversation(null);
305    // Clear URL when starting new conversation
306    window.history.replaceState({}, "", "/");
307    setDrawerOpen(false);
308  };
309
310  const selectConversation = (conversation: Conversation) => {
311    setCurrentConversationId(conversation.conversation_id);
312    setViewedConversation(conversation);
313    setDrawerOpen(false);
314  };
315
316  const toggleDrawerCollapsed = () => {
317    setDrawerCollapsed((prev) => !prev);
318  };
319
320  const updateConversation = (updatedConversation: Conversation) => {
321    // Skip subagent conversations for the main list
322    if (updatedConversation.parent_conversation_id) {
323      return;
324    }
325    setConversations((prev) =>
326      prev.map((conv) =>
327        conv.conversation_id === updatedConversation.conversation_id
328          ? { ...updatedConversation, working: conv.working }
329          : conv,
330      ),
331    );
332  };
333
334  const handleConversationArchived = (conversationId: string) => {
335    setConversations((prev) => prev.filter((conv) => conv.conversation_id !== conversationId));
336    // If the archived conversation was current, switch to another or clear
337    if (currentConversationId === conversationId) {
338      const remaining = conversations.filter((conv) => conv.conversation_id !== conversationId);
339      setCurrentConversationId(remaining.length > 0 ? remaining[0].conversation_id : null);
340    }
341  };
342
343  const handleConversationUnarchived = (conversation: Conversation) => {
344    // Add the unarchived conversation back to the list (not working by default)
345    setConversations((prev) => [{ ...conversation, working: false }, ...prev]);
346  };
347
348  const handleConversationRenamed = (conversation: Conversation) => {
349    // Update the conversation in the list with the new slug, preserving working state
350    setConversations((prev) =>
351      prev.map((c) =>
352        c.conversation_id === conversation.conversation_id
353          ? { ...conversation, working: c.working }
354          : c,
355      ),
356    );
357  };
358
359  if (loading && conversations.length === 0) {
360    return (
361      <div className="loading-container">
362        <div className="loading-content">
363          <div className="spinner" style={{ margin: "0 auto 1rem" }}></div>
364          <p className="text-secondary">Loading...</p>
365        </div>
366      </div>
367    );
368  }
369
370  if (error && conversations.length === 0) {
371    return (
372      <div className="error-container">
373        <div className="error-content">
374          <p className="error-message" style={{ marginBottom: "1rem" }}>
375            {error}
376          </p>
377          <button onClick={loadConversations} className="btn-primary">
378            Retry
379          </button>
380        </div>
381      </div>
382    );
383  }
384
385  const currentConversation = conversations.find(
386    (conv) => conv.conversation_id === currentConversationId,
387  );
388
389  // Get the CWD from the current conversation, or fall back to the most recent conversation
390  const mostRecentCwd =
391    currentConversation?.cwd || (conversations.length > 0 ? conversations[0].cwd : null);
392
393  const handleFirstMessage = async (message: string, model: string, cwd?: string) => {
394    try {
395      const response = await api.sendMessageWithNewConversation({ message, model, cwd });
396      const newConversationId = response.conversation_id;
397
398      // Fetch the new conversation details
399      const updatedConvs = await api.getConversations();
400      setConversations(updatedConvs);
401      setCurrentConversationId(newConversationId);
402    } catch (err) {
403      console.error("Failed to send first message:", err);
404      setError("Failed to send message");
405      throw err;
406    }
407  };
408
409  const handleContinueConversation = async (
410    sourceConversationId: string,
411    model: string,
412    cwd?: string,
413  ) => {
414    try {
415      const response = await api.continueConversation(sourceConversationId, model, cwd);
416      const newConversationId = response.conversation_id;
417
418      // Fetch the new conversation details
419      const updatedConvs = await api.getConversations();
420      setConversations(updatedConvs);
421      setCurrentConversationId(newConversationId);
422    } catch (err) {
423      console.error("Failed to continue conversation:", err);
424      setError("Failed to continue conversation");
425      throw err;
426    }
427  };
428
429  return (
430    <WorkerPoolContextProvider
431      poolOptions={diffsPoolOptions}
432      highlighterOptions={diffsHighlighterOptions}
433    >
434      <div className="app-container">
435        {/* Conversations drawer */}
436        <ConversationDrawer
437          isOpen={drawerOpen}
438          isCollapsed={drawerCollapsed}
439          onClose={() => setDrawerOpen(false)}
440          onToggleCollapse={toggleDrawerCollapsed}
441          conversations={conversations}
442          currentConversationId={currentConversationId}
443          viewedConversation={viewedConversation}
444          onSelectConversation={selectConversation}
445          onNewConversation={startNewConversation}
446          onConversationArchived={handleConversationArchived}
447          onConversationUnarchived={handleConversationUnarchived}
448          onConversationRenamed={handleConversationRenamed}
449          subagentUpdate={subagentUpdate}
450          subagentStateUpdate={subagentStateUpdate}
451        />
452
453        {/* Main chat interface */}
454        <div className="main-content">
455          <ChatInterface
456            conversationId={currentConversationId}
457            onOpenDrawer={() => setDrawerOpen(true)}
458            onNewConversation={startNewConversation}
459            currentConversation={currentConversation}
460            onConversationUpdate={updateConversation}
461            onConversationListUpdate={handleConversationListUpdate}
462            onConversationStateUpdate={handleConversationStateUpdate}
463            onFirstMessage={handleFirstMessage}
464            onContinueConversation={handleContinueConversation}
465            mostRecentCwd={mostRecentCwd}
466            isDrawerCollapsed={drawerCollapsed}
467            onToggleDrawerCollapse={toggleDrawerCollapsed}
468            openDiffViewerTrigger={diffViewerTrigger}
469            modelsRefreshTrigger={modelsRefreshTrigger}
470            onOpenModelsModal={() => setModelsModalOpen(true)}
471          />
472        </div>
473
474        {/* Command Palette */}
475        <CommandPalette
476          isOpen={commandPaletteOpen}
477          onClose={() => setCommandPaletteOpen(false)}
478          conversations={conversations}
479          onNewConversation={() => {
480            startNewConversation();
481            setCommandPaletteOpen(false);
482          }}
483          onSelectConversation={(conversation) => {
484            selectConversation(conversation);
485            setCommandPaletteOpen(false);
486          }}
487          onOpenDiffViewer={() => {
488            setDiffViewerTrigger((prev) => prev + 1);
489            setCommandPaletteOpen(false);
490          }}
491          onOpenModelsModal={() => {
492            setModelsModalOpen(true);
493            setCommandPaletteOpen(false);
494          }}
495          hasCwd={!!(currentConversation?.cwd || mostRecentCwd)}
496        />
497
498        <ModelsModal
499          isOpen={modelsModalOpen}
500          onClose={() => setModelsModalOpen(false)}
501          onModelsChanged={() => setModelsRefreshTrigger((prev) => prev + 1)}
502        />
503
504        {/* Backdrop for mobile drawer */}
505        {drawerOpen && (
506          <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
507        )}
508      </div>
509    </WorkerPoolContextProvider>
510  );
511}
512
513export default App;