Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/apps/desktop/src/api/acp_client_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,26 @@ pub struct ProbeAcpClientRequirementsRequest {
pub force_refresh: bool,
}

fn emit_acp_model_round_completed(
app_handle: &AppHandle,
session_id: &str,
turn_id: &str,
round_id: String,
has_tool_calls: bool,
) -> Result<(), bitfun_core::util::errors::BitFunError> {
app_handle
.emit(
"agentic://model-round-completed",
serde_json::json!({
"sessionId": session_id,
"turnId": turn_id,
"roundId": round_id,
"hasToolCalls": has_tool_calls,
}),
)
.map_err(|e| bitfun_core::util::errors::BitFunError::service(e.to_string()))
}

#[tauri::command]
pub async fn initialize_acp_clients(
state: State<'_, AppState>,
Expand Down Expand Up @@ -287,6 +307,7 @@ pub async fn start_acp_dialog_turn(
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let mut current_round_id: Option<String> = None;
let mut current_round_has_tool_calls = false;
let result = service
.prompt_agent_stream(
&request.client_id,
Expand All @@ -303,7 +324,17 @@ pub async fn start_acp_dialog_turn(
round_index,
disable_explore_grouping,
} => {
if let Some(previous_round_id) = current_round_id.take() {
emit_acp_model_round_completed(
&app_handle,
&request.session_id,
&request.turn_id,
previous_round_id,
current_round_has_tool_calls,
)?;
}
current_round_id = Some(round_id.clone());
current_round_has_tool_calls = false;
app_handle
.emit(
"agentic://model-round-started",
Expand Down Expand Up @@ -367,12 +398,19 @@ pub async fn start_acp_dialog_turn(
})?;
}
AcpClientStreamEvent::ToolEvent(tool_event) => {
let round_id = current_round_id.clone().ok_or_else(|| {
bitfun_core::util::errors::BitFunError::service(
"ACP tool event arrived before model round start".to_string(),
)
})?;
current_round_has_tool_calls = true;
app_handle
.emit(
"agentic://tool-event",
serde_json::json!({
"sessionId": request.session_id,
"turnId": request.turn_id,
"roundId": round_id,
"toolEvent": tool_event,
"subagentParentInfo": null,
}),
Expand Down Expand Up @@ -442,6 +480,15 @@ pub async fn start_acp_dialog_turn(
})?;
}
AcpClientStreamEvent::Completed => {
if let Some(round_id) = current_round_id.take() {
emit_acp_model_round_completed(
&app_handle,
&request.session_id,
&request.turn_id,
round_id,
current_round_has_tool_calls,
)?;
}
app_handle
.emit(
"agentic://dialog-turn-completed",
Expand All @@ -457,6 +504,15 @@ pub async fn start_acp_dialog_turn(
})?;
}
AcpClientStreamEvent::Cancelled => {
if let Some(round_id) = current_round_id.take() {
emit_acp_model_round_completed(
&app_handle,
&request.session_id,
&request.turn_id,
round_id,
current_round_has_tool_calls,
)?;
}
app_handle
.emit(
"agentic://dialog-turn-cancelled",
Expand Down
12 changes: 11 additions & 1 deletion src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ interface TaskWithSubagentWrapperProps {
turnId: string;
roundId?: string;
completedToolExitNowMs: number;
allowCompletedToolExit?: boolean;
}

const TaskWithSubagentWrapper: React.FC<TaskWithSubagentWrapperProps> = React.memo(({
Expand All @@ -189,6 +190,7 @@ const TaskWithSubagentWrapper: React.FC<TaskWithSubagentWrapperProps> = React.me
turnId,
roundId,
completedToolExitNowMs,
allowCompletedToolExit = false,
}) => {
const isCollapsed = useTaskCollapsed(parentTaskToolId);
const isTaskRunning =
Expand All @@ -211,6 +213,7 @@ const TaskWithSubagentWrapper: React.FC<TaskWithSubagentWrapperProps> = React.me
roundId={roundId}
isLastItem={false}
completedToolExitNowMs={completedToolExitNowMs}
allowCompletedToolExit={allowCompletedToolExit}
/>
<SubagentProjectionView
parentTaskToolId={parentTaskToolId}
Expand Down Expand Up @@ -438,6 +441,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
roundId={options.roundId}
isLastItem={isLast && itemIdx === group.items.length - 1}
completedToolExitNowMs={transientNowMs}
allowCompletedToolExit
/>
));

Expand All @@ -456,6 +460,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnId={turnId}
roundId={options.roundId}
completedToolExitNowMs={transientNowMs}
allowCompletedToolExit={false}
/>
);
}
Expand All @@ -467,6 +472,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
roundId={options.roundId}
isLastItem={isLast}
completedToolExitNowMs={transientNowMs}
allowCompletedToolExit={false}
/>
);
}
Expand Down Expand Up @@ -814,6 +820,7 @@ interface FlowItemRendererProps {
roundId?: string;
isLastItem?: boolean;
completedToolExitNowMs: number;
allowCompletedToolExit?: boolean;
}

// Do not memoize: streaming content updates frequently.
Expand All @@ -823,6 +830,7 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
roundId,
isLastItem,
completedToolExitNowMs,
allowCompletedToolExit = false,
}) => {
const {
onToolConfirm,
Expand Down Expand Up @@ -861,10 +869,12 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
const isCompletedTool = toolItem.status === 'completed';
const isCollapsible = isCollapsibleTool(toolItem.toolName);
const shouldAnimateCompletedExit =
allowCompletedToolExit &&
isCollapsible &&
isCompletedTool &&
isCompletedToolInTransientWindow(toolItem, completedToolExitNowMs);
const isSettledCompletedTool = isCollapsible && isCompletedTool && !shouldAnimateCompletedExit;
const isSettledCompletedTool =
allowCompletedToolExit && isCollapsible && isCompletedTool && !shouldAnimateCompletedExit;
const toolClassName = [
'flowchat-flow-item',
isCollapsible && isCompletedTool ? 'flowchat-flow-item--tool-transition' : null,
Expand Down
Loading