feat: Introducing Codex to the Claude code UI project. Improve the Settings and Onboarding UX to accomodate more agents.

This commit is contained in:
simosmik
2025-12-27 22:30:32 +00:00
parent 7a173071f1
commit fbbf7465fb
26 changed files with 3719 additions and 1053 deletions

View File

@@ -25,6 +25,7 @@ import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx';
import CursorLogo from './CursorLogo.jsx';
import CodexLogo from './CodexLogo.jsx';
import NextTaskBanner from './NextTaskBanner.jsx';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
@@ -442,13 +443,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<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' ? (
<CursorLogo className="w-full h-full" />
) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? (
<CodexLogo className="w-full h-full" />
) : (
<ClaudeLogo className="w-full h-full" />
)}
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')}
</div>
</div>
)}
@@ -1522,6 +1525,23 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<span className="font-medium">Read todo list</span>
</div>
</div>
) : message.isThinking ? (
/* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2">
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>💭 Thinking...</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
{message.content}
</Markdown>
</div>
</details>
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
@@ -1537,7 +1557,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
</details>
)}
{(() => {
const content = formatUsageLimitText(String(message.content || ''));
@@ -1708,6 +1728,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [claudeModel, setClaudeModel] = useState(() => {
return localStorage.getItem('claude-model') || 'sonnet';
});
const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || 'gpt-5.2';
});
// Load permission mode for the current session
useEffect(() => {
if (selectedSession?.id) {
@@ -2112,24 +2135,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}, []);
// Load session messages from API with pagination
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => {
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false, provider = 'claude') => {
if (!projectName || !sessionId) return [];
const isInitialLoad = !loadMore;
if (isInitialLoad) {
setIsLoadingSessionMessages(true);
} else {
setIsLoadingMoreMessages(true);
}
try {
const currentOffset = loadMore ? messagesOffset : 0;
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset);
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider);
if (!response.ok) {
throw new Error('Failed to load session messages');
}
const data = await response.json();
// Extract token usage if present (Codex includes it in messages response)
if (isInitialLoad && data.tokenUsage) {
setTokenBudget(data.tokenUsage);
}
// Handle paginated response
if (data.hasMore !== undefined) {
setHasMoreMessages(data.hasMore);
@@ -2572,6 +2600,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
// Handle thinking messages (Codex reasoning)
else if (msg.type === 'thinking' && msg.message?.content) {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(msg.message.content),
timestamp: msg.timestamp || new Date().toISOString(),
isThinking: true
});
}
// Handle tool_use messages (Codex function calls)
else if (msg.type === 'tool_use' && msg.toolName) {
converted.push({
type: 'assistant',
content: '',
timestamp: msg.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: msg.toolName,
toolInput: msg.toolInput || '',
toolCallId: msg.toolCallId
});
}
// Handle tool_result messages (Codex function outputs)
else if (msg.type === 'tool_result') {
// Find the matching tool_use by callId, or the last tool_use without a result
for (let i = converted.length - 1; i >= 0; i--) {
if (converted[i].isToolUse && !converted[i].toolResult) {
if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) {
converted[i].toolResult = {
content: msg.output || '',
isError: false
};
break;
}
}
}
}
// Handle assistant messages
else if (msg.message?.role === 'assistant' && msg.message?.content) {
if (Array.isArray(msg.message.content)) {
@@ -2666,7 +2733,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const previousScrollTop = container.scrollTop;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true);
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
if (moreMessages.length > 0) {
// Prepend new messages to the existing ones
@@ -2757,7 +2824,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Only load messages from API if this is a user-initiated session change
// For system-initiated changes, preserve existing messages and rely on WebSocket
if (!isSystemSessionChange) {
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude');
setSessionMessages(messages);
// convertedMessages will be automatically updated via useMemo
// Scroll will be handled by the main scroll useEffect after messages are rendered
@@ -2806,8 +2873,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setSessionMessages([]);
setChatMessages(converted);
} else {
// Reload Claude messages from API/JSONL
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
// Reload Claude/Codex messages from API/JSONL
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude');
setSessionMessages(messages);
// convertedMessages will be automatically updated via useMemo
@@ -2893,7 +2960,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Filter messages by session ID to prevent cross-session interference
// Skip filtering for global messages that apply to all sessions
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete'];
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete'];
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
// For new sessions (currentSessionId is null), allow messages through
@@ -2920,8 +2987,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
case 'token-budget':
// Token budget now fetched via API after message completion instead of WebSocket
// This case is kept for compatibility but does nothing
// Use token budget from WebSocket for active sessions
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
}
break;
case 'claude-response':
@@ -3314,23 +3383,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
// Fetch updated token usage after message completes
if (selectedProject && selectedSession?.id) {
const fetchUpdatedTokenUsage = async () => {
try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
const data = await response.json();
setTokenBudget(data);
}
} catch (error) {
console.error('Failed to fetch updated token usage:', error);
}
};
fetchUpdatedTokenUsage();
}
}
// Always mark the completed session as inactive and not processing
@@ -3358,7 +3410,154 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
case 'codex-response':
// Handle Codex SDK responses
const codexData = latestMessage.data;
if (codexData) {
// Handle item events
if (codexData.type === 'item') {
switch (codexData.itemType) {
case 'agent_message':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages(prev => [...prev, {
type: 'assistant',
content: content,
timestamp: new Date()
}]);
}
break;
case 'reasoning':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages(prev => [...prev, {
type: 'assistant',
content: content,
timestamp: new Date(),
isThinking: true
}]);
}
break;
case 'command_execution':
if (codexData.command) {
setChatMessages(prev => [...prev, {
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'Bash',
toolInput: codexData.command,
toolResult: codexData.output || null,
exitCode: codexData.exitCode
}]);
}
break;
case 'file_change':
if (codexData.changes?.length > 0) {
const changesList = codexData.changes.map(c => `${c.kind}: ${c.path}`).join('\n');
setChatMessages(prev => [...prev, {
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'FileChanges',
toolInput: changesList,
toolResult: `Status: ${codexData.status}`
}]);
}
break;
case 'mcp_tool_call':
setChatMessages(prev => [...prev, {
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: `${codexData.server}:${codexData.tool}`,
toolInput: JSON.stringify(codexData.arguments, null, 2),
toolResult: codexData.result ? JSON.stringify(codexData.result, null, 2) : (codexData.error?.message || null)
}]);
break;
case 'error':
if (codexData.message?.content) {
setChatMessages(prev => [...prev, {
type: 'error',
content: codexData.message.content,
timestamp: new Date()
}]);
}
break;
default:
console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
}
}
// Handle turn complete
if (codexData.type === 'turn_complete') {
// Turn completed, message stream done
setIsLoading(false);
}
// Handle turn failed
if (codexData.type === 'turn_failed') {
setIsLoading(false);
setChatMessages(prev => [...prev, {
type: 'error',
content: codexData.error?.message || 'Turn failed',
timestamp: new Date()
}]);
}
}
break;
case 'codex-complete':
// Handle Codex session completion
const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
if (codexCompletedSessionId === currentSessionId || !currentSessionId) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
if (codexCompletedSessionId) {
if (onSessionInactive) {
onSessionInactive(codexCompletedSessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(codexCompletedSessionId);
}
}
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId);
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
if (selectedProject) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
case 'codex-error':
// Handle Codex errors
setIsLoading(false);
setCanAbortSession(false);
setChatMessages(prev => [...prev, {
type: 'error',
content: latestMessage.error || 'An error occurred with Codex',
timestamp: new Date()
}]);
break;
case 'session-aborted': {
// Get session ID from message or fall back to current session
const abortedSessionId = latestMessage.sessionId || currentSessionId;
@@ -3609,21 +3808,26 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input]);
// Load token usage when session changes (but don't poll to avoid conflicts with WebSocket)
// Load token usage when session changes for Claude sessions only
// (Codex token usage is included in messages response, Cursor doesn't support it)
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
// Reset for new/empty sessions
setTokenBudget(null);
return;
}
// Fetch token usage once when session loads
const sessionProvider = selectedSession.__provider || 'claude';
// Skip for Codex (included in messages) and Cursor (not supported)
if (sessionProvider !== 'claude') {
return;
}
// Fetch token usage for Claude sessions
const fetchInitialTokenUsage = async () => {
try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
const data = await response.json();
setTokenBudget(data);
@@ -3636,7 +3840,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
};
fetchInitialTokenUsage();
}, [selectedSession?.id, selectedProject?.path]);
}, [selectedSession?.id, selectedSession?.__provider, selectedProject?.path]);
const handleTranscript = useCallback((text) => {
if (text.trim()) {
@@ -3808,7 +4012,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Get tools settings from localStorage based on provider
const getToolsSettings = () => {
try {
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-settings' : 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
@@ -3843,6 +4047,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
toolsSettings: toolsSettings
}
});
} else if (provider === 'codex') {
// Send Codex command
sendMessage({
type: 'codex-command',
command: input,
sessionId: effectiveSessionId,
options: {
cwd: selectedProject.fullPath || selectedProject.path,
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: effectiveSessionId,
resume: !!effectiveSessionId,
model: codexModel,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode
}
});
} else {
// Send Claude command (existing code)
sendMessage({
@@ -3876,7 +4095,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject) {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
@@ -3986,7 +4205,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle Tab key for mode switching (only when dropdowns are not showing)
if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
e.preventDefault();
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
// Codex doesn't support plan mode
const modes = provider === 'codex'
? ['default', 'acceptEdits', 'bypassPermissions']
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
const newMode = modes[nextIndex];
@@ -4155,7 +4377,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
};
const handleModeSwitch = () => {
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
// Codex doesn't support plan mode
const modes = provider === 'codex'
? ['default', 'acceptEdits', 'bypassPermissions']
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
const newMode = modes[nextIndex];
@@ -4273,8 +4498,40 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
)}
</button>
{/* Codex Button */}
<button
onClick={() => {
setProvider('codex');
localStorage.setItem('selected-provider', 'codex');
// 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 === 'codex'
? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<CodexLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p>
</div>
</div>
{provider === 'codex' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-gray-800 dark:bg-gray-300 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white dark:text-gray-800" 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 - Always reserve space to prevent jumping */}
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -4296,6 +4553,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<option value="opusplan">Opus Plan</option>
<option value="sonnet[1m]">Sonnet [1M]</option>
</select>
) : provider === 'codex' ? (
<select
value={codexModel}
onChange={(e) => {
const newModel = e.target.value;
setCodexModel(newModel);
localStorage.setItem('codex-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-gray-500 focus:border-gray-500 min-w-[140px]"
>
<option value="gpt-5.2">GPT-5.2</option>
<option value="gpt-5.1-codex-max">GPT-5.1 Codex Max</option>
<option value="o3">O3</option>
<option value="o4-mini">O4-mini</option>
</select>
) : (
<select
value={cursorModel}
@@ -4333,6 +4605,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
: provider === 'cursor'
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
: provider === 'codex'
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
: 'Select a provider above to begin'
}
</p>
@@ -4433,11 +4707,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
<CursorLogo className="w-full h-full" />
) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? (
<CodexLogo className="w-full h-full" />
) : (
<ClaudeLogo className="w-full h-full" />
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude'}</div>
{/* Abort button removed - functionality not yet implemented at backend */}
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">