Skip to content

add split view for switching chats#286

Open
jasonkneen wants to merge 3 commits intosugyan:mainfrom
jasonkneen:feature/ui-updates
Open

add split view for switching chats#286
jasonkneen wants to merge 3 commits intosugyan:mainfrom
jasonkneen:feature/ui-updates

Conversation

@jasonkneen
Copy link

Added a split view to show projects, sessions with search and allow switching more easily as well as creating new chats.

@sugyan
Copy link
Owner

sugyan commented Sep 15, 2025

Thanks for the PR! However, there are a few issues that need to be addressed before merging:

  1. CI is failing - Please fix the lint errors (unused imports/variables and hook dependencies)
  2. Mobile responsiveness is broken - I tested this and it's completely unusable on smartphones due to the fixed-width sidebar. Since mobile support is a key feature of this app, this is a blocking issue.
  3. PR description is too brief - For such a major UI change, please provide more details including screenshots.

Could you address these issues, especially the mobile responsiveness?

add split view for switching chats
remove emoji in favour of lucide icons
Fast switch chats
Updated responsiveness of project list
Unified message handling by introducing AllMessage type in SplitView and related components. Updated streaming context to use content string for updating last message and improved session cache typing for consistency. Minor fix in ChatMessages to handle scrollTimeout initialization.
Copilot AI review requested due to automatic review settings November 6, 2025 19:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a unified split-view interface that consolidates project selection and chat functionality into a single page, adds session caching for improved performance when switching between conversations, and modernizes the UI by replacing emoji icons with the lucide-react icon library.

Key Changes:

  • Implements session caching with LRU eviction and 30-minute expiry for faster conversation switching
  • Replaces separate ProjectSelector and ChatPage components with a unified SplitView component featuring a collapsible sidebar
  • Migrates from emoji icons to lucide-react for more consistent and professional iconography

Reviewed Changes

Copilot reviewed 13 out of 16 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
frontend/src/components/SplitView.tsx New main component that combines project/session navigation with chat interface in a responsive split layout
frontend/src/hooks/useSessionCache.ts New hook providing session caching with automatic expiry, size limits, and scroll position preservation
frontend/src/hooks/useCachedHistoryLoader.ts New hook that loads conversation history with cache integration for improved performance
frontend/src/components/chat/ChatMessages.tsx Enhanced with scroll position management and restoration for cached sessions
frontend/src/components/MessageComponents.tsx Updated all message components to use lucide-react icons instead of emojis
frontend/src/components/chat/ChatInput.tsx Removed emoji decorations from permission mode indicators and added layout improvements
frontend/src/components/messages/CollapsibleDetails.tsx Added overflow handling for better text wrapping in code blocks
frontend/src/components/messages/MessageContainer.tsx Added min-width constraint to prevent layout issues
frontend/src/components/ChatPage.tsx Added back button for improved navigation
frontend/src/App.tsx Updated routing to use SplitView as the main entry point
frontend/src/types.ts Added exports for ConversationSummary and ConversationHistory types
frontend/package.json Added lucide-react dependency for icon components
frontend/vite.config.ts Changed proxy target from localhost to 127.0.0.1 for better compatibility
backend/deno.lock Updated @types/node dependency to version 24.x
.gitignore Added .env to ignored files
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)

frontend/src/components/chat/ChatInput.tsx:173

  • The functions getPermissionModeIndicator and getPermissionModeName are now identical after removing the emojis. Consider removing one of them and using the other in both places to reduce code duplication.
  const getPermissionModeIndicator = (mode: PermissionMode): string => {
    switch (mode) {
      case "default":
        return "normal mode";
      case "plan":
        return "plan mode";
      case "acceptEdits":
        return "accept edits";
    }
  };

  // Get clean permission mode name (without emoji)
  const getPermissionModeName = (mode: PermissionMode): string => {
    switch (mode) {
      case "default":
        return "normal mode";
      case "plan":
        return "plan mode";
      case "acceptEdits":
        return "accept edits";
    }
  };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +184 to +192
useEffect(() => {
if (projectPath && encodedProjectName && sessionId) {
historyLoader.loadHistory(projectPath, encodedProjectName, sessionId);
} else if (!sessionId) {
// Only clear if there's no sessionId
historyLoader.clearHistory();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectPath, encodedProjectName, sessionId]);
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing historyLoader.loadHistory in the dependency array will cause this effect to use a stale version of the function. While it might work due to useCallback, it's better practice to include all dependencies or explicitly disable the rule with a comment explaining why it's safe.

ESLint is already disabled here, but the comment should explain why loadHistory and clearHistory are intentionally excluded from the dependency array.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +84
const handleScroll = () => {
if (
isRestoringScroll.current ||
!messagesContainerRef.current ||
!onScrollPositionChange
) {
return;
}

// Debounce scroll position updates
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}

scrollTimeout.current = setTimeout(() => {
if (messagesContainerRef.current) {
onScrollPositionChange(messagesContainerRef.current.scrollTop);
}
}, 150);
};
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout created in handleScroll is stored in a ref but never cleaned up when the component unmounts. This can cause the callback to execute after unmount, potentially leading to state updates on unmounted components.

Add cleanup in a useEffect return function:

useEffect(() => {
  return () => {
    if (scrollTimeout.current) {
      clearTimeout(scrollTimeout.current);
    }
  };
}, []);

Copilot uses AI. Check for mistakes.
Comment on lines +348 to +367
updateLastMessage: (content: string) => {
// The streaming context expects us to update with content string
updateLastMessage(content);
// Update cache when messages are updated during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage && 'content' in lastMessage) {
updatedMessages[updatedMessages.length - 1] = {
...lastMessage,
content
} as AllMessage;
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
}
},
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar stale closure issue: The messages array used here is from the closure and doesn't reflect the updated content from updateLastMessage. The cache will contain outdated message content.

Consider using a useEffect to sync the cache with the messages state, or restructure to avoid closure staleness.

