Improve performance of conversation (#692)
* Stream endpoint for execution processes (vibe-kanban c5144da6)
I want an endpoint that's similar to task stream in crates/server/src/routes/tasks.rs but contains execution processes.
The structure of the document should be:
```json
{
"execution_processes": {
[EXECUTION_PROCESS_ID]: {
... execution process fields
}
}
}
```
The endpoint should be at `/api/execution_processes/stream?task_attempt_id=...`
crates/server/src/routes/execution_processes.rs
* add virtualizedlist component
* WIP remove execution processes
* rebase syntax fix
* tmp fix lint
* lint
* VirtuosoMessageList
* cache
* event based hook
* historic
* handle failed historic
* running processes
* user message
* loading
* cleanup
* render user message
* style
* fmt
* better indication for setup/cleanup scripts
* fix ref issue
* virtuoso license
* fmt
* update loader
* loading
* fmt
* loading improvements
* copy as markdown styles
* spacing improvement
* flush all historic at once
* padding fix
* markdown copy sticky
* make user message editable
* edit message
* reset
* cleanup
* hook order
* remove dead code
This commit is contained in:
committed by
GitHub
parent
bb410a14b2
commit
15dddacfe2
2
.github/workflows/pre-release.yml
vendored
2
.github/workflows/pre-release.yml
vendored
@@ -115,6 +115,8 @@ jobs:
|
|||||||
build-frontend:
|
build-frontend:
|
||||||
needs: bump-version
|
needs: bump-version
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY: ${{ secrets.PUBLIC_REACT_VIRTUOSO_LICENSE_KEY }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub enum NormalizedEntryType {
|
|||||||
SystemMessage,
|
SystemMessage,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
Thinking,
|
Thinking,
|
||||||
|
Loading,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
BoxError, Extension, Router,
|
BoxError, Extension, Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
middleware::from_fn_with_state,
|
middleware::from_fn_with_state,
|
||||||
response::{
|
response::{
|
||||||
Json as ResponseJson, Sse,
|
Json as ResponseJson, Sse,
|
||||||
@@ -10,7 +11,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use db::models::execution_process::ExecutionProcess;
|
use db::models::execution_process::ExecutionProcess;
|
||||||
use deployment::Deployment;
|
use deployment::Deployment;
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::{Stream, TryStreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use services::services::container::ContainerService;
|
use services::services::container::ContainerService;
|
||||||
use utils::response::ApiResponse;
|
use utils::response::ApiResponse;
|
||||||
@@ -83,6 +84,19 @@ pub async fn stop_execution_process(
|
|||||||
Ok(ResponseJson(ApiResponse::success(())))
|
Ok(ResponseJson(ApiResponse::success(())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn stream_execution_processes(
|
||||||
|
State(deployment): State<DeploymentImpl>,
|
||||||
|
Query(query): Query<ExecutionProcessQuery>,
|
||||||
|
) -> Result<Sse<impl Stream<Item = Result<Event, BoxError>>>, StatusCode> {
|
||||||
|
let stream = deployment
|
||||||
|
.events()
|
||||||
|
.stream_execution_processes_for_attempt(query.task_attempt_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Sse::new(stream.map_err(|e| -> BoxError { e.into() })).keep_alive(KeepAlive::default()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||||
let task_attempt_id_router = Router::new()
|
let task_attempt_id_router = Router::new()
|
||||||
.route("/", get(get_execution_process_by_id))
|
.route("/", get(get_execution_process_by_id))
|
||||||
@@ -96,6 +110,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
|||||||
|
|
||||||
let task_attempts_router = Router::new()
|
let task_attempts_router = Router::new()
|
||||||
.route("/", get(get_execution_processes))
|
.route("/", get(get_execution_processes))
|
||||||
|
.route("/stream", get(stream_execution_processes))
|
||||||
.nest("/{id}", task_attempt_id_router);
|
.nest("/{id}", task_attempt_id_router);
|
||||||
|
|
||||||
Router::new().nest("/execution-processes", task_attempts_router)
|
Router::new().nest("/execution-processes", task_attempts_router)
|
||||||
|
|||||||
@@ -77,6 +77,57 @@ pub mod task_patch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper functions for creating execution process-specific patches
|
||||||
|
pub mod execution_process_patch {
|
||||||
|
use db::models::execution_process::ExecutionProcess;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Escape JSON Pointer special characters
|
||||||
|
fn escape_pointer_segment(s: &str) -> String {
|
||||||
|
s.replace('~', "~0").replace('/', "~1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create path for execution process operation
|
||||||
|
fn execution_process_path(process_id: Uuid) -> String {
|
||||||
|
format!(
|
||||||
|
"/execution_processes/{}",
|
||||||
|
escape_pointer_segment(&process_id.to_string())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create patch for adding a new execution process
|
||||||
|
pub fn add(process: &ExecutionProcess) -> Patch {
|
||||||
|
Patch(vec![PatchOperation::Add(AddOperation {
|
||||||
|
path: execution_process_path(process.id)
|
||||||
|
.try_into()
|
||||||
|
.expect("Execution process path should be valid"),
|
||||||
|
value: serde_json::to_value(process)
|
||||||
|
.expect("Execution process serialization should not fail"),
|
||||||
|
})])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create patch for updating an existing execution process
|
||||||
|
pub fn replace(process: &ExecutionProcess) -> Patch {
|
||||||
|
Patch(vec![PatchOperation::Replace(ReplaceOperation {
|
||||||
|
path: execution_process_path(process.id)
|
||||||
|
.try_into()
|
||||||
|
.expect("Execution process path should be valid"),
|
||||||
|
value: serde_json::to_value(process)
|
||||||
|
.expect("Execution process serialization should not fail"),
|
||||||
|
})])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create patch for removing an execution process
|
||||||
|
pub fn remove(process_id: Uuid) -> Patch {
|
||||||
|
Patch(vec![PatchOperation::Remove(RemoveOperation {
|
||||||
|
path: execution_process_path(process_id)
|
||||||
|
.try_into()
|
||||||
|
.expect("Execution process path should be valid"),
|
||||||
|
})])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct EventService {
|
pub struct EventService {
|
||||||
msg_store: Arc<MsgStore>,
|
msg_store: Arc<MsgStore>,
|
||||||
@@ -116,6 +167,7 @@ pub enum RecordTypes {
|
|||||||
DeletedExecutionProcess {
|
DeletedExecutionProcess {
|
||||||
rowid: i64,
|
rowid: i64,
|
||||||
task_attempt_id: Option<Uuid>,
|
task_attempt_id: Option<Uuid>,
|
||||||
|
process_id: Option<Uuid>,
|
||||||
},
|
},
|
||||||
DeletedFollowUpDraft {
|
DeletedFollowUpDraft {
|
||||||
rowid: i64,
|
rowid: i64,
|
||||||
@@ -196,16 +248,21 @@ impl EventService {
|
|||||||
RecordTypes::DeletedTaskAttempt { rowid, task_id }
|
RecordTypes::DeletedTaskAttempt { rowid, task_id }
|
||||||
}
|
}
|
||||||
(HookTables::ExecutionProcesses, SqliteOperation::Delete) => {
|
(HookTables::ExecutionProcesses, SqliteOperation::Delete) => {
|
||||||
// Try to get execution_process before deletion to capture task_attempt_id
|
// Try to get execution_process before deletion to capture full process data
|
||||||
let task_attempt_id =
|
if let Ok(Some(process)) =
|
||||||
ExecutionProcess::find_by_rowid(&db.pool, rowid)
|
ExecutionProcess::find_by_rowid(&db.pool, rowid).await
|
||||||
.await
|
{
|
||||||
.ok()
|
RecordTypes::DeletedExecutionProcess {
|
||||||
.flatten()
|
rowid,
|
||||||
.map(|process| process.task_attempt_id);
|
task_attempt_id: Some(process.task_attempt_id),
|
||||||
RecordTypes::DeletedExecutionProcess {
|
process_id: Some(process.id),
|
||||||
rowid,
|
}
|
||||||
task_attempt_id,
|
} else {
|
||||||
|
RecordTypes::DeletedExecutionProcess {
|
||||||
|
rowid,
|
||||||
|
task_attempt_id: None,
|
||||||
|
process_id: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(HookTables::Tasks, _) => {
|
(HookTables::Tasks, _) => {
|
||||||
@@ -244,6 +301,7 @@ impl EventService {
|
|||||||
Ok(None) => RecordTypes::DeletedExecutionProcess {
|
Ok(None) => RecordTypes::DeletedExecutionProcess {
|
||||||
rowid,
|
rowid,
|
||||||
task_attempt_id: None,
|
task_attempt_id: None,
|
||||||
|
process_id: None,
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -371,6 +429,27 @@ impl EventService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RecordTypes::ExecutionProcess(process) => {
|
||||||
|
let patch = match hook.operation {
|
||||||
|
SqliteOperation::Insert => {
|
||||||
|
execution_process_patch::add(process)
|
||||||
|
}
|
||||||
|
SqliteOperation::Update => {
|
||||||
|
execution_process_patch::replace(process)
|
||||||
|
}
|
||||||
|
_ => execution_process_patch::replace(process), // fallback
|
||||||
|
};
|
||||||
|
msg_store_for_hook.push_patch(patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RecordTypes::DeletedExecutionProcess {
|
||||||
|
process_id: Some(process_id),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let patch = execution_process_patch::remove(*process_id);
|
||||||
|
msg_store_for_hook.push_patch(patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,10 +628,22 @@ impl EventService {
|
|||||||
// Get initial snapshot of execution processes
|
// Get initial snapshot of execution processes
|
||||||
let processes =
|
let processes =
|
||||||
ExecutionProcess::find_by_task_attempt_id(&self.db.pool, task_attempt_id).await?;
|
ExecutionProcess::find_by_task_attempt_id(&self.db.pool, task_attempt_id).await?;
|
||||||
|
|
||||||
|
// Convert processes array to object keyed by process ID
|
||||||
|
let processes_map: serde_json::Map<String, serde_json::Value> = processes
|
||||||
|
.into_iter()
|
||||||
|
.map(|process| {
|
||||||
|
(
|
||||||
|
process.id.to_string(),
|
||||||
|
serde_json::to_value(process).unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let initial_patch = json!([{
|
let initial_patch = json!([{
|
||||||
"op": "replace",
|
"op": "replace",
|
||||||
"path": "/",
|
"path": "/execution_processes",
|
||||||
"value": { "execution_processes": processes }
|
"value": processes_map
|
||||||
}]);
|
}]);
|
||||||
let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap());
|
let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap());
|
||||||
|
|
||||||
@@ -562,26 +653,61 @@ impl EventService {
|
|||||||
match msg_result {
|
match msg_result {
|
||||||
Ok(LogMsg::JsonPatch(patch)) => {
|
Ok(LogMsg::JsonPatch(patch)) => {
|
||||||
// Filter events based on task_attempt_id
|
// Filter events based on task_attempt_id
|
||||||
if let Some(event_patch_op) = patch.0.first()
|
if let Some(patch_op) = patch.0.first() {
|
||||||
&& let Ok(event_patch_value) = serde_json::to_value(event_patch_op)
|
// Check if this is a modern execution process patch
|
||||||
&& let Ok(event_patch) =
|
if patch_op.path().starts_with("/execution_processes/") {
|
||||||
serde_json::from_value::<EventPatch>(event_patch_value)
|
match patch_op {
|
||||||
{
|
json_patch::PatchOperation::Add(op) => {
|
||||||
match &event_patch.value.record {
|
// Parse execution process data directly from value
|
||||||
RecordTypes::ExecutionProcess(process) => {
|
if let Ok(process) =
|
||||||
if process.task_attempt_id == task_attempt_id {
|
serde_json::from_value::<ExecutionProcess>(
|
||||||
|
op.value.clone(),
|
||||||
|
)
|
||||||
|
&& process.task_attempt_id == task_attempt_id
|
||||||
|
{
|
||||||
|
return Some(Ok(LogMsg::JsonPatch(patch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json_patch::PatchOperation::Replace(op) => {
|
||||||
|
// Parse execution process data directly from value
|
||||||
|
if let Ok(process) =
|
||||||
|
serde_json::from_value::<ExecutionProcess>(
|
||||||
|
op.value.clone(),
|
||||||
|
)
|
||||||
|
&& process.task_attempt_id == task_attempt_id
|
||||||
|
{
|
||||||
|
return Some(Ok(LogMsg::JsonPatch(patch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json_patch::PatchOperation::Remove(_) => {
|
||||||
|
// For remove operations, we can't verify task_attempt_id
|
||||||
|
// so we allow all removals and let the client handle filtering
|
||||||
return Some(Ok(LogMsg::JsonPatch(patch)));
|
return Some(Ok(LogMsg::JsonPatch(patch)));
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
RecordTypes::DeletedExecutionProcess {
|
}
|
||||||
task_attempt_id: Some(deleted_attempt_id),
|
// Fallback to legacy EventPatch format for backward compatibility
|
||||||
..
|
else if let Ok(event_patch_value) = serde_json::to_value(patch_op)
|
||||||
} => {
|
&& let Ok(event_patch) =
|
||||||
if *deleted_attempt_id == task_attempt_id {
|
serde_json::from_value::<EventPatch>(event_patch_value)
|
||||||
return Some(Ok(LogMsg::JsonPatch(patch)));
|
{
|
||||||
|
match &event_patch.value.record {
|
||||||
|
RecordTypes::ExecutionProcess(process) => {
|
||||||
|
if process.task_attempt_id == task_attempt_id {
|
||||||
|
return Some(Ok(LogMsg::JsonPatch(patch)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
RecordTypes::DeletedExecutionProcess {
|
||||||
|
task_attempt_id: Some(deleted_attempt_id),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *deleted_attempt_id == task_attempt_id {
|
||||||
|
return Some(Ok(LogMsg::JsonPatch(patch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -39,12 +39,14 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.85.5",
|
"@tanstack/react-query-devtools": "^5.85.5",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@uiw/react-codemirror": "^4.25.1",
|
"@uiw/react-codemirror": "^4.25.1",
|
||||||
|
"@virtuoso.dev/message-list": "^1.13.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"click-to-react-component": "^1.1.2",
|
"click-to-react-component": "^1.1.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-diff-viewer-continued": "^3.4.0",
|
"react-diff-viewer-continued": "^3.4.0",
|
||||||
@@ -52,7 +54,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
"react-virtuoso": "^4.13.0",
|
"react-virtuoso": "^4.14.0",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"rfc6902": "^5.1.2",
|
"rfc6902": "^5.1.2",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
@@ -82,4 +84,4 @@
|
|||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx';
|
|||||||
import {
|
import {
|
||||||
ActionType,
|
ActionType,
|
||||||
NormalizedEntry,
|
NormalizedEntry,
|
||||||
|
TaskAttempt,
|
||||||
type NormalizedEntryType,
|
type NormalizedEntryType,
|
||||||
} from 'shared/types.ts';
|
} from 'shared/types.ts';
|
||||||
import type { ProcessStartPayload } from '@/types/logs';
|
import type { ProcessStartPayload } from '@/types/logs';
|
||||||
@@ -25,11 +26,14 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import RawLogText from '../common/RawLogText';
|
import RawLogText from '../common/RawLogText';
|
||||||
|
import UserMessage from './UserMessage';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entry: NormalizedEntry | ProcessStartPayload;
|
entry: NormalizedEntry | ProcessStartPayload;
|
||||||
expansionKey: string;
|
expansionKey: string;
|
||||||
diffDeletable?: boolean;
|
diffDeletable?: boolean;
|
||||||
|
executionProcessId?: string;
|
||||||
|
taskAttempt?: TaskAttempt;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FileEditAction = Extract<ActionType, { action: 'file_edit' }>;
|
type FileEditAction = Extract<ActionType, { action: 'file_edit' }>;
|
||||||
@@ -89,36 +93,51 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
|
|||||||
return <Settings className={iconSize} />;
|
return <Settings className={iconSize} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExitStatusVisualisation = 'success' | 'error' | 'pending';
|
||||||
|
|
||||||
const getStatusIndicator = (entryType: NormalizedEntryType) => {
|
const getStatusIndicator = (entryType: NormalizedEntryType) => {
|
||||||
const result =
|
let status_visualisation: ExitStatusVisualisation | null = null;
|
||||||
|
if (
|
||||||
entryType.type === 'tool_use' &&
|
entryType.type === 'tool_use' &&
|
||||||
entryType.action_type.action === 'command_run'
|
entryType.action_type.action === 'command_run'
|
||||||
? entryType.action_type.result?.exit_status
|
) {
|
||||||
: null;
|
status_visualisation = 'pending';
|
||||||
|
if (entryType.action_type.result?.exit_status?.type === 'success') {
|
||||||
|
if (entryType.action_type.result?.exit_status?.success) {
|
||||||
|
status_visualisation = 'success';
|
||||||
|
} else {
|
||||||
|
status_visualisation = 'error';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
entryType.action_type.result?.exit_status?.type === 'exit_code'
|
||||||
|
) {
|
||||||
|
if (entryType.action_type.result?.exit_status?.code === 0) {
|
||||||
|
status_visualisation = 'success';
|
||||||
|
} else {
|
||||||
|
status_visualisation = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const status =
|
// If pending, should be a pulsing primary-foreground
|
||||||
result?.type === 'success'
|
const colorMap: Record<ExitStatusVisualisation, string> = {
|
||||||
? result.success
|
|
||||||
? 'success'
|
|
||||||
: 'error'
|
|
||||||
: result?.type === 'exit_code'
|
|
||||||
? result.code === 0
|
|
||||||
? 'success'
|
|
||||||
: 'error'
|
|
||||||
: 'unknown';
|
|
||||||
|
|
||||||
if (status === 'unknown') return null;
|
|
||||||
|
|
||||||
const colorMap: Record<typeof status, string> = {
|
|
||||||
success: 'bg-green-300',
|
success: 'bg-green-300',
|
||||||
error: 'bg-red-300',
|
error: 'bg-red-300',
|
||||||
|
pending: 'bg-primary-foreground/50',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!status_visualisation) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={`${colorMap[status]} h-1.5 w-1.5 rounded-full absolute -left-1 -bottom-4`}
|
className={`${colorMap[status_visualisation]} h-1.5 w-1.5 rounded-full absolute -left-1 -bottom-4`}
|
||||||
/>
|
/>
|
||||||
|
{status_visualisation === 'pending' && (
|
||||||
|
<div
|
||||||
|
className={`${colorMap[status_visualisation]} h-1.5 w-1.5 rounded-full absolute -left-1 -bottom-4 animate-ping`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -463,11 +482,27 @@ const ToolCallCard: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LoadingCard = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex animate-pulse space-x-2 items-center">
|
||||||
|
<div className="size-3 bg-foreground/10"></div>
|
||||||
|
<div className="flex-1 h-3 bg-foreground/10"></div>
|
||||||
|
<div className="flex-1 h-3"></div>
|
||||||
|
<div className="flex-1 h-3"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/*******************
|
/*******************
|
||||||
* Main component *
|
* Main component *
|
||||||
*******************/
|
*******************/
|
||||||
|
|
||||||
function DisplayConversationEntry({ entry, expansionKey }: Props) {
|
function DisplayConversationEntry({
|
||||||
|
entry,
|
||||||
|
expansionKey,
|
||||||
|
executionProcessId,
|
||||||
|
taskAttempt,
|
||||||
|
}: Props) {
|
||||||
const isNormalizedEntry = (
|
const isNormalizedEntry = (
|
||||||
entry: NormalizedEntry | ProcessStartPayload
|
entry: NormalizedEntry | ProcessStartPayload
|
||||||
): entry is NormalizedEntry => 'entry_type' in entry;
|
): entry is NormalizedEntry => 'entry_type' in entry;
|
||||||
@@ -492,10 +527,23 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
|
|||||||
const isSystem = entryType.type === 'system_message';
|
const isSystem = entryType.type === 'system_message';
|
||||||
const isError = entryType.type === 'error_message';
|
const isError = entryType.type === 'error_message';
|
||||||
const isToolUse = entryType.type === 'tool_use';
|
const isToolUse = entryType.type === 'tool_use';
|
||||||
|
const isUserMessage = entryType.type === 'user_message';
|
||||||
|
const isLoading = entryType.type === 'loading';
|
||||||
const isFileEdit = (a: ActionType): a is FileEditAction =>
|
const isFileEdit = (a: ActionType): a is FileEditAction =>
|
||||||
a.action === 'file_edit';
|
a.action === 'file_edit';
|
||||||
|
|
||||||
|
if (isUserMessage) {
|
||||||
|
return (
|
||||||
|
<UserMessage
|
||||||
|
content={entry.content}
|
||||||
|
executionProcessId={executionProcessId}
|
||||||
|
taskAttempt={taskAttempt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="px-4 py-2 text-sm">
|
||||||
{isSystem || isError ? (
|
{isSystem || isError ? (
|
||||||
<CollapsibleEntry
|
<CollapsibleEntry
|
||||||
content={isNormalizedEntry(entry) ? entry.content : ''}
|
content={isNormalizedEntry(entry) ? entry.content : ''}
|
||||||
@@ -528,6 +576,8 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
|
|||||||
expansionKey={expansionKey}
|
expansionKey={expansionKey}
|
||||||
entryContent={isNormalizedEntry(entry) ? entry.content : ''}
|
entryContent={isNormalizedEntry(entry) ? entry.content : ''}
|
||||||
/>
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
|
<LoadingCard />
|
||||||
) : (
|
) : (
|
||||||
<div className={getContentClassName(entryType)}>
|
<div className={getContentClassName(entryType)}>
|
||||||
{shouldRenderMarkdown(entryType) ? (
|
{shouldRenderMarkdown(entryType) ? (
|
||||||
@@ -543,7 +593,7 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const renderJson = (v: JsonValue) => (
|
export const renderJson = (v: JsonValue) => (
|
||||||
<pre>{JSON.stringify(v, null, 2)}</pre>
|
<pre className="whitespace-pre-wrap">{JSON.stringify(v, null, 2)}</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function ToolDetails({
|
export default function ToolDetails({
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import MarkdownRenderer from '@/components/ui/markdown-renderer';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Pencil, Send, X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useProcessRetry } from '@/hooks/useProcessRetry';
|
||||||
|
import { TaskAttempt } from 'shared/types';
|
||||||
|
|
||||||
|
const UserMessage = ({
|
||||||
|
content,
|
||||||
|
executionProcessId,
|
||||||
|
taskAttempt,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
executionProcessId?: string;
|
||||||
|
taskAttempt?: TaskAttempt;
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editContent, setEditContent] = useState(content);
|
||||||
|
const retryHook = useProcessRetry(taskAttempt);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!executionProcessId) return;
|
||||||
|
retryHook?.retryProcess(executionProcessId, editContent).then(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="bg-background px-4 py-2 text-sm border-y border-dashed flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<Textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer
|
||||||
|
content={content}
|
||||||
|
className="whitespace-pre-wrap break-words flex flex-col gap-1 font-light py-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{executionProcessId && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsEditing(!isEditing)}
|
||||||
|
variant="ghost"
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isEditing && (
|
||||||
|
<Button onClick={handleEdit} variant="ghost" className="p-2">
|
||||||
|
<Send className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserMessage;
|
||||||
@@ -50,7 +50,6 @@ const OnboardingDialog = NiceModal.create(() => {
|
|||||||
const [customCommand, setCustomCommand] = useState<string>('');
|
const [customCommand, setCustomCommand] = useState<string>('');
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
console.log('DEBUG1');
|
|
||||||
modal.resolve({
|
modal.resolve({
|
||||||
profile,
|
profile,
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
|
|
||||||
import ProcessStartCard from '@/components/logs/ProcessStartCard';
|
|
||||||
import LogEntryRow from '@/components/logs/LogEntryRow';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
header: ProcessStartPayload;
|
|
||||||
entries: UnifiedLogEntry[];
|
|
||||||
isCollapsed: boolean;
|
|
||||||
onToggle: (processId: string) => void;
|
|
||||||
retry?: {
|
|
||||||
onRetry: (processId: string, newPrompt: string) => void;
|
|
||||||
retryProcessId?: string;
|
|
||||||
retryDisabled?: boolean;
|
|
||||||
retryDisabledReason?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProcessGroup({
|
|
||||||
header,
|
|
||||||
entries,
|
|
||||||
isCollapsed,
|
|
||||||
onToggle,
|
|
||||||
retry,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 mt-4">
|
|
||||||
<ProcessStartCard
|
|
||||||
payload={header}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onRetry={retry?.onRetry}
|
|
||||||
retryProcessId={retry?.retryProcessId}
|
|
||||||
retryDisabled={retry?.retryDisabled}
|
|
||||||
retryDisabledReason={retry?.retryDisabledReason}
|
|
||||||
/>
|
|
||||||
<div className="text-sm">
|
|
||||||
{!isCollapsed &&
|
|
||||||
entries.length > 0 &&
|
|
||||||
entries.map((entry, i) => (
|
|
||||||
<LogEntryRow key={entry.id} entry={entry} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { ChevronDown, SquarePen, X, Check } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { ProcessStartPayload } from '@/types/logs';
|
|
||||||
import type { ExecutorAction } from 'shared/types';
|
|
||||||
import { PROCESS_RUN_REASONS } from '@/constants/processes';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
|
|
||||||
|
|
||||||
interface ProcessStartCardProps {
|
|
||||||
payload: ProcessStartPayload;
|
|
||||||
isCollapsed: boolean;
|
|
||||||
onToggle: (processId: string) => void;
|
|
||||||
// Retry flow (replaces restore): edit prompt then retry
|
|
||||||
onRetry?: (processId: string, newPrompt: string) => void;
|
|
||||||
retryProcessId?: string; // explicit id if payload lacks it in future
|
|
||||||
retryDisabled?: boolean;
|
|
||||||
retryDisabledReason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractPromptFromAction = (
|
|
||||||
action?: ExecutorAction | null
|
|
||||||
): string | null => {
|
|
||||||
if (!action) return null;
|
|
||||||
const t = action.typ;
|
|
||||||
if (!t) return null;
|
|
||||||
if (
|
|
||||||
(t.type === 'CodingAgentInitialRequest' ||
|
|
||||||
t.type === 'CodingAgentFollowUpRequest') &&
|
|
||||||
typeof t.prompt === 'string' &&
|
|
||||||
t.prompt.trim()
|
|
||||||
) {
|
|
||||||
return t.prompt;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ProcessStartCard({
|
|
||||||
payload,
|
|
||||||
isCollapsed,
|
|
||||||
onToggle,
|
|
||||||
onRetry,
|
|
||||||
retryProcessId,
|
|
||||||
retryDisabled,
|
|
||||||
retryDisabledReason,
|
|
||||||
}: ProcessStartCardProps) {
|
|
||||||
const getProcessLabel = (p: ProcessStartPayload) => {
|
|
||||||
if (p.runReason === PROCESS_RUN_REASONS.CODING_AGENT) {
|
|
||||||
const prompt = extractPromptFromAction(p.action);
|
|
||||||
return prompt || 'Coding Agent';
|
|
||||||
}
|
|
||||||
switch (p.runReason) {
|
|
||||||
case PROCESS_RUN_REASONS.SETUP_SCRIPT:
|
|
||||||
return 'Setup Script';
|
|
||||||
case PROCESS_RUN_REASONS.CLEANUP_SCRIPT:
|
|
||||||
return 'Cleanup Script';
|
|
||||||
case PROCESS_RUN_REASONS.DEV_SERVER:
|
|
||||||
return 'Dev Server';
|
|
||||||
default:
|
|
||||||
return p.runReason;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
onToggle(payload.processId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (isEditing) return;
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggle(payload.processId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = getProcessLabel(payload);
|
|
||||||
const shouldTruncate =
|
|
||||||
isCollapsed && payload.runReason === PROCESS_RUN_REASONS.CODING_AGENT;
|
|
||||||
|
|
||||||
// Inline edit state for retry flow
|
|
||||||
const isCodingAgent = payload.runReason === PROCESS_RUN_REASONS.CODING_AGENT;
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editValue, setEditValue] = useState(label);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEditing) setEditValue(label);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [label]);
|
|
||||||
|
|
||||||
const canRetry = useMemo(
|
|
||||||
() => !!onRetry && isCodingAgent,
|
|
||||||
[onRetry, isCodingAgent]
|
|
||||||
);
|
|
||||||
const doRetry = () => {
|
|
||||||
if (!onRetry) return;
|
|
||||||
const prompt = (editValue || '').trim();
|
|
||||||
if (!prompt) return; // no-op on empty
|
|
||||||
onRetry(retryProcessId || payload.processId, prompt);
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="p-2 border cursor-pointer select-none transition-colors w-full bg-background"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => {
|
|
||||||
// Avoid toggling while editing or interacting with controls
|
|
||||||
if (isEditing) return;
|
|
||||||
handleClick();
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-light">
|
|
||||||
<div className="flex items-center gap-2 text-foreground min-w-0 flex-1">
|
|
||||||
{isEditing && canRetry ? (
|
|
||||||
<div
|
|
||||||
className="flex items-center w-full"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AutoExpandingTextarea
|
|
||||||
value={editValue || ''}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditValue(label);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'min-h-[36px] text-sm bg-inherit',
|
|
||||||
shouldTruncate ? 'truncate' : 'whitespace-normal break-words'
|
|
||||||
)}
|
|
||||||
maxRows={12}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7"
|
|
||||||
disabled={!!retryDisabled || !(editValue || '').trim()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
doRetry();
|
|
||||||
}}
|
|
||||||
aria-label="Confirm edit and retry"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditValue(label);
|
|
||||||
}}
|
|
||||||
area-label="Cancel edit"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
shouldTruncate ? 'truncate' : 'whitespace-normal break-words'
|
|
||||||
)}
|
|
||||||
title={shouldTruncate ? label : undefined}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right controls: edit icon, status, chevron */}
|
|
||||||
{canRetry && !isEditing && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
{/* Wrap disabled button so tooltip still works */}
|
|
||||||
<span
|
|
||||||
className="ml-2 inline-flex"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
aria-disabled={retryDisabled ? true : undefined}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'p-1 rounded transition-colors',
|
|
||||||
retryDisabled
|
|
||||||
? 'cursor-not-allowed text-muted-foreground/60'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (retryDisabled) return;
|
|
||||||
setIsEditing(true);
|
|
||||||
setEditValue(label);
|
|
||||||
}}
|
|
||||||
aria-label="Edit prompt and retry from here"
|
|
||||||
disabled={!!retryDisabled}
|
|
||||||
>
|
|
||||||
<SquarePen className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{retryDisabled
|
|
||||||
? retryDisabledReason ||
|
|
||||||
'Unavailable while another process is running.'
|
|
||||||
: 'Edit prompt and retry'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'ml-1 text-xs px-2 py-1 rounded-full',
|
|
||||||
payload.status === 'running'
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: payload.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: payload.status === 'failed'
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{payload.status}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 text-muted-foreground transition-transform',
|
|
||||||
isCollapsed && '-rotate-90'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProcessStartCard;
|
|
||||||
127
frontend/src/components/logs/VirtualizedList.tsx
Normal file
127
frontend/src/components/logs/VirtualizedList.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
VirtuosoMessageListProps,
|
||||||
|
VirtuosoMessageListMethods,
|
||||||
|
VirtuosoMessageListLicense,
|
||||||
|
VirtuosoMessageList,
|
||||||
|
DataWithScrollModifier,
|
||||||
|
ScrollModifier,
|
||||||
|
} from '@virtuoso.dev/message-list';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import DisplayConversationEntry from '../NormalizedConversation/DisplayConversationEntry';
|
||||||
|
import {
|
||||||
|
useConversationHistory,
|
||||||
|
PatchTypeWithKey,
|
||||||
|
AddEntryType,
|
||||||
|
} from '@/hooks/useConversationHistory';
|
||||||
|
import { TaskAttempt } from 'shared/types';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VirtualizedListProps {
|
||||||
|
attempt: TaskAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelData = DataWithScrollModifier<PatchTypeWithKey> | null;
|
||||||
|
|
||||||
|
const InitialDataScrollModifier: ScrollModifier = {
|
||||||
|
type: 'item-location',
|
||||||
|
location: {
|
||||||
|
index: 'LAST',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
purgeItemSizes: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AutoScrollToBottom: ScrollModifier = {
|
||||||
|
type: 'auto-scroll-to-bottom',
|
||||||
|
autoScroll: ({ atBottom, scrollInProgress }) => {
|
||||||
|
if (atBottom || scrollInProgress) {
|
||||||
|
return 'smooth';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||||
|
const [channelData, setChannelData] = useState<ChannelData>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// When attempt changes, set loading
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
}, [attempt.id]);
|
||||||
|
|
||||||
|
const onEntriesUpdated = (
|
||||||
|
newEntries: PatchTypeWithKey[],
|
||||||
|
addType: AddEntryType,
|
||||||
|
newLoading: boolean
|
||||||
|
) => {
|
||||||
|
// initial defaults to scrolling to the latest
|
||||||
|
let scrollModifier: ScrollModifier = InitialDataScrollModifier;
|
||||||
|
|
||||||
|
if (addType === 'running' && !loading) {
|
||||||
|
scrollModifier = AutoScrollToBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelData({ data: newEntries, scrollModifier });
|
||||||
|
if (loading) {
|
||||||
|
setLoading(newLoading);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useConversationHistory({ attempt, onEntriesUpdated });
|
||||||
|
|
||||||
|
const messageListRef = useRef<VirtuosoMessageListMethods | null>(null);
|
||||||
|
|
||||||
|
const ItemContent: VirtuosoMessageListProps<
|
||||||
|
PatchTypeWithKey,
|
||||||
|
null
|
||||||
|
>['ItemContent'] = ({ data }) => {
|
||||||
|
if (data.type === 'STDOUT') {
|
||||||
|
return <p>{data.content}</p>;
|
||||||
|
} else if (data.type === 'STDERR') {
|
||||||
|
return <p>{data.content}</p>;
|
||||||
|
} else if (data.type === 'NORMALIZED_ENTRY') {
|
||||||
|
return (
|
||||||
|
<DisplayConversationEntry
|
||||||
|
key={data.patchKey}
|
||||||
|
expansionKey={data.patchKey}
|
||||||
|
entry={data.content}
|
||||||
|
executionProcessId={data.executionProcessId}
|
||||||
|
taskAttempt={attempt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeItemKey: VirtuosoMessageListProps<
|
||||||
|
PatchTypeWithKey,
|
||||||
|
null
|
||||||
|
>['computeItemKey'] = ({ data }) => {
|
||||||
|
return `l-${data.patchKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VirtuosoMessageListLicense
|
||||||
|
licenseKey={import.meta.env.PUBLIC_REACT_VIRTUOSO_LICENSE_KEY}
|
||||||
|
>
|
||||||
|
<VirtuosoMessageList<PatchTypeWithKey, null>
|
||||||
|
ref={messageListRef}
|
||||||
|
className="flex-1"
|
||||||
|
data={channelData}
|
||||||
|
computeItemKey={computeItemKey}
|
||||||
|
ItemContent={ItemContent}
|
||||||
|
Header={() => <div className="h-2"></div>} // Padding
|
||||||
|
Footer={() => <div className="h-2"></div>} // Padding
|
||||||
|
/>
|
||||||
|
</VirtuosoMessageListLicense>
|
||||||
|
{loading && (
|
||||||
|
<div className="float-left top-0 left-0 w-full h-full bg-primary flex flex-col gap-2 justify-center items-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p>Loading History</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualizedList;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry';
|
||||||
|
import { useNormalizedLogs } from '@/hooks/useNormalizedLogs';
|
||||||
|
import { ExecutionProcess } from 'shared/types';
|
||||||
|
|
||||||
|
interface ConversationExecutionLogsProps {
|
||||||
|
executionProcess: ExecutionProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversationExecutionLogs = ({
|
||||||
|
executionProcess,
|
||||||
|
}: ConversationExecutionLogsProps) => {
|
||||||
|
const { entries } = useNormalizedLogs(executionProcess.id);
|
||||||
|
|
||||||
|
console.log('DEBUG7', entries);
|
||||||
|
|
||||||
|
return entries.map((entry, i) => {
|
||||||
|
return (
|
||||||
|
<DisplayConversationEntry
|
||||||
|
expansionKey={`expansion-${executionProcess.id}-${i}`}
|
||||||
|
entry={entry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConversationExecutionLogs;
|
||||||
@@ -1,494 +1,12 @@
|
|||||||
import {
|
import type { TaskAttempt } from 'shared/types';
|
||||||
useRef,
|
import VirtualizedList from '@/components/logs/VirtualizedList';
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
import { Cog } from 'lucide-react';
|
|
||||||
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
|
||||||
import { useBranchStatus } from '@/hooks/useBranchStatus';
|
|
||||||
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
|
|
||||||
import ProcessGroup from '@/components/logs/ProcessGroup';
|
|
||||||
import {
|
|
||||||
shouldShowInLogs,
|
|
||||||
isAutoCollapsibleProcess,
|
|
||||||
isProcessCompleted,
|
|
||||||
isCodingAgent,
|
|
||||||
getLatestCodingAgent,
|
|
||||||
PROCESS_STATUSES,
|
|
||||||
PROCESS_RUN_REASONS,
|
|
||||||
} from '@/constants/processes';
|
|
||||||
import type { ExecutionProcessStatus, TaskAttempt } from 'shared/types';
|
|
||||||
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
|
|
||||||
import { showModal } from '@/lib/modals';
|
|
||||||
|
|
||||||
function addAll<T>(set: Set<T>, items: T[]): Set<T> {
|
|
||||||
items.forEach((i: T) => set.add(i));
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
// State management types
|
|
||||||
type LogsState = {
|
|
||||||
userCollapsed: Set<string>;
|
|
||||||
autoCollapsed: Set<string>;
|
|
||||||
prevStatus: Map<string, ExecutionProcessStatus>;
|
|
||||||
prevLatestAgent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LogsAction =
|
|
||||||
| { type: 'RESET_ATTEMPT' }
|
|
||||||
| { type: 'TOGGLE_USER'; id: string }
|
|
||||||
| { type: 'AUTO_COLLAPSE'; ids: string[] }
|
|
||||||
| { type: 'AUTO_EXPAND'; ids: string[] }
|
|
||||||
| { type: 'UPDATE_STATUS'; id: string; status: ExecutionProcessStatus }
|
|
||||||
| { type: 'NEW_RUNNING_AGENT'; id: string };
|
|
||||||
|
|
||||||
const initialState: LogsState = {
|
|
||||||
userCollapsed: new Set(),
|
|
||||||
autoCollapsed: new Set(),
|
|
||||||
prevStatus: new Map(),
|
|
||||||
prevLatestAgent: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reducer(state: LogsState, action: LogsAction): LogsState {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'RESET_ATTEMPT':
|
|
||||||
return { ...initialState };
|
|
||||||
|
|
||||||
case 'TOGGLE_USER': {
|
|
||||||
const newUserCollapsed = new Set(state.userCollapsed);
|
|
||||||
const newAutoCollapsed = new Set(state.autoCollapsed);
|
|
||||||
|
|
||||||
const isCurrentlyCollapsed =
|
|
||||||
newUserCollapsed.has(action.id) || newAutoCollapsed.has(action.id);
|
|
||||||
|
|
||||||
if (isCurrentlyCollapsed) {
|
|
||||||
// we want to EXPAND
|
|
||||||
newUserCollapsed.delete(action.id);
|
|
||||||
newAutoCollapsed.delete(action.id);
|
|
||||||
} else {
|
|
||||||
// we want to COLLAPSE
|
|
||||||
newUserCollapsed.add(action.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
userCollapsed: newUserCollapsed,
|
|
||||||
autoCollapsed: newAutoCollapsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'AUTO_COLLAPSE': {
|
|
||||||
const newAutoCollapsed = new Set(state.autoCollapsed);
|
|
||||||
addAll(newAutoCollapsed, action.ids);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
autoCollapsed: newAutoCollapsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'AUTO_EXPAND': {
|
|
||||||
const newAutoCollapsed = new Set(state.autoCollapsed);
|
|
||||||
action.ids.forEach((id) => newAutoCollapsed.delete(id));
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
autoCollapsed: newAutoCollapsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'UPDATE_STATUS': {
|
|
||||||
const newPrevStatus = new Map(state.prevStatus);
|
|
||||||
newPrevStatus.set(action.id, action.status);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
prevStatus: newPrevStatus,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'NEW_RUNNING_AGENT':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
prevLatestAgent: action.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedAttempt: TaskAttempt | null;
|
selectedAttempt: TaskAttempt;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LogsTab({ selectedAttempt }: Props) {
|
function LogsTab({ selectedAttempt }: Props) {
|
||||||
const { attemptData, refetch } = useAttemptExecution(selectedAttempt?.id);
|
return <VirtualizedList attempt={selectedAttempt} />;
|
||||||
const { data: branchStatus, refetch: refetchBranch } = useBranchStatus(
|
|
||||||
selectedAttempt?.id
|
|
||||||
);
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
|
||||||
|
|
||||||
// Filter out dev server processes before passing to useProcessesLogs
|
|
||||||
const filteredProcesses = useMemo(() => {
|
|
||||||
const processes = attemptData.processes || [];
|
|
||||||
return processes.filter(
|
|
||||||
(process) => shouldShowInLogs(process.run_reason) && !process.dropped
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
attemptData.processes
|
|
||||||
?.map((p) => `${p.id}:${p.status}:${p.dropped}`)
|
|
||||||
.join(','),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Detect if any process is running
|
|
||||||
const anyRunning = useMemo(
|
|
||||||
() => (attemptData.processes || []).some((p) => p.status === 'running'),
|
|
||||||
[attemptData.processes?.map((p) => p.status).join(',')]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { entries } = useProcessesLogs(filteredProcesses, true);
|
|
||||||
const [restoreBusy, setRestoreBusy] = useState(false);
|
|
||||||
|
|
||||||
// Combined collapsed processes (auto + user)
|
|
||||||
const allCollapsedProcesses = useMemo(() => {
|
|
||||||
const combined = new Set(state.autoCollapsed);
|
|
||||||
state.userCollapsed.forEach((id: string) => combined.add(id));
|
|
||||||
return combined;
|
|
||||||
}, [state.autoCollapsed, state.userCollapsed]);
|
|
||||||
|
|
||||||
// Toggle collapsed state for a process (user action)
|
|
||||||
const toggleProcessCollapse = useCallback((processId: string) => {
|
|
||||||
dispatch({ type: 'TOGGLE_USER', id: processId });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Effect #1: Reset state when attempt changes
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch({ type: 'RESET_ATTEMPT' });
|
|
||||||
}, [selectedAttempt?.id]);
|
|
||||||
|
|
||||||
// Effect #2: Handle setup/cleanup script auto-collapse and auto-expand
|
|
||||||
useEffect(() => {
|
|
||||||
const toCollapse: string[] = [];
|
|
||||||
const toExpand: string[] = [];
|
|
||||||
|
|
||||||
filteredProcesses.forEach((process) => {
|
|
||||||
if (isAutoCollapsibleProcess(process.run_reason)) {
|
|
||||||
const prevStatus = state.prevStatus.get(process.id);
|
|
||||||
const currentStatus = process.status;
|
|
||||||
|
|
||||||
// Auto-collapse completed setup/cleanup scripts
|
|
||||||
const shouldAutoCollapse =
|
|
||||||
(prevStatus === PROCESS_STATUSES.RUNNING ||
|
|
||||||
prevStatus === undefined) &&
|
|
||||||
isProcessCompleted(currentStatus) &&
|
|
||||||
!state.userCollapsed.has(process.id) &&
|
|
||||||
!state.autoCollapsed.has(process.id);
|
|
||||||
|
|
||||||
if (shouldAutoCollapse) {
|
|
||||||
toCollapse.push(process.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-expand scripts that restart after completion
|
|
||||||
const becameRunningAgain =
|
|
||||||
prevStatus &&
|
|
||||||
isProcessCompleted(prevStatus) &&
|
|
||||||
currentStatus === PROCESS_STATUSES.RUNNING &&
|
|
||||||
state.autoCollapsed.has(process.id);
|
|
||||||
|
|
||||||
if (becameRunningAgain) {
|
|
||||||
toExpand.push(process.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status tracking
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_STATUS',
|
|
||||||
id: process.id,
|
|
||||||
status: currentStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (toCollapse.length > 0) {
|
|
||||||
dispatch({ type: 'AUTO_COLLAPSE', ids: toCollapse });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toExpand.length > 0) {
|
|
||||||
dispatch({ type: 'AUTO_EXPAND', ids: toExpand });
|
|
||||||
}
|
|
||||||
}, [filteredProcesses, state.userCollapsed, state.autoCollapsed]);
|
|
||||||
|
|
||||||
// Effect #3: Handle coding agent succession logic
|
|
||||||
useEffect(() => {
|
|
||||||
const latestCodingAgentId = getLatestCodingAgent(filteredProcesses);
|
|
||||||
if (!latestCodingAgentId) return;
|
|
||||||
|
|
||||||
// Collapse previous agents when a new latest agent appears
|
|
||||||
if (latestCodingAgentId !== state.prevLatestAgent) {
|
|
||||||
// Collapse all other coding agents that aren't user-collapsed
|
|
||||||
const toCollapse = filteredProcesses
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
isCodingAgent(p.run_reason) &&
|
|
||||||
p.id !== latestCodingAgentId &&
|
|
||||||
!state.userCollapsed.has(p.id) &&
|
|
||||||
!state.autoCollapsed.has(p.id)
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
if (toCollapse.length > 0) {
|
|
||||||
dispatch({ type: 'AUTO_COLLAPSE', ids: toCollapse });
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({ type: 'NEW_RUNNING_AGENT', id: latestCodingAgentId });
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
filteredProcesses,
|
|
||||||
state.prevLatestAgent,
|
|
||||||
state.userCollapsed,
|
|
||||||
state.autoCollapsed,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const map = new Map<
|
|
||||||
string,
|
|
||||||
{ header?: ProcessStartPayload; entries: UnifiedLogEntry[] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
filteredProcesses.forEach((p) => {
|
|
||||||
map.set(p.id, { header: undefined, entries: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
entries.forEach((e: UnifiedLogEntry) => {
|
|
||||||
const bucket = map.get(e.processId);
|
|
||||||
if (!bucket) return;
|
|
||||||
|
|
||||||
if (e.channel === 'process_start') {
|
|
||||||
bucket.header = e.payload as ProcessStartPayload;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always store entries; whether they show is decided by group collapse
|
|
||||||
bucket.entries.push(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredProcesses
|
|
||||||
.map((p) => ({
|
|
||||||
processId: p.id,
|
|
||||||
...(map.get(p.id) || { entries: [] }),
|
|
||||||
}))
|
|
||||||
.filter((g) => g.header) as Array<{
|
|
||||||
processId: string;
|
|
||||||
header: ProcessStartPayload;
|
|
||||||
entries: UnifiedLogEntry[];
|
|
||||||
}>;
|
|
||||||
}, [filteredProcesses, entries]);
|
|
||||||
|
|
||||||
const itemContent = useCallback(
|
|
||||||
(
|
|
||||||
_index: number,
|
|
||||||
group: {
|
|
||||||
processId: string;
|
|
||||||
header: ProcessStartPayload;
|
|
||||||
entries: UnifiedLogEntry[];
|
|
||||||
}
|
|
||||||
) =>
|
|
||||||
(() => {
|
|
||||||
// Compute retry props (replaces restore)
|
|
||||||
let retry:
|
|
||||||
| {
|
|
||||||
onRetry: (pid: string, newPrompt: string) => void;
|
|
||||||
retryProcessId?: string;
|
|
||||||
retryDisabled?: boolean;
|
|
||||||
retryDisabledReason?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
{
|
|
||||||
const proc = (attemptData.processes || []).find(
|
|
||||||
(p) => p.id === group.processId
|
|
||||||
);
|
|
||||||
const isRunningProc = proc?.status === 'running';
|
|
||||||
const isCoding = proc?.run_reason === 'codingagent';
|
|
||||||
// Always show for coding agent processes
|
|
||||||
const shouldShow = !!isCoding;
|
|
||||||
|
|
||||||
if (shouldShow) {
|
|
||||||
const disabled = anyRunning || restoreBusy || isRunningProc;
|
|
||||||
let disabledReason: string | undefined;
|
|
||||||
if (isRunningProc)
|
|
||||||
disabledReason = 'Finish or stop this run to retry.';
|
|
||||||
else if (anyRunning)
|
|
||||||
disabledReason = 'Cannot retry while a process is running.';
|
|
||||||
else if (restoreBusy) disabledReason = 'Retry in progress.';
|
|
||||||
|
|
||||||
retry = {
|
|
||||||
retryProcessId: group.processId,
|
|
||||||
retryDisabled: disabled,
|
|
||||||
retryDisabledReason: disabledReason,
|
|
||||||
onRetry: async (pid: string, newPrompt: string) => {
|
|
||||||
const p2 = (attemptData.processes || []).find(
|
|
||||||
(p) => p.id === pid
|
|
||||||
);
|
|
||||||
type WithBefore = { before_head_commit?: string | null };
|
|
||||||
const before =
|
|
||||||
(p2 as WithBefore | undefined)?.before_head_commit || null;
|
|
||||||
let targetSubject = null;
|
|
||||||
let commitsToReset = null;
|
|
||||||
let isLinear = null;
|
|
||||||
|
|
||||||
if (before && selectedAttempt?.id) {
|
|
||||||
try {
|
|
||||||
const { commitsApi } = await import('@/lib/api');
|
|
||||||
const info = await commitsApi.getInfo(
|
|
||||||
selectedAttempt.id,
|
|
||||||
before
|
|
||||||
);
|
|
||||||
targetSubject = info.subject;
|
|
||||||
const cmp = await commitsApi.compareToHead(
|
|
||||||
selectedAttempt.id,
|
|
||||||
before
|
|
||||||
);
|
|
||||||
commitsToReset = cmp.is_linear ? cmp.ahead_from_head : null;
|
|
||||||
isLinear = cmp.is_linear;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = branchStatus?.head_oid || null;
|
|
||||||
const dirty = !!branchStatus?.has_uncommitted_changes;
|
|
||||||
const needReset = !!(before && (before !== head || dirty));
|
|
||||||
const canGitReset = needReset && !dirty;
|
|
||||||
|
|
||||||
// Calculate later process counts for dialog
|
|
||||||
const procs = (attemptData.processes || []).filter(
|
|
||||||
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
|
|
||||||
);
|
|
||||||
const idx = procs.findIndex((p) => p.id === pid);
|
|
||||||
const laterCount = idx >= 0 ? procs.length - (idx + 1) : 0;
|
|
||||||
const later = idx >= 0 ? procs.slice(idx + 1) : [];
|
|
||||||
const laterCoding = later.filter((p) =>
|
|
||||||
isCodingAgent(p.run_reason)
|
|
||||||
).length;
|
|
||||||
const laterSetup = later.filter(
|
|
||||||
(p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT
|
|
||||||
).length;
|
|
||||||
const laterCleanup = later.filter(
|
|
||||||
(p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
|
|
||||||
).length;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await showModal<{
|
|
||||||
action: 'confirmed' | 'canceled';
|
|
||||||
performGitReset?: boolean;
|
|
||||||
forceWhenDirty?: boolean;
|
|
||||||
}>('restore-logs', {
|
|
||||||
targetSha: before,
|
|
||||||
targetSubject,
|
|
||||||
commitsToReset,
|
|
||||||
isLinear,
|
|
||||||
laterCount,
|
|
||||||
laterCoding,
|
|
||||||
laterSetup,
|
|
||||||
laterCleanup,
|
|
||||||
needGitReset: needReset,
|
|
||||||
canGitReset,
|
|
||||||
hasRisk: dirty,
|
|
||||||
uncommittedCount: branchStatus?.uncommitted_count ?? 0,
|
|
||||||
untrackedCount: branchStatus?.untracked_count ?? 0,
|
|
||||||
// Always default to performing a worktree reset
|
|
||||||
initialWorktreeResetOn: true,
|
|
||||||
initialForceReset: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.action === 'confirmed' && selectedAttempt?.id) {
|
|
||||||
const { attemptsApi } = await import('@/lib/api');
|
|
||||||
try {
|
|
||||||
setRestoreBusy(true);
|
|
||||||
// Determine variant from the original process executor profile if available
|
|
||||||
let variant: string | null = null;
|
|
||||||
const typ = p2?.executor_action?.typ;
|
|
||||||
if (
|
|
||||||
typ &&
|
|
||||||
(typ.type === 'CodingAgentInitialRequest' ||
|
|
||||||
typ.type === 'CodingAgentFollowUpRequest')
|
|
||||||
) {
|
|
||||||
variant = typ.executor_profile_id?.variant ?? null;
|
|
||||||
}
|
|
||||||
await attemptsApi.replaceProcess(selectedAttempt.id, {
|
|
||||||
process_id: pid,
|
|
||||||
prompt: newPrompt,
|
|
||||||
variant,
|
|
||||||
perform_git_reset: result.performGitReset ?? true,
|
|
||||||
force_when_dirty: result.forceWhenDirty ?? false,
|
|
||||||
});
|
|
||||||
await refetch();
|
|
||||||
await refetchBranch();
|
|
||||||
} finally {
|
|
||||||
setRestoreBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// User cancelled - do nothing
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProcessGroup
|
|
||||||
header={group.header}
|
|
||||||
entries={group.entries}
|
|
||||||
isCollapsed={allCollapsedProcesses.has(group.processId)}
|
|
||||||
onToggle={toggleProcessCollapse}
|
|
||||||
retry={retry}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})(),
|
|
||||||
[
|
|
||||||
allCollapsedProcesses,
|
|
||||||
toggleProcessCollapse,
|
|
||||||
anyRunning,
|
|
||||||
restoreBusy,
|
|
||||||
selectedAttempt?.id,
|
|
||||||
attemptData.processes,
|
|
||||||
branchStatus?.head_oid,
|
|
||||||
branchStatus?.has_uncommitted_changes,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!filteredProcesses || filteredProcesses.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
||||||
<div className="text-center">
|
|
||||||
<Cog className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>No execution processes found for this attempt.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Virtuoso
|
|
||||||
ref={virtuosoRef}
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
data={groups}
|
|
||||||
itemContent={itemContent}
|
|
||||||
followOutput
|
|
||||||
increaseViewportBy={200}
|
|
||||||
overscan={5}
|
|
||||||
components={{ Footer: () => <div className="pb-4" /> }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LogsTab; // Filter entries to hide logs from collapsed processes
|
export default LogsTab;
|
||||||
|
|||||||
@@ -177,29 +177,33 @@ export function TaskDetailsPanel({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||||
<TabNavigation
|
{selectedAttempt && (
|
||||||
activeTab={activeTab}
|
<>
|
||||||
setActiveTab={setActiveTab}
|
<TabNavigation
|
||||||
selectedAttempt={selectedAttempt}
|
activeTab={activeTab}
|
||||||
/>
|
setActiveTab={setActiveTab}
|
||||||
|
selectedAttempt={selectedAttempt}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{activeTab === 'diffs' ? (
|
{activeTab === 'diffs' ? (
|
||||||
<DiffTab selectedAttempt={selectedAttempt} />
|
<DiffTab selectedAttempt={selectedAttempt} />
|
||||||
) : activeTab === 'processes' ? (
|
) : activeTab === 'processes' ? (
|
||||||
<ProcessesTab attemptId={selectedAttempt?.id} />
|
<ProcessesTab attemptId={selectedAttempt?.id} />
|
||||||
) : (
|
) : (
|
||||||
<LogsTab selectedAttempt={selectedAttempt} />
|
<LogsTab selectedAttempt={selectedAttempt} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskFollowUpSection
|
<TaskFollowUpSection
|
||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectedAttemptId={selectedAttempt?.id}
|
selectedAttemptId={selectedAttempt?.id}
|
||||||
selectedAttemptProfile={selectedAttempt?.executor}
|
selectedAttemptProfile={selectedAttempt?.executor}
|
||||||
jumpToLogsTab={jumpToLogsTab}
|
jumpToLogsTab={jumpToLogsTab}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -231,7 +235,9 @@ export function TaskDetailsPanel({
|
|||||||
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LogsTab selectedAttempt={selectedAttempt} />
|
{selectedAttempt && (
|
||||||
|
<LogsTab selectedAttempt={selectedAttempt} />
|
||||||
|
)}
|
||||||
|
|
||||||
<TaskFollowUpSection
|
<TaskFollowUpSection
|
||||||
task={task}
|
task={task}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function MarkdownRenderer({
|
|||||||
try {
|
try {
|
||||||
await writeClipboardViaBridge(content);
|
await writeClipboardViaBridge(content);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1400);
|
window.setTimeout(() => setCopied(false), 400);
|
||||||
} catch {
|
} catch {
|
||||||
// noop – bridge handles fallback
|
// noop – bridge handles fallback
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ function MarkdownRenderer({
|
|||||||
return (
|
return (
|
||||||
<div className={`relative group`}>
|
<div className={`relative group`}>
|
||||||
{enableCopyButton && (
|
{enableCopyButton && (
|
||||||
<div className="sticky top-2 z-10 pointer-events-none">
|
<div className="sticky top-2 right-2 z-10 pointer-events-none h-0">
|
||||||
<div className="flex justify-end pr-1">
|
<div className="flex justify-end pr-1">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -113,7 +113,7 @@ function MarkdownRenderer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="pointer-events-auto opacity-0 group-hover:opacity-100 group-hover:delay-200 delay-0 transition-opacity duration-150 h-8 w-8 rounded-md bg-background/95 backdrop-blur border border-border shadow-sm"
|
className="pointer-events-auto opacity-0 group-hover:opacity-100 delay-0 transition-opacity duration-50 h-8 w-8 rounded-md bg-background/95 backdrop-blur border border-border shadow-sm"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
|||||||
474
frontend/src/hooks/useConversationHistory.ts
Normal file
474
frontend/src/hooks/useConversationHistory.ts
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
// useConversationHistory.ts
|
||||||
|
import {
|
||||||
|
CommandExitStatus,
|
||||||
|
ExecutionProcess,
|
||||||
|
ExecutorAction,
|
||||||
|
NormalizedEntry,
|
||||||
|
PatchType,
|
||||||
|
TaskAttempt,
|
||||||
|
} from 'shared/types';
|
||||||
|
import { useExecutionProcesses } from './useExecutionProcesses';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { streamSseJsonPatchEntries } from '@/utils/streamSseJsonPatchEntries';
|
||||||
|
|
||||||
|
export type PatchTypeWithKey = PatchType & {
|
||||||
|
patchKey: string;
|
||||||
|
executionProcessId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddEntryType = 'initial' | 'running' | 'historic';
|
||||||
|
|
||||||
|
export type OnEntriesUpdated = (
|
||||||
|
newEntries: PatchTypeWithKey[],
|
||||||
|
addType: AddEntryType,
|
||||||
|
loading: boolean
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type ExecutionProcessStaticInfo = {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
executor_action: ExecutorAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecutionProcessState = {
|
||||||
|
executionProcess: ExecutionProcessStaticInfo;
|
||||||
|
entries: PatchTypeWithKey[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecutionProcessStateStore = Record<string, ExecutionProcessState>;
|
||||||
|
|
||||||
|
interface UseConversationHistoryParams {
|
||||||
|
attempt: TaskAttempt;
|
||||||
|
onEntriesUpdated: OnEntriesUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseConversationHistoryResult {}
|
||||||
|
|
||||||
|
const MIN_INITIAL_ENTRIES = 10;
|
||||||
|
const REMAINING_BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
export const useConversationHistory = ({
|
||||||
|
attempt,
|
||||||
|
onEntriesUpdated,
|
||||||
|
}: UseConversationHistoryParams): UseConversationHistoryResult => {
|
||||||
|
const { executionProcesses: executionProcessesRaw } = useExecutionProcesses(
|
||||||
|
attempt.id
|
||||||
|
);
|
||||||
|
const executionProcesses = useRef<ExecutionProcess[]>(executionProcessesRaw);
|
||||||
|
const displayedExecutionProcesses = useRef<ExecutionProcessStateStore>({});
|
||||||
|
const loadedInitialEntries = useRef(false);
|
||||||
|
const lastRunningProcessId = useRef<string | null>(null);
|
||||||
|
const onEntriesUpdatedRef = useRef<OnEntriesUpdated | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
onEntriesUpdatedRef.current = onEntriesUpdated;
|
||||||
|
}, [onEntriesUpdated]);
|
||||||
|
|
||||||
|
// Keep executionProcesses up to date with executionProcessesRaw
|
||||||
|
useEffect(() => {
|
||||||
|
executionProcesses.current = executionProcessesRaw;
|
||||||
|
}, [executionProcessesRaw]);
|
||||||
|
|
||||||
|
const loadEntriesForHistoricExecutionProcess = (
|
||||||
|
executionProcess: ExecutionProcess
|
||||||
|
) => {
|
||||||
|
let url = '';
|
||||||
|
if (executionProcess.executor_action.typ.type === 'ScriptRequest') {
|
||||||
|
url = `/api/execution-processes/${executionProcess.id}/raw-logs`;
|
||||||
|
} else {
|
||||||
|
url = `/api/execution-processes/${executionProcess.id}/normalized-logs`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<PatchType[]>((resolve) => {
|
||||||
|
const controller = streamSseJsonPatchEntries<PatchType>(url, {
|
||||||
|
onFinished: (allEntries) => {
|
||||||
|
controller.close();
|
||||||
|
resolve(allEntries);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.warn!(
|
||||||
|
`Error loading entries for historic execution process ${executionProcess.id}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
controller.close();
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiveExecutionProcess = (
|
||||||
|
executionProcessId: string
|
||||||
|
): ExecutionProcess | undefined => {
|
||||||
|
return executionProcesses?.current.find(
|
||||||
|
(executionProcess) => executionProcess.id === executionProcessId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// This emits its own events as they are streamed
|
||||||
|
const loadRunningAndEmit = (
|
||||||
|
executionProcess: ExecutionProcess
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let url = '';
|
||||||
|
if (executionProcess.executor_action.typ.type === 'ScriptRequest') {
|
||||||
|
url = `/api/execution-processes/${executionProcess.id}/raw-logs`;
|
||||||
|
} else {
|
||||||
|
url = `/api/execution-processes/${executionProcess.id}/normalized-logs`;
|
||||||
|
}
|
||||||
|
const controller = streamSseJsonPatchEntries<PatchType>(url, {
|
||||||
|
onEntries(entries) {
|
||||||
|
const patchesWithKey = entries.map((entry, index) =>
|
||||||
|
patchWithKey(entry, executionProcess.id, index)
|
||||||
|
);
|
||||||
|
const localEntries = displayedExecutionProcesses.current;
|
||||||
|
localEntries[executionProcess.id] = {
|
||||||
|
executionProcess,
|
||||||
|
entries: patchesWithKey,
|
||||||
|
};
|
||||||
|
displayedExecutionProcesses.current = localEntries;
|
||||||
|
emitEntries(localEntries, 'running', false);
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
controller.close();
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
controller.close();
|
||||||
|
reject();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sometimes it can take a few seconds for the stream to start, wrap the loadRunningAndEmit method
|
||||||
|
const loadRunningAndEmitWithBackoff = async (
|
||||||
|
executionProcess: ExecutionProcess
|
||||||
|
) => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
await loadRunningAndEmit(executionProcess);
|
||||||
|
break;
|
||||||
|
} catch (_) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRunningExecutionProcesses = (): ExecutionProcess | null => {
|
||||||
|
// If more than one, throw an error
|
||||||
|
const runningProcesses = executionProcesses?.current.filter(
|
||||||
|
(p) => p.status === 'running'
|
||||||
|
);
|
||||||
|
if (runningProcesses.length > 1) {
|
||||||
|
throw new Error('More than one running execution process found');
|
||||||
|
}
|
||||||
|
return runningProcesses[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenEntries = (
|
||||||
|
executionProcessState: ExecutionProcessStateStore
|
||||||
|
): PatchTypeWithKey[] => {
|
||||||
|
return Object.values(executionProcessState)
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.executionProcess.executor_action.typ.type ===
|
||||||
|
'CodingAgentFollowUpRequest' ||
|
||||||
|
p.executionProcess.executor_action.typ.type ===
|
||||||
|
'CodingAgentInitialRequest'
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(
|
||||||
|
a.executionProcess.created_at as unknown as string
|
||||||
|
).getTime() -
|
||||||
|
new Date(b.executionProcess.created_at as unknown as string).getTime()
|
||||||
|
)
|
||||||
|
.flatMap((p) => p.entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadingPatch: PatchTypeWithKey = {
|
||||||
|
type: 'NORMALIZED_ENTRY',
|
||||||
|
content: {
|
||||||
|
entry_type: {
|
||||||
|
type: 'loading',
|
||||||
|
},
|
||||||
|
content: '',
|
||||||
|
timestamp: null,
|
||||||
|
},
|
||||||
|
patchKey: 'loading',
|
||||||
|
executionProcessId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenEntriesForEmit = (
|
||||||
|
executionProcessState: ExecutionProcessStateStore
|
||||||
|
): PatchTypeWithKey[] => {
|
||||||
|
// Create user messages + tool calls for setup/cleanup scripts
|
||||||
|
const allEntries = Object.values(executionProcessState)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(
|
||||||
|
a.executionProcess.created_at as unknown as string
|
||||||
|
).getTime() -
|
||||||
|
new Date(b.executionProcess.created_at as unknown as string).getTime()
|
||||||
|
)
|
||||||
|
.flatMap((p) => {
|
||||||
|
const entries: PatchTypeWithKey[] = [];
|
||||||
|
if (
|
||||||
|
p.executionProcess.executor_action.typ.type ===
|
||||||
|
'CodingAgentInitialRequest' ||
|
||||||
|
p.executionProcess.executor_action.typ.type ===
|
||||||
|
'CodingAgentFollowUpRequest'
|
||||||
|
) {
|
||||||
|
// New user message
|
||||||
|
const userNormalizedEntry: NormalizedEntry = {
|
||||||
|
entry_type: {
|
||||||
|
type: 'user_message',
|
||||||
|
},
|
||||||
|
content: p.executionProcess.executor_action.typ.prompt,
|
||||||
|
timestamp: null,
|
||||||
|
};
|
||||||
|
const userPatch: PatchType = {
|
||||||
|
type: 'NORMALIZED_ENTRY',
|
||||||
|
content: userNormalizedEntry,
|
||||||
|
};
|
||||||
|
const userPatchTypeWithKey = patchWithKey(
|
||||||
|
userPatch,
|
||||||
|
p.executionProcess.id,
|
||||||
|
'user'
|
||||||
|
);
|
||||||
|
entries.push(userPatchTypeWithKey);
|
||||||
|
|
||||||
|
// Remove all coding agent added user messages, replace with our custom one
|
||||||
|
const entriesExcludingUser = p.entries.filter(
|
||||||
|
(e) =>
|
||||||
|
e.type !== 'NORMALIZED_ENTRY' ||
|
||||||
|
e.content.entry_type.type !== 'user_message'
|
||||||
|
);
|
||||||
|
entries.push(...entriesExcludingUser);
|
||||||
|
if (
|
||||||
|
getLiveExecutionProcess(p.executionProcess.id)?.status === 'running'
|
||||||
|
) {
|
||||||
|
entries.push(loadingPatch);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
p.executionProcess.executor_action.typ.type === 'ScriptRequest'
|
||||||
|
) {
|
||||||
|
// Add setup and cleanup script as a tool call
|
||||||
|
let toolName = '';
|
||||||
|
switch (p.executionProcess.executor_action.typ.context) {
|
||||||
|
case 'SetupScript':
|
||||||
|
toolName = 'Setup Script';
|
||||||
|
break;
|
||||||
|
case 'CleanupScript':
|
||||||
|
toolName = 'Cleanup Script';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionProcess = getLiveExecutionProcess(
|
||||||
|
p.executionProcess.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const exit_status: CommandExitStatus | null =
|
||||||
|
executionProcess?.status === 'running'
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
type: 'exit_code',
|
||||||
|
code: Number(executionProcess?.exit_code) || 0,
|
||||||
|
};
|
||||||
|
const output = p.entries.map((line) => line.content).join('\n');
|
||||||
|
|
||||||
|
const toolNormalizedEntry: NormalizedEntry = {
|
||||||
|
entry_type: {
|
||||||
|
type: 'tool_use',
|
||||||
|
tool_name: toolName,
|
||||||
|
action_type: {
|
||||||
|
action: 'command_run',
|
||||||
|
command: p.executionProcess.executor_action.typ.script,
|
||||||
|
result: {
|
||||||
|
output,
|
||||||
|
exit_status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: toolName,
|
||||||
|
timestamp: null,
|
||||||
|
};
|
||||||
|
const toolPatch: PatchType = {
|
||||||
|
type: 'NORMALIZED_ENTRY',
|
||||||
|
content: toolNormalizedEntry,
|
||||||
|
};
|
||||||
|
const toolPatchWithKey: PatchTypeWithKey = patchWithKey(
|
||||||
|
toolPatch,
|
||||||
|
p.executionProcess.id,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.push(toolPatchWithKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
return allEntries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchWithKey = (
|
||||||
|
patch: PatchType,
|
||||||
|
executionProcessId: string,
|
||||||
|
index: number | 'user'
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
...patch,
|
||||||
|
patchKey: `${executionProcessId}:${index}`,
|
||||||
|
executionProcessId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadInitialEntries = async (): Promise<ExecutionProcessStateStore> => {
|
||||||
|
const localDisplayedExecutionProcesses: ExecutionProcessStateStore = {};
|
||||||
|
|
||||||
|
if (!executionProcesses?.current) return localDisplayedExecutionProcesses;
|
||||||
|
|
||||||
|
for (const executionProcess of [...executionProcesses.current].reverse()) {
|
||||||
|
if (executionProcess.status === 'running') continue;
|
||||||
|
|
||||||
|
const entries =
|
||||||
|
await loadEntriesForHistoricExecutionProcess(executionProcess);
|
||||||
|
const entriesWithKey = entries.map((e, idx) =>
|
||||||
|
patchWithKey(e, executionProcess.id, idx)
|
||||||
|
);
|
||||||
|
|
||||||
|
localDisplayedExecutionProcesses[executionProcess.id] = {
|
||||||
|
executionProcess,
|
||||||
|
entries: entriesWithKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
flattenEntries(localDisplayedExecutionProcesses).length >
|
||||||
|
MIN_INITIAL_ENTRIES
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localDisplayedExecutionProcesses;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRemainingEntriesInBatches = async (
|
||||||
|
batchSize: number
|
||||||
|
): Promise<ExecutionProcessStateStore | null> => {
|
||||||
|
const local = displayedExecutionProcesses.current; // keep ref if intentional
|
||||||
|
if (!executionProcesses?.current) return null;
|
||||||
|
|
||||||
|
let anyUpdated = false;
|
||||||
|
for (const executionProcess of [...executionProcesses.current].reverse()) {
|
||||||
|
if (local[executionProcess.id] || executionProcess.status === 'running')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const entries =
|
||||||
|
await loadEntriesForHistoricExecutionProcess(executionProcess);
|
||||||
|
const entriesWithKey = entries.map((e, idx) =>
|
||||||
|
patchWithKey(e, executionProcess.id, idx)
|
||||||
|
);
|
||||||
|
|
||||||
|
local[executionProcess.id] = {
|
||||||
|
executionProcess,
|
||||||
|
entries: entriesWithKey,
|
||||||
|
};
|
||||||
|
if (flattenEntries(local).length > batchSize) {
|
||||||
|
anyUpdated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
anyUpdated = true;
|
||||||
|
}
|
||||||
|
return anyUpdated ? local : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitEntries = (
|
||||||
|
executionProcessState: ExecutionProcessStateStore,
|
||||||
|
addEntryType: AddEntryType,
|
||||||
|
loading: boolean
|
||||||
|
) => {
|
||||||
|
// Flatten entries in chronological order of process start
|
||||||
|
const entries = flattenEntriesForEmit(executionProcessState);
|
||||||
|
onEntriesUpdatedRef.current?.(entries, addEntryType, loading);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stable key for dependency arrays when process list changes
|
||||||
|
const idListKey = useMemo(
|
||||||
|
() => executionProcessesRaw?.map((p) => p.id).join(','),
|
||||||
|
[executionProcessesRaw]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial load when attempt changes
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
// Waiting for execution processes to load
|
||||||
|
if (
|
||||||
|
executionProcesses?.current.length === 0 ||
|
||||||
|
loadedInitialEntries.current
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Initial entries
|
||||||
|
const allInitialEntries = await loadInitialEntries();
|
||||||
|
if (cancelled) return;
|
||||||
|
displayedExecutionProcesses.current = allInitialEntries;
|
||||||
|
emitEntries(allInitialEntries, 'initial', false);
|
||||||
|
loadedInitialEntries.current = true;
|
||||||
|
|
||||||
|
// Then load the remaining in batches
|
||||||
|
let updatedEntries;
|
||||||
|
while (
|
||||||
|
!cancelled &&
|
||||||
|
(updatedEntries =
|
||||||
|
await loadRemainingEntriesInBatches(REMAINING_BATCH_SIZE))
|
||||||
|
) {
|
||||||
|
if (cancelled) return;
|
||||||
|
displayedExecutionProcesses.current = updatedEntries;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
emitEntries(displayedExecutionProcesses.current, 'historic', false);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [attempt.id, idListKey]); // include idListKey so new processes trigger reload
|
||||||
|
|
||||||
|
// Running processes
|
||||||
|
useEffect(() => {
|
||||||
|
const runningProcess = getRunningExecutionProcesses();
|
||||||
|
if (runningProcess && lastRunningProcessId.current !== runningProcess.id) {
|
||||||
|
lastRunningProcessId.current = runningProcess.id;
|
||||||
|
loadRunningAndEmitWithBackoff(runningProcess);
|
||||||
|
}
|
||||||
|
}, [attempt.id, idListKey]);
|
||||||
|
|
||||||
|
// If an execution process is removed, remove it from the state
|
||||||
|
useEffect(() => {
|
||||||
|
if (!executionProcessesRaw) return;
|
||||||
|
|
||||||
|
const removedProcessIds = Object.keys(
|
||||||
|
displayedExecutionProcesses.current
|
||||||
|
).filter((id) => !executionProcessesRaw.some((p) => p.id === id));
|
||||||
|
|
||||||
|
removedProcessIds.forEach((id) => {
|
||||||
|
delete displayedExecutionProcesses.current[id];
|
||||||
|
});
|
||||||
|
}, [attempt.id, idListKey]);
|
||||||
|
|
||||||
|
// Reset state when attempt changes
|
||||||
|
useEffect(() => {
|
||||||
|
displayedExecutionProcesses.current = {};
|
||||||
|
loadedInitialEntries.current = false;
|
||||||
|
lastRunningProcessId.current = null;
|
||||||
|
// Emit blank entries
|
||||||
|
emitEntries(displayedExecutionProcesses.current, 'initial', true);
|
||||||
|
}, [attempt.id]);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
54
frontend/src/hooks/useExecutionProcesses.ts
Normal file
54
frontend/src/hooks/useExecutionProcesses.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useJsonPatchStream } from './useJsonPatchStream';
|
||||||
|
import type { ExecutionProcess } from 'shared/types';
|
||||||
|
|
||||||
|
type ExecutionProcessState = {
|
||||||
|
execution_processes: Record<string, ExecutionProcess>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseExecutionProcessesResult {
|
||||||
|
executionProcesses: ExecutionProcess[];
|
||||||
|
executionProcessesById: Record<string, ExecutionProcess>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream tasks for a project via SSE (JSON Patch) and expose as array + map.
|
||||||
|
* Server sends initial snapshot: replace /tasks with an object keyed by id.
|
||||||
|
* Live updates arrive at /tasks/<id> via add/replace/remove operations.
|
||||||
|
*/
|
||||||
|
export const useExecutionProcesses = (
|
||||||
|
taskAttemptId: string
|
||||||
|
): UseExecutionProcessesResult => {
|
||||||
|
const endpoint = `/api/execution-processes/stream?task_attempt_id=${encodeURIComponent(taskAttemptId)}`;
|
||||||
|
|
||||||
|
const initialData = useCallback(
|
||||||
|
(): ExecutionProcessState => ({ execution_processes: {} }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isConnected, error } =
|
||||||
|
useJsonPatchStream<ExecutionProcessState>(
|
||||||
|
endpoint,
|
||||||
|
!!taskAttemptId,
|
||||||
|
initialData
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionProcessesById = data?.execution_processes ?? {};
|
||||||
|
const executionProcesses = Object.values(executionProcessesById).sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.created_at as unknown as string).getTime() -
|
||||||
|
new Date(b.created_at as unknown as string).getTime()
|
||||||
|
);
|
||||||
|
const isLoading = !data && !error; // until first snapshot
|
||||||
|
|
||||||
|
return {
|
||||||
|
executionProcesses,
|
||||||
|
executionProcessesById,
|
||||||
|
isLoading,
|
||||||
|
isConnected,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
58
frontend/src/hooks/useNormalizedLogs.tsx
Normal file
58
frontend/src/hooks/useNormalizedLogs.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// useNormalizedLogs.ts
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useJsonPatchStream } from './useJsonPatchStream';
|
||||||
|
import { NormalizedEntry } from 'shared/types';
|
||||||
|
|
||||||
|
type EntryType = { type: string };
|
||||||
|
|
||||||
|
export interface NormalizedEntryContent {
|
||||||
|
timestamp: string | null;
|
||||||
|
entry_type: EntryType;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedLogsState {
|
||||||
|
entries: NormalizedEntry[];
|
||||||
|
session_id: string | null;
|
||||||
|
executor_type: string;
|
||||||
|
prompt: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNormalizedLogsResult {
|
||||||
|
entries: NormalizedEntry[];
|
||||||
|
state: NormalizedLogsState | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNormalizedLogs = (
|
||||||
|
processId: string,
|
||||||
|
enabled: boolean = true
|
||||||
|
): UseNormalizedLogsResult => {
|
||||||
|
const endpoint = `/api/execution-processes/${encodeURIComponent(processId)}/normalized-logs`;
|
||||||
|
|
||||||
|
const initialData = useCallback<() => NormalizedLogsState>(
|
||||||
|
() => ({
|
||||||
|
entries: [],
|
||||||
|
session_id: null,
|
||||||
|
executor_type: '',
|
||||||
|
prompt: null,
|
||||||
|
summary: null,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isConnected, error } = useJsonPatchStream<NormalizedLogsState>(
|
||||||
|
endpoint,
|
||||||
|
Boolean(processId) && enabled,
|
||||||
|
initialData
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = useMemo(() => data?.entries ?? [], [data?.entries]);
|
||||||
|
const isLoading = !data && !error;
|
||||||
|
|
||||||
|
return { entries, state: data, isLoading, isConnected, error };
|
||||||
|
};
|
||||||
229
frontend/src/hooks/useProcessRetry.ts
Normal file
229
frontend/src/hooks/useProcessRetry.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// hooks/useProcessRetry.ts
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
||||||
|
import { useBranchStatus } from '@/hooks/useBranchStatus';
|
||||||
|
import { showModal } from '@/lib/modals';
|
||||||
|
import {
|
||||||
|
shouldShowInLogs,
|
||||||
|
isCodingAgent,
|
||||||
|
PROCESS_RUN_REASONS,
|
||||||
|
} from '@/constants/processes';
|
||||||
|
import type { ExecutionProcess, TaskAttempt } from 'shared/types';
|
||||||
|
import type {
|
||||||
|
ExecutorActionType,
|
||||||
|
CodingAgentInitialRequest,
|
||||||
|
CodingAgentFollowUpRequest,
|
||||||
|
} from 'shared/types';
|
||||||
|
|
||||||
|
function isCodingAgentActionType(
|
||||||
|
t: ExecutorActionType
|
||||||
|
): t is
|
||||||
|
| ({ type: 'CodingAgentInitialRequest' } & CodingAgentInitialRequest)
|
||||||
|
| ({ type: 'CodingAgentFollowUpRequest' } & CodingAgentFollowUpRequest) {
|
||||||
|
return (
|
||||||
|
t.type === 'CodingAgentInitialRequest' ||
|
||||||
|
t.type === 'CodingAgentFollowUpRequest'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable hook to retry a process given its executionProcessId and a new prompt.
|
||||||
|
* Handles:
|
||||||
|
* - Preventing retry while anything is running (or that process is already running)
|
||||||
|
* - Optional worktree reset (via modal)
|
||||||
|
* - Variant extraction for coding-agent processes
|
||||||
|
* - Refetching attempt + branch data after replace
|
||||||
|
*/
|
||||||
|
export function useProcessRetry(attempt: TaskAttempt | undefined) {
|
||||||
|
const attemptId = attempt?.id;
|
||||||
|
|
||||||
|
// Fetch attempt + branch state the same way your component did
|
||||||
|
const { attemptData, refetch: refetchAttempt } =
|
||||||
|
useAttemptExecution(attemptId);
|
||||||
|
const { data: branchStatus, refetch: refetchBranch } =
|
||||||
|
useBranchStatus(attemptId);
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Any process running at all?
|
||||||
|
const anyRunning = useMemo(
|
||||||
|
() => (attemptData.processes || []).some((p) => p.status === 'running'),
|
||||||
|
[attemptData.processes?.map((p) => p.status).join(',')]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convenience lookups
|
||||||
|
const getProcessById = useCallback(
|
||||||
|
(pid: string): ExecutionProcess | undefined =>
|
||||||
|
(attemptData.processes || []).find((p) => p.id === pid),
|
||||||
|
[attemptData.processes]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a process is currently allowed to retry, and why not.
|
||||||
|
* Useful if you want to gray out buttons in any component.
|
||||||
|
*/
|
||||||
|
const getRetryDisabledState = useCallback(
|
||||||
|
(pid: string) => {
|
||||||
|
const proc = getProcessById(pid);
|
||||||
|
const isRunningProc = proc?.status === 'running';
|
||||||
|
const disabled = busy || anyRunning || isRunningProc;
|
||||||
|
let reason: string | undefined;
|
||||||
|
if (isRunningProc) reason = 'Finish or stop this run to retry.';
|
||||||
|
else if (anyRunning) reason = 'Cannot retry while a process is running.';
|
||||||
|
else if (busy) reason = 'Retry in progress.';
|
||||||
|
return { disabled, reason };
|
||||||
|
},
|
||||||
|
[busy, anyRunning, getProcessById]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary entrypoint: retry a process with a new prompt.
|
||||||
|
*/
|
||||||
|
const retryProcess = useCallback(
|
||||||
|
async (executionProcessId: string, newPrompt: string) => {
|
||||||
|
if (!attemptId) return;
|
||||||
|
|
||||||
|
const proc = getProcessById(executionProcessId);
|
||||||
|
if (!proc) return;
|
||||||
|
|
||||||
|
// Respect current disabled state
|
||||||
|
const { disabled } = getRetryDisabledState(executionProcessId);
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
type WithBefore = { before_head_commit?: string | null };
|
||||||
|
const before =
|
||||||
|
(proc as WithBefore | undefined)?.before_head_commit || null;
|
||||||
|
|
||||||
|
// Try to gather comparison info (best-effort)
|
||||||
|
let targetSubject: string | null = null;
|
||||||
|
let commitsToReset: number | null = null;
|
||||||
|
let isLinear: boolean | null = null;
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
try {
|
||||||
|
const { commitsApi } = await import('@/lib/api');
|
||||||
|
const info = await commitsApi.getInfo(attemptId, before);
|
||||||
|
targetSubject = info.subject;
|
||||||
|
const cmp = await commitsApi.compareToHead(attemptId, before);
|
||||||
|
commitsToReset = cmp.is_linear ? cmp.ahead_from_head : null;
|
||||||
|
isLinear = cmp.is_linear;
|
||||||
|
} catch {
|
||||||
|
// ignore best-effort enrichments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = branchStatus?.head_oid || null;
|
||||||
|
const dirty = !!branchStatus?.has_uncommitted_changes;
|
||||||
|
const needReset = !!(before && (before !== head || dirty));
|
||||||
|
const canGitReset = needReset && !dirty;
|
||||||
|
|
||||||
|
// Compute “later processes” context for the dialog
|
||||||
|
const procs = (attemptData.processes || []).filter(
|
||||||
|
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
|
||||||
|
);
|
||||||
|
const idx = procs.findIndex((p) => p.id === executionProcessId);
|
||||||
|
const later = idx >= 0 ? procs.slice(idx + 1) : [];
|
||||||
|
const laterCount = later.length;
|
||||||
|
const laterCoding = later.filter((p) =>
|
||||||
|
isCodingAgent(p.run_reason)
|
||||||
|
).length;
|
||||||
|
const laterSetup = later.filter(
|
||||||
|
(p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT
|
||||||
|
).length;
|
||||||
|
const laterCleanup = later.filter(
|
||||||
|
(p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Ask user for confirmation / reset options
|
||||||
|
let modalResult:
|
||||||
|
| {
|
||||||
|
action: 'confirmed' | 'canceled';
|
||||||
|
performGitReset?: boolean;
|
||||||
|
forceWhenDirty?: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
modalResult = await showModal<
|
||||||
|
typeof modalResult extends infer T
|
||||||
|
? T extends object
|
||||||
|
? T
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
>('restore-logs', {
|
||||||
|
targetSha: before,
|
||||||
|
targetSubject,
|
||||||
|
commitsToReset,
|
||||||
|
isLinear,
|
||||||
|
laterCount,
|
||||||
|
laterCoding,
|
||||||
|
laterSetup,
|
||||||
|
laterCleanup,
|
||||||
|
needGitReset: needReset,
|
||||||
|
canGitReset,
|
||||||
|
hasRisk: dirty,
|
||||||
|
uncommittedCount: branchStatus?.uncommitted_count ?? 0,
|
||||||
|
untrackedCount: branchStatus?.untracked_count ?? 0,
|
||||||
|
// Defaults
|
||||||
|
initialWorktreeResetOn: true,
|
||||||
|
initialForceReset: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// user closed dialog
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modalResult || modalResult.action !== 'confirmed') return;
|
||||||
|
|
||||||
|
let variant: string | null = null;
|
||||||
|
|
||||||
|
const typ = proc?.executor_action?.typ; // type: ExecutorActionType
|
||||||
|
|
||||||
|
if (typ && isCodingAgentActionType(typ)) {
|
||||||
|
// executor_profile_id is ExecutorProfileId -> has `variant: string | null`
|
||||||
|
variant = typ.executor_profile_id.variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the replacement
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
const { attemptsApi } = await import('@/lib/api');
|
||||||
|
await attemptsApi.replaceProcess(attemptId, {
|
||||||
|
process_id: executionProcessId,
|
||||||
|
prompt: newPrompt,
|
||||||
|
variant,
|
||||||
|
perform_git_reset: modalResult.performGitReset ?? true,
|
||||||
|
force_when_dirty: modalResult.forceWhenDirty ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh local caches
|
||||||
|
await refetchAttempt();
|
||||||
|
await refetchBranch();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
attemptId,
|
||||||
|
attemptData.processes,
|
||||||
|
branchStatus?.head_oid,
|
||||||
|
branchStatus?.has_uncommitted_changes,
|
||||||
|
branchStatus?.uncommitted_count,
|
||||||
|
branchStatus?.untracked_count,
|
||||||
|
getProcessById,
|
||||||
|
getRetryDisabledState,
|
||||||
|
refetchAttempt,
|
||||||
|
refetchBranch,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
retryProcess,
|
||||||
|
busy,
|
||||||
|
anyRunning,
|
||||||
|
/** Helpful for buttons/tooltips */
|
||||||
|
getRetryDisabledState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseProcessRetryReturn = ReturnType<typeof useProcessRetry>;
|
||||||
@@ -19,12 +19,8 @@ interface UseProjectTasksResult {
|
|||||||
* Server sends initial snapshot: replace /tasks with an object keyed by id.
|
* Server sends initial snapshot: replace /tasks with an object keyed by id.
|
||||||
* Live updates arrive at /tasks/<id> via add/replace/remove operations.
|
* Live updates arrive at /tasks/<id> via add/replace/remove operations.
|
||||||
*/
|
*/
|
||||||
export const useProjectTasks = (
|
export const useProjectTasks = (projectId: string): UseProjectTasksResult => {
|
||||||
projectId: string | undefined
|
const endpoint = `/api/tasks/stream?project_id=${encodeURIComponent(projectId)}`;
|
||||||
): UseProjectTasksResult => {
|
|
||||||
const endpoint = projectId
|
|
||||||
? `/api/tasks/stream?project_id=${encodeURIComponent(projectId)}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const initialData = useCallback((): TasksState => ({ tasks: {} }), []);
|
const initialData = useCallback((): TasksState => ({ tasks: {} }), []);
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function ProjectTasks() {
|
|||||||
tasksById,
|
tasksById,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: streamError,
|
error: streamError,
|
||||||
} = useProjectTasks(projectId);
|
} = useProjectTasks(projectId || '');
|
||||||
|
|
||||||
// Sync selectedTask with URL params and live task updates
|
// Sync selectedTask with URL params and live task updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
126
frontend/src/utils/streamSseJsonPatchEntries.ts
Normal file
126
frontend/src/utils/streamSseJsonPatchEntries.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// sseJsonPatchEntries.ts
|
||||||
|
import { applyPatch, type Operation } from 'rfc6902';
|
||||||
|
|
||||||
|
type PatchContainer<E = unknown> = { entries: E[] };
|
||||||
|
|
||||||
|
export interface StreamOptions<E = unknown> {
|
||||||
|
initial?: PatchContainer<E>;
|
||||||
|
eventSourceInit?: EventSourceInit;
|
||||||
|
/** called after each successful patch application */
|
||||||
|
onEntries?: (entries: E[]) => void;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
/** called once when a "finished" event is received */
|
||||||
|
onFinished?: (entries: E[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to an SSE endpoint that emits:
|
||||||
|
* event: json_patch
|
||||||
|
* data: [ { op, path, value? }, ... ]
|
||||||
|
*
|
||||||
|
* Maintains an in-memory { entries: [] } snapshot and returns a controller.
|
||||||
|
*/
|
||||||
|
export function streamSseJsonPatchEntries<E = unknown>(
|
||||||
|
url: string,
|
||||||
|
opts: StreamOptions<E> = {}
|
||||||
|
) {
|
||||||
|
let connected = false;
|
||||||
|
let snapshot: PatchContainer<E> = structuredClone(
|
||||||
|
opts.initial ?? ({ entries: [] } as PatchContainer<E>)
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribers = new Set<(entries: E[]) => void>();
|
||||||
|
if (opts.onEntries) subscribers.add(opts.onEntries);
|
||||||
|
|
||||||
|
const es = new EventSource(url, opts.eventSourceInit);
|
||||||
|
|
||||||
|
const notify = () => {
|
||||||
|
for (const cb of subscribers) {
|
||||||
|
try {
|
||||||
|
cb(snapshot.entries);
|
||||||
|
} catch {
|
||||||
|
/* swallow subscriber errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePatchEvent = (e: MessageEvent<string>) => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(e.data) as Operation[];
|
||||||
|
const ops = dedupeOps(raw);
|
||||||
|
|
||||||
|
// Apply to a working copy (applyPatch mutates)
|
||||||
|
const next = structuredClone(snapshot);
|
||||||
|
applyPatch(next as unknown as object, ops);
|
||||||
|
|
||||||
|
snapshot = next;
|
||||||
|
notify();
|
||||||
|
} catch (err) {
|
||||||
|
opts.onError?.(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener('open', () => {
|
||||||
|
connected = true;
|
||||||
|
opts.onConnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server uses a named event: "json_patch"
|
||||||
|
es.addEventListener('json_patch', handlePatchEvent);
|
||||||
|
|
||||||
|
es.addEventListener('finished', () => {
|
||||||
|
opts.onFinished?.(snapshot.entries);
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('error', (err) => {
|
||||||
|
connected = false; // EventSource will auto-retry; this just reflects current state
|
||||||
|
opts.onError?.(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Current entries array (immutable snapshot) */
|
||||||
|
getEntries(): E[] {
|
||||||
|
return snapshot.entries;
|
||||||
|
},
|
||||||
|
/** Full { entries } snapshot */
|
||||||
|
getSnapshot(): PatchContainer<E> {
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
/** Best-effort connection state (EventSource will auto-reconnect) */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return connected;
|
||||||
|
},
|
||||||
|
/** Subscribe to updates; returns an unsubscribe function */
|
||||||
|
onChange(cb: (entries: E[]) => void): () => void {
|
||||||
|
subscribers.add(cb);
|
||||||
|
// push current state immediately
|
||||||
|
cb(snapshot.entries);
|
||||||
|
return () => subscribers.delete(cb);
|
||||||
|
},
|
||||||
|
/** Close the stream */
|
||||||
|
close(): void {
|
||||||
|
es.close();
|
||||||
|
subscribers.clear();
|
||||||
|
connected = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedupe multiple ops that touch the same path within a single event.
|
||||||
|
* Last write for a path wins, while preserving the overall left-to-right
|
||||||
|
* order of the *kept* final operations.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* add /entries/4, replace /entries/4 -> keep only the final replace
|
||||||
|
*/
|
||||||
|
function dedupeOps(ops: Operation[]): Operation[] {
|
||||||
|
const lastIndexByPath = new Map<string, number>();
|
||||||
|
ops.forEach((op, i) => lastIndexByPath.set(op.path, i));
|
||||||
|
|
||||||
|
// Keep only the last op for each path, in ascending order of their final index
|
||||||
|
const keptIndices = [...lastIndexByPath.values()].sort((a, b) => a - b);
|
||||||
|
return keptIndices.map((i) => ops[i]!);
|
||||||
|
}
|
||||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -99,6 +99,9 @@ importers:
|
|||||||
'@uiw/react-codemirror':
|
'@uiw/react-codemirror':
|
||||||
specifier: ^4.25.1
|
specifier: ^4.25.1
|
||||||
version: 4.25.1(@babel/runtime@7.27.6)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.1)(codemirror@6.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 4.25.1(@babel/runtime@7.27.6)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.1)(codemirror@6.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@virtuoso.dev/message-list':
|
||||||
|
specifier: ^1.13.3
|
||||||
|
version: 1.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -117,6 +120,9 @@ importers:
|
|||||||
fancy-ansi:
|
fancy-ansi:
|
||||||
specifier: ^0.1.3
|
specifier: ^0.1.3
|
||||||
version: 0.1.3
|
version: 0.1.3
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.539.0
|
specifier: ^0.539.0
|
||||||
version: 0.539.0(react@18.3.1)
|
version: 0.539.0(react@18.3.1)
|
||||||
@@ -139,8 +145,8 @@ importers:
|
|||||||
specifier: ^2.1.7
|
specifier: ^2.1.7
|
||||||
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-virtuoso:
|
react-virtuoso:
|
||||||
specifier: ^4.13.0
|
specifier: ^4.14.0
|
||||||
version: 4.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 4.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-window:
|
react-window:
|
||||||
specifier: ^1.8.11
|
specifier: ^1.8.11
|
||||||
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -1710,6 +1716,18 @@ packages:
|
|||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
'@virtuoso.dev/gurx@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Z2mOKEgmMPnkf7hb3JaqJCVuvBD7qaK9KuI95BMBzOioHvPqhI79UcC6dHCprz9aZef/YaZ5ZwcYRRGVtfLU0g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16 || >= 17 || >= 18 || >= 19'
|
||||||
|
react-dom: '>= 16 || >= 17 || >= 18 || >= 19'
|
||||||
|
|
||||||
|
'@virtuoso.dev/message-list@1.13.3':
|
||||||
|
resolution: {integrity: sha512-kzKFpPJqrQCFuQ2piPEuy7p3qvzphIYZIKiJeHii8zUs73ggOhk6YBwr9BoPyWNX8DUKpSWdcLFx+cd/bAyRTA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16 || >= 17 || >= 18 || >= 19'
|
||||||
|
react-dom: '>= 16 || >= 17 || >= 18 || >= 19'
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.5.2':
|
'@vitejs/plugin-react@4.5.2':
|
||||||
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
|
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -2277,6 +2295,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -2874,8 +2895,8 @@ packages:
|
|||||||
react-dom:
|
react-dom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
react-virtuoso@4.13.0:
|
react-virtuoso@4.14.0:
|
||||||
resolution: {integrity: sha512-XHv2Fglpx80yFPdjZkV9d1baACKghg/ucpDFEXwaix7z0AfVQj+mF6lM+YQR6UC/TwzXG2rJKydRMb3+7iV3PA==}
|
resolution: {integrity: sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16 || >=17 || >= 18 || >= 19'
|
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||||
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||||
@@ -4892,6 +4913,17 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
'@virtuoso.dev/gurx@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@virtuoso.dev/message-list@1.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@virtuoso.dev/gurx': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.5.2(vite@5.4.19)':
|
'@vitejs/plugin-react@4.5.2(vite@5.4.19)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.27.4
|
'@babel/core': 7.27.4
|
||||||
@@ -5544,6 +5576,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -6207,7 +6241,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react-virtuoso@4.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-virtuoso@4.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export type EventPatch = { op: string, path: string, value: EventPatchInner, };
|
|||||||
|
|
||||||
export type EventPatchInner = { db_op: string, record: RecordTypes, };
|
export type EventPatchInner = { db_op: string, record: RecordTypes, };
|
||||||
|
|
||||||
export type RecordTypes = { "type": "TASK", "data": Task } | { "type": "TASK_ATTEMPT", "data": TaskAttempt } | { "type": "EXECUTION_PROCESS", "data": ExecutionProcess } | { "type": "FOLLOW_UP_DRAFT", "data": FollowUpDraft } | { "type": "DELETED_TASK", "data": { rowid: bigint, project_id: string | null, task_id: string | null, } } | { "type": "DELETED_TASK_ATTEMPT", "data": { rowid: bigint, task_id: string | null, } } | { "type": "DELETED_EXECUTION_PROCESS", "data": { rowid: bigint, task_attempt_id: string | null, } } | { "type": "DELETED_FOLLOW_UP_DRAFT", "data": { rowid: bigint, task_attempt_id: string | null, } };
|
export type RecordTypes = { "type": "TASK", "data": Task } | { "type": "TASK_ATTEMPT", "data": TaskAttempt } | { "type": "EXECUTION_PROCESS", "data": ExecutionProcess } | { "type": "FOLLOW_UP_DRAFT", "data": FollowUpDraft } | { "type": "DELETED_TASK", "data": { rowid: bigint, project_id: string | null, task_id: string | null, } } | { "type": "DELETED_TASK_ATTEMPT", "data": { rowid: bigint, task_id: string | null, } } | { "type": "DELETED_EXECUTION_PROCESS", "data": { rowid: bigint, task_attempt_id: string | null, process_id: string | null, } } | { "type": "DELETED_FOLLOW_UP_DRAFT", "data": { rowid: bigint, task_attempt_id: string | null, } };
|
||||||
|
|
||||||
export type FollowUpDraft = { id: string, task_attempt_id: string, prompt: string, queued: boolean, sending: boolean, variant: string | null, image_ids: Array<string> | null, created_at: string, updated_at: string, version: bigint, };
|
export type FollowUpDraft = { id: string, task_attempt_id: string, prompt: string, queued: boolean, sending: boolean, variant: string | null, image_ids: Array<string> | null, created_at: string, updated_at: string, version: bigint, };
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export type NormalizedConversation = { entries: Array<NormalizedEntry>, session_
|
|||||||
|
|
||||||
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
|
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
|
||||||
|
|
||||||
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" };
|
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" } | { "type": "loading" };
|
||||||
|
|
||||||
export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit",
|
export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit",
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user