Compare commits

..

14 Commits

Author SHA1 Message Date
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
7 changed files with 180 additions and 89 deletions

View File

@@ -280,7 +280,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
sendMessage(ws, { sendMessage(ws, {
type: 'codex-complete', type: 'codex-complete',
sessionId: currentSessionId sessionId: currentSessionId,
actualSessionId: thread.id
}); });
} catch (error) { } catch (error) {

View File

@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath); const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path // Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) { // Handle Windows long paths with \\?\ prefix
const sessionCwd = sessionData?.cwd || '';
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
sessions.push({ sessions.push({
id: sessionData.id, id: sessionData.id,
summary: sessionData.summary || 'Codex Session', summary: sessionData.summary || 'Codex Session',
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary // Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++; messageCount++;
if (entry.payload.text) { if (entry.payload.message) {
lastUserMessage = entry.payload.text; lastUserMessage = entry.payload.message;
} }
} }
if (entry.type === 'response_item' && entry.payload?.type === 'message') { if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++; messageCount++;
} }

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router(); const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => { router.get('/config', async (req, res) => {
try { try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml'); const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => { router.get('/mcp/cli/list', async (req, res) => {
try { try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) }); respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else { } else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr }); respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message }); res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args); cliArgs.push(...args);
} }
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message }); res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message }); res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) }); respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else { } else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr }); respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message }); res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });

View File

@@ -177,7 +177,9 @@ function AppContent() {
// If so, and the session is not active, trigger a message reload in ChatInterface // If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) { if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl") // Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/'); const normalized = latestMessage.changedFile.replace(/\\/g, '/');
const changedFileParts = normalized.split('/');
if (changedFileParts.length >= 2) { if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1]; const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', ''); const changedSessionId = filename.replace('.jsonl', '');

View File

@@ -16,7 +16,7 @@
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
*/ */
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
@@ -1696,6 +1696,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = useRef(null); const inputContainerRef = useRef(null);
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
const isLoadingMoreRef = useRef(false);
const topLoadLockRef = useRef(false);
const pendingScrollRestoreRef = useRef(null);
// Streaming throttle buffers // Streaming throttle buffers
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const streamTimerRef = useRef(null); const streamTimerRef = useRef(null);
@@ -2710,6 +2713,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return scrollHeight - scrollTop - clientHeight < 50; return scrollHeight - scrollTop - clientHeight < 50;
}, []); }, []);
const loadOlderMessages = useCallback(async (container) => {
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider === 'cursor') return false;
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const moreMessages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
true,
sessionProvider
);
if (moreMessages.length > 0) {
pendingScrollRestoreRef.current = {
height: previousScrollHeight,
top: previousScrollTop
};
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
}
return true;
} finally {
isLoadingMoreRef.current = false;
}
}, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
// Handle scroll events to detect when user manually scrolls up and load more messages // Handle scroll events to detect when user manually scrolls up and load more messages
const handleScroll = useCallback(async () => { const handleScroll = useCallback(async () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@@ -2719,32 +2755,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top) // Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100; const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude'; if (!scrolledNearTop) {
topLoadLockRef.current = false;
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { } else if (!topLoadLockRef.current) {
// Save current scroll position const didLoad = await loadOlderMessages(container);
const previousScrollHeight = container.scrollHeight; if (didLoad) {
const previousScrollTop = container.scrollTop; topLoadLockRef.current = true;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
if (moreMessages.length > 0) {
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
// Restore scroll position after DOM update
setTimeout(() => {
if (scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - previousScrollHeight;
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
}
}, 0);
} }
} }
} }
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); }, [isNearBottom, loadOlderMessages]);
// Restore scroll position after paginated messages render
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
const { height, top } = pendingScrollRestoreRef.current;
const container = scrollContainerRef.current;
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - height;
container.scrollTop = top + Math.max(scrollDiff, 0);
pendingScrollRestoreRef.current = null;
}, [chatMessages.length]);
useEffect(() => { useEffect(() => {
// Load session messages when session changes // Load session messages when session changes
@@ -2873,7 +2906,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo // convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom // Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) { const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
} }
// If user scrolled up, preserve their position (they're reading history) // If user scrolled up, preserve their position (they're reading history)
@@ -2971,6 +3005,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.sessionId && !currentSessionId) { if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
// Mark as system change to prevent clearing messages when session ID updates
setIsSystemSessionChange(true);
// Session Protection: Replace temporary "new-session-*" identifier with real session ID // Session Protection: Replace temporary "new-session-*" identifier with real session ID
// This maintains protection continuity - no gap between temp ID and real ID // This maintains protection continuity - no gap between temp ID and real ID
// The temporary session is removed and real session is marked as active // The temporary session is removed and real session is marked as active
@@ -3530,8 +3567,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) { if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId); setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId); console.log('Codex session complete, ID set to:', codexPendingSessionId);
} }
@@ -4410,6 +4452,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Messages Area - Scrollable Middle Section */} {/* Messages Area - Scrollable Middle Section */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative" className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (

View File

@@ -25,13 +25,15 @@ function LoginModal({
const getCommand = () => { const getCommand = () => {
if (customCommand) return customCommand; if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return 'codex login'; return isPlatform ? 'codex login --device-auth' : 'codex login';
default: default:
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
} }

View File

@@ -595,9 +595,44 @@ function Sidebar({
</div> </div>
</div> </div>
{/* Search Filter and Actions */} {/* Action Buttons - Desktop only - Always show when not loading */}
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
</div>
)}
{/* Search Filter - Only show when there are projects */}
{projects.length > 0 && !isLoading && ( {projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2"> <div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -616,39 +651,6 @@ function Sidebar({
</button> </button>
)} )}
</div> </div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div> </div>
)} )}
@@ -1392,4 +1394,4 @@ function Sidebar({
); );
} }
export default Sidebar; export default Sidebar;