Copilot uses AI. Check for mistakes.
Comment on lines +370 to +373
// Cache the conversation when we get a session ID
if (selectedProject && messages.length > 0) {
setCachedSession(selectedProject, sessionId, messages);
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache is being updated with the old messages array before the new session starts. Since this is a new session, messages at this point might contain messages from a previous session or be empty. This could cache incorrect data for the new session ID.

Consider caching after the first message is actually added to the new session, not at session creation time.

Suggested change
// Cache the conversation when we get a session ID
if (selectedProject && messages.length > 0) {
setCachedSession(selectedProject, sessionId, messages);
}

Copilot uses AI. Check for mistakes.
);
if (response.ok) {
const data: ConversationHistory = await response.json();
setCachedSession(projectPath, sessionId, data.messages as FrontendAllMessage[]);
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preloadSession function has a potential race condition. Between checking cache.current.has(key) (line 143) and setting loadingRef.current.add(key) (line 147), another call could pass the check. Although JavaScript is single-threaded, async operations can interleave. Consider combining the check and set:

if (cache.current.has(key) || loadingRef.current.has(key)) {
  return;
}
loadingRef.current.add(key);

This is already done correctly. However, the issue is that after an async fetch, the session might have been manually cached by another operation, leading to duplicate work. Consider re-checking the cache before calling setCachedSession:

if (response.ok) {
  const data: ConversationHistory = await response.json();
  // Re-check in case it was cached while we were fetching
  if (!cache.current.has(key)) {
    setCachedSession(projectPath, sessionId, data.messages as FrontendAllMessage[]);
  }
}
Suggested change
setCachedSession(projectPath, sessionId, data.messages as FrontendAllMessage[]);
// Re-check in case it was cached while we were fetching
if (!cache.current.has(key)) {
setCachedSession(projectPath, sessionId, data.messages as FrontendAllMessage[]);
}

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +854
export function SplitView() {
const [projects, setProjects] = useState<ProjectWithSessions[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<
"welcome" | "chat" | "history"
>("welcome");
const [searchParams, setSearchParams] = useSearchParams();

const getProjectDisplayName = (path: string): string => {
return path.split("/").filter(Boolean).pop() || path;
};

const filteredProjects = useMemo(() => {
if (!searchTerm.trim()) return projects;

const lowercaseSearch = searchTerm.toLowerCase();
return projects.filter(
(project) =>
getProjectDisplayName(project.path)
.toLowerCase()
.includes(lowercaseSearch) ||
project.path.toLowerCase().includes(lowercaseSearch),
);
}, [projects, searchTerm]);

useEffect(() => {
loadProjects();
}, []);

const loadProjects = async () => {
try {
setLoading(true);
const response = await fetch(getProjectsUrl());
if (!response.ok) {
throw new Error(`Failed to load projects: ${response.statusText}`);
}
const data = await response.json();
setProjects(
data.projects.map((project: ProjectInfo) => ({
...project,
expanded: false,
})),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load projects");
} finally {
setLoading(false);
}
};

const loadProjectSessions = async (
projectIndex: number,
encodedName: string,
) => {
const updatedProjects = [...projects];
const project = updatedProjects[projectIndex];
project.loadingSessions = true;
setProjects(updatedProjects);

try {
const response = await fetch(getHistoriesUrl(encodedName));
if (response.ok) {
const data = await response.json();
const sessions = data.conversations || [];
project.sessions = sessions;

// Preload the most recent 3 sessions for faster switching
const recentSessions = sessions.slice(0, 3);
for (const session of recentSessions) {
try {
await preloadSession(project.path, session.sessionId, encodedName);
} catch (error) {
console.warn(
"Failed to preload session:",
session.sessionId,
error,
);
}
}
}
} catch (error) {
console.error("Failed to load sessions:", error);
project.sessions = [];
} finally {
project.loadingSessions = false;
setProjects([...updatedProjects]);
}
};

const toggleProjectExpansion = (projectPath: string) => {
const updatedProjects = [...projects];
const projectIndex = updatedProjects.findIndex(
(p) => p.path === projectPath,
);
if (projectIndex === -1) return;

const project = updatedProjects[projectIndex];
project.expanded = !project.expanded;

if (project.expanded && !project.sessions && !project.loadingSessions) {
loadProjectSessions(projectIndex, project.encodedName);
}

setProjects(updatedProjects);
};

const handleNewSession = (projectPath: string) => {
setSelectedProject(projectPath);
setCurrentView("chat");
setIsSidebarOpen(false); // Close sidebar on mobile when selecting a project
// Clear any existing session parameters
setSearchParams({});
};

const handleSessionSelect = (projectPath: string, sessionId: string) => {
setSelectedProject(projectPath);
setCurrentView("chat");
setIsSidebarOpen(false); // Close sidebar on mobile when selecting a session
// Set session parameter for loading existing conversation
setSearchParams({ sessionId });
};

const handleBackToWelcome = () => {
setCurrentView("welcome");
setSelectedProject(null);
setSearchParams({});
};

const handleSettingsClick = () => {
setIsSettingsOpen(true);
};

const handleSettingsClose = () => {
setIsSettingsOpen(false);
};

const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};

// Close sidebar when clicking outside on mobile
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (isSidebarOpen && !target.closest('[data-sidebar]') && !target.closest('[data-sidebar-toggle]')) {
setIsSidebarOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isSidebarOpen]);

// Chat-related hooks and state
const sessionId = searchParams.get("sessionId");
const workingDirectory = selectedProject
? normalizeWindowsPath(selectedProject)
: undefined;

// Get encoded name for current working directory
const getEncodedName = useCallback(() => {
if (!workingDirectory || !projects.length) {
return null;
}

const project = projects.find((p) => p.path === workingDirectory);
const normalizedWorking = normalizeWindowsPath(workingDirectory);
const normalizedProject = projects.find(
(p) => normalizeWindowsPath(p.path) === normalizedWorking,
);
const finalProject = project || normalizedProject;
return finalProject?.encodedName || null;
}, [workingDirectory, projects]);

const { processStreamLine } = useClaudeStreaming();
const { abortRequest, createAbortHandler } = useAbortController();
const { permissionMode, setPermissionMode } = usePermissionMode();
const { preloadSession, setCachedSession, updateScrollPosition } =
useSessionCache();

// Load conversation history if sessionId is provided
const {
messages: historyMessages,
loading: historyLoading,
error: historyError,
sessionId: loadedSessionId,
fromCache: historyFromCache,
scrollPosition: historyScrollPosition,
} = useAutoCachedHistoryLoader(
selectedProject || undefined,
getEncodedName() || undefined,
sessionId || undefined,
);

// Initialize chat state with loaded history
const {
messages,
input,
isLoading,
currentSessionId,
currentRequestId,
hasShownInitMessage,
currentAssistantMessage,
setInput,
setCurrentSessionId,
setHasShownInitMessage,
setHasReceivedInit,
setCurrentAssistantMessage,
addMessage,
updateLastMessage,
clearInput,
generateRequestId,
resetRequestState,
startRequest,
} = useChatState({
initialMessages: historyMessages,
initialSessionId: loadedSessionId || undefined,
});

const {
allowedTools,
permissionRequest,
showPermissionRequest,
closePermissionRequest,
allowToolTemporary,
allowToolPermanent,
isPermissionMode,
} = usePermissions({
onPermissionModeChange: setPermissionMode,
});

// Chat message sending functionality
const handlePermissionError = useCallback(
(toolName: string, patterns: string[]) => {
showPermissionRequest(toolName, patterns, "");
},
[showPermissionRequest],
);

const sendMessage = useCallback(
async (
messageContent?: string,
tools?: string[],
hideUserMessage = false,
overridePermissionMode?: PermissionMode,
) => {
const content = messageContent || input.trim();
if (!content || isLoading || !selectedProject) return;

const requestId = generateRequestId();

if (!hideUserMessage) {
const userMessage: ChatMessage = {
type: "chat",
role: "user",
content: content,
timestamp: Date.now(),
};
addMessage(userMessage);
}

if (!messageContent) clearInput();
startRequest();

try {
const response = await fetch(getChatUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: content,
requestId,
...(currentSessionId ? { sessionId: currentSessionId } : {}),
allowedTools: tools || allowedTools,
workingDirectory: selectedProject,
permissionMode: overridePermissionMode || permissionMode,
} as ChatRequest),
});

if (!response.body) throw new Error("No response body");

const reader = response.body.getReader();
const decoder = new TextDecoder();
let localHasReceivedInit = false;
let shouldAbort = false;

const streamingContext: StreamingContext = {
currentAssistantMessage,
setCurrentAssistantMessage,
addMessage: (msg: AllMessage) => {
addMessage(msg);
// Update cache when new messages are added during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages, msg];
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
},
updateLastMessage: (content: string) => {
// The streaming context expects us to update with content string
updateLastMessage(content);
// Update cache when messages are updated during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage && 'content' in lastMessage) {
updatedMessages[updatedMessages.length - 1] = {
...lastMessage,
content
} as AllMessage;
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
}
},
onSessionId: (sessionId: string) => {
setCurrentSessionId(sessionId);
// Cache the conversation when we get a session ID
if (selectedProject && messages.length > 0) {
setCachedSession(selectedProject, sessionId, messages);
}
},
shouldShowInitMessage: () => !hasShownInitMessage,
onInitMessageShown: () => setHasShownInitMessage(true),
get hasReceivedInit() {
return localHasReceivedInit;
},
setHasReceivedInit: (received: boolean) => {
localHasReceivedInit = received;
setHasReceivedInit(received);
},
onPermissionError: handlePermissionError,
onAbortRequest: async () => {
shouldAbort = true;
await createAbortHandler(requestId)();
},
};

while (true) {
const { done, value } = await reader.read();
if (done || shouldAbort) break;

const chunk = decoder.decode(value);
const lines = chunk.split("\n").filter((line) => line.trim());

for (const line of lines) {
if (shouldAbort) break;
processStreamLine(line, streamingContext);
}

if (shouldAbort) break;
}
} catch (error) {
console.error("Failed to send message:", error);
addMessage({
type: "chat",
role: "assistant",
content: "Error: Failed to get response",
timestamp: Date.now(),
});
} finally {
resetRequestState();
}
},
[
input,
isLoading,
selectedProject,
currentSessionId,
allowedTools,
hasShownInitMessage,
currentAssistantMessage,
permissionMode,
generateRequestId,
clearInput,
startRequest,
addMessage,
updateLastMessage,
setCurrentSessionId,
setHasShownInitMessage,
setHasReceivedInit,
setCurrentAssistantMessage,
resetRequestState,
processStreamLine,
handlePermissionError,
createAbortHandler,
messages,
setCachedSession,
],
);

