Merge pull request #11 from valueonag/feat/cursor-style-feature
Feat/cursor style feature
This commit is contained in:
commit
8c28ef791c
12 changed files with 1299 additions and 1 deletions
|
|
@ -164,6 +164,9 @@ function App() {
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
|
{/* Code Editor Feature Views */}
|
||||||
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.chatplayground': <FaPlay />,
|
'feature.chatplayground': <FaPlay />,
|
||||||
|
'feature.codeeditor': <FaFileAlt />,
|
||||||
'feature.automation': <FaCogs />,
|
'feature.automation': <FaCogs />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import { PlaygroundPage, WorkflowsPage } from './workflows';
|
||||||
// Automation Views (reusing existing workflow pages)
|
// Automation Views (reusing existing workflow pages)
|
||||||
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
|
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
|
||||||
|
|
||||||
|
// CodeEditor Views
|
||||||
|
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
|
||||||
|
|
||||||
// Teamsbot Views
|
// Teamsbot Views
|
||||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||||
|
|
@ -123,6 +126,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
templates: AutomationTemplatesPage,
|
templates: AutomationTemplatesPage,
|
||||||
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
|
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
|
||||||
},
|
},
|
||||||
|
codeeditor: {
|
||||||
|
editor: CodeEditorPage,
|
||||||
|
workflows: CodeEditorWorkflowsPage,
|
||||||
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
dashboard: TeamsbotDashboardView,
|
dashboard: TeamsbotDashboardView,
|
||||||
sessions: TeamsbotSessionView,
|
sessions: TeamsbotSessionView,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaFileAlt, FaHeadset } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature } from '../api/storeApi';
|
||||||
|
|
@ -16,6 +16,7 @@ import styles from './Store.module.css';
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
chatplayground: <FaComments />,
|
chatplayground: <FaComments />,
|
||||||
|
codeeditor: <FaFileAlt />,
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -30,6 +31,11 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
||||||
en: 'Test and experiment with AI chat models in an interactive environment.',
|
en: 'Test and experiment with AI chat models in an interactive environment.',
|
||||||
fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.',
|
fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.',
|
||||||
},
|
},
|
||||||
|
codeeditor: {
|
||||||
|
de: 'AI-gestuetzter Editor fuer Text-Dateien mit Cursor-artigem Chat und Diff-Preview.',
|
||||||
|
en: 'AI-powered editor for text files with Cursor-style chat and diff preview.',
|
||||||
|
fr: 'Editeur de fichiers texte assiste par IA avec chat et apercu des modifications.',
|
||||||
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||||
|
|
|
||||||
496
src/pages/views/codeeditor/CodeEditor.module.css
Normal file
496
src/pages/views/codeeditor/CodeEditor.module.css
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
/* CodeEditor Feature Styles */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panels {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filePanel,
|
||||||
|
.chatPanel,
|
||||||
|
.diffPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainArea {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: var(--border-color, #e0e0e0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider:hover {
|
||||||
|
background: var(--primary-color, #4a90d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging .divider {
|
||||||
|
background: var(--primary-color, #4a90d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File List Panel */
|
||||||
|
.fileList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItems {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem:hover {
|
||||||
|
background: var(--hover-bg, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
color: var(--text-secondary, #ccc);
|
||||||
|
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 {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileSize {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Panel */
|
||||||
|
.messagesArea {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputArea {
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--primary-color, #4a90d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
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 */
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButton,
|
||||||
|
.stopButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButton {
|
||||||
|
background: var(--primary-color, #4a90d9);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stopButton {
|
||||||
|
background: var(--danger-color, #dc3545);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diff Preview Panel */
|
||||||
|
.diffPreview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffItems {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard {
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard_pending {
|
||||||
|
border-color: var(--warning-color, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard_accepted {
|
||||||
|
border-color: var(--success-color, #28a745);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard_rejected {
|
||||||
|
border-color: var(--danger-color, #dc3545);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-bg, #f8f9fa);
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffFileName {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffStatus_pending {
|
||||||
|
background: var(--warning-light, #fff3cd);
|
||||||
|
color: var(--warning-dark, #856404);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffStatus_accepted {
|
||||||
|
background: var(--success-light, #d4edda);
|
||||||
|
color: var(--success-dark, #155724);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffStatus_rejected {
|
||||||
|
background: var(--danger-light, #f8d7da);
|
||||||
|
color: var(--danger-dark, #721c24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffContent {
|
||||||
|
padding: 8px 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffOld,
|
||||||
|
.diffNew {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffOld pre,
|
||||||
|
.diffNew pre {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffOld pre {
|
||||||
|
background: var(--danger-light, #fff0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffNew pre {
|
||||||
|
background: var(--success-light, #f0fff0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acceptButton,
|
||||||
|
.rejectButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acceptButton {
|
||||||
|
background: var(--success-color, #28a745);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejectButton {
|
||||||
|
background: var(--danger-color, #dc3545);
|
||||||
|
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; }
|
||||||
226
src/pages/views/codeeditor/CodeEditorPage.tsx
Normal file
226
src/pages/views/codeeditor/CodeEditorPage.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
src/pages/views/codeeditor/CodeEditorWorkflowsPage.tsx
Normal file
83
src/pages/views/codeeditor/CodeEditorWorkflowsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
95
src/pages/views/codeeditor/DiffPreviewPanel.tsx
Normal file
95
src/pages/views/codeeditor/DiffPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* DiffPreviewPanel
|
||||||
|
*
|
||||||
|
* Shows file edit proposals as side-by-side text diffs.
|
||||||
|
* Each edit has Accept/Reject buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaCheck, FaTimes } from 'react-icons/fa';
|
||||||
|
import styles from './CodeEditor.module.css';
|
||||||
|
|
||||||
|
export interface FileEditProposal {
|
||||||
|
id: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
oldContent: string | null;
|
||||||
|
newContent: string;
|
||||||
|
status: 'pending' | 'accepted' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffPreviewPanelProps {
|
||||||
|
edits: FileEditProposal[];
|
||||||
|
onAccept: (editId: string) => void;
|
||||||
|
onReject: (editId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiffPreviewPanel: React.FC<DiffPreviewPanelProps> = ({ edits, onAccept, onReject }) => {
|
||||||
|
const pendingEdits = edits.filter(e => e.status === 'pending');
|
||||||
|
const resolvedEdits = edits.filter(e => e.status !== 'pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.diffPreview}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<h3>Changes ({pendingEdits.length} pending)</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.diffItems}>
|
||||||
|
{edits.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>No changes proposed yet</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{pendingEdits.map((edit) => (
|
||||||
|
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
|
||||||
|
))}
|
||||||
|
{resolvedEdits.map((edit) => (
|
||||||
|
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiffCard: React.FC<{
|
||||||
|
edit: FileEditProposal;
|
||||||
|
onAccept: (id: string) => void;
|
||||||
|
onReject: (id: string) => void;
|
||||||
|
}> = ({ edit, onAccept, onReject }) => {
|
||||||
|
const isPending = edit.status === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.diffCard} ${styles[`diffCard_${edit.status}`]}`}>
|
||||||
|
<div className={styles.diffCardHeader}>
|
||||||
|
<span className={styles.diffFileName}>{edit.fileName}</span>
|
||||||
|
<span className={`${styles.diffStatus} ${styles[`diffStatus_${edit.status}`]}`}>
|
||||||
|
{edit.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.diffContent}>
|
||||||
|
{edit.oldContent && (
|
||||||
|
<div className={styles.diffOld}>
|
||||||
|
<div className={styles.diffLabel}>Old</div>
|
||||||
|
<pre>{edit.oldContent}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.diffNew}>
|
||||||
|
<div className={styles.diffLabel}>New</div>
|
||||||
|
<pre>{edit.newContent}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className={styles.diffActions}>
|
||||||
|
<button className={styles.acceptButton} onClick={() => onAccept(edit.id)}>
|
||||||
|
<FaCheck /> Accept
|
||||||
|
</button>
|
||||||
|
<button className={styles.rejectButton} onClick={() => onReject(edit.id)}>
|
||||||
|
<FaTimes /> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
src/pages/views/codeeditor/FileListPanel.tsx
Normal file
110
src/pages/views/codeeditor/FileListPanel.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* FileListPanel
|
||||||
|
*
|
||||||
|
* Lists text files grouped by date, draggable into the prompt textarea.
|
||||||
|
* Drag a file into the chat input to insert an @fileName reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FaFile, FaGripVertical } from 'react-icons/fa';
|
||||||
|
import styles from './CodeEditor.module.css';
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileListPanelProps {
|
||||||
|
files: FileInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.fileList}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<h3>Files ({files.length})</h3>
|
||||||
|
<span className={styles.dragHint}>drag into prompt</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileItems}>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>No text files uploaded yet</div>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<div key={group.label} className={styles.dateGroup}>
|
||||||
|
<div className={styles.dateGroupHeader}>{group.label}</div>
|
||||||
|
{group.files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.fileId}
|
||||||
|
className={styles.fileItem}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, file)}
|
||||||
|
>
|
||||||
|
<FaGripVertical className={styles.dragHandle} />
|
||||||
|
<FaFile className={styles.fileIcon} />
|
||||||
|
<span className={styles.fileName}>{file.fileName}</span>
|
||||||
|
<span className={styles.fileSize}>{_formatSize(file.sizeBytes)}</span>
|
||||||
|
</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 {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
2
src/pages/views/codeeditor/index.ts
Normal file
2
src/pages/views/codeeditor/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { CodeEditorPage } from './CodeEditorPage';
|
||||||
|
export { CodeEditorWorkflowsPage } from './CodeEditorWorkflowsPage';
|
||||||
260
src/pages/views/codeeditor/useCodeEditor.ts
Normal file
260
src/pages/views/codeeditor/useCodeEditor.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/**
|
||||||
|
* useCodeEditor Hook
|
||||||
|
*
|
||||||
|
* Manages SSE connection, message state, edit proposals, and agent progress.
|
||||||
|
* File references are extracted from @fileName tags in the prompt text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../../../utils/csrfUtils';
|
||||||
|
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
import type { FileInfo } from './FileListPanel';
|
||||||
|
import type { FileEditProposal } from './DiffPreviewPanel';
|
||||||
|
|
||||||
|
export interface AgentProgress {
|
||||||
|
round: number;
|
||||||
|
totalAiCalls: number;
|
||||||
|
totalToolCalls: number;
|
||||||
|
costCHF: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCodeEditorReturn {
|
||||||
|
messages: Message[];
|
||||||
|
pendingEdits: FileEditProposal[];
|
||||||
|
acceptEdit: (editId: string) => void;
|
||||||
|
rejectEdit: (editId: string) => void;
|
||||||
|
isProcessing: boolean;
|
||||||
|
sendMessage: (prompt: string, mode?: 'simple' | 'agent') => void;
|
||||||
|
stopProcessing: () => void;
|
||||||
|
files: FileInfo[];
|
||||||
|
agentProgress: AgentProgress | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||||
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||||
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
_loadFiles(instanceId, setFiles);
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((prompt: string, mode: 'simple' | 'agent' = 'simple') => {
|
||||||
|
if (!instanceId || isProcessing) return;
|
||||||
|
|
||||||
|
const referencedFileIds = _extractFileRefs(prompt, files);
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setAgentProgress(null);
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
workflowId: workflowId || '',
|
||||||
|
role: 'user',
|
||||||
|
message: prompt,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (workflowId) params.set('workflowId', workflowId);
|
||||||
|
params.set('mode', mode);
|
||||||
|
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
if (!getCSRFToken()) {
|
||||||
|
generateAndStoreCSRFToken();
|
||||||
|
}
|
||||||
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt,
|
||||||
|
listFileId: referencedFileIds,
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(jsonStr);
|
||||||
|
_handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId, setAgentProgress);
|
||||||
|
|
||||||
|
if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unparseable lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsProcessing(false);
|
||||||
|
}).catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
console.error('CodeEditor SSE error:', err);
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'system',
|
||||||
|
message: `Connection error: ${err.message}`,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
}]);
|
||||||
|
setIsProcessing(false);
|
||||||
|
});
|
||||||
|
}, [instanceId, isProcessing, workflowId, files]);
|
||||||
|
|
||||||
|
const stopProcessing = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
}
|
||||||
|
if (!instanceId || !workflowId) return;
|
||||||
|
api.post(`/api/codeeditor/${instanceId}/${workflowId}/stop`).catch(console.error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}, [instanceId, workflowId]);
|
||||||
|
|
||||||
|
const acceptEdit = useCallback((editId: string) => {
|
||||||
|
const edit = pendingEdits.find(e => e.id === editId);
|
||||||
|
if (!edit || !instanceId || !workflowId) return;
|
||||||
|
|
||||||
|
api.post(`/api/codeeditor/${instanceId}/${workflowId}/apply`, {
|
||||||
|
fileId: edit.fileId,
|
||||||
|
fileName: edit.fileName,
|
||||||
|
newContent: edit.newContent,
|
||||||
|
}).then(() => {
|
||||||
|
setPendingEdits(prev => prev.map(e =>
|
||||||
|
e.id === editId ? { ...e, status: 'accepted' as const } : e
|
||||||
|
));
|
||||||
|
_loadFiles(instanceId, setFiles);
|
||||||
|
}).catch(console.error);
|
||||||
|
}, [pendingEdits, instanceId, workflowId]);
|
||||||
|
|
||||||
|
const rejectEdit = useCallback((editId: string) => {
|
||||||
|
setPendingEdits(prev => prev.map(e =>
|
||||||
|
e.id === editId ? { ...e, status: 'rejected' as const } : e
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
pendingEdits,
|
||||||
|
acceptEdit,
|
||||||
|
rejectEdit,
|
||||||
|
isProcessing,
|
||||||
|
sendMessage,
|
||||||
|
stopProcessing,
|
||||||
|
files,
|
||||||
|
agentProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadFiles(instanceId: string, setFiles: React.Dispatch<React.SetStateAction<FileInfo[]>>) {
|
||||||
|
api.get(`/api/codeeditor/${instanceId}/files`)
|
||||||
|
.then(res => setFiles(res.data.files || []))
|
||||||
|
.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(
|
||||||
|
event: any,
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||||
|
setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>,
|
||||||
|
setWorkflowId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
setAgentProgress: React.Dispatch<React.SetStateAction<AgentProgress | null>>
|
||||||
|
) {
|
||||||
|
if (event.type === 'message' && event.item) {
|
||||||
|
const item = event.item;
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: item.id || `msg-${Date.now()}-${Math.random()}`,
|
||||||
|
workflowId: item.workflowId || '',
|
||||||
|
role: item.role || 'assistant',
|
||||||
|
message: item.content || '',
|
||||||
|
publishedAt: item.createdAt || Date.now() / 1000,
|
||||||
|
documents: item.documents,
|
||||||
|
}]);
|
||||||
|
} else if (event.type === 'file_edit_proposal' && event.item) {
|
||||||
|
setPendingEdits(prev => [...prev, event.item]);
|
||||||
|
} else if (event.type === 'status') {
|
||||||
|
setMessages(prev => {
|
||||||
|
const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status';
|
||||||
|
const statusMsg: Message = {
|
||||||
|
id: `status-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'status',
|
||||||
|
message: event.label || '',
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
setWorkflowId(event.workflowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -250,6 +250,15 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
codeeditor: {
|
||||||
|
code: 'codeeditor',
|
||||||
|
label: { de: 'Code Editor', en: 'Code Editor' },
|
||||||
|
icon: 'description',
|
||||||
|
views: [
|
||||||
|
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
|
||||||
|
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||||
|
]
|
||||||
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
code: 'teamsbot',
|
code: 'teamsbot',
|
||||||
label: { de: 'Teams Bot', en: 'Teams Bot' },
|
label: { de: 'Teams Bot', en: 'Teams Bot' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue