mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-13 13:49:43 +00:00
feat: Multiple features, improvements, and bug fixes (#208)
* feat: Add token budget tracking and multiple improvements ## Features - **Token Budget Visualization**: Added real-time token usage tracking with pie chart display showing percentage used (blue < 50%, orange < 75%, red ≥ 75%) - **Show Thinking Toggle**: Added quick settings option to show/hide reasoning sections in messages - **Cache Clearing Utility**: Added `/clear-cache.html` page for clearing service workers, caches, and storage ## Improvements - **Package Upgrades**: Migrated from deprecated `xterm` to `@xterm/*` scoped packages - **Testing Setup**: Added Playwright for end-to-end testing - **Build Optimization**: Implemented code splitting for React, CodeMirror, and XTerm vendors to improve initial load time - **Deployment Scripts**: Added `scripts/start.sh` and `scripts/stop.sh` for cleaner server management with automatic port conflict resolution - **Vite Update**: Upgraded Vite from 7.0.5 to 7.1.8 ## Bug Fixes - Fixed static file serving to properly handle routes vs assets - Fixed session state reset to preserve token budget on initial load - Updated default Vite dev server port to 5173 (Vite's standard) ## Technical Details - Token budget is parsed from Claude CLI `modelUsage` field in result messages - Budget updates are sent via WebSocket as `token-budget` events - Calculation includes input, output, cache read, and cache creation tokens - Token budget state persists during active sessions but resets on session switch * feat: Add session processing state persistence Fixes issue where "Thinking..." banner and stop button disappear when switching between sessions. Users can now navigate freely while Claude is processing without losing the ability to monitor or stop the session. Features: - Processing state tracked in processingSessions Set (App.jsx) - Backend session status queries via check-session-status WebSocket message - UI state (banner + stop button) restored when returning to processing sessions - Works after page reload by querying backend's authoritative process maps - Proper cleanup when sessions complete in background Backend Changes: - Added sessionId to claude-complete, cursor-result, session-aborted messages - Exported isClaudeSessionActive, isCursorSessionActive helper functions - Exported getActiveClaudeSessions, getActiveCursorSessions for status queries - Added check-session-status and get-active-sessions WebSocket handlers Frontend Changes: - processingSessions state tracking in App.jsx - onSessionProcessing/onSessionNotProcessing callbacks - Session status check on session load and switch - Completion handlers only update UI if message is for current session - Always clean up processing state regardless of which session is active * feat: Make context window size configurable via environment variables Removes hardcoded 160k token limit and makes it configurable through environment variables. This allows easier adjustment for different Claude models or use cases. Changes: - Added CONTEXT_WINDOW env var for backend (default: 160000) - Added VITE_CONTEXT_WINDOW env var for frontend (default: 160000) - Updated .env.example with documentation - Replaced hardcoded values in token usage calculations - Replaced hardcoded values in pie chart display Why 160k? Claude Code reserves ~40k tokens for auto-compact feature, leaving 160k available for actual usage from the 200k context window. * fix: Decode HTML entities in chat message display HTML entities like < and > were showing as-is instead of being decoded to < and > characters. Added decodeHtmlEntities helper function to properly display angle brackets and other special characters. Applied to: - Regular message content - Streaming content deltas - Session history loading - Both string and array content types * refactor: Align package.json with main branch standards - Revert to main branch's package.json scripts structure - Remove custom scripts/start.sh and scripts/stop.sh - Update xterm dependencies to scoped @xterm packages (required for code compatibility) - Replace xterm with @xterm/xterm - Replace xterm-addon-fit with @xterm/addon-fit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Replace CLI implementation with Claude Agents SDK This commit completes the migration to the Claude Agents SDK, removing the legacy CLI-based implementation and making the SDK the exclusive integration method. Changes: - Remove claude-cli.js legacy implementation - Add claude-sdk.js with full SDK integration - Remove CLAUDE_USE_SDK feature flag (SDK is now always used) - Update server/index.js to use SDK functions directly - Add .serena/ to .gitignore for AI assistant cache Benefits: - Better performance (no child process overhead) - Native session management with interrupt support - Cleaner codebase without CLI/SDK branching - Full feature parity with previous CLI implementation - Maintains compatibility with Cursor integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update server/claude-sdk.js Whoops. This is correct. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Left my test code in, but that's fixed. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Prevent stale token-usage data from updating state on session switch - Add AbortController to cancel in-flight token-usage requests when session/project changes - Capture session/project IDs before fetch and verify they match before updating state - Handle AbortError gracefully without logging as error - Prevents race condition where old session data overwrites current session's token budget 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update src/components/TokenUsagePie.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: viper151 <simosmik@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -26,10 +26,22 @@ import NextTaskBanner from './NextTaskBanner.jsx';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { api, authenticatedFetch } from '../utils/api';
|
||||
|
||||
|
||||
// Helper function to decode HTML entities in text
|
||||
function decodeHtmlEntities(text) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
// Format "Claude AI usage limit reached|<epoch>" into a local time string
|
||||
function formatUsageLimitText(text) {
|
||||
try {
|
||||
@@ -156,7 +168,7 @@ const safeLocalStorage = {
|
||||
};
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
@@ -1053,7 +1065,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Thinking accordion for reasoning */}
|
||||
{message.reasoning && (
|
||||
{showThinking && 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...
|
||||
@@ -1166,7 +1178,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
@@ -1216,6 +1228,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [slashCommands, setSlashCommands] = useState([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState([]);
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState(null);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||||
const [slashPosition, setSlashPosition] = useState(-1);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
||||
@@ -1409,10 +1422,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
for (const part of content.content) {
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
textParts.push(part.text);
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
// Handle reasoning type - will be displayed in a collapsible section
|
||||
reasoningText = part.text;
|
||||
reasoningText = decodeHtmlEntities(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) {
|
||||
@@ -1708,20 +1721,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
for (const part of msg.message.content) {
|
||||
if (part.type === 'text') {
|
||||
textParts.push(part.text);
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
}
|
||||
// Skip tool_result parts - they're handled in the first pass
|
||||
}
|
||||
|
||||
content = textParts.join('\n');
|
||||
} else if (typeof msg.message.content === 'string') {
|
||||
content = msg.message.content;
|
||||
content = decodeHtmlEntities(msg.message.content);
|
||||
} else {
|
||||
content = String(msg.message.content);
|
||||
content = decodeHtmlEntities(String(msg.message.content));
|
||||
}
|
||||
|
||||
// Skip command messages and empty content
|
||||
if (content && !content.startsWith('<command-name>') && !content.startsWith('[Request interrupted')) {
|
||||
// Unescape double-escaped newlines and other escape sequences
|
||||
content = content.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\r/g, '\r');
|
||||
converted.push({
|
||||
type: messageType,
|
||||
content: content,
|
||||
@@ -1735,9 +1752,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (Array.isArray(msg.message.content)) {
|
||||
for (const part of msg.message.content) {
|
||||
if (part.type === 'text') {
|
||||
// Unescape double-escaped newlines and other escape sequences
|
||||
let text = part.text;
|
||||
if (typeof text === 'string') {
|
||||
text = text.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\r/g, '\r');
|
||||
}
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: part.text,
|
||||
content: text,
|
||||
timestamp: msg.timestamp || new Date().toISOString()
|
||||
});
|
||||
} else if (part.type === 'tool_use') {
|
||||
@@ -1758,9 +1782,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
} else if (typeof msg.message.content === 'string') {
|
||||
// Unescape double-escaped newlines and other escape sequences
|
||||
let text = msg.message.content;
|
||||
text = text.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\r/g, '\r');
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: msg.message.content,
|
||||
content: text,
|
||||
timestamp: msg.timestamp || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
@@ -1775,6 +1804,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
return convertSessionMessages(sessionMessages);
|
||||
}, [sessionMessages]);
|
||||
|
||||
// Note: Token budgets are not saved to JSONL files, only sent via WebSocket
|
||||
// So we don't try to extract them from loaded sessionMessages
|
||||
|
||||
// Define scroll functions early to avoid hoisting issues in useEffect dependencies
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
@@ -1832,11 +1864,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const loadMessages = async () => {
|
||||
if (selectedSession && selectedProject) {
|
||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||
|
||||
// Reset pagination state when switching sessions
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
|
||||
// Only reset state if the session ID actually changed (not initial load)
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
|
||||
if (sessionChanged) {
|
||||
// Reset pagination state when switching sessions
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
// Reset token budget when switching sessions
|
||||
// It will update when user sends a message and receives new budget from WebSocket
|
||||
setTokenBudget(null);
|
||||
// Reset loading state when switching sessions (unless the new session is processing)
|
||||
// The restore effect will set it back to true if needed
|
||||
setIsLoading(false);
|
||||
|
||||
// Check if the session is currently processing on the backend
|
||||
if (ws && sendMessage) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider
|
||||
});
|
||||
}
|
||||
} else if (currentSessionId === null) {
|
||||
// Initial load - reset pagination but not token budget
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
|
||||
// Check if the session is currently processing on the backend
|
||||
if (ws && sendMessage) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
// For Cursor, set the session ID for resuming
|
||||
@@ -1933,6 +1999,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [selectedProject?.name]);
|
||||
|
||||
// Track processing state: notify parent when isLoading becomes true
|
||||
// Note: onSessionNotProcessing is called directly in completion message handlers
|
||||
useEffect(() => {
|
||||
if (currentSessionId && isLoading && onSessionProcessing) {
|
||||
onSessionProcessing(currentSessionId);
|
||||
}
|
||||
}, [isLoading, currentSessionId, onSessionProcessing]);
|
||||
|
||||
// Restore processing state when switching to a processing session
|
||||
useEffect(() => {
|
||||
if (currentSessionId && processingSessions) {
|
||||
const shouldBeProcessing = processingSessions.has(currentSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true); // Assume processing sessions can be aborted
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, processingSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle WebSocket messages
|
||||
@@ -1954,15 +2038,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'token-budget':
|
||||
// Token budget is now fetched from API endpoint, ignore WebSocket data
|
||||
console.log('📊 Ignoring WebSocket token budget (using API instead)');
|
||||
break;
|
||||
|
||||
case 'claude-response':
|
||||
const messageData = latestMessage.data.message || latestMessage.data;
|
||||
|
||||
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
||||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||||
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
|
||||
// Buffer deltas and flush periodically to reduce rerenders
|
||||
streamBufferRef.current += messageData.delta.text;
|
||||
// Decode HTML entities and buffer deltas
|
||||
const decodedText = decodeHtmlEntities(messageData.delta.text);
|
||||
streamBufferRef.current += decodedText;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
@@ -2090,9 +2180,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
toolResult: null // Will be updated when result comes in
|
||||
}]);
|
||||
} else if (part.type === 'text' && part.text?.trim()) {
|
||||
// Normalize usage limit message to local time
|
||||
let content = formatUsageLimitText(part.text);
|
||||
|
||||
// Decode HTML entities and normalize usage limit message to local time
|
||||
let content = decodeHtmlEntities(part.text);
|
||||
content = formatUsageLimitText(content);
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
@@ -2102,9 +2193,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||||
// Normalize usage limit message to local time
|
||||
let content = formatUsageLimitText(messageData.content);
|
||||
|
||||
// Decode HTML entities and normalize usage limit message to local time
|
||||
let content = decodeHtmlEntities(messageData.content);
|
||||
content = formatUsageLimitText(content);
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
@@ -2237,50 +2329,64 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'cursor-result':
|
||||
// Handle Cursor completion and final result text
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
try {
|
||||
const r = latestMessage.data || {};
|
||||
const textResult = typeof r.result === 'string' ? r.result : '';
|
||||
// Flush buffered deltas before finalizing
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const pendingChunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
// Get session ID from message or fall back to current session
|
||||
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
// Try to consolidate into the last streaming assistant message
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
// Replace streaming content with the final content so deltas don't remain
|
||||
const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || '');
|
||||
last.content = finalContent;
|
||||
last.isStreaming = false;
|
||||
} else if (textResult && textResult.trim()) {
|
||||
updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false });
|
||||
// Only update UI state if this is the current session
|
||||
if (cursorCompletedSessionId === currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
// Always mark the completed session as inactive and not processing
|
||||
if (cursorCompletedSessionId) {
|
||||
if (onSessionInactive) {
|
||||
onSessionInactive(cursorCompletedSessionId);
|
||||
}
|
||||
if (onSessionNotProcessing) {
|
||||
onSessionNotProcessing(cursorCompletedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Only process result for current session
|
||||
if (cursorCompletedSessionId === currentSessionId) {
|
||||
try {
|
||||
const r = latestMessage.data || {};
|
||||
const textResult = typeof r.result === 'string' ? r.result : '';
|
||||
// Flush buffered deltas before finalizing
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Error handling cursor-result message:', e);
|
||||
const pendingChunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
// Try to consolidate into the last streaming assistant message
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
// Replace streaming content with the final content so deltas don't remain
|
||||
const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || '');
|
||||
last.content = finalContent;
|
||||
last.isStreaming = false;
|
||||
} else if (textResult && textResult.trim()) {
|
||||
updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Error handling cursor-result message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session as inactive
|
||||
const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
if (cursorSessionId && onSessionInactive) {
|
||||
onSessionInactive(cursorSessionId);
|
||||
}
|
||||
|
||||
// Store session ID for future use and trigger refresh
|
||||
if (cursorSessionId && !currentSessionId) {
|
||||
setCurrentSessionId(cursorSessionId);
|
||||
|
||||
// Store session ID for future use and trigger refresh (for new sessions)
|
||||
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
|
||||
setCurrentSessionId(cursorCompletedSessionId);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
|
||||
|
||||
// Trigger a project refresh to update the sidebar with the new session
|
||||
if (window.refreshProjects) {
|
||||
setTimeout(() => window.refreshProjects(), 500);
|
||||
@@ -2320,17 +2426,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'claude-complete':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
// Get session ID from message or fall back to current session
|
||||
const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
|
||||
// Session Protection: Mark session as inactive to re-enable automatic project updates
|
||||
// Conversation is complete, safe to allow project updates again
|
||||
// Use real session ID if available, otherwise use pending session ID
|
||||
const activeSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
if (activeSessionId && onSessionInactive) {
|
||||
onSessionInactive(activeSessionId);
|
||||
// Only update UI state if this is the current session
|
||||
if (completedSessionId === currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
// Always mark the completed session as inactive and not processing
|
||||
if (completedSessionId) {
|
||||
if (onSessionInactive) {
|
||||
onSessionInactive(completedSessionId);
|
||||
}
|
||||
if (onSessionNotProcessing) {
|
||||
onSessionNotProcessing(completedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a pending session ID and the conversation completed successfully, use it
|
||||
@@ -2352,16 +2465,26 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'session-aborted':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
|
||||
// Session Protection: Mark session as inactive when aborted
|
||||
// User or system aborted the conversation, re-enable project updates
|
||||
if (currentSessionId && onSessionInactive) {
|
||||
onSessionInactive(currentSessionId);
|
||||
// Get session ID from message or fall back to current session
|
||||
const abortedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
|
||||
// Only update UI state if this is the current session
|
||||
if (abortedSessionId === currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
|
||||
// Always mark the aborted session as inactive and not processing
|
||||
if (abortedSessionId) {
|
||||
if (onSessionInactive) {
|
||||
onSessionInactive(abortedSessionId);
|
||||
}
|
||||
if (onSessionNotProcessing) {
|
||||
onSessionNotProcessing(abortedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: 'Session interrupted by user.',
|
||||
@@ -2369,6 +2492,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'session-status':
|
||||
// Response to check-session-status request
|
||||
const statusSessionId = latestMessage.sessionId;
|
||||
const isCurrentSession = statusSessionId === currentSessionId ||
|
||||
(selectedSession && statusSessionId === selectedSession.id);
|
||||
if (isCurrentSession && latestMessage.isProcessing) {
|
||||
// Session is currently processing, restore UI state
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
if (onSessionProcessing) {
|
||||
onSessionProcessing(statusSessionId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'claude-status':
|
||||
// Handle Claude working status messages
|
||||
const statusData = latestMessage.data;
|
||||
@@ -2571,6 +2709,102 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
// Poll token usage from JSONL file
|
||||
useEffect(() => {
|
||||
console.log('🔍 Token usage polling effect triggered', {
|
||||
sessionId: selectedSession?.id,
|
||||
projectPath: selectedProject?.path
|
||||
});
|
||||
|
||||
if (!selectedProject) {
|
||||
console.log('⚠️ Skipping token usage fetch - missing project');
|
||||
return;
|
||||
}
|
||||
|
||||
// No session selected - reset to zero (new session state)
|
||||
if (!selectedSession) {
|
||||
console.log('🆕 No session selected, resetting token budget to zero');
|
||||
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// For new sessions without an ID yet, reset to zero
|
||||
if (!selectedSession.id || selectedSession.id.startsWith('new-session-')) {
|
||||
console.log('🆕 New session detected, resetting token budget to zero');
|
||||
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create AbortController to cancel in-flight requests when session/project changes
|
||||
let abortController = new AbortController();
|
||||
|
||||
const fetchTokenUsage = async () => {
|
||||
// Abort previous request if still in flight
|
||||
if (abortController.signal.aborted) {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
// Capture current session/project to verify before updating state
|
||||
const currentSessionId = selectedSession.id;
|
||||
const currentProjectPath = selectedProject.path;
|
||||
|
||||
try {
|
||||
const url = `/api/sessions/${currentSessionId}/token-usage?projectPath=${encodeURIComponent(currentProjectPath)}`;
|
||||
console.log('📊 Fetching token usage from:', url);
|
||||
|
||||
const response = await authenticatedFetch(url, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
// Only update state if session/project hasn't changed
|
||||
if (currentSessionId !== selectedSession?.id || currentProjectPath !== selectedProject?.path) {
|
||||
console.log('⚠️ Session/project changed during fetch, discarding stale data');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ Token usage data received:', data);
|
||||
setTokenBudget(data);
|
||||
} else {
|
||||
console.error('❌ Token usage fetch failed:', response.status, await response.text());
|
||||
// Reset to zero if fetch fails (likely new session with no JSONL yet)
|
||||
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't log error if request was aborted (expected behavior)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('🚫 Token usage fetch aborted (session/project changed)');
|
||||
return;
|
||||
}
|
||||
console.error('Failed to fetch token usage:', error);
|
||||
// Reset to zero on error
|
||||
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch immediately on mount/session change
|
||||
fetchTokenUsage();
|
||||
|
||||
// Then poll every 5 seconds
|
||||
const interval = setInterval(fetchTokenUsage, 5000);
|
||||
|
||||
// Also fetch when page becomes visible (tab focus/refresh)
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
fetchTokenUsage();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
// Abort any in-flight requests when effect cleans up
|
||||
abortController.abort();
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [selectedSession?.id, selectedProject?.path]);
|
||||
|
||||
const handleTranscript = useCallback((text) => {
|
||||
if (text.trim()) {
|
||||
setInput(prevInput => {
|
||||
@@ -3181,6 +3415,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -3265,7 +3500,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Token usage pie chart - positioned next to mode indicator */}
|
||||
<TokenUsagePie
|
||||
used={tokenBudget?.used || 0}
|
||||
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button - positioned next to mode indicator */}
|
||||
{isUserScrolledUp && chatMessages.length > 0 && (
|
||||
<button
|
||||
@@ -3366,7 +3606,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const isExpanded = e.target.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(isExpanded);
|
||||
}}
|
||||
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
||||
placeholder={`Ask ${provider === 'cursor' ? 'Cursor' : 'Claude'} to help with your code...`}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
||||
@@ -3464,9 +3704,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
|
||||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes"
|
||||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes"}
|
||||
</div>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
|
||||
Reference in New Issue
Block a user