Phase 7: Collaborative Editing (Manual output editing in review modal)
Browse files
backend/routers/agent_runner.py
CHANGED
|
@@ -341,6 +341,36 @@ async def run_task(task_id: str, background_tasks: BackgroundTasks, use_queue: b
|
|
| 341 |
|
| 342 |
return {"message": "Task execution started", "task_id": task_id}
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
@router.post("/{task_id}/approve")
|
| 345 |
async def approve_task(task_id: str, background_tasks: BackgroundTasks):
|
| 346 |
task_res = supabase.table("tasks").select("*").eq("id", task_id).single().execute()
|
|
|
|
| 341 |
|
| 342 |
return {"message": "Task execution started", "task_id": task_id}
|
| 343 |
|
| 344 |
+
@router.patch("/{task_id}/output")
|
| 345 |
+
async def update_task_output(task_id: str, payload: dict):
|
| 346 |
+
"""
|
| 347 |
+
Updates the output_data of a task. Allows for manual human corrections.
|
| 348 |
+
"""
|
| 349 |
+
if "output_data" not in payload:
|
| 350 |
+
raise HTTPException(status_code=400, detail="Missing output_data in payload")
|
| 351 |
+
|
| 352 |
+
# Verify task existence and project state
|
| 353 |
+
task_res = supabase.table("tasks").select("id, project_id").eq("id", task_id).single().execute()
|
| 354 |
+
if not task_res.data:
|
| 355 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 356 |
+
_assert_task_project_is_mutable(task_res.data)
|
| 357 |
+
|
| 358 |
+
result = supabase.table("tasks").update({
|
| 359 |
+
"output_data": payload["output_data"]
|
| 360 |
+
}).eq("id", task_id).execute()
|
| 361 |
+
|
| 362 |
+
if not result.data:
|
| 363 |
+
raise HTTPException(status_code=500, detail="Failed to update task output")
|
| 364 |
+
|
| 365 |
+
await audit_service.log_action(
|
| 366 |
+
user_id=None,
|
| 367 |
+
action="task_output_manually_edited",
|
| 368 |
+
task_id=task_id,
|
| 369 |
+
metadata={"project_id": task_res.data["project_id"]}
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
return {"message": "Task output updated", "task": result.data[0]}
|
| 373 |
+
|
| 374 |
@router.post("/{task_id}/approve")
|
| 375 |
async def approve_task(task_id: str, background_tasks: BackgroundTasks):
|
| 376 |
task_res = supabase.table("tasks").select("*").eq("id", task_id).single().execute()
|
frontend/src/components/ProjectDetail.tsx
CHANGED
|
@@ -94,6 +94,8 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, uiMode, initia
|
|
| 94 |
const [reportLoading, setReportLoading] = useState(false);
|
| 95 |
const [pdfLoading, setPdfLoading] = useState(false);
|
| 96 |
const [activeTab, setActiveTab] = useState<'tasks' | 'evidence'>('tasks');
|
|
|
|
|
|
|
| 97 |
const defaultProvider = getDefaultProvider();
|
| 98 |
const defaultModel = getDefaultModel(defaultProvider);
|
| 99 |
|
|
@@ -653,6 +655,31 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, uiMode, initia
|
|
| 653 |
}
|
| 654 |
};
|
| 655 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
const downloadFinalReportPdf = async () => {
|
| 657 |
setPdfLoading(true);
|
| 658 |
setError(null);
|
|
@@ -1083,8 +1110,41 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, uiMode, initia
|
|
| 1083 |
<div className="modal-overlay" onClick={() => setSelectedTask(null)}>
|
| 1084 |
<div className="glass-panel modal-content task-review-modal" onClick={(e) => e.stopPropagation()}>
|
| 1085 |
<h3>Review: {selectedTask.title}</h3>
|
| 1086 |
-
<div className="task-output-preview">
|
| 1087 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
</div>
|
| 1089 |
{taskActionError && <div className="inline-status modal-error">{taskActionError}</div>}
|
| 1090 |
<div className="button-row modal-actions">
|
|
@@ -1101,6 +1161,10 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, uiMode, initia
|
|
| 1101 |
This task has a saved execution error and needs to be retried.
|
| 1102 |
</div>
|
| 1103 |
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
) : selectedTask.status === 'awaiting_approval' ? (
|
| 1105 |
<>
|
| 1106 |
<button className="btn btn-primary" onClick={() => approveTask(selectedTask.id)} disabled={taskActionPending}>
|
|
@@ -1115,7 +1179,10 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, uiMode, initia
|
|
| 1115 |
This task is completed and approved.
|
| 1116 |
</div>
|
| 1117 |
)}
|
| 1118 |
-
<button className="btn btn-glass" onClick={() =>
|
|
|
|
|
|
|
|
|
|
| 1119 |
Close
|
| 1120 |
</button>
|
| 1121 |
</div>
|
|
|
|
| 94 |
const [reportLoading, setReportLoading] = useState(false);
|
| 95 |
const [pdfLoading, setPdfLoading] = useState(false);
|
| 96 |
const [activeTab, setActiveTab] = useState<'tasks' | 'evidence'>('tasks');
|
| 97 |
+
const [isEditingOutput, setIsEditingOutput] = useState(false);
|
| 98 |
+
const [editedOutput, setEditedOutput] = useState('');
|
| 99 |
const defaultProvider = getDefaultProvider();
|
| 100 |
const defaultModel = getDefaultModel(defaultProvider);
|
| 101 |
|
|
|
|
| 655 |
}
|
| 656 |
};
|
| 657 |
|
| 658 |
+
const saveEditedOutput = async () => {
|
| 659 |
+
if (!selectedTask || !canModifyProject) return;
|
| 660 |
+
setTaskActionPending(true);
|
| 661 |
+
setTaskActionError(null);
|
| 662 |
+
try {
|
| 663 |
+
const apiUrl = getApiUrl();
|
| 664 |
+
const response = await fetch(`${apiUrl}/tasks/${selectedTask.id}/output`, {
|
| 665 |
+
method: 'PATCH',
|
| 666 |
+
headers: { 'Content-Type': 'application/json' },
|
| 667 |
+
body: JSON.stringify({ output_data: editedOutput })
|
| 668 |
+
});
|
| 669 |
+
await ensureBackendOk(response);
|
| 670 |
+
|
| 671 |
+
const updatedTask = { ...selectedTask, output_data: editedOutput };
|
| 672 |
+
setSelectedTask(updatedTask);
|
| 673 |
+
setTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t));
|
| 674 |
+
setIsEditingOutput(false);
|
| 675 |
+
setMessage('Task output updated manually.');
|
| 676 |
+
} catch (exc) {
|
| 677 |
+
setTaskActionError(exc instanceof Error ? exc.message : 'Failed to update output.');
|
| 678 |
+
} finally {
|
| 679 |
+
setTaskActionPending(false);
|
| 680 |
+
}
|
| 681 |
+
};
|
| 682 |
+
|
| 683 |
const downloadFinalReportPdf = async () => {
|
| 684 |
setPdfLoading(true);
|
| 685 |
setError(null);
|
|
|
|
| 1110 |
<div className="modal-overlay" onClick={() => setSelectedTask(null)}>
|
| 1111 |
<div className="glass-panel modal-content task-review-modal" onClick={(e) => e.stopPropagation()}>
|
| 1112 |
<h3>Review: {selectedTask.title}</h3>
|
| 1113 |
+
<div className="task-output-preview" style={{ position: 'relative' }}>
|
| 1114 |
+
{isEditingOutput ? (
|
| 1115 |
+
<textarea
|
| 1116 |
+
className="edit-output-textarea"
|
| 1117 |
+
value={editedOutput}
|
| 1118 |
+
onChange={(e) => setEditedOutput(e.target.value)}
|
| 1119 |
+
style={{
|
| 1120 |
+
width: '100%',
|
| 1121 |
+
height: '400px',
|
| 1122 |
+
background: 'rgba(0,0,0,0.2)',
|
| 1123 |
+
color: 'var(--text-main)',
|
| 1124 |
+
border: '1px solid var(--accent)',
|
| 1125 |
+
borderRadius: '8px',
|
| 1126 |
+
padding: 'var(--space-md)',
|
| 1127 |
+
fontFamily: 'monospace',
|
| 1128 |
+
fontSize: '0.9rem'
|
| 1129 |
+
}}
|
| 1130 |
+
/>
|
| 1131 |
+
) : (
|
| 1132 |
+
<pre>{formatTaskOutput(selectedTask.output_data)}</pre>
|
| 1133 |
+
)}
|
| 1134 |
+
|
| 1135 |
+
{canModifyProject && !hasTaskErrorOutput(selectedTask) && selectedTask.status !== 'done' && (
|
| 1136 |
+
<button
|
| 1137 |
+
className="btn btn-glass btn-sm"
|
| 1138 |
+
onClick={() => {
|
| 1139 |
+
if (!isEditingOutput) setEditedOutput(formatTaskOutput(selectedTask.output_data));
|
| 1140 |
+
setIsEditingOutput(!isEditingOutput);
|
| 1141 |
+
}}
|
| 1142 |
+
style={{ position: 'absolute', top: '-40px', right: '0' }}
|
| 1143 |
+
>
|
| 1144 |
+
<FilePenLine size={14} />
|
| 1145 |
+
{isEditingOutput ? 'Cancel Editing' : 'Edit Output Manually'}
|
| 1146 |
+
</button>
|
| 1147 |
+
)}
|
| 1148 |
</div>
|
| 1149 |
{taskActionError && <div className="inline-status modal-error">{taskActionError}</div>}
|
| 1150 |
<div className="button-row modal-actions">
|
|
|
|
| 1161 |
This task has a saved execution error and needs to be retried.
|
| 1162 |
</div>
|
| 1163 |
</>
|
| 1164 |
+
) : isEditingOutput ? (
|
| 1165 |
+
<button className="btn btn-primary" onClick={saveEditedOutput} disabled={taskActionPending}>
|
| 1166 |
+
{taskActionPending ? 'Saving...' : 'Save Changes'}
|
| 1167 |
+
</button>
|
| 1168 |
) : selectedTask.status === 'awaiting_approval' ? (
|
| 1169 |
<>
|
| 1170 |
<button className="btn btn-primary" onClick={() => approveTask(selectedTask.id)} disabled={taskActionPending}>
|
|
|
|
| 1179 |
This task is completed and approved.
|
| 1180 |
</div>
|
| 1181 |
)}
|
| 1182 |
+
<button className="btn btn-glass" onClick={() => {
|
| 1183 |
+
setSelectedTask(null);
|
| 1184 |
+
setIsEditingOutput(false);
|
| 1185 |
+
}} disabled={taskActionPending}>
|
| 1186 |
Close
|
| 1187 |
</button>
|
| 1188 |
</div>
|