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;