// Permission handlers (simplified versions from ChatPage)
const handlePermissionAllow = useCallback(() => {
if (!permissionRequest) return;
let updatedAllowedTools = allowedTools;
permissionRequest.patterns.forEach((pattern) => {
updatedAllowedTools = allowToolTemporary(pattern, updatedAllowedTools);
});
closePermissionRequest();
if (currentSessionId) {
sendMessage("continue", updatedAllowedTools, true);
}
}, [
permissionRequest,
currentSessionId,
sendMessage,
allowedTools,
allowToolTemporary,
closePermissionRequest,
]);

const handlePermissionAllowPermanent = useCallback(() => {
if (!permissionRequest) return;
let updatedAllowedTools = allowedTools;
permissionRequest.patterns.forEach((pattern) => {
updatedAllowedTools = allowToolPermanent(pattern, updatedAllowedTools);
});
closePermissionRequest();
if (currentSessionId) {
sendMessage("continue", updatedAllowedTools, true);
}
}, [
permissionRequest,
currentSessionId,
sendMessage,
allowedTools,
allowToolPermanent,
closePermissionRequest,
]);

const handlePermissionDeny = useCallback(() => {
closePermissionRequest();
}, [closePermissionRequest]);

// Handle scroll position changes to update cache
const handleScrollPositionChange = useCallback(
(position: number) => {
if (selectedProject && currentSessionId) {
updateScrollPosition(selectedProject, currentSessionId, position);
}
},
[selectedProject, currentSessionId, updateScrollPosition],
);

