phase 2 codeeditor

This commit is contained in:
ValueOn AG 2026-02-23 22:09:33 +01:00
parent 5d4ef43d5f
commit 60887682a5
6 changed files with 281 additions and 13 deletions

View file

@ -33,7 +33,7 @@ import { PlaygroundPage, WorkflowsPage } from './workflows';
import { AutomationsPage, AutomationTemplatesPage } from './workflows'; import { AutomationsPage, AutomationTemplatesPage } from './workflows';
// CodeEditor Views // CodeEditor Views
import { CodeEditorPage } from './views/codeeditor'; import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
// Teamsbot Views // Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -128,7 +128,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
}, },
codeeditor: { codeeditor: {
editor: CodeEditorPage, editor: CodeEditorPage,
workflows: WorkflowsPage, workflows: CodeEditorWorkflowsPage,
}, },
teamsbot: { teamsbot: {
dashboard: TeamsbotDashboardView, dashboard: TeamsbotDashboardView,

View file

@ -161,6 +161,65 @@
background: var(--disabled-bg, #f5f5f5); background: var(--disabled-bg, #f5f5f5);
} }
/* Mode Toggle */
.modeToggle {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.modeButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: transparent;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-secondary, #666);
}
.modeButton:hover {
background: var(--hover-bg, #f5f5f5);
}
.modeActive {
background: var(--primary-color, #4a90d9);
color: white;
border-color: var(--primary-color, #4a90d9);
}
.modeActive:hover {
background: var(--primary-dark, #3a7bc8);
}
.modeButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Agent Progress */
.agentProgress {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--info-light, #e8f4fd);
border-radius: 6px;
font-size: 12px;
color: var(--info-dark, #0c5460);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.inputActions { .inputActions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -342,3 +401,65 @@
background: var(--danger-color, #dc3545); background: var(--danger-color, #dc3545);
color: white; color: white;
} }
/* Workflows Page */
.workflowsPage {
padding: 16px 24px;
}
.workflowsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.workflowsHeader h3 {
margin: 0;
}
.refreshButton {
padding: 6px 14px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 13px;
}
.refreshButton:hover {
background: var(--hover-bg, #f5f5f5);
}
.workflowTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.workflowTable th,
.workflowTable td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.workflowTable th {
font-weight: 600;
color: var(--text-secondary, #666);
font-size: 12px;
text-transform: uppercase;
}
.statusBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.status_running { background: var(--info-light, #e8f4fd); color: var(--info-dark, #0c5460); }
.status_completed { background: var(--success-light, #d4edda); color: var(--success-dark, #155724); }
.status_stopped { background: var(--warning-light, #fff3cd); color: var(--warning-dark, #856404); }
.status_error { background: var(--danger-light, #f8d7da); color: var(--danger-dark, #721c24); }
.status_unknown { background: #f0f0f0; color: #666; }

View file

@ -3,6 +3,7 @@
* *
* Main page for the CodeEditor feature. * Main page for the CodeEditor feature.
* Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right) * Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right)
* Supports simple mode (Phase 1) and agent mode (Phase 2).
*/ */
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
@ -12,7 +13,7 @@ import { FileListPanel } from './FileListPanel';
import { DiffPreviewPanel } from './DiffPreviewPanel'; import { DiffPreviewPanel } from './DiffPreviewPanel';
import { useResizablePanels } from '../../../hooks/useResizablePanels'; import { useResizablePanels } from '../../../hooks/useResizablePanels';
import { Messages } from '../../../components/UiComponents'; import { Messages } from '../../../components/UiComponents';
import { FaPaperPlane, FaStop } from 'react-icons/fa'; import { FaPaperPlane, FaStop, FaRobot, FaEdit } from 'react-icons/fa';
import styles from './CodeEditor.module.css'; import styles from './CodeEditor.module.css';
export const CodeEditorPage: React.FC = () => { export const CodeEditorPage: React.FC = () => {
@ -20,6 +21,7 @@ export const CodeEditorPage: React.FC = () => {
const instanceId = instance?.id || ''; const instanceId = instance?.id || '';
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [mode, setMode] = useState<'simple' | 'agent'>('simple');
const { const {
messages, messages,
@ -32,6 +34,7 @@ export const CodeEditorPage: React.FC = () => {
sendMessage, sendMessage,
stopProcessing, stopProcessing,
files, files,
agentProgress,
} = useCodeEditor(instanceId); } = useCodeEditor(instanceId);
const { const {
@ -61,9 +64,9 @@ export const CodeEditorPage: React.FC = () => {
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim(); const trimmed = inputValue.trim();
if (!trimmed || isProcessing) return; if (!trimmed || isProcessing) return;
sendMessage(trimmed, selectedFileIds); sendMessage(trimmed, selectedFileIds, mode);
setInputValue(''); setInputValue('');
}, [inputValue, isProcessing, sendMessage, selectedFileIds]); }, [inputValue, isProcessing, sendMessage, selectedFileIds, mode]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -95,22 +98,57 @@ export const CodeEditorPage: React.FC = () => {
<div className={styles.chatPanel} style={{ width: `${chatWidth}%` }}> <div className={styles.chatPanel} style={{ width: `${chatWidth}%` }}>
<div className={styles.messagesArea}> <div className={styles.messagesArea}>
<Messages messages={messages} /> <Messages messages={messages} />
{agentProgress && isProcessing && (
<div className={styles.agentProgress}>
<FaRobot />
<span>
Round {agentProgress.round} | {agentProgress.totalToolCalls} tools |{' '}
{agentProgress.costCHF.toFixed(4)} CHF
</span>
</div>
)}
</div> </div>
<div className={styles.inputArea}> <div className={styles.inputArea}>
<div className={styles.modeToggle}>
<button
className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`}
onClick={() => setMode('simple')}
disabled={isProcessing}
title="Single AI call with selected files as context"
>
<FaEdit /> Simple
</button>
<button
className={`${styles.modeButton} ${mode === 'agent' ? styles.modeActive : ''}`}
onClick={() => setMode('agent')}
disabled={isProcessing}
title="AI agent with tools (reads files autonomously, multi-step)"
>
<FaRobot /> Agent
</button>
</div>
<textarea <textarea
ref={inputRef} ref={inputRef}
className={styles.input} className={styles.input}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Describe what you want to change..." placeholder={mode === 'agent'
? "Describe a complex task (e.g. 'Document all Python files')..."
: "Describe what you want to change..."
}
rows={2} rows={2}
disabled={isProcessing} disabled={isProcessing}
/> />
<div className={styles.inputActions}> <div className={styles.inputActions}>
<span className={styles.fileCount}> <span className={styles.fileCount}>
{selectedFileIds.length} file(s) selected {mode === 'simple'
? `${selectedFileIds.length} file(s) selected`
: `Agent mode: AI reads files autonomously`
}
</span> </span>
{isProcessing ? ( {isProcessing ? (
<button className={styles.stopButton} onClick={stopProcessing}> <button className={styles.stopButton} onClick={stopProcessing}>

View file

@ -0,0 +1,83 @@
/**
* CodeEditorWorkflowsPage
*
* Lists CodeEditor workflows for the current feature instance.
* Uses the codeeditor-specific API endpoint instead of the generic /api/workflows/.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import styles from './CodeEditor.module.css';
interface WorkflowItem {
id: string;
label?: string;
status?: string;
workflowMode?: string;
startedAt?: number;
lastActivity?: number;
}
export const CodeEditorWorkflowsPage: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
const [loading, setLoading] = useState(false);
const loadWorkflows = useCallback(() => {
if (!instanceId) return;
setLoading(true);
api.get(`/api/codeeditor/${instanceId}/workflows`)
.then(res => {
const items = res.data?.items || res.data?.workflows || [];
setWorkflows(Array.isArray(items) ? items : []);
})
.catch(err => console.error('Failed to load workflows:', err))
.finally(() => setLoading(false));
}, [instanceId]);
useEffect(() => { loadWorkflows(); }, [loadWorkflows]);
return (
<div className={styles.workflowsPage}>
<div className={styles.workflowsHeader}>
<h3>CodeEditor Workflows</h3>
<button className={styles.refreshButton} onClick={loadWorkflows} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
{workflows.length === 0 ? (
<div className={styles.emptyState}>
{loading ? 'Loading workflows...' : 'No workflows yet. Start a conversation in the Editor view.'}
</div>
) : (
<table className={styles.workflowTable}>
<thead>
<tr>
<th>Label</th>
<th>Status</th>
<th>Started</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
{workflows.map(wf => (
<tr key={wf.id}>
<td>{wf.label || wf.id.slice(0, 8)}</td>
<td>
<span className={`${styles.statusBadge} ${styles[`status_${wf.status || 'unknown'}`]}`}>
{wf.status || 'unknown'}
</span>
</td>
<td>{wf.startedAt ? new Date(wf.startedAt * 1000).toLocaleString() : '-'}</td>
<td>{wf.lastActivity ? new Date(wf.lastActivity * 1000).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};

View file

@ -1 +1,2 @@
export { CodeEditorPage } from './CodeEditorPage'; export { CodeEditorPage } from './CodeEditorPage';
export { CodeEditorWorkflowsPage } from './CodeEditorWorkflowsPage';

View file

@ -1,8 +1,8 @@
/** /**
* useCodeEditor Hook * useCodeEditor Hook
* *
* Manages SSE connection, file selection, message state, and edit proposals * Manages SSE connection, file selection, message state, edit proposals,
* for the CodeEditor feature. * and agent mode progress for the CodeEditor feature.
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
@ -12,6 +12,13 @@ import type { Message } from '../../../components/UiComponents/Messages/Messages
import type { FileInfo } from './FileListPanel'; import type { FileInfo } from './FileListPanel';
import type { FileEditProposal } from './DiffPreviewPanel'; import type { FileEditProposal } from './DiffPreviewPanel';
export interface AgentProgress {
round: number;
totalAiCalls: number;
totalToolCalls: number;
costCHF: number;
}
interface UseCodeEditorReturn { interface UseCodeEditorReturn {
messages: Message[]; messages: Message[];
selectedFileIds: string[]; selectedFileIds: string[];
@ -20,9 +27,10 @@ interface UseCodeEditorReturn {
acceptEdit: (editId: string) => void; acceptEdit: (editId: string) => void;
rejectEdit: (editId: string) => void; rejectEdit: (editId: string) => void;
isProcessing: boolean; isProcessing: boolean;
sendMessage: (prompt: string, fileIds: string[]) => void; sendMessage: (prompt: string, fileIds: string[], mode?: 'simple' | 'agent') => void;
stopProcessing: () => void; stopProcessing: () => void;
files: FileInfo[]; files: FileInfo[];
agentProgress: AgentProgress | null;
} }
export function useCodeEditor(instanceId: string): UseCodeEditorReturn { export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
@ -32,6 +40,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<FileInfo[]>([]); const [files, setFiles] = useState<FileInfo[]>([]);
const [workflowId, setWorkflowId] = useState<string | null>(null); const [workflowId, setWorkflowId] = useState<string | null>(null);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
@ -45,10 +54,11 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
); );
}, []); }, []);
const sendMessage = useCallback((prompt: string, fileIds: string[]) => { const sendMessage = useCallback((prompt: string, fileIds: string[], mode: 'simple' | 'agent' = 'simple') => {
if (!instanceId || isProcessing) return; if (!instanceId || isProcessing) return;
setIsProcessing(true); setIsProcessing(true);
setAgentProgress(null);
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: `user-${Date.now()}`, id: `user-${Date.now()}`,
workflowId: workflowId || '', workflowId: workflowId || '',
@ -64,6 +74,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (workflowId) params.set('workflowId', workflowId); if (workflowId) params.set('workflowId', workflowId);
params.set('mode', mode);
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`; const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`;
@ -115,7 +126,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
const jsonStr = line.slice(6); const jsonStr = line.slice(6);
try { try {
const event = JSON.parse(jsonStr); const event = JSON.parse(jsonStr);
_handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId); _handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId, setAgentProgress);
if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') { if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') {
setIsProcessing(false); setIsProcessing(false);
@ -183,6 +194,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
sendMessage, sendMessage,
stopProcessing, stopProcessing,
files, files,
agentProgress,
}; };
} }
@ -196,7 +208,8 @@ function _handleSseEvent(
event: any, event: any,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>, setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>, setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>,
setWorkflowId: React.Dispatch<React.SetStateAction<string | null>> setWorkflowId: React.Dispatch<React.SetStateAction<string | null>>,
setAgentProgress: React.Dispatch<React.SetStateAction<AgentProgress | null>>
) { ) {
if (event.type === 'message' && event.item) { if (event.type === 'message' && event.item) {
const item = event.item; const item = event.item;
@ -222,6 +235,18 @@ function _handleSseEvent(
}; };
return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg]; return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg];
}); });
} else if (event.type === 'agent_progress' && event.item) {
setAgentProgress(event.item);
} else if (event.type === 'agent_summary' && event.item) {
const s = event.item;
setMessages(prev => [...prev, {
id: `summary-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Agent completed: ${s.rounds} rounds, ${s.totalToolCalls} tool calls, ${s.costCHF} CHF, ${s.processingTime}s`,
publishedAt: Date.now() / 1000,
}]);
setAgentProgress(null);
} else if (event.type === 'complete' && event.workflowId) { } else if (event.type === 'complete' && event.workflowId) {
setWorkflowId(event.workflowId); setWorkflowId(event.workflowId);
} }