frontend_nyla/src/pages/views/codeeditor/CodeEditorPage.tsx
2026-02-23 23:01:27 +01:00

226 lines
7.9 KiB
TypeScript

/**
* CodeEditorPage
*
* Main page for the CodeEditor feature.
* Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right)
* Files are dragged from FileList into the prompt textarea as @fileName references.
*/
import React, { useState, useRef, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useCodeEditor } from './useCodeEditor';
import { FileListPanel } from './FileListPanel';
import { DiffPreviewPanel } from './DiffPreviewPanel';
import { useResizablePanels } from '../../../hooks/useResizablePanels';
import { Messages } from '../../../components/UiComponents';
import { FaPaperPlane, FaStop, FaRobot, FaEdit } from 'react-icons/fa';
import styles from './CodeEditor.module.css';
export const CodeEditorPage: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const inputRef = useRef<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useState('');
const [mode, setMode] = useState<'simple' | 'agent'>('simple');
const [isDragOver, setIsDragOver] = useState(false);
const {
messages,
pendingEdits,
acceptEdit,
rejectEdit,
isProcessing,
sendMessage,
stopProcessing,
files,
agentProgress,
} = useCodeEditor(instanceId);
const {
leftWidth: fileListWidth,
handleMouseDown: fileListMouseDown,
containerRef: outerContainerRef,
isDragging: isDraggingLeft,
} = useResizablePanels({
storageKey: 'codeeditor-filelist-width',
defaultLeftWidth: 20,
minLeftWidth: 10,
maxLeftWidth: 40,
});
const {
leftWidth: chatWidth,
handleMouseDown: chatMouseDown,
containerRef: innerContainerRef,
isDragging: isDraggingRight,
} = useResizablePanels({
storageKey: 'codeeditor-chat-width',
defaultLeftWidth: 60,
minLeftWidth: 30,
maxLeftWidth: 80,
});
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim();
if (!trimmed || isProcessing) return;
sendMessage(trimmed, mode);
setInputValue('');
}, [inputValue, isProcessing, sendMessage, mode]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}, [handleSubmit]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/x-codeeditor-file')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const fileDataStr = e.dataTransfer.getData('application/x-codeeditor-file');
if (!fileDataStr) return;
try {
const fileData = JSON.parse(fileDataStr);
const tag = `@${fileData.fileName}`;
const textarea = inputRef.current;
if (textarea) {
const pos = textarea.selectionStart || inputValue.length;
const before = inputValue.slice(0, pos);
const after = inputValue.slice(pos);
const spaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n') ? ' ' : '';
const spaceAfter = after.length > 0 && !after.startsWith(' ') && !after.startsWith('\n') ? ' ' : '';
const newValue = `${before}${spaceBefore}${tag}${spaceAfter}${after}`;
setInputValue(newValue);
requestAnimationFrame(() => {
const newPos = pos + spaceBefore.length + tag.length + spaceAfter.length;
textarea.focus();
textarea.setSelectionRange(newPos, newPos);
});
} else {
setInputValue(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + tag + ' ');
}
} catch {
// ignore malformed drop data
}
}, [inputValue]);
return (
<div className={styles.container}>
<div
className={`${styles.panels} ${isDraggingLeft || isDraggingRight ? styles.dragging : ''}`}
ref={outerContainerRef}
>
{/* Left: File List */}
<div className={styles.filePanel} style={{ width: `${fileListWidth}%` }}>
<FileListPanel files={files} />
</div>
<div className={styles.divider} onMouseDown={fileListMouseDown} />
{/* Center + Right */}
<div className={styles.mainArea} style={{ width: `${100 - fileListWidth}%` }} ref={innerContainerRef}>
{/* Center: Chat */}
<div className={styles.chatPanel} style={{ width: `${chatWidth}%` }}>
<div className={styles.messagesArea}>
<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 className={styles.inputArea}>
<div className={styles.modeToggle}>
<button
className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`}
onClick={() => setMode('simple')}
disabled={isProcessing}
title="Single AI call -- drag files into prompt as @references"
>
<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
ref={inputRef}
className={`${styles.input} ${isDragOver ? styles.inputDropTarget : ''}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
placeholder={mode === 'agent'
? "Describe a complex task (e.g. 'Document all Python files')..."
: "Drag files here and describe changes (e.g. 'In @config.yaml change the port to 8080')..."
}
rows={3}
disabled={isProcessing}
/>
<div className={styles.inputActions}>
<span className={styles.fileCount}>
{mode === 'simple'
? `Drag files from the list into your prompt`
: `Agent mode: AI reads files autonomously`
}
</span>
{isProcessing ? (
<button className={styles.stopButton} onClick={stopProcessing}>
<FaStop /> Stop
</button>
) : (
<button
className={styles.sendButton}
onClick={handleSubmit}
disabled={!inputValue.trim()}
>
<FaPaperPlane /> Send
</button>
)}
</div>
</div>
</div>
<div className={styles.divider} onMouseDown={chatMouseDown} />
{/* Right: Diff Preview */}
<div className={styles.diffPanel} style={{ width: `${100 - chatWidth}%` }}>
<DiffPreviewPanel
edits={pendingEdits}
onAccept={acceptEdit}
onReject={rejectEdit}
/>
</div>
</div>
</div>
</div>
);
};