226 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
};
|