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;