// Create permission data for inline permission interface
const permissionData = permissionRequest
? {
patterns: permissionRequest.patterns,
onAllow: handlePermissionAllow,
onAllowPermanent: handlePermissionAllowPermanent,
onDeny: handlePermissionDeny,
}
: undefined;

if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-slate-600 dark:text-slate-400">
Loading projects...
</div>
</div>
);
}

if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-600 dark:text-red-400">Error: {error}</div>
</div>
);
}

return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 transition-colors duration-300">
<div className="flex h-screen relative">
{/* Overlay for mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}

{/* Side Panel */}
<div
data-sidebar
className={`
fixed lg:relative lg:translate-x-0 z-50 lg:z-auto
w-80 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700
flex flex-col transition-transform duration-300 ease-in-out
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
h-full lg:h-screen
`}
>
{/* Header */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
Projects
</h2>
<div className="flex items-center gap-2">
<SettingsButton onClick={handleSettingsClick} />
{/* Close button for mobile */}
<button
onClick={() => setIsSidebarOpen(false)}
className="lg:hidden p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label="Close sidebar"
>
<XMarkIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
</div>
</div>

{/* Search Input */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>

{/* Projects List */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-2">
{filteredProjects.map((project) => (
<div
key={project.path}
className="bg-slate-50 dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700"
>
<div
className="flex items-center gap-2 p-3 hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer transition-colors"
onClick={() => toggleProjectExpansion(project.path)}
>
{project.expanded ? (
<ChevronDownIcon className="h-4 w-4 text-slate-500 dark:text-slate-400 flex-shrink-0" />
) : (
<ChevronRightIcon className="h-4 w-4 text-slate-500 dark:text-slate-400 flex-shrink-0" />
)}
<FolderIcon className="h-4 w-4 text-slate-500 dark:text-slate-400 flex-shrink-0" />
<span className="text-sm font-mono text-slate-700 dark:text-slate-300 truncate">
{getProjectDisplayName(project.path)}
</span>
</div>

{project.expanded && (
<div className="border-t border-slate-200 dark:border-slate-700">
{/* New Session Button */}
<button
onClick={() => handleNewSession(project.path)}
className="w-full flex items-center gap-2 p-3 text-left hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border-b border-slate-200 dark:border-slate-700"
>
<PlusIcon className="h-4 w-4 text-blue-500 flex-shrink-0" />
<span className="text-sm text-blue-600 dark:text-blue-400">
New Session
</span>
</button>

{/* Sessions */}
{project.loadingSessions ? (
<div className="p-3 text-center">
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin mx-auto"></div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Loading sessions...
</div>
</div>
) : project.sessions && project.sessions.length > 0 ? (
<div className="max-h-60 overflow-y-auto">
{project.sessions.map((session) => (
<button
key={session.sessionId}
onClick={() =>
handleSessionSelect(
project.path,
session.sessionId,
)
}
className="w-full p-3 text-left hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border-b border-slate-200 dark:border-slate-700 last:border-b-0"
>
<div className="text-xs font-mono text-slate-600 dark:text-slate-400">
{session.sessionId.substring(0, 8)}...
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{new Date(
session.startTime,
).toLocaleDateString()}{" "}
• {session.messageCount} messages
</div>
<div className="text-xs text-slate-600 dark:text-slate-300 mt-1 line-clamp-2">
{session.lastMessagePreview}
</div>
</button>
))}
</div>
) : project.sessions ? (
<div className="p-3 text-center">
<div className="text-xs text-slate-500 dark:text-slate-400">
No sessions yet
</div>
</div>
) : null}
</div>
)}
</div>
))}
</div>
</div>
</div>

{/* Central Content */}
<div className="flex-1 min-w-0 flex flex-col">
{/* Mobile Header with Burger Menu */}
<div className="lg:hidden bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 p-4">
<div className="flex items-center justify-between">
<button
data-sidebar-toggle
onClick={toggleSidebar}
className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
aria-label="Toggle sidebar"
>
<Bars3Icon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
{selectedProject && (
<div className="flex-1 min-w-0 mx-4">
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100 truncate">
{getProjectDisplayName(selectedProject)}
</h1>
{currentSessionId && (
<div className="text-sm text-slate-500 dark:text-slate-400 font-mono">
Session: {currentSessionId.substring(0, 8)}...
</div>
)}
</div>
)}
{selectedProject && (
<button
onClick={handleBackToWelcome}
className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
aria-label="Back to project selection"
>
<ChevronLeftIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
)}
</div>
</div>
{currentView === "welcome" && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center justify-center">
<FolderIcon className="w-8 h-8 text-slate-400 dark:text-slate-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2">
Welcome to Claude Code Web UI
</h1>
<p className="text-slate-600 dark:text-slate-400 mb-6">
Select a project from the sidebar to start a new conversation,
or choose an existing session to continue where you left off.
</p>
<div className="space-y-2 text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center justify-center gap-2">
<ChevronRightIcon className="h-4 w-4" />
<span>Click on a project to see its sessions</span>
</div>
<div className="flex items-center justify-center gap-2">
<PlusIcon className="h-4 w-4" />
<span>Click "New Session" to start fresh</span>
</div>
<div className="lg:hidden mt-4">
<button
onClick={toggleSidebar}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<Bars3Icon className="h-4 w-4" />
<span>Open Projects</span>
</button>
</div>
</div>
</div>
</div>
)}

{currentView === "history" && selectedProject && (
<HistoryView
workingDirectory={selectedProject}
encodedName={getEncodedName()}
onBack={handleBackToWelcome}
/>
)}

{currentView === "chat" && selectedProject && (
<div className="flex-1 flex flex-col p-4 min-h-0">
{/* Desktop Chat Header - hidden on mobile */}
<div className="hidden lg:flex items-center justify-between mb-4 pb-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-4">
<button
onClick={handleBackToWelcome}
className="p-2 rounded-lg bg-white/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-800 transition-all duration-200 backdrop-blur-sm shadow-sm hover:shadow-md"
aria-label="Back to project selection"
>
<ChevronLeftIcon className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
<div>
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{getProjectDisplayName(selectedProject)}
</h1>
{currentSessionId && (
<div className="text-sm text-slate-500 dark:text-slate-400 font-mono flex items-center gap-2">
<span>
Session: {currentSessionId.substring(0, 8)}...
</span>
{historyFromCache && (
<span className="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 px-2 py-0.5 rounded-full">
cached
</span>
)}
</div>
)}
</div>
</div>
</div>

{historyLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600 dark:text-slate-400">
Loading conversation history...
</p>
</div>
</div>
) : historyError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-slate-800 dark:text-slate-100 text-xl font-semibold mb-2">
Error Loading Conversation
</h2>
<p className="text-slate-600 dark:text-slate-400 text-sm mb-4">
{historyError}
</p>
</div>
</div>
) : (
<>
<ChatMessages
messages={messages}
isLoading={isLoading}
restoreScrollPosition={historyScrollPosition}
onScrollPositionChange={handleScrollPositionChange}
shouldAutoScroll={
!historyFromCache || historyScrollPosition === undefined
}
/>
<ChatInput
input={input}
isLoading={isLoading}
currentRequestId={currentRequestId}
onInputChange={setInput}
onSubmit={() => sendMessage()}
onAbort={() =>
abortRequest(
currentRequestId,
isLoading,
resetRequestState,
)
}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
showPermissions={isPermissionMode}
permissionData={permissionData}
planPermissionData={undefined}
/>
</>
)}
</div>
)}
</div>
</div>

