mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-16 11:27:24 +00:00
fix(cursor-chat): stabilize first-run UX and clean cursor message rendering
Fix three Cursor chat regressions observed on first message runs: 1. Full-screen UI refresh/flicker after first response. 2. Internal wrapper tags rendered in user messages. 3. Duplicate assistant message on response finalization. Root causes - Project refresh from chat completion used the global loading path, toggling app-level loading UI. - Cursor history conversion rendered raw internal wrapper payloads as user-visible message text. - Cursor response handling could finalize through overlapping stream/ result paths, and stdout chunk parsing could split JSON lines. Changes - Added non-blocking project refresh plumbing for chat/session flows. - Introduced fetch options in useProjectsState (showLoadingState flag). - Added refreshProjectsSilently() to update metadata without global loading UI. - Wired window.refreshProjects to refreshProjectsSilently in AppContent. - Added Cursor user-message sanitization during history conversion. - Added extractCursorUserQuery() to keep only <user_query> payload. - Added sanitizeCursorUserMessageText() to strip internal wrappers: <user_info>, <agent_skills>, <available_skills>, <environment_context>, <environment_info>. - Applied sanitization only for role === 'user' in convertCursorSessionMessages(). - Hardened Cursor backend stream parsing and finalization. - Added line-buffered stdout parser for chunk-split JSON payloads. - Flushed trailing unterminated stdout line on process close. - Removed redundant content_block_stop emission on Cursor result. - Added frontend duplicate guard in cursor-result handling. - Skips a second assistant bubble when final result text equals already-rendered streamed content. Code comments - Added focused comments describing silent refresh behavior, tag stripping rationale, duplicate guard behavior, and line buffering. Validation - ESLint passes for touched files. - Production build succeeds. Files - server/cursor-cli.js - src/components/app/AppContent.tsx - src/components/chat/hooks/useChatRealtimeHandlers.ts - src/components/chat/utils/messageTransforms.ts - src/hooks/useProjectsState.ts
This commit is contained in:
@@ -26,7 +26,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
|
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let messageBuffer = ''; // Buffer for accumulating assistant messages
|
|
||||||
let hasRetriedWithTrust = false;
|
let hasRetriedWithTrust = false;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
@@ -81,6 +80,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
const runCursorProcess = (args, runReason = 'initial') => {
|
const runCursorProcess = (args, runReason = 'initial') => {
|
||||||
const isTrustRetry = runReason === 'trust-retry';
|
const isTrustRetry = runReason === 'trust-retry';
|
||||||
let runSawWorkspaceTrustPrompt = false;
|
let runSawWorkspaceTrustPrompt = false;
|
||||||
|
let stdoutLineBuffer = '';
|
||||||
|
|
||||||
if (isTrustRetry) {
|
if (isTrustRetry) {
|
||||||
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
||||||
@@ -110,136 +110,137 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processCursorOutputLine = (line) => {
|
||||||
|
if (!line || !line.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(line);
|
||||||
|
console.log('Parsed JSON response:', response);
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
switch (response.type) {
|
||||||
|
case 'system':
|
||||||
|
if (response.subtype === 'init') {
|
||||||
|
// Capture session ID
|
||||||
|
if (response.session_id && !capturedSessionId) {
|
||||||
|
capturedSessionId = response.session_id;
|
||||||
|
console.log('Captured session ID:', capturedSessionId);
|
||||||
|
|
||||||
|
// Update process key with captured session ID
|
||||||
|
if (processKey !== capturedSessionId) {
|
||||||
|
activeCursorProcesses.delete(processKey);
|
||||||
|
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session ID on writer (for API endpoint compatibility)
|
||||||
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
|
ws.setSessionId(capturedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send session-created event only once for new sessions
|
||||||
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
|
sessionCreatedSent = true;
|
||||||
|
ws.send({
|
||||||
|
type: 'session-created',
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
model: response.model,
|
||||||
|
cwd: response.cwd
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send system info to frontend
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-system',
|
||||||
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user':
|
||||||
|
// Forward user message
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-user',
|
||||||
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'assistant':
|
||||||
|
// Accumulate assistant message chunks
|
||||||
|
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||||
|
const textContent = response.message.content[0].text;
|
||||||
|
|
||||||
|
// Send as Claude-compatible format for frontend
|
||||||
|
ws.send({
|
||||||
|
type: 'claude-response',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
delta: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: textContent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'result':
|
||||||
|
// Session complete
|
||||||
|
console.log('Cursor session result:', response);
|
||||||
|
|
||||||
|
// Do not emit an extra content_block_stop here.
|
||||||
|
// The UI already finalizes the streaming message in cursor-result handling,
|
||||||
|
// and emitting both can produce duplicate assistant messages.
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-result',
|
||||||
|
sessionId: capturedSessionId || sessionId,
|
||||||
|
data: response,
|
||||||
|
success: response.subtype === 'success'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Forward any other message types
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-response',
|
||||||
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log('Non-JSON response:', line);
|
||||||
|
|
||||||
|
if (shouldSuppressForTrustRetry(line)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not JSON, send as raw text
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-output',
|
||||||
|
data: line,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle stdout (streaming JSON responses)
|
// Handle stdout (streaming JSON responses)
|
||||||
cursorProcess.stdout.on('data', (data) => {
|
cursorProcess.stdout.on('data', (data) => {
|
||||||
const rawOutput = data.toString();
|
const rawOutput = data.toString();
|
||||||
console.log('Cursor CLI stdout:', rawOutput);
|
console.log('Cursor CLI stdout:', rawOutput);
|
||||||
|
|
||||||
const lines = rawOutput.split('\n').filter((line) => line.trim());
|
// Stream chunks can split JSON objects across packets; keep trailing partial line.
|
||||||
|
stdoutLineBuffer += rawOutput;
|
||||||
|
const completeLines = stdoutLineBuffer.split(/\r?\n/);
|
||||||
|
stdoutLineBuffer = completeLines.pop() || '';
|
||||||
|
|
||||||
for (const line of lines) {
|
completeLines.forEach((line) => {
|
||||||
try {
|
processCursorOutputLine(line.trim());
|
||||||
const response = JSON.parse(line);
|
});
|
||||||
console.log('Parsed JSON response:', response);
|
|
||||||
|
|
||||||
// Handle different message types
|
|
||||||
switch (response.type) {
|
|
||||||
case 'system':
|
|
||||||
if (response.subtype === 'init') {
|
|
||||||
// Capture session ID
|
|
||||||
if (response.session_id && !capturedSessionId) {
|
|
||||||
capturedSessionId = response.session_id;
|
|
||||||
console.log('Captured session ID:', capturedSessionId);
|
|
||||||
|
|
||||||
// Update process key with captured session ID
|
|
||||||
if (processKey !== capturedSessionId) {
|
|
||||||
activeCursorProcesses.delete(processKey);
|
|
||||||
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set session ID on writer (for API endpoint compatibility)
|
|
||||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
|
||||||
ws.setSessionId(capturedSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send session-created event only once for new sessions
|
|
||||||
if (!sessionId && !sessionCreatedSent) {
|
|
||||||
sessionCreatedSent = true;
|
|
||||||
ws.send({
|
|
||||||
type: 'session-created',
|
|
||||||
sessionId: capturedSessionId,
|
|
||||||
model: response.model,
|
|
||||||
cwd: response.cwd
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send system info to frontend
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-system',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'user':
|
|
||||||
// Forward user message
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-user',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'assistant':
|
|
||||||
// Accumulate assistant message chunks
|
|
||||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
|
||||||
const textContent = response.message.content[0].text;
|
|
||||||
messageBuffer += textContent;
|
|
||||||
|
|
||||||
// Send as Claude-compatible format for frontend
|
|
||||||
ws.send({
|
|
||||||
type: 'claude-response',
|
|
||||||
data: {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
delta: {
|
|
||||||
type: 'text_delta',
|
|
||||||
text: textContent
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'result':
|
|
||||||
// Session complete
|
|
||||||
console.log('Cursor session result:', response);
|
|
||||||
|
|
||||||
// Send final message if we have buffered content
|
|
||||||
if (messageBuffer) {
|
|
||||||
ws.send({
|
|
||||||
type: 'claude-response',
|
|
||||||
data: {
|
|
||||||
type: 'content_block_stop'
|
|
||||||
},
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send completion event
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-result',
|
|
||||||
sessionId: capturedSessionId || sessionId,
|
|
||||||
data: response,
|
|
||||||
success: response.subtype === 'success'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Forward any other message types
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-response',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.log('Non-JSON response:', line);
|
|
||||||
|
|
||||||
if (shouldSuppressForTrustRetry(line)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not JSON, send as raw text
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-output',
|
|
||||||
data: line,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle stderr
|
// Handle stderr
|
||||||
@@ -265,6 +266,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
activeCursorProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
|
// Flush any final unterminated stdout line before completion handling.
|
||||||
|
if (stdoutLineBuffer.trim()) {
|
||||||
|
processCursorOutputLine(stdoutLineBuffer.trim());
|
||||||
|
stdoutLineBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
runSawWorkspaceTrustPrompt &&
|
runSawWorkspaceTrustPrompt &&
|
||||||
code !== 0 &&
|
code !== 0 &&
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function AppContent() {
|
|||||||
setIsInputFocused,
|
setIsInputFocused,
|
||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
} = useProjectsState({
|
} = useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -51,14 +51,16 @@ export default function AppContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.refreshProjects = fetchProjects;
|
// Expose a non-blocking refresh for chat/session flows.
|
||||||
|
// Full loading refreshes are still available through direct fetchProjects calls.
|
||||||
|
window.refreshProjects = refreshProjectsSilently;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (window.refreshProjects === fetchProjects) {
|
if (window.refreshProjects === refreshProjectsSilently) {
|
||||||
delete window.refreshProjects;
|
delete window.refreshProjects;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fetchProjects]);
|
}, [refreshProjectsSilently]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.openSettings = openSettings;
|
window.openSettings = openSettings;
|
||||||
|
|||||||
@@ -692,14 +692,28 @@ export function useChatRealtimeHandlers({
|
|||||||
const updated = [...previous];
|
const updated = [...previous];
|
||||||
const lastIndex = updated.length - 1;
|
const lastIndex = updated.length - 1;
|
||||||
const last = updated[lastIndex];
|
const last = updated[lastIndex];
|
||||||
|
const normalizedTextResult = textResult.trim();
|
||||||
|
|
||||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||||
const finalContent =
|
const finalContent =
|
||||||
textResult && textResult.trim()
|
normalizedTextResult
|
||||||
? textResult
|
? textResult
|
||||||
: `${last.content || ''}${pendingChunk || ''}`;
|
: `${last.content || ''}${pendingChunk || ''}`;
|
||||||
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
||||||
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
||||||
} else if (textResult && textResult.trim()) {
|
} else if (normalizedTextResult) {
|
||||||
|
const lastAssistantText =
|
||||||
|
last && last.type === 'assistant' && !last.isToolUse
|
||||||
|
? String(last.content || '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Cursor can emit the same final text through both streaming and result payloads.
|
||||||
|
// Skip adding a second assistant bubble when the final text is unchanged.
|
||||||
|
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
|
||||||
|
if (isDuplicateFinalText) {
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
updated.push({
|
updated.push({
|
||||||
type: resultData.is_error ? 'error' : 'assistant',
|
type: resultData.is_error ? 'error' : 'assistant',
|
||||||
content: textResult,
|
content: textResult,
|
||||||
|
|||||||
@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
|
||||||
|
/<user_info>[\s\S]*?<\/user_info>/gi,
|
||||||
|
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
|
||||||
|
/<available_skills>[\s\S]*?<\/available_skills>/gi,
|
||||||
|
/<environment_context>[\s\S]*?<\/environment_context>/gi,
|
||||||
|
/<environment_info>[\s\S]*?<\/environment_info>/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
const extractCursorUserQuery = (rawText: string): string => {
|
||||||
|
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
|
||||||
|
if (userQueryMatches.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return userQueryMatches
|
||||||
|
.map((match) => (match[1] || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeCursorUserMessageText = (rawText: string): string => {
|
||||||
|
const decodedText = decodeHtmlEntities(rawText || '').trim();
|
||||||
|
if (!decodedText) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
|
||||||
|
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
|
||||||
|
const extractedUserQuery = extractCursorUserQuery(decodedText);
|
||||||
|
if (extractedUserQuery) {
|
||||||
|
return extractedUserQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitizedText = decodedText;
|
||||||
|
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
|
||||||
|
sanitizedText = sanitizedText.replace(pattern, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizedText.trim();
|
||||||
|
};
|
||||||
|
|
||||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
|||||||
console.log('Error parsing blob content:', error);
|
console.log('Error parsing blob content:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
text = sanitizeCursorUserMessageText(text);
|
||||||
|
}
|
||||||
|
|
||||||
if (text && text.trim()) {
|
if (text && text.trim()) {
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
type: role,
|
type: role,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
|
|||||||
activeSessions: Set<string>;
|
activeSessions: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FetchProjectsOptions = {
|
||||||
|
showLoadingState?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||||
|
|
||||||
const projectsHaveChanges = (
|
const projectsHaveChanges = (
|
||||||
@@ -152,9 +156,11 @@ export function useProjectsState({
|
|||||||
|
|
||||||
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchProjects = useCallback(async () => {
|
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||||
try {
|
try {
|
||||||
setIsLoadingProjects(true);
|
if (showLoadingState) {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
}
|
||||||
const response = await api.projects();
|
const response = await api.projects();
|
||||||
const projectData = (await response.json()) as Project[];
|
const projectData = (await response.json()) as Project[];
|
||||||
|
|
||||||
@@ -170,10 +176,17 @@ export function useProjectsState({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingProjects(false);
|
if (showLoadingState) {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshProjectsSilently = useCallback(async () => {
|
||||||
|
// Keep chat view stable while still syncing sidebar/session metadata in background.
|
||||||
|
await fetchProjects({ showLoadingState: false });
|
||||||
|
}, [fetchProjects]);
|
||||||
|
|
||||||
const openSettings = useCallback((tab = 'tools') => {
|
const openSettings = useCallback((tab = 'tools') => {
|
||||||
setSettingsInitialTab(tab);
|
setSettingsInitialTab(tab);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
@@ -547,6 +560,7 @@ export function useProjectsState({
|
|||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleProjectSelect,
|
handleProjectSelect,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
|
|||||||
Reference in New Issue
Block a user