fixed cursor feature

This commit is contained in:
ValueOn AG 2026-02-23 23:01:27 +01:00
parent 60887682a5
commit 6f0585192a
4 changed files with 190 additions and 63 deletions

View file

@ -74,6 +74,12 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
} }
.dragHint {
font-size: 11px;
color: var(--text-secondary, #999);
font-style: italic;
}
.fileItems { .fileItems {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -84,8 +90,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 16px; padding: 6px 16px;
cursor: pointer; cursor: grab;
transition: background 0.1s; transition: background 0.1s;
} }
@ -93,13 +99,32 @@
background: var(--hover-bg, #f5f5f5); background: var(--hover-bg, #f5f5f5);
} }
.fileItemSelected { .fileItem:active {
background: var(--selected-bg, #e8f0fe); cursor: grabbing;
opacity: 0.7;
} }
.fileCheckbox { .dragHandle {
color: var(--primary-color, #4a90d9); color: var(--text-secondary, #ccc);
flex-shrink: 0; flex-shrink: 0;
font-size: 10px;
}
.fileItem:hover .dragHandle {
color: var(--text-secondary, #999);
}
.dateGroup {
margin-bottom: 4px;
}
.dateGroupHeader {
padding: 6px 16px 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary, #999);
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.fileIcon { .fileIcon {
@ -161,6 +186,12 @@
background: var(--disabled-bg, #f5f5f5); background: var(--disabled-bg, #f5f5f5);
} }
.inputDropTarget {
border-color: var(--primary-color, #4a90d9);
background: var(--selected-bg, #e8f0fe);
box-shadow: 0 0 0 2px var(--primary-color, #4a90d9) inset;
}
/* Mode Toggle */ /* Mode Toggle */
.modeToggle { .modeToggle {
display: flex; display: flex;

View file

@ -3,7 +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). * Files are dragged from FileList into the prompt textarea as @fileName references.
*/ */
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
@ -22,11 +22,10 @@ export const CodeEditorPage: React.FC = () => {
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 [mode, setMode] = useState<'simple' | 'agent'>('simple');
const [isDragOver, setIsDragOver] = useState(false);
const { const {
messages, messages,
selectedFileIds,
toggleFileSelection,
pendingEdits, pendingEdits,
acceptEdit, acceptEdit,
rejectEdit, rejectEdit,
@ -64,9 +63,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, mode); sendMessage(trimmed, mode);
setInputValue(''); setInputValue('');
}, [inputValue, isProcessing, sendMessage, selectedFileIds, mode]); }, [inputValue, isProcessing, sendMessage, 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) {
@ -75,6 +74,50 @@ export const CodeEditorPage: React.FC = () => {
} }
}, [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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div <div
@ -83,11 +126,7 @@ export const CodeEditorPage: React.FC = () => {
> >
{/* Left: File List */} {/* Left: File List */}
<div className={styles.filePanel} style={{ width: `${fileListWidth}%` }}> <div className={styles.filePanel} style={{ width: `${fileListWidth}%` }}>
<FileListPanel <FileListPanel files={files} />
files={files}
selectedFileIds={selectedFileIds}
onToggle={toggleFileSelection}
/>
</div> </div>
<div className={styles.divider} onMouseDown={fileListMouseDown} /> <div className={styles.divider} onMouseDown={fileListMouseDown} />
@ -116,7 +155,7 @@ export const CodeEditorPage: React.FC = () => {
className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`} className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`}
onClick={() => setMode('simple')} onClick={() => setMode('simple')}
disabled={isProcessing} disabled={isProcessing}
title="Single AI call with selected files as context" title="Single AI call -- drag files into prompt as @references"
> >
<FaEdit /> Simple <FaEdit /> Simple
</button> </button>
@ -132,21 +171,24 @@ export const CodeEditorPage: React.FC = () => {
<textarea <textarea
ref={inputRef} ref={inputRef}
className={styles.input} className={`${styles.input} ${isDragOver ? styles.inputDropTarget : ''}`}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
placeholder={mode === 'agent' placeholder={mode === 'agent'
? "Describe a complex task (e.g. 'Document all Python files')..." ? "Describe a complex task (e.g. 'Document all Python files')..."
: "Describe what you want to change..." : "Drag files here and describe changes (e.g. 'In @config.yaml change the port to 8080')..."
} }
rows={2} rows={3}
disabled={isProcessing} disabled={isProcessing}
/> />
<div className={styles.inputActions}> <div className={styles.inputActions}>
<span className={styles.fileCount}> <span className={styles.fileCount}>
{mode === 'simple' {mode === 'simple'
? `${selectedFileIds.length} file(s) selected` ? `Drag files from the list into your prompt`
: `Agent mode: AI reads files autonomously` : `Agent mode: AI reads files autonomously`
} }
</span> </span>

View file

@ -1,12 +1,12 @@
/** /**
* FileListPanel * FileListPanel
* *
* Lists uploaded text files with checkbox selection. * Lists text files grouped by date, draggable into the prompt textarea.
* Selected files are sent as context to the AI. * Drag a file into the chat input to insert an @fileName reference.
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { FaFile, FaCheckSquare, FaRegSquare } from 'react-icons/fa'; import { FaFile, FaGripVertical } from 'react-icons/fa';
import styles from './CodeEditor.module.css'; import styles from './CodeEditor.module.css';
export interface FileInfo { export interface FileInfo {
@ -14,48 +14,95 @@ export interface FileInfo {
fileName: string; fileName: string;
mimeType: string; mimeType: string;
sizeBytes: number; sizeBytes: number;
modifiedAt?: number;
} }
interface FileListPanelProps { interface FileListPanelProps {
files: FileInfo[]; files: FileInfo[];
selectedFileIds: string[];
onToggle: (fileId: string) => void;
} }
export const FileListPanel: React.FC<FileListPanelProps> = ({ files, selectedFileIds, onToggle }) => { interface DateGroup {
label: string;
files: FileInfo[];
}
export const FileListPanel: React.FC<FileListPanelProps> = ({ files }) => {
const groups = useMemo(() => _groupByDate(files), [files]);
const handleDragStart = (e: React.DragEvent, file: FileInfo) => {
e.dataTransfer.setData('application/x-codeeditor-file', JSON.stringify({
fileId: file.fileId,
fileName: file.fileName,
}));
e.dataTransfer.setData('text/plain', `@${file.fileName}`);
e.dataTransfer.effectAllowed = 'copy';
};
return ( return (
<div className={styles.fileList}> <div className={styles.fileList}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<h3>Files ({files.length})</h3> <h3>Files ({files.length})</h3>
<span className={styles.selectedCount}>{selectedFileIds.length} selected</span> <span className={styles.dragHint}>drag into prompt</span>
</div> </div>
<div className={styles.fileItems}> <div className={styles.fileItems}>
{files.length === 0 ? ( {files.length === 0 ? (
<div className={styles.emptyState}>No text files uploaded yet</div> <div className={styles.emptyState}>No text files uploaded yet</div>
) : ( ) : (
files.map((file) => { groups.map((group) => (
const isSelected = selectedFileIds.includes(file.fileId); <div key={group.label} className={styles.dateGroup}>
return ( <div className={styles.dateGroupHeader}>{group.label}</div>
{group.files.map((file) => (
<div <div
key={file.fileId} key={file.fileId}
className={`${styles.fileItem} ${isSelected ? styles.fileItemSelected : ''}`} className={styles.fileItem}
onClick={() => onToggle(file.fileId)} draggable
onDragStart={(e) => handleDragStart(e, file)}
> >
<span className={styles.fileCheckbox}> <FaGripVertical className={styles.dragHandle} />
{isSelected ? <FaCheckSquare /> : <FaRegSquare />}
</span>
<FaFile className={styles.fileIcon} /> <FaFile className={styles.fileIcon} />
<span className={styles.fileName}>{file.fileName}</span> <span className={styles.fileName}>{file.fileName}</span>
<span className={styles.fileSize}>{_formatSize(file.sizeBytes)}</span> <span className={styles.fileSize}>{_formatSize(file.sizeBytes)}</span>
</div> </div>
); ))}
}) </div>
))
)} )}
</div> </div>
</div> </div>
); );
}; };
function _groupByDate(files: FileInfo[]): DateGroup[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const yesterdayStart = todayStart - 86400;
const today: FileInfo[] = [];
const yesterday: FileInfo[] = [];
const older: FileInfo[] = [];
const sorted = [...files].sort((a, b) => (b.modifiedAt || 0) - (a.modifiedAt || 0));
for (const file of sorted) {
const ts = file.modifiedAt || 0;
if (ts >= todayStart) {
today.push(file);
} else if (ts >= yesterdayStart) {
yesterday.push(file);
} else {
older.push(file);
}
}
const groups: DateGroup[] = [];
if (today.length > 0) groups.push({ label: 'Today', files: today });
if (yesterday.length > 0) groups.push({ label: 'Yesterday', files: yesterday });
if (older.length > 0) groups.push({ label: 'Older', files: older });
if (groups.length === 0 && files.length > 0) groups.push({ label: 'All files', files: sorted });
return groups;
}
function _formatSize(bytes: number): string { function _formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;

View file

@ -1,8 +1,8 @@
/** /**
* useCodeEditor Hook * useCodeEditor Hook
* *
* Manages SSE connection, file selection, message state, edit proposals, * Manages SSE connection, message state, edit proposals, and agent progress.
* and agent mode progress for the CodeEditor feature. * File references are extracted from @fileName tags in the prompt text.
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
@ -21,13 +21,11 @@ export interface AgentProgress {
interface UseCodeEditorReturn { interface UseCodeEditorReturn {
messages: Message[]; messages: Message[];
selectedFileIds: string[];
toggleFileSelection: (fileId: string) => void;
pendingEdits: FileEditProposal[]; pendingEdits: FileEditProposal[];
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[], mode?: 'simple' | 'agent') => void; sendMessage: (prompt: string, mode?: 'simple' | 'agent') => void;
stopProcessing: () => void; stopProcessing: () => void;
files: FileInfo[]; files: FileInfo[];
agentProgress: AgentProgress | null; agentProgress: AgentProgress | null;
@ -35,7 +33,6 @@ interface UseCodeEditorReturn {
export function useCodeEditor(instanceId: string): UseCodeEditorReturn { export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]); const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<FileInfo[]>([]); const [files, setFiles] = useState<FileInfo[]>([]);
@ -48,15 +45,11 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
_loadFiles(instanceId, setFiles); _loadFiles(instanceId, setFiles);
}, [instanceId]); }, [instanceId]);
const toggleFileSelection = useCallback((fileId: string) => { const sendMessage = useCallback((prompt: string, mode: 'simple' | 'agent' = 'simple') => {
setSelectedFileIds(prev =>
prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]
);
}, []);
const sendMessage = useCallback((prompt: string, fileIds: string[], mode: 'simple' | 'agent' = 'simple') => {
if (!instanceId || isProcessing) return; if (!instanceId || isProcessing) return;
const referencedFileIds = _extractFileRefs(prompt, files);
setIsProcessing(true); setIsProcessing(true);
setAgentProgress(null); setAgentProgress(null);
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
@ -96,7 +89,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
prompt: prompt, prompt: prompt,
listFileId: fileIds, listFileId: referencedFileIds,
}), }),
credentials: 'include', credentials: 'include',
signal: abortRef.current.signal, signal: abortRef.current.signal,
@ -150,7 +143,7 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
}]); }]);
setIsProcessing(false); setIsProcessing(false);
}); });
}, [instanceId, isProcessing, workflowId]); }, [instanceId, isProcessing, workflowId, files]);
const stopProcessing = useCallback(() => { const stopProcessing = useCallback(() => {
if (abortRef.current) { if (abortRef.current) {
@ -185,8 +178,6 @@ export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
return { return {
messages, messages,
selectedFileIds,
toggleFileSelection,
pendingEdits, pendingEdits,
acceptEdit, acceptEdit,
rejectEdit, rejectEdit,
@ -204,6 +195,22 @@ function _loadFiles(instanceId: string, setFiles: React.Dispatch<React.SetStateA
.catch(err => console.error('Failed to load files:', err)); .catch(err => console.error('Failed to load files:', err));
} }
function _extractFileRefs(prompt: string, files: FileInfo[]): string[] {
const atPattern = /@([\w.\-]+)/g;
const matchedIds: string[] = [];
let match;
while ((match = atPattern.exec(prompt)) !== null) {
const refName = match[1];
const file = files.find(f => f.fileName === refName || f.fileName.toLowerCase() === refName.toLowerCase());
if (file && !matchedIds.includes(file.fileId)) {
matchedIds.push(file.fileId);
}
}
return matchedIds;
}
function _handleSseEvent( function _handleSseEvent(
event: any, event: any,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>, setMessages: React.Dispatch<React.SetStateAction<Message[]>>,