feat: Implement Cursor session fetching and enhance message parsing in ChatInterface

This commit is contained in:
simos
2025-08-12 13:09:03 +03:00
parent 4e5aa50505
commit 0a39079c5c
5 changed files with 585 additions and 72 deletions

View File

@@ -2,6 +2,10 @@ import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
// Cache for extracted project directories
const projectDirectoryCache = new Map();
@@ -207,6 +211,14 @@ async function getProjects() {
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
}
// Also fetch Cursor sessions for this project
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = [];
}
projects.push(project);
}
}
@@ -236,9 +248,17 @@ async function getProjects() {
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
sessions: [],
cursorSessions: []
};
// Try to fetch Cursor sessions for manual projects too
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
}
projects.push(project);
}
}
@@ -615,6 +635,117 @@ async function addProjectManually(projectPath, displayName = null) {
};
}
// Fetch Cursor sessions for a given project path
async function getCursorSessions(projectPath) {
try {
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return [];
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp
let dbStatMtimeMs = null;
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Get message count
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
await db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
let createdAt = null;
if (metadata.createdAt) {
createdAt = new Date(metadata.createdAt).toISOString();
} else if (dbStatMtimeMs) {
createdAt = new Date(dbStatMtimeMs).toISOString();
} else {
createdAt = new Date().toISOString();
}
sessions.push({
id: sessionId,
name: sessionName,
createdAt: createdAt,
lastActivity: createdAt, // For compatibility with Claude sessions
messageCount: messageCountResult.count || 0,
projectPath: projectPath
});
} catch (error) {
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
}
}
// Sort sessions by creation time (newest first)
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
} catch (error) {
console.error('Error fetching Cursor sessions:', error);
return [];
}
}
export {
getProjects,

View File

@@ -622,15 +622,18 @@ router.get('/sessions/:sessionId', async (req, res) => {
}
}
// Parse blob data to extract messages
// Parse blob data to extract messages - only include blobs with valid JSON
const messages = [];
for (const blob of blobs) {
try {
// Attempt direct JSON parse first
const raw = blob.data.toString('utf8');
let parsed;
let isValidJson = false;
try {
parsed = JSON.parse(raw);
isValidJson = true;
} catch (_) {
// If not JSON, try to extract JSON from within binary-looking string
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
@@ -640,20 +643,28 @@ router.get('/sessions/:sessionId', async (req, res) => {
const jsonStr = cleaned.slice(start, end + 1);
try {
parsed = JSON.parse(jsonStr);
isValidJson = true;
} catch (_) {
parsed = null;
isValidJson = false;
}
}
}
if (parsed) {
// Only include blobs that contain valid JSON data
if (isValidJson && parsed) {
// Filter out ONLY system messages at the server level
// Check both direct role and nested message.role
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue; // Skip only system messages
}
messages.push({ id: blob.id, content: parsed });
} else {
// Fallback to cleaned text content
const text = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '').trim();
messages.push({ id: blob.id, content: text });
}
// Skip non-JSON blobs (binary data) completely
} catch (e) {
messages.push({ id: blob.id, content: blob.data.toString() });
// Skip blobs that cause errors
console.log(`Skipping blob ${blob.id}: ${e.message}`);
}
}

View File

@@ -27,6 +27,7 @@ import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api';
// Safe localStorage utility to handle quota exceeded errors
const safeLocalStorage = {
setItem: (key, value) => {
@@ -180,7 +181,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)}
</div>
) : (
/* Claude/Error messages on the left */
/* Claude/Error/Tool messages on the left */
<div className="w-full">
{!isGrouped && (
<div className="flex items-center space-x-3 mb-2">
@@ -188,6 +189,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
!
</div>
) : message.type === 'tool' ? (
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
🔧
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
@@ -198,7 +203,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? 'Error' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
</div>
</div>
)}
@@ -330,11 +335,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
{message.toolInput && message.toolName !== 'Edit' && (() => {
// Debug log to see what we're dealing with
console.log('Tool display - name:', message.toolName, 'input type:', typeof message.toolInput);
// Special handling for Write tool
if (message.toolName === 'Write') {
console.log('Write tool detected, toolInput:', message.toolInput);
try {
let input;
// Handle both JSON string and already parsed object
@@ -344,7 +347,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
input = message.toolInput;
}
console.log('Parsed Write input:', input);
if (input.file_path && input.content !== undefined) {
return (
@@ -1003,6 +1005,20 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
💭 Thinking...
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</div>
</details>
)}
{message.type === 'assistant' ? (
<div className="prose prose-sm max-w-none dark:prose-invert prose-gray [&_code]:!bg-transparent [&_code]:!p-0">
<ReactMarkdown
@@ -1271,49 +1287,239 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const data = await res.json();
const blobs = data?.session?.messages || [];
const converted = [];
const now = Date.now();
let idx = 0;
for (const blob of blobs) {
const toolUseMap = {}; // Map to store tool uses by ID for linking results
// First pass: process all messages maintaining order
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) {
const blob = blobs[blobIdx];
const content = blob.content;
let text = '';
let role = 'assistant';
let reasoningText = null; // Move to outer scope
try {
if (typeof content === 'string') {
// Attempt to extract embedded JSON first
const cleaned = content.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
let extractedTexts = [];
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start !== -1 && end !== -1 && end > start) {
const jsonStr = cleaned.slice(start, end + 1);
try {
const parsed = JSON.parse(jsonStr);
if (parsed && parsed.content && Array.isArray(parsed.content)) {
for (const part of parsed.content) {
if (part?.type === 'text' && part.text) {
extractedTexts.push(part.text);
// Handle different Cursor message formats
if (content?.role && content?.content) {
// Direct format: {"role":"user","content":[{"type":"text","text":"..."}]}
// Skip system messages
if (content.role === 'system') {
continue;
}
// Handle tool messages
if (content.role === 'tool') {
// Tool result format - find the matching tool use message and update it
if (Array.isArray(content.content)) {
for (const item of content.content) {
if (item?.type === 'tool-result') {
// Map ApplyPatch to Edit for consistency
let toolName = item.toolName || 'Unknown Tool';
if (toolName === 'ApplyPatch') {
toolName = 'Edit';
}
const toolCallId = item.toolCallId || content.id;
const result = item.result || '';
// Store the tool result to be linked later
if (toolUseMap[toolCallId]) {
toolUseMap[toolCallId].toolResult = {
content: result,
isError: false
};
} else {
// No matching tool use found, create a standalone result message
converted.push({
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id,
isToolUse: true,
toolName: toolName,
toolId: toolCallId,
toolInput: null,
toolResult: {
content: result,
isError: false
}
});
}
}
}
} catch (_) {
// JSON parse failed; fall back to cleaned text
}
continue; // Don't add tool messages as regular messages
} else {
// User or assistant messages
role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
// Extract text, reasoning, and tool calls from content array
const textParts = [];
for (const part of content.content) {
if (part?.type === 'text' && part?.text) {
textParts.push(part.text);
} else if (part?.type === 'reasoning' && part?.text) {
// Handle reasoning type - will be displayed in a collapsible section
reasoningText = part.text;
} else if (part?.type === 'tool-call') {
// First, add any text/reasoning we've collected so far as a message
if (textParts.length > 0 || reasoningText) {
converted.push({
type: role,
content: textParts.join('\n'),
reasoning: reasoningText,
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id
});
textParts.length = 0;
reasoningText = null;
}
// Tool call in assistant message - format like Claude Code
// Map ApplyPatch to Edit for consistency with Claude Code
let toolName = part.toolName || 'Unknown Tool';
if (toolName === 'ApplyPatch') {
toolName = 'Edit';
}
const toolId = part.toolCallId || `tool_${blobIdx}`;
// Create a tool use message with Claude Code format
// Map Cursor args format to Claude Code format
let toolInput = part.args;
if (toolName === 'Edit' && part.args) {
// ApplyPatch uses 'patch' format, convert to Edit format
if (part.args.patch) {
// Parse the patch to extract old and new content
const patchLines = part.args.patch.split('\n');
let oldLines = [];
let newLines = [];
let inPatch = false;
for (const line of patchLines) {
if (line.startsWith('@@')) {
inPatch = true;
} else if (inPatch) {
if (line.startsWith('-')) {
oldLines.push(line.substring(1));
} else if (line.startsWith('+')) {
newLines.push(line.substring(1));
} else if (line.startsWith(' ')) {
// Context line - add to both
oldLines.push(line.substring(1));
newLines.push(line.substring(1));
}
}
}
const filePath = part.args.file_path;
const absolutePath = filePath && !filePath.startsWith('/')
? `${projectPath}/${filePath}`
: filePath;
toolInput = {
file_path: absolutePath,
old_string: oldLines.join('\n') || part.args.patch,
new_string: newLines.join('\n') || part.args.patch
};
} else {
// Direct edit format
toolInput = part.args;
}
} else if (toolName === 'Read' && part.args) {
// Map 'path' to 'file_path'
// Convert relative path to absolute if needed
const filePath = part.args.path || part.args.file_path;
const absolutePath = filePath && !filePath.startsWith('/')
? `${projectPath}/${filePath}`
: filePath;
toolInput = {
file_path: absolutePath
};
} else if (toolName === 'Write' && part.args) {
// Map fields for Write tool
const filePath = part.args.path || part.args.file_path;
const absolutePath = filePath && !filePath.startsWith('/')
? `${projectPath}/${filePath}`
: filePath;
toolInput = {
file_path: absolutePath,
content: part.args.contents || part.args.content
};
}
const toolMessage = {
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id,
isToolUse: true,
toolName: toolName,
toolId: toolId,
toolInput: toolInput ? JSON.stringify(toolInput) : null,
toolResult: null // Will be filled when we get the tool result
};
converted.push(toolMessage);
toolUseMap[toolId] = toolMessage; // Store for linking results
} else if (part?.type === 'tool_use') {
// Old format support
if (textParts.length > 0 || reasoningText) {
converted.push({
type: role,
content: textParts.join('\n'),
reasoning: reasoningText,
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id
});
textParts.length = 0;
reasoningText = null;
}
const toolName = part.name || 'Unknown Tool';
const toolId = part.id || `tool_${blobIdx}`;
const toolMessage = {
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id,
isToolUse: true,
toolName: toolName,
toolId: toolId,
toolInput: part.input ? JSON.stringify(part.input) : null,
toolResult: null
};
converted.push(toolMessage);
toolUseMap[toolId] = toolMessage;
} else if (typeof part === 'string') {
textParts.push(part);
}
}
// Add any remaining text/reasoning
if (textParts.length > 0) {
text = textParts.join('\n');
if (reasoningText && !text) {
// Just reasoning, no text
converted.push({
type: role,
content: '',
reasoning: reasoningText,
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id
});
text = ''; // Clear to avoid duplicate
}
} else {
text = '';
}
} else if (typeof content.content === 'string') {
text = content.content;
}
}
if (extractedTexts.length > 0) {
extractedTexts.forEach(t => converted.push({ type: 'assistant', content: t, timestamp: new Date(now + (idx++)) }));
} else if (content?.message?.role && content?.message?.content) {
// Nested message format
if (content.message.role === 'system') {
continue;
}
// No JSON; use cleaned readable text if any
const readable = cleaned.trim();
if (readable) {
// Heuristic: short single token like 'hey' → user, otherwise assistant
const isLikelyUser = /^[a-zA-Z0-9.,!?\s]{1,10}$/.test(readable) && readable.toLowerCase().includes('hey');
role = isLikelyUser ? 'user' : 'assistant';
text = readable;
} else {
text = '';
}
} else if (content?.message?.role && content?.message?.content) {
role = content.message.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.message.content)) {
text = content.message.content
@@ -1322,26 +1528,38 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
} else {
text = JSON.stringify(content.message.content);
}
} else if (content?.content) {
// Some Cursor blobs may have { content: string }
text = typeof content.content === 'string' ? content.content : JSON.stringify(content.content);
} else {
text = JSON.stringify(content);
}
} catch (e) {
text = String(content);
console.log('Error parsing blob content:', e);
}
if (text && text.trim()) {
converted.push({
const message = {
type: role,
content: text,
timestamp: new Date(now + (idx++))
});
timestamp: new Date(Date.now() + blobIdx),
blobId: blob.id
};
// Add reasoning if we have it
if (reasoningText) {
message.reasoning = reasoningText;
}
converted.push(message);
}
}
// Sort messages by blob ID to maintain chronological order
converted.sort((a, b) => {
// First sort by blobId if available
if (a.blobId && b.blobId) {
return parseInt(a.blobId) - parseInt(b.blobId);
}
// Fallback to timestamp
return new Date(a.timestamp) - new Date(b.timestamp);
});
return converted;
} catch (e) {
console.error('Error loading Cursor session messages:', e);
@@ -1865,7 +2083,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
// For other cursor-system messages, avoid dumping raw objects to chat
console.log('Cursor system message:', latestMessage.data);
} catch (e) {
console.warn('Error handling cursor-system message:', e);
}
@@ -1873,7 +2090,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
case 'cursor-user':
// Handle Cursor user messages (usually echoes)
console.log('Cursor user message:', latestMessage.data);
// Don't add user messages as they're already shown from input
break;
@@ -1994,7 +2210,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
case 'claude-status':
// Handle Claude working status messages
console.log('🔔 Received claude-status message:', latestMessage);
const statusData = latestMessage.data;
if (statusData) {
// Parse the status message to extract relevant information
@@ -2025,7 +2240,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
statusInfo.can_interrupt = statusData.can_interrupt;
}
console.log('📊 Setting claude status:', statusInfo);
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
@@ -2622,12 +2836,118 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
<p className="font-bold text-lg sm:text-xl mb-3">Start a conversation with Claude</p>
<p className="text-sm sm:text-base leading-relaxed">
Ask questions about your code, request changes, or get help with development tasks
</p>
</div>
{!selectedSession && (
<div className="text-center px-6 sm:px-4 py-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Select a provider to start a new conversation
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
{/* Claude Button */}
<button
onClick={() => {
setProvider('claude');
localStorage.setItem('selected-provider', 'claude');
// Focus input after selection
setTimeout(() => textareaRef.current?.focus(), 100);
}}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'claude'
? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<ClaudeLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p>
</div>
</div>
{provider === 'claude' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
{/* Cursor Button */}
<button
onClick={() => {
setProvider('cursor');
localStorage.setItem('selected-provider', 'cursor');
// Focus input after selection
setTimeout(() => textareaRef.current?.focus(), 100);
}}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'cursor'
? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<CursorLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p>
</div>
</div>
{provider === 'cursor' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-purple-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
</div>
{/* Model Selection for Cursor - Always reserve space to prevent jumping */}
<div className={`mb-6 transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{provider === 'cursor' ? 'Select Model' : '\u00A0'}
</label>
<select
value={cursorModel}
onChange={(e) => {
const newModel = e.target.value;
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
disabled={provider !== 'cursor'}
>
<option value="gpt-5">GPT-5</option>
<option value="sonnet-4">Sonnet-4</option>
<option value="opus-4.1">Opus 4.1</option>
</select>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{provider === 'claude'
? 'Ready to use Claude AI. Start typing your message below.'
: provider === 'cursor'
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
: 'Select a provider above to begin'
}
</p>
</div>
)}
{selectedSession && (
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
<p className="font-bold text-lg sm:text-xl mb-3">Continue your conversation</p>
<p className="text-sm sm:text-base leading-relaxed">
Ask questions about your code, request changes, or get help with development tasks
</p>
</div>
)}
</div>
) : (
<>
@@ -2734,6 +3054,56 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)}
<span className="text-sm capitalize">{selectedSession.__provider}</span>
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center gap-2">
<button
onClick={() => {
setProvider('claude');
localStorage.setItem('selected-provider', 'claude');
}}
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-all ${
provider === 'claude'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-300 dark:border-blue-700'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
disabled={isLoading}
>
<ClaudeLogo className="w-4 h-4" />
<span>Claude</span>
</button>
<button
onClick={() => {
setProvider('cursor');
localStorage.setItem('selected-provider', 'cursor');
}}
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-all ${
provider === 'cursor'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-300 dark:border-purple-700'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
disabled={isLoading}
>
<CursorLogo className="w-4 h-4" />
<span>Cursor</span>
</button>
{/* Always reserve space for model dropdown to prevent jumping */}
<div className={`transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<select
value={cursorModel}
onChange={(e) => {
const newModel = e.target.value;
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="pl-3 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[120px]"
disabled={isLoading || provider !== 'cursor'}
>
<option value="gpt-5">GPT-5</option>
<option value="sonnet-4">Sonnet-4</option>
<option value="opus-4.1">Opus 4.1</option>
</select>
</div>
</div>
) : (
<>
<select
@@ -2749,7 +3119,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<option value="claude">Claude</option>
<option value="cursor">Cursor</option>
</select>
{provider === 'cursor' && (
{/* Always reserve space for model dropdown to prevent jumping */}
<div className={`transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<select
value={cursorModel}
onChange={(e) => {
@@ -2757,14 +3128,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
className="pl-3 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[120px]"
disabled={isLoading || provider !== 'cursor'}
>
<option value="gpt-5">GPT-5</option>
<option value="sonnet-4">Sonnet-4</option>
<option value="opus-4.1">Opus 4.1</option>
</select>
)}
</div>
</>
)}
</div>

View File

@@ -60,14 +60,12 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchGitStatus = async () => {
if (!selectedProject) return;
console.log('Fetching git status for project:', selectedProject.name, 'path:', selectedProject.path);
setIsLoading(true);
try {
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
console.log('Git status response:', data);
if (data.error) {
console.error('Git status error:', data.error);

2
test.html Normal file
View File

@@ -0,0 +1,2 @@
world world 3
world world 4