{/* Settings Modal */}
<SettingsModal isOpen={isSettingsOpen} onClose={handleSettingsClose} />
</div>
);
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The large SplitView component (854 lines) has multiple responsibilities: project management, session management, chat functionality, and UI layout. Consider breaking this down into smaller, more focused components:

  • ProjectSidebar for the project/session list
  • ChatView for the chat interface
  • Keep SplitView as a layout coordinator

This would improve maintainability and testability.

Copilot uses AI. Check for mistakes.
</p>
<div className="bg-blue-100/50 dark:bg-blue-800/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
<pre className="text-sm text-blue-900 dark:text-blue-100 whitespace-pre-wrap font-mono leading-relaxed">
<pre className="text-sm text-blue-900 dark:text-blue-100 whitespace-pre-wrap break-words overflow-x-auto font-mono leading-relaxed">
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same redundancy: break-words and overflow-x-auto conflict with each other.

Suggested change
<pre className="text-sm text-blue-900 dark:text-blue-100 whitespace-pre-wrap break-words overflow-x-auto font-mono leading-relaxed">
<pre className="text-sm text-blue-900 dark:text-blue-100 whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +366
// Update cache when new messages are added during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages, msg];
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
},
updateLastMessage: (content: string) => {
// The streaming context expects us to update with content string
updateLastMessage(content);
// Update cache when messages are updated during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage && 'content' in lastMessage) {
updatedMessages[updatedMessages.length - 1] = {
...lastMessage,
content
} as AllMessage;
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache update logic is reading from a stale messages array. When addMessage is called, it adds to the component state, but the cache update on line 340 uses the messages array from the closure, which won't include the message that was just added. This means the cache will be one message behind.

Instead, you should update the cache after the message is added to state, or use a functional state update to get the current messages. Consider moving the cache update to a useEffect that watches the messages state when currentSessionId changes.

Suggested change
// Update cache when new messages are added during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages, msg];
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
},
updateLastMessage: (content: string) => {
// The streaming context expects us to update with content string
updateLastMessage(content);
// Update cache when messages are updated during streaming
if (currentSessionId && selectedProject) {
const updatedMessages = [...messages];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage && 'content' in lastMessage) {
updatedMessages[updatedMessages.length - 1] = {
...lastMessage,
content
} as AllMessage;
setCachedSession(
selectedProject,
currentSessionId,
updatedMessages,
);
}
}
},
updateLastMessage: (content: string) => {
// The streaming context expects us to update with content string
updateLastMessage(content);

Copilot uses AI. Check for mistakes.

if (cached) {
// Update timestamp to mark as recently accessed
cached.timestamp = Date.now();
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating the cached object's timestamp directly can cause issues with stale references. If another part of the code holds a reference to the cached session object, it will see the timestamp change unexpectedly.

Consider creating a new object instead: cache.current.set(key, { ...cached, timestamp: Date.now() });

Suggested change
cached.timestamp = Date.now();
cache.current.set(key, { ...cached, timestamp: Date.now() });

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +128
existing.scrollPosition = scrollPosition;
existing.timestamp = Date.now(); // Mark as recently accessed
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar mutation issue: Directly mutating the cached session object's properties can lead to unexpected side effects if other code holds references to the object.

Consider replacing the object instead: cache.current.set(key, { ...existing, scrollPosition, timestamp: Date.now() });

Suggested change
existing.scrollPosition = scrollPosition;
existing.timestamp = Date.now(); // Mark as recently accessed
cache.current.set(key, {
...existing,
scrollPosition,
timestamp: Date.now(), // Mark as recently accessed
});

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments