From a7b535a769444a2f8e3c6fc2777fd263709362a4 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 16:35:36 -1000 Subject: [PATCH 01/36] fix: compact desktop markdown headings --- .../ui/src/components/ai-elements/message.tsx | 30 +++++++++++++++++++ .../chat/message-response-style.test.ts | 16 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 0fae748a..905d7ab6 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -274,6 +274,35 @@ export type MessageResponseProps = ComponentProps const streamdownPlugins = { cjk, code, math, mermaid } +type TranscriptMarkdownHeadingProps = ComponentProps<"h1"> & { node?: unknown } + +function TranscriptMarkdownHeading({ + className, + node: _node, + ...props +}: TranscriptMarkdownHeadingProps) { + return ( +

+ ) +} + +// Product requirement: transcript Markdown headings should look like bold body text, +// not oversized section titles or headings with divider rules. +const transcriptMarkdownComponents: NonNullable = { + h1: TranscriptMarkdownHeading, + h2: TranscriptMarkdownHeading, + h3: TranscriptMarkdownHeading, + h4: TranscriptMarkdownHeading, + h5: TranscriptMarkdownHeading, + h6: TranscriptMarkdownHeading, +} + export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", className, )} + components={transcriptMarkdownComponents} plugins={streamdownPlugins} {...props} /> diff --git a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts index 0a6f354c..1bab5541 100644 --- a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts +++ b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts @@ -21,4 +21,20 @@ describe("MessageResponse markdown surfaces", () => { tableHeaderSurface: true, }) }) + + test("keeps transcript markdown headings visually compact", () => { + expect({ + requirementComment: messageSource.includes( + "transcript Markdown headings should look like bold body text", + ), + headingComponents: messageSource.includes("const transcriptMarkdownComponents"), + headingStyle: messageSource.includes( + "my-2 border-0 pb-0 text-sm font-semibold leading-6 text-foreground", + ), + }).toEqual({ + requirementComment: true, + headingComponents: true, + headingStyle: true, + }) + }) }) From 1ff5c7ed6e1826d3e297d6be71a41f10656cddc5 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 17:10:50 -1000 Subject: [PATCH 02/36] refactor: make query event callbacks async --- crates/cli/src/prompt_command.rs | 9 +- crates/core/src/query.rs | 452 +++++++++++++++---------- crates/core/src/tools/router.rs | 59 ++-- crates/core/tests/real_llm_e2e.rs | 13 +- crates/server/src/runtime/research.rs | 18 +- crates/server/src/runtime/turn_exec.rs | 263 +++----------- 6 files changed, 384 insertions(+), 430 deletions(-) diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 377badba..baece56e 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -424,9 +424,12 @@ fn jsonl_event_callback( } Some(Arc::new(move |event| { - if let Err(error) = write_query_event_jsonl(session_id.as_str(), &event) { - eprintln!("devo [prompt] failed to write jsonl event: {error}"); - } + let session_id = session_id.clone(); + Box::pin(async move { + if let Err(error) = write_query_event_jsonl(session_id.as_str(), &event) { + eprintln!("devo [prompt] failed to write jsonl event: {error}"); + } + }) })) } diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index 807adc3c..58057a70 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -19,6 +19,7 @@ use devo_protocol::StreamEvent; use devo_protocol::ToolDefinition; use devo_protocol::TruncationPolicy; use futures::StreamExt; +use futures::future::BoxFuture; use tokio::time::sleep; use tracing::debug; use tracing::info; @@ -163,8 +164,29 @@ pub enum QueryEvent { Usage { usage: devo_protocol::Usage }, } -/// Callback for streaming query events to the UI layer. -pub type EventCallback = Arc; +/// Async sink for streaming `QueryEvent`s out of the core query loop. +/// +/// The type is intentionally erased so `query()` can accept callbacks from tests, the server +/// runtime, and tool-progress plumbing without knowing their concrete future types: +/// +/// - `Arc`: shared, cheap-to-clone ownership. The same callback is cloned into model-stream and +/// tool-progress paths that may outlive the immediate stack frame. +/// - `dyn Fn(QueryEvent)`: dynamic callback interface. Callers provide any closure that accepts one +/// event and can be invoked repeatedly. +/// - `BoxFuture<'static, ()>`: boxed async work returned by the callback. Boxing hides the +/// closure's concrete future type behind one trait-object shape; `'static` prevents borrowed +/// stack data from escaping into spawned or delayed event paths. +/// - `Send + Sync`: the callback can be shared and awaited across Tokio tasks and worker threads. +/// +/// Awaiting this future is what lets callers use bounded async channels for backpressure instead of +/// the old synchronous callback bridge. +pub type EventCallback = Arc BoxFuture<'static, ()> + Send + Sync>; + +async fn emit_query_event(on_event: &Option, event: QueryEvent) { + if let Some(callback) = on_event { + callback(event).await; + } +} // --------------------------------------------------------------------------- // Error classification @@ -593,21 +615,23 @@ fn hosted_tool_input_or_previous( } } -fn emit_hosted_tool_start( - emit: &F, +async fn emit_hosted_tool_start( + on_event: &Option, emitted_tool_use_starts: &mut HashSet, id: &str, name: &str, input: &serde_json::Value, -) where - F: Fn(QueryEvent), -{ +) { if emitted_tool_use_starts.insert(id.to_string()) { - emit(QueryEvent::ToolUseStart { - id: id.to_string(), - name: name.to_string(), - input: input.clone(), - }); + emit_query_event( + on_event, + QueryEvent::ToolUseStart { + id: id.to_string(), + name: name.to_string(), + input: input.clone(), + }, + ) + .await; } } @@ -619,14 +643,12 @@ struct HostedToolResultEvent<'a> { status: Option, } -fn emit_hosted_tool_result( - emit: &F, +async fn emit_hosted_tool_result( + on_event: &Option, emitted_tool_results: &mut HashSet, session_cwd: &std::path::Path, event: HostedToolResultEvent<'_>, -) where - F: Fn(QueryEvent), -{ +) { let HostedToolResultEvent { id, name, @@ -648,15 +670,19 @@ fn emit_hosted_tool_result( ToolContent::Text(text.clone()) }; let summary = crate::tools::tool_summary::tool_summary(name, input, session_cwd); - emit(QueryEvent::ToolResult { - tool_use_id: id.to_string(), - tool_name: name.to_string(), - input: input.clone(), - content, - display_content: Some(text), - is_error: hosted_tool_status_is_error(status.as_deref()), - summary, - }); + emit_query_event( + on_event, + QueryEvent::ToolResult { + tool_use_id: id.to_string(), + tool_name: name.to_string(), + input: input.clone(), + content, + display_content: Some(text), + is_error: hosted_tool_status_is_error(status.as_deref()), + summary, + }, + ) + .await; } fn hosted_tool_result_text( @@ -752,13 +778,6 @@ pub async fn query( runtime: &ToolRuntime, on_event: Option, ) -> Result<(), AgentError> { - // emit is the event callback function. - let emit = |event: QueryEvent| { - if let Some(ref cb) = on_event { - cb(event); - } - }; - let agents_md_manager = AgentsMdManager::new(session.config.agents_md.clone()); let current_agents_snapshot = load_workspace_instructions(&session.cwd, &agents_md_manager); let agent_scope = runtime.agent_scope(); @@ -1063,15 +1082,15 @@ pub async fn query( Ok(StreamEvent::TextStart { .. }) => {} Ok(StreamEvent::TextDelta { text, .. }) => { assistant_text.push_str(&text); - emit(QueryEvent::TextDelta(text)); + emit_query_event(&on_event, QueryEvent::TextDelta(text)).await; } Ok(StreamEvent::ReasoningStart { .. }) => {} Ok(StreamEvent::ReasoningDelta { text, .. }) => { reasoning_text.push_str(&text); - emit(QueryEvent::ReasoningDelta(text)); + emit_query_event(&on_event, QueryEvent::ReasoningDelta(text)).await; } Ok(StreamEvent::ReasoningDone { .. }) => { - emit(QueryEvent::ReasoningCompleted); + emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; } Ok(StreamEvent::ToolCallStart { index, @@ -1091,12 +1110,13 @@ pub async fn query( let name = normalize_hosted_tool_name(name); hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); emit_hosted_tool_start( - &emit, + &on_event, &mut emitted_hosted_tool_starts, &id, &name, &input, - ); + ) + .await; } Ok(StreamEvent::HostedToolCallDone { index, @@ -1114,14 +1134,15 @@ pub async fn query( let input = hosted_tool_input_or_previous(input, previous_input); hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); emit_hosted_tool_start( - &emit, + &on_event, &mut emitted_hosted_tool_starts, &id, &name, &input, - ); + ) + .await; emit_hosted_tool_result( - &emit, + &on_event, &mut emitted_hosted_tool_results, &session.cwd, HostedToolResultEvent { @@ -1131,7 +1152,8 @@ pub async fn query( output, status, }, - ); + ) + .await; } Ok(StreamEvent::ToolCallInputDelta { index, @@ -1161,12 +1183,16 @@ pub async fn query( session.last_input_tokens = response.usage.input_tokens; session.last_turn_tokens = response.usage.display_total_tokens(); - emit(QueryEvent::Usage { - usage: response.usage.clone(), - }); + emit_query_event( + &on_event, + QueryEvent::Usage { + usage: response.usage.clone(), + }, + ) + .await; } Ok(StreamEvent::UsageDelta(usage)) => { - emit(QueryEvent::UsageDelta { usage }); + emit_query_event(&on_event, QueryEvent::UsageDelta { usage }).await; } Err(e) => { warn!( @@ -1299,15 +1325,16 @@ pub async fn query( }); hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); emit_hosted_tool_start( - &emit, + &on_event, &mut emitted_hosted_tool_starts, &id, &name, &input, - ); + ) + .await; if output.is_some() || status.is_some() { emit_hosted_tool_result( - &emit, + &on_event, &mut emitted_hosted_tool_results, &session.cwd, HostedToolResultEvent { @@ -1317,7 +1344,8 @@ pub async fn query( output: output.clone(), status: status.clone(), }, - ); + ) + .await; } } ResponseContent::ProviderReasoning { provider, payload } => { @@ -1344,8 +1372,12 @@ pub async fn query( }) .collect::(); if !final_reasoning.is_empty() { - emit(QueryEvent::ReasoningDelta(final_reasoning.clone())); - emit(QueryEvent::ReasoningCompleted); + emit_query_event( + &on_event, + QueryEvent::ReasoningDelta(final_reasoning.clone()), + ) + .await; + emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; reasoning_text = final_reasoning; } } @@ -1360,8 +1392,12 @@ pub async fn query( }) .collect::(); if !final_reasoning.is_empty() { - emit(QueryEvent::ReasoningDelta(final_reasoning.clone())); - emit(QueryEvent::ReasoningCompleted); + emit_query_event( + &on_event, + QueryEvent::ReasoningDelta(final_reasoning.clone()), + ) + .await; + emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; reasoning_text = final_reasoning; } } @@ -1372,9 +1408,16 @@ pub async fn query( .map(|(id, (_index, name, input))| (id.clone(), name.clone(), input.clone())) .collect::>(); for (id, name, input) in pending_hosted_tools { - emit_hosted_tool_start(&emit, &mut emitted_hosted_tool_starts, &id, &name, &input); + emit_hosted_tool_start( + &on_event, + &mut emitted_hosted_tool_starts, + &id, + &name, + &input, + ) + .await; emit_hosted_tool_result( - &emit, + &on_event, &mut emitted_hosted_tool_results, &session.cwd, HostedToolResultEvent { @@ -1384,7 +1427,8 @@ pub async fn query( output: None, status: Some("completed".to_string()), }, - ); + ) + .await; } // Build assistant message @@ -1432,33 +1476,35 @@ pub async fn query( }) .unwrap_or_default(); - let tool_calls: Vec = tool_uses - .into_iter() - .map(|(_index, id, name, initial_input, json_str, saw_delta)| { - let input = if saw_delta { - serde_json::from_str(&json_str).unwrap_or_else(|_| { - final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) - }) - } else { + let mut tool_calls = Vec::with_capacity(tool_uses.len()); + for (_index, id, name, initial_input, json_str, saw_delta) in tool_uses { + let input = if saw_delta { + serde_json::from_str(&json_str).unwrap_or_else(|_| { final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) - }; - if emitted_tool_use_starts.insert(id.clone()) { - emit(QueryEvent::ToolUseStart { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }); - } - if !final_response_tool_use_ids.contains(&id) { - assistant_content.push(ContentBlock::ToolUse { + }) + } else { + final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) + }; + if emitted_tool_use_starts.insert(id.clone()) { + emit_query_event( + &on_event, + QueryEvent::ToolUseStart { id: id.clone(), name: name.clone(), input: input.clone(), - }); - } - ToolCall { id, name, input } - }) - .collect(); + }, + ) + .await; + } + if !final_response_tool_use_ids.contains(&id) { + assistant_content.push(ContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } + tool_calls.push(ToolCall { id, name, input }); + } let assistant_content_contains_dsml_tool_call = assistant_content_contains_dsml_tool_call_text(&assistant_content); @@ -1511,7 +1557,7 @@ pub async fn query( } if let Some(sr) = stop_reason { - emit(QueryEvent::TurnComplete { stop_reason: sr }); + emit_query_event(&on_event, QueryEvent::TurnComplete { stop_reason: sr }).await; } debug!("no tool calls, ending query loop"); session.end_turn(); @@ -1546,27 +1592,36 @@ pub async fn query( .execute_batch_streaming_with_completion( &tool_calls, move |tool_use_id, progress| { - progress_events(QueryEvent::ToolProgress { - tool_use_id: tool_use_id.to_string(), - progress, - }); + let progress_events = Arc::clone(&progress_events); + Box::pin(async move { + progress_events(QueryEvent::ToolProgress { + tool_use_id, + progress, + }) + .await; + }) }, move |result| { - let (tool_name, input, summary) = metadata - .get(result.tool_use_id.as_str()) - .cloned() - .unwrap_or_else(|| { - (String::new(), serde_json::Value::Null, String::new()) - }); - completion_events(QueryEvent::ToolResult { - tool_use_id: result.tool_use_id.clone(), - tool_name, - input, - content: result.content.clone(), - display_content: result.display_content.clone(), - is_error: result.is_error, - summary, - }); + let completion_events = Arc::clone(&completion_events); + let metadata = Arc::clone(&metadata); + Box::pin(async move { + let (tool_name, input, summary) = metadata + .get(result.tool_use_id.as_str()) + .cloned() + .unwrap_or_else(|| { + (String::new(), serde_json::Value::Null, String::new()) + }); + completion_events(QueryEvent::ToolResult { + tool_use_id: result.tool_use_id, + tool_name, + input, + content: result.content, + display_content: result.display_content, + is_error: result.is_error, + summary, + }) + .await; + }) }, ) .await @@ -1693,6 +1748,7 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; + use crate::EventCallback; use crate::tools::ToolAgentScope; use crate::tools::ToolContent; use crate::tools::ToolPreparationFeedback; @@ -3005,8 +3061,11 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - seen_clone.lock().unwrap().push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) }); query( @@ -3159,8 +3218,11 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - seen_clone.lock().unwrap().push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) }); query( @@ -3263,8 +3325,11 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - seen_clone.lock().unwrap().push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) }); query( @@ -4115,8 +4180,11 @@ mod tests { session.push_message(Message::user("hello")); let seen_events = Arc::new(Mutex::new(Vec::new())); let callback_events = Arc::clone(&seen_events); - let callback = Arc::new(move |event: QueryEvent| { - callback_events.lock().expect("lock callback").push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) }); query( @@ -4216,8 +4284,11 @@ mod tests { session.push_message(Message::user("hello")); let seen_events = Arc::new(Mutex::new(Vec::new())); let callback_events = Arc::clone(&seen_events); - let callback = Arc::new(move |event: QueryEvent| { - callback_events.lock().expect("lock callback").push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) }); query( @@ -4377,8 +4448,11 @@ mod tests { session.push_message(Message::user("hello")); let seen_events = Arc::new(Mutex::new(Vec::new())); let callback_events = Arc::clone(&seen_events); - let callback = Arc::new(move |event: QueryEvent| { - callback_events.lock().expect("lock callback").push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) }); query( @@ -4725,10 +4799,13 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolResult { summary, .. } = event { - seen_clone.lock().unwrap().push(summary); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { summary, .. } = event { + seen_clone.lock().unwrap().push(summary); + } + }) }); query( @@ -4779,13 +4856,16 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolResult { - tool_name, input, .. - } = event - { - seen_clone.lock().unwrap().push((tool_name, input)); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + tool_name, input, .. + } = event + { + seen_clone.lock().unwrap().push((tool_name, input)); + } + }) }); query( @@ -4832,13 +4912,16 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolResult { - tool_use_id, input, .. - } = event - { - seen_clone.lock().unwrap().push((tool_use_id, input)); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + tool_use_id, input, .. + } = event + { + seen_clone.lock().unwrap().push((tool_use_id, input)); + } + }) }); query( @@ -4897,18 +4980,21 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolResult { - content, - display_content, - .. - } = event - { - seen_clone - .lock() - .expect("lock seen events") - .push((content.into_string(), display_content)); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + content, + display_content, + .. + } = event + { + seen_clone + .lock() + .expect("lock seen events") + .push((content.into_string(), display_content)); + } + }) }); query( @@ -4979,10 +5065,13 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolUseStart { id, input, .. } = event { - seen_clone.lock().unwrap().push((id, input)); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolUseStart { id, input, .. } = event { + seen_clone.lock().unwrap().push((id, input)); + } + }) }); query( @@ -5033,15 +5122,18 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - if let QueryEvent::ToolResult { - content, - display_content, - .. - } = event - { - seen_clone.lock().unwrap().push((content, display_content)); - } + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + content, + display_content, + .. + } = event + { + seen_clone.lock().unwrap().push((content, display_content)); + } + }) }); query( @@ -5092,8 +5184,11 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| { - seen_clone.lock().unwrap().push(event); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) }); query( @@ -5170,25 +5265,30 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); let seen_clone = Arc::clone(&seen); - let callback = Arc::new(move |event: QueryEvent| match event { - QueryEvent::ToolUseStart { id, .. } => { - seen_clone - .lock() - .expect("lock events") - .push(format!("start:{id}")); - } - QueryEvent::ToolResult { - tool_use_id, - content, - .. - } => { - let content = content.into_string(); - seen_clone - .lock() - .expect("lock events") - .push(format!("result:{tool_use_id}:{content}")); - } - _ => {} + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + match event { + QueryEvent::ToolUseStart { id, .. } => { + seen_clone + .lock() + .expect("lock events") + .push(format!("start:{id}")); + } + QueryEvent::ToolResult { + tool_use_id, + content, + .. + } => { + let content = content.into_string(); + seen_clone + .lock() + .expect("lock events") + .push(format!("result:{tool_use_id}:{content}")); + } + _ => {} + } + }) }); query( diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index 5d30894b..475e8aa2 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -8,6 +8,7 @@ use devo_safety::ResourceKind; use devo_tools::contracts::ToolBudgets; use devo_tools::contracts::ToolProgress; use futures::StreamExt; +use futures::future::BoxFuture; use futures::stream::FuturesUnordered; use tokio::sync::RwLock; use tokio::sync::mpsc; @@ -24,11 +25,11 @@ use devo_tools::ClientTerminal; use devo_tools::ToolAgentScope; use tokio_util::sync::CancellationToken; -type ProgressCallback = dyn Fn(&str, ToolProgress) + Send + Sync; +type ProgressCallback = dyn Fn(String, ToolProgress) -> BoxFuture<'static, ()> + Send + Sync; type ProgressCallbackArc = Arc; -type CompletionCallback = dyn Fn(&ToolCallResult) + Send + Sync; +type CompletionCallback = dyn Fn(ToolCallResult) -> BoxFuture<'static, ()> + Send + Sync; type CompletionCallbackArc = Arc; -type ExecutionStartCallback = dyn Fn(&ToolCall) + Send + Sync; +type ExecutionStartCallback = dyn Fn(ToolCall) -> BoxFuture<'static, ()> + Send + Sync; type ExecutionStartCallbackArc = Arc; type PermissionFuture = futures::future::BoxFuture<'static, Result<(), String>>; type PermissionCheckFn = dyn Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync; @@ -137,7 +138,7 @@ impl ToolRuntime { pub async fn execute_batch_streaming( &self, calls: &[ToolCall], - on_progress: impl Fn(&str, ToolProgress) + Send + Sync + 'static, + on_progress: impl Fn(String, ToolProgress) -> BoxFuture<'static, ()> + Send + Sync + 'static, ) -> Vec { self.execute_batch_inner( calls, @@ -150,8 +151,8 @@ impl ToolRuntime { pub async fn execute_batch_streaming_with_completion( &self, calls: &[ToolCall], - on_progress: impl Fn(&str, ToolProgress) + Send + Sync + 'static, - on_completion: impl Fn(&ToolCallResult) + Send + Sync + 'static, + on_progress: impl Fn(String, ToolProgress) -> BoxFuture<'static, ()> + Send + Sync + 'static, + on_completion: impl Fn(ToolCallResult) -> BoxFuture<'static, ()> + Send + Sync + 'static, ) -> Vec { self.execute_batch_inner( calls, @@ -190,7 +191,7 @@ impl ToolRuntime { .collect(); while let Some((index, result)) = futures.next().await { if let Some(callback) = &on_completion { - callback(&result); + callback(result.clone()).await; } indexed_results.push((index, result)); } @@ -200,7 +201,7 @@ impl ToolRuntime { let _guard = self.gate.write().await; let result = self.execute_single(call, &on_progress).await; if let Some(callback) = &on_completion { - callback(&result); + callback(result.clone()).await; } indexed_results.push((index, result)); } @@ -285,7 +286,7 @@ impl ToolRuntime { } if let Some(callback) = &self.execution_options.on_tool_execution_start { - callback(call); + callback(call.clone()).await; } info!(tool = %tool_name, id = %call.id, "executing tool"); @@ -313,7 +314,7 @@ impl ToolRuntime { let tool_use_id = call.id.clone(); let task = tokio::spawn(async move { while let Some(progress) = progress_rx.recv().await { - callback(&tool_use_id, progress); + callback(tool_use_id.clone(), progress).await; } }); (Some(progress_tx), Some(task)) @@ -1567,12 +1568,15 @@ mod tests { let results = runtime .execute_batch_streaming_with_completion( &calls, - |_tool_use_id, _content| {}, + |_tool_use_id, _content| Box::pin(async {}), move |result| { - completions_clone - .lock() - .expect("lock completions") - .push(result.tool_use_id.clone()); + let completions_clone = Arc::clone(&completions_clone); + Box::pin(async move { + completions_clone + .lock() + .expect("lock completions") + .push(result.tool_use_id.clone()); + }) }, ) .await; @@ -1708,13 +1712,16 @@ mod tests { let results = runtime .execute_batch_streaming(&[call], move |tool_use_id, progress| { - let ToolProgress::OutputDelta { delta } = progress else { - return; - }; - progress_items_for_callback - .lock() - .expect("progress lock") - .push(format!("{tool_use_id}:{delta}")); + let progress_items_for_callback = Arc::clone(&progress_items_for_callback); + Box::pin(async move { + let ToolProgress::OutputDelta { delta } = progress else { + return; + }; + progress_items_for_callback + .lock() + .expect("progress lock") + .push(format!("{tool_use_id}:{delta}")); + }) }) .await; @@ -1731,7 +1738,9 @@ mod tests { async fn execute_batch_streaming_empty() { let registry = make_streaming_registry(); let runtime = ToolRuntime::new_without_permissions(registry); - let results = runtime.execute_batch_streaming(&[], |_, _| {}).await; + let results = runtime + .execute_batch_streaming(&[], |_, _| Box::pin(async {})) + .await; assert!(results.is_empty()); } @@ -1744,7 +1753,9 @@ mod tests { name: "nonexistent".into(), input: serde_json::json!({}), }; - let results = runtime.execute_batch_streaming(&[call], |_, _| {}).await; + let results = runtime + .execute_batch_streaming(&[call], |_, _| Box::pin(async {})) + .await; assert_eq!(results.len(), 1); assert!(results[0].is_error); } diff --git a/crates/core/tests/real_llm_e2e.rs b/crates/core/tests/real_llm_e2e.rs index ca31c81b..3fa5c48a 100644 --- a/crates/core/tests/real_llm_e2e.rs +++ b/crates/core/tests/real_llm_e2e.rs @@ -121,11 +121,14 @@ async fn run_query( let provider = RealLlmConfig::from_env()?.provider(); let seen_events = Arc::new(std::sync::Mutex::new(Vec::new())); let callback_events = Arc::clone(&seen_events); - let callback = Arc::new(move |event: QueryEvent| { - callback_events - .lock() - .expect("query event callback mutex should not be poisoned") - .push(event); + let callback: devo_core::EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events + .lock() + .expect("query event callback mutex should not be poisoned") + .push(event); + }) }); query( diff --git a/crates/server/src/runtime/research.rs b/crates/server/src/runtime/research.rs index 1a6a363d..28557c8f 100644 --- a/crates/server/src/runtime/research.rs +++ b/crates/server/src/runtime/research.rs @@ -14,6 +14,7 @@ use crate::session_context::SessionRuntimeContext; pub(crate) const RESEARCH_FILE_TOOL_NAMES: &[&str] = &["read", "write", "apply_patch"]; const RESEARCH_NO_TOOL_NAMES: &[&str] = &[]; +const RESEARCH_QUERY_EVENT_CHANNEL_CAPACITY: usize = 1024; const RESEARCH_CLARIFICATION_TOOL_NAMES: &[&str] = &["request_user_input"]; const RESEARCH_SUPERVISOR_TOOL_NAMES: &[&str] = &[ "spawn_agent", @@ -973,9 +974,12 @@ impl ServerRuntime { stage: ResearchStageKind, mut capture: ResearchStageCapture<'_>, ) -> anyhow::Result<()> { - let (tx, mut rx) = mpsc::unbounded_channel(); - let callback = Arc::new(move |event: QueryEvent| { - let _ = tx.send(event); + let (tx, mut rx) = mpsc::channel::(RESEARCH_QUERY_EVENT_CHANNEL_CAPACITY); + let callback: devo_core::EventCallback = Arc::new(move |event: QueryEvent| { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(event).await; + }) }); let mut stage_turn_config = runtime.turn_config.clone(); stage_turn_config.web_search = devo_core::ResolvedWebSearchConfig::Disabled; @@ -2272,10 +2276,10 @@ impl ServerRuntime { }, ToolExecutionOptions { cancel_token: turn_cancel_token, - on_tool_execution_start: Some(Arc::new(move |call: &ToolCall| { + on_tool_execution_start: Some(Arc::new(move |call: ToolCall| { let runtime = Arc::clone(&tool_execution_start_runtime); - let tool_call_id = call.id.clone(); - tokio::spawn(async move { + let tool_call_id = call.id; + Box::pin(async move { runtime .broadcast_event(ServerEvent::ToolCallStatusUpdated( devo_protocol::ToolCallStatusUpdatedPayload { @@ -2287,7 +2291,7 @@ impl ServerRuntime { }, )) .await; - }); + }) })), ..ToolExecutionOptions::default() }, diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 511673e8..0b74145f 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -1,10 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; -use std::sync::mpsc as std_mpsc; -use std::time::Duration; use std::time::Instant; use super::proposed_plan::{ProposedPlanParser, ProposedPlanSegment}; @@ -15,8 +11,6 @@ use devo_util_git::extract_paths_from_patch; use tokio::sync::mpsc; const QUERY_EVENT_CHANNEL_CAPACITY: usize = 1024; -const QUERY_EVENT_FORWARD_CHANNEL_CAPACITY: usize = 1; -const QUERY_EVENT_BACKPRESSURE_LOG_THRESHOLD: Duration = Duration::from_millis(50); struct PendingToolCall { item_id: Option, @@ -69,122 +63,32 @@ fn turn_failure_reason_from_error( } } +/// Inputs captured at turn-start time and handed to the background turn executor. pub(super) struct ExecuteTurnRequest { + /// Runtime session that owns the turn and receives emitted items, usage, and status updates. pub(super) session_id: SessionId, + /// Pre-created turn metadata persisted at turn start; execution mutates a local copy to its + /// terminal status before appending the final turn record. pub(super) turn: TurnMetadata, + /// Resolved model, provider, reasoning, tool, web, and token-budget settings for this turn. pub(super) turn_config: TurnConfig, + /// User-facing rendering of the submitted input. Visible turns persist this as the displayed + /// user message; hidden continuation turns keep it out of the transcript. pub(super) display_input: String, + /// Canonical resolved prompt text. Visible turns push this as the user-role message when the + /// input resolver did not return structured `input_messages`. pub(super) input: String, + /// Structured user-role messages produced by input resolution, such as expanded skill content. + /// When non-empty, these are pushed instead of the single `input` string. pub(super) input_messages: Vec, + /// Collaboration mode to install on the core session for this query; it also drives + /// mode-specific stream handling such as proposed-plan parsing. pub(super) collaboration_mode: devo_protocol::CollaborationMode, + /// Controls whether this executor emits/pushes a visible user message or runs hidden work such + /// as goal continuation, and carries the hidden goal context when needed. pub(super) input_mode: TurnInputMode, } -#[derive(Clone)] -struct BoundedQueryEventSender { - tx: std_mpsc::SyncSender, - queue_depth: Arc, - queue_max_depth: Arc, -} - -impl BoundedQueryEventSender { - fn send(&self, event: QueryEvent) { - let event_kind = query_event_trace_kind(&event); - let delta_len = query_event_trace_delta_len(&event); - let assistant_token_text = query_event_trace_token_preview(&event); - let depth = self.queue_depth.fetch_add(1, Ordering::AcqRel) + 1; - self.queue_max_depth.fetch_max(depth, Ordering::AcqRel); - if let Some(assistant_token_text) = assistant_token_text.as_deref() { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - event_kind, - delta_len, - queue_depth = depth, - assistant_token_text, - "query event bridge enqueue requested" - ); - } else { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - event_kind, - delta_len, - queue_depth = depth, - "query event bridge enqueue requested" - ); - } - match self.tx.try_send(event) { - Ok(()) => { - tracing::trace!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - event_kind, - queue_depth = depth, - "query event bridge enqueue accepted" - ); - } - Err(std_mpsc::TrySendError::Full(event)) => { - let send_started_at = Instant::now(); - if self.tx.send(event).is_err() { - decrement_query_event_queue_depth(&self.queue_depth); - return; - } - let waited = send_started_at.elapsed(); - if waited >= QUERY_EVENT_BACKPRESSURE_LOG_THRESHOLD { - tracing::warn!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - event_kind, - waited_ms = waited.as_millis(), - threshold_ms = QUERY_EVENT_BACKPRESSURE_LOG_THRESHOLD.as_millis(), - "query event bridge applied backpressure" - ); - } - } - Err(std_mpsc::TrySendError::Disconnected(_)) => { - decrement_query_event_queue_depth(&self.queue_depth); - } - } - } -} - -fn bounded_query_event_channel( - capacity: usize, - queue_depth: Arc, - queue_max_depth: Arc, -) -> ( - BoundedQueryEventSender, - mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - let (ingress_tx, ingress_rx) = std_mpsc::sync_channel::(capacity); - let (event_tx, event_rx) = mpsc::channel::(QUERY_EVENT_FORWARD_CHANNEL_CAPACITY); - let queue_depth_for_forwarder = Arc::clone(&queue_depth); - let forwarder = tokio::task::spawn_blocking(move || { - while let Ok(event) = ingress_rx.recv() { - if event_tx.blocking_send(event).is_err() { - decrement_query_event_queue_depth(&queue_depth_for_forwarder); - while ingress_rx.try_recv().is_ok() { - decrement_query_event_queue_depth(&queue_depth_for_forwarder); - } - break; - } - } - }); - ( - BoundedQueryEventSender { - tx: ingress_tx, - queue_depth, - queue_max_depth, - }, - event_rx, - forwarder, - ) -} - -fn decrement_query_event_queue_depth(queue_depth: &AtomicUsize) { - let _ = queue_depth.fetch_update(Ordering::AcqRel, Ordering::Acquire, |depth| { - Some(depth.saturating_sub(1)) - }); -} - fn stream_trace_elapsed_ms() -> u128 { static STREAM_TRACE_START: OnceLock = OnceLock::new(); STREAM_TRACE_START @@ -1011,10 +915,13 @@ impl ServerRuntime { }, ToolExecutionOptions { cancel_token: turn_cancel_token, - on_tool_execution_start: Some(Arc::new(move |call: &ToolCall| { + on_tool_execution_start: Some(Arc::new(move |call: ToolCall| { let runtime = Arc::clone(&tool_execution_start_runtime); - let tool_call_id = call.id.clone(); - tokio::spawn(async move { + let tool_call_id = call.id; + // `on_tool_execution_start` is stored as a trait-object callback that returns + // `BoxFuture<'static, ()>`. `Box::pin` gives this async block the erased future + // shape while still letting the body await `broadcast_event`. + Box::pin(async move { runtime .broadcast_event(ServerEvent::ToolCallStatusUpdated( devo_protocol::ToolCallStatusUpdatedPayload { @@ -1026,7 +933,7 @@ impl ServerRuntime { }, )) .await; - }); + }) })), ..ToolExecutionOptions::default() }, @@ -1168,8 +1075,12 @@ impl ServerRuntime { turn.turn_id, ItemKind::UserMessage, TurnItem::UserMessage(TextItem { + // TODO: Note that future additions of multimodal support + // will require modifications here. text: display_input.clone(), }), + // Keep the legacy text payload shape for live item/completed clients; durable + // user-message semantics come from the TurnItem above. serde_json::json!({ "title": "You", "text": display_input.clone() }), ) .await; @@ -1178,13 +1089,7 @@ impl ServerRuntime { let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() else { return; }; - let event_queue_depth = Arc::new(AtomicUsize::new(0)); - let event_queue_max_depth = Arc::new(AtomicUsize::new(0)); - let (event_tx, mut event_rx, event_forwarder_task) = bounded_query_event_channel( - QUERY_EVENT_CHANNEL_CAPACITY, - Arc::clone(&event_queue_depth), - Arc::clone(&event_queue_max_depth), - ); + let (event_tx, mut event_rx) = mpsc::channel::(QUERY_EVENT_CHANNEL_CAPACITY); let runtime = Arc::clone(&self); let turn_for_events = turn.clone(); let turn_for_plan_updates = turn.clone(); @@ -1194,8 +1099,6 @@ impl ServerRuntime { self.tool_registry_for_session(&session) }; let usage_context_window = Some(turn_config.model.context_window as u64); - let event_queue_depth_for_task = Arc::clone(&event_queue_depth); - let event_queue_max_depth_for_task = Arc::clone(&event_queue_max_depth); let event_task = tokio::spawn(async move { // This task owns the streamed model output. It turns raw query // callbacks into persisted turn items and keeps enough state to @@ -1218,14 +1121,12 @@ impl ServerRuntime { let mut stop_reason: Option = None; let mut usage_base: Option<(usize, usize, usize, usize)> = None; while let Some(event) = event_rx.recv().await { - decrement_query_event_queue_depth(&event_queue_depth_for_task); let assistant_token_text = query_event_trace_token_preview(&event); if let Some(assistant_token_text) = assistant_token_text.as_deref() { tracing::debug!( stream_elapsed_ms = stream_trace_elapsed_ms(), event_kind = query_event_trace_kind(&event), delta_len = query_event_trace_delta_len(&event), - queue_depth = event_queue_depth_for_task.load(Ordering::Acquire), assistant_token_text, "query event bridge dequeued by turn event task" ); @@ -1234,7 +1135,6 @@ impl ServerRuntime { stream_elapsed_ms = stream_trace_elapsed_ms(), event_kind = query_event_trace_kind(&event), delta_len = query_event_trace_delta_len(&event), - queue_depth = event_queue_depth_for_task.load(Ordering::Acquire), "query event bridge dequeued by turn event task" ); } @@ -1955,10 +1855,6 @@ impl ServerRuntime { tracing::debug!( session_id = %session_id, turn_id = %turn_for_events.turn_id, - query_event_queue_max_depth = - event_queue_max_depth_for_task.load(Ordering::Acquire), - query_event_queue_remaining = - event_queue_depth_for_task.load(Ordering::Acquire), "query event stream drained" ); TurnEventStreamSummary { @@ -2027,9 +1923,16 @@ impl ServerRuntime { } } let event_callback_tx = event_tx.clone(); - let callback = std::sync::Arc::new(move |event: QueryEvent| { - event_callback_tx.send(event); - }); + let callback: devo_core::EventCallback = + std::sync::Arc::new(move |event: QueryEvent| { + let event_callback_tx = event_callback_tx.clone(); + // `EventCallback` returns `BoxFuture` so the core query loop can await an + // async event sink without knowing this closure's concrete future type. Boxing + // also lets the bounded channel send apply backpressure at the callback boundary. + Box::pin(async move { + let _ = event_callback_tx.send(event).await; + }) + }); let tool_execution_start_tx = event_tx.clone(); let agent_context_mode = core_session .session_context @@ -2100,10 +2003,15 @@ impl ServerRuntime { }, ToolExecutionOptions { cancel_token: turn_cancel_token, - on_tool_execution_start: Some(Arc::new(move |call: &ToolCall| { - tool_execution_start_tx.send(QueryEvent::ToolExecutionStart { - id: call.id.clone(), - }); + on_tool_execution_start: Some(Arc::new(move |call: ToolCall| { + let tool_execution_start_tx = tool_execution_start_tx.clone(); + // Match the router callback's `BoxFuture` return type and keep the tool-start + // notification on the same bounded async event path as other query events. + Box::pin(async move { + let _ = tool_execution_start_tx + .send(QueryEvent::ToolExecutionStart { id: call.id }) + .await; + }) })), ..ToolExecutionOptions::default() }, @@ -2129,14 +2037,6 @@ impl ServerRuntime { ) }; drop(event_tx); - if let Err(error) = event_forwarder_task.await { - tracing::warn!( - session_id = %session_id, - turn_id = %turn.turn_id, - error = %error, - "query event forwarder failed" - ); - } // Wait for the event task to finish draining buffered stream events // before we persist the terminal turn state. let event_summary = event_task.await.ok(); @@ -2471,6 +2371,8 @@ impl ServerRuntime { .await; // Chain directly instead of spawning so this drain loop can keep // consuming queued input until the queue is empty. + // This is an async self-call into `execute_turn`; boxing adds the heap indirection Rust + // needs so the future does not recursively contain another copy of its own type. Box::pin(Arc::clone(&self).execute_turn(ExecuteTurnRequest { session_id, turn, @@ -2935,75 +2837,6 @@ mod tests { ); } - #[tokio::test] - async fn bounded_query_event_bridge_preserves_order_and_depth() { - let queue_depth = Arc::new(AtomicUsize::new(0)); - let queue_max_depth = Arc::new(AtomicUsize::new(0)); - let (sender, mut rx, forwarder) = bounded_query_event_channel( - /*capacity*/ 2, - Arc::clone(&queue_depth), - Arc::clone(&queue_max_depth), - ); - - sender.send(QueryEvent::TextDelta("one".to_string())); - sender.send(QueryEvent::ReasoningDelta("two".to_string())); - drop(sender); - - let first = rx.recv().await.expect("first event"); - decrement_query_event_queue_depth(&queue_depth); - let second = rx.recv().await.expect("second event"); - decrement_query_event_queue_depth(&queue_depth); - forwarder.await.expect("forwarder"); - - assert!(matches!(first, QueryEvent::TextDelta(text) if text == "one")); - assert!(matches!(second, QueryEvent::ReasoningDelta(text) if text == "two")); - assert_eq!(queue_depth.load(Ordering::Acquire), 0); - assert!(queue_max_depth.load(Ordering::Acquire) >= 1); - assert!(rx.recv().await.is_none()); - } - - #[tokio::test] - async fn bounded_query_event_bridge_keeps_terminal_event_after_backpressure() { - let queue_depth = Arc::new(AtomicUsize::new(0)); - let queue_max_depth = Arc::new(AtomicUsize::new(0)); - let (sender, mut rx, forwarder) = bounded_query_event_channel( - /*capacity*/ 1, - Arc::clone(&queue_depth), - Arc::clone(&queue_max_depth), - ); - let sender_for_task = sender.clone(); - let send_task = tokio::task::spawn_blocking(move || { - sender_for_task.send(QueryEvent::TextDelta("first".to_string())); - sender_for_task.send(QueryEvent::TextDelta("second".to_string())); - sender_for_task.send(QueryEvent::TurnComplete { - stop_reason: devo_core::StopReason::EndTurn, - }); - }); - - tokio::time::sleep(Duration::from_millis(20)).await; - let first = rx.recv().await.expect("first event"); - decrement_query_event_queue_depth(&queue_depth); - let second = rx.recv().await.expect("second event"); - decrement_query_event_queue_depth(&queue_depth); - let terminal = rx.recv().await.expect("terminal event"); - decrement_query_event_queue_depth(&queue_depth); - - send_task.await.expect("send task"); - drop(sender); - forwarder.await.expect("forwarder"); - - assert!(matches!(first, QueryEvent::TextDelta(text) if text == "first")); - assert!(matches!(second, QueryEvent::TextDelta(text) if text == "second")); - assert!(matches!( - terminal, - QueryEvent::TurnComplete { - stop_reason: devo_core::StopReason::EndTurn, - } - )); - assert_eq!(queue_depth.load(Ordering::Acquire), 0); - assert!(queue_max_depth.load(Ordering::Acquire) >= 2); - } - #[test] fn command_actions_from_read_tool_input_builds_read_action() { let actions = command_actions_from_tool_input( From 73b4a7be428f03b5a4a459abbc132940c84a6b73 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 18:24:19 -1000 Subject: [PATCH 03/36] fix: hide transcript markdown rules --- .../packages/ui/src/components/ai-elements/message.tsx | 9 +++++++-- .../components/chat/message-response-style.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 905d7ab6..02e47bbf 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -284,16 +284,20 @@ function TranscriptMarkdownHeading({ return (

) } +function TranscriptMarkdownRule() { + return null +} + // Product requirement: transcript Markdown headings should look like bold body text, -// not oversized section titles or headings with divider rules. +// not oversized section titles, heading divider rules, or decorative rules above headings. const transcriptMarkdownComponents: NonNullable = { h1: TranscriptMarkdownHeading, h2: TranscriptMarkdownHeading, @@ -301,6 +305,7 @@ const transcriptMarkdownComponents: NonNullable { "transcript Markdown headings should look like bold body text", ), headingComponents: messageSource.includes("const transcriptMarkdownComponents"), + headingClassWins: messageSource.includes( + "className,\n\t\t\t\t\"my-2 border-0 pb-0 text-sm font-semibold leading-6 text-foreground\"", + ), headingStyle: messageSource.includes( "my-2 border-0 pb-0 text-sm font-semibold leading-6 text-foreground", ), + decorativeRulesHidden: messageSource.includes("hr: TranscriptMarkdownRule"), }).toEqual({ requirementComment: true, headingComponents: true, + headingClassWins: true, headingStyle: true, + decorativeRulesHidden: true, }) }) }) From f1ed046f3e2af1b22de5d500f1e3d9c3290b619f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 18:25:59 -1000 Subject: [PATCH 04/36] fix: preserve transcript markdown rules --- .../packages/ui/src/components/ai-elements/message.tsx | 7 +------ .../components/chat/message-response-style.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 02e47bbf..5166623c 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -292,12 +292,8 @@ function TranscriptMarkdownHeading({ ) } -function TranscriptMarkdownRule() { - return null -} - // Product requirement: transcript Markdown headings should look like bold body text, -// not oversized section titles, heading divider rules, or decorative rules above headings. +// not oversized section titles or headings with divider rules. const transcriptMarkdownComponents: NonNullable = { h1: TranscriptMarkdownHeading, h2: TranscriptMarkdownHeading, @@ -305,7 +301,6 @@ const transcriptMarkdownComponents: NonNullable { headingStyle: messageSource.includes( "my-2 border-0 pb-0 text-sm font-semibold leading-6 text-foreground", ), - decorativeRulesHidden: messageSource.includes("hr: TranscriptMarkdownRule"), + markdownRulesStillRender: !messageSource.includes("hr: TranscriptMarkdownRule"), }).toEqual({ requirementComment: true, headingComponents: true, headingClassWins: true, headingStyle: true, - decorativeRulesHidden: true, + markdownRulesStillRender: true, }) }) }) From 75a99a496622e2b1326650a5830ed7999ee134d7 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 18:33:29 -1000 Subject: [PATCH 05/36] fix: polish desktop markdown controls --- .../ui/src/components/ai-elements/message.tsx | 9 ++++++ .../chat/message-response-style.test.ts | 32 +++++++++++++++++++ apps/desktop/src/renderer/index.css | 32 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 5166623c..96a29326 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -274,6 +274,14 @@ export type MessageResponseProps = ComponentProps const streamdownPlugins = { cjk, code, math, mermaid } +// Product requirement: regular transcript Markdown tables should keep copy and +// download controls, but not show a fullscreen control. +const transcriptMarkdownControls: NonNullable = { + table: { + fullscreen: false, + }, +} + type TranscriptMarkdownHeadingProps = ComponentProps<"h1"> & { node?: unknown } function TranscriptMarkdownHeading({ @@ -311,6 +319,7 @@ export const MessageResponse = memo( className, )} components={transcriptMarkdownComponents} + controls={transcriptMarkdownControls} plugins={streamdownPlugins} {...props} /> diff --git a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts index 0539cdd8..5c9e04b0 100644 --- a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts +++ b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts @@ -43,4 +43,36 @@ describe("MessageResponse markdown surfaces", () => { markdownRulesStillRender: true, }) }) + + test("keeps streamdown code block actions in the language header row", () => { + expect({ + headerPadding: rendererCssSource.includes('[data-streamdown="code-block-header"]'), + actionsSiblingSelector: rendererCssSource.includes( + '> div:has(> [data-streamdown="code-block-actions"])', + ), + actionsAbsolute: rendererCssSource.includes("position: absolute;"), + actionsStillClickable: rendererCssSource.includes("pointer-events: auto;"), + }).toEqual({ + headerPadding: true, + actionsSiblingSelector: true, + actionsAbsolute: true, + actionsStillClickable: true, + }) + }) + + test("removes fullscreen from regular markdown table controls only", () => { + expect({ + controlsConfig: messageSource.includes("const transcriptMarkdownControls"), + tableFullscreenDisabled: messageSource.includes("fullscreen: false"), + controlsPassedToStreamdown: messageSource.includes("controls={transcriptMarkdownControls}"), + tableCopyNotDisabled: !messageSource.includes("copy: false"), + tableDownloadNotDisabled: !messageSource.includes("download: false"), + }).toEqual({ + controlsConfig: true, + tableFullscreenDisabled: true, + controlsPassedToStreamdown: true, + tableCopyNotDisabled: true, + tableDownloadNotDisabled: true, + }) + }) }) diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 73421b94..84bdbd55 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -34,6 +34,38 @@ body { border-color: var(--border); } +.devo-message-response [data-streamdown="code-block"] { + position: relative; +} + +.devo-message-response [data-streamdown="code-block-header"] { + padding-right: 5rem; +} + +/* Streamdown renders code actions as a sibling of the language header; keep + them visually in the same toolbar row instead of a second row. */ +.devo-message-response + [data-streamdown="code-block"] + > div:has(> [data-streamdown="code-block-actions"]) { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 10; + display: flex; + height: 2rem; + align-items: center; + justify-content: flex-end; + margin-top: 0; + pointer-events: none; +} + +.devo-message-response + [data-streamdown="code-block"] + > div:has(> [data-streamdown="code-block-actions"]) + > [data-streamdown="code-block-actions"] { + pointer-events: auto; +} + :root.dark .devo-message-response [data-streamdown="code-block-actions"], :root.dark .devo-message-response [data-streamdown="mermaid-block-actions"] { background: var(--devo-markdown-surface-subtle); From 69cf03ca8a3a22c92c6151ac21fb47a7685f70c8 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 18:37:24 -1000 Subject: [PATCH 06/36] fix: enable streamdown code highlighting --- .../packages/ui/src/styles/globals.css | 5 ++++ .../chat/message-response-style.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/desktop/packages/ui/src/styles/globals.css b/apps/desktop/packages/ui/src/styles/globals.css index eaf610a1..65417b9f 100644 --- a/apps/desktop/packages/ui/src/styles/globals.css +++ b/apps/desktop/packages/ui/src/styles/globals.css @@ -3,6 +3,11 @@ @import "shadcn/tailwind.css"; @source "../components"; +@source "../../../../node_modules/streamdown/dist/*.js"; +@source "../../../../node_modules/@streamdown/code/dist/*.js"; +@source "../../../../node_modules/@streamdown/cjk/dist/*.js"; +@source "../../../../node_modules/@streamdown/math/dist/*.js"; +@source "../../../../node_modules/@streamdown/mermaid/dist/*.js"; @custom-variant dark (&:is(.dark *)); diff --git a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts index 5c9e04b0..b33f5a83 100644 --- a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts +++ b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts @@ -5,6 +5,10 @@ const messageSource = readFileSync( new URL("../../../../packages/ui/src/components/ai-elements/message.tsx", import.meta.url), "utf8", ) +const uiStylesSource = readFileSync( + new URL("../../../../packages/ui/src/styles/globals.css", import.meta.url), + "utf8", +) const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") describe("MessageResponse markdown surfaces", () => { @@ -75,4 +79,28 @@ describe("MessageResponse markdown surfaces", () => { tableDownloadNotDisabled: true, }) }) + + test("includes streamdown sources so code highlighting classes are generated", () => { + expect({ + streamdownSource: uiStylesSource.includes('@source "../../../../node_modules/streamdown/dist/*.js";'), + codePluginSource: uiStylesSource.includes( + '@source "../../../../node_modules/@streamdown/code/dist/*.js";', + ), + cjkPluginSource: uiStylesSource.includes( + '@source "../../../../node_modules/@streamdown/cjk/dist/*.js";', + ), + mathPluginSource: uiStylesSource.includes( + '@source "../../../../node_modules/@streamdown/math/dist/*.js";', + ), + mermaidPluginSource: uiStylesSource.includes( + '@source "../../../../node_modules/@streamdown/mermaid/dist/*.js";', + ), + }).toEqual({ + streamdownSource: true, + codePluginSource: true, + cjkPluginSource: true, + mathPluginSource: true, + mermaidPluginSource: true, + }) + }) }) From ef28ce4bda1135e647e9f347d5228c83fc40515d Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 18:59:54 -1000 Subject: [PATCH 07/36] fix: blend windows desktop startup chrome --- apps/desktop/src/main/index.ts | 10 ++++++--- apps/desktop/src/main/liquid-glass.test.ts | 11 ++++++++++ apps/desktop/src/main/liquid-glass.ts | 6 ++++++ .../components/sidebar-layout.test.ts | 16 ++++++++++++++ .../renderer/components/sidebar-layout.tsx | 18 ++++++++++++++++ apps/desktop/src/renderer/desktop-chrome.css | 10 +++++++++ .../src/renderer/desktop-chrome.test.ts | 21 +++++++++++++++++++ 7 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b358364a..6dac6e6f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, se import { initAutomations, shutdownAutomations } from "./automation" import { initCredentialStore } from "./credential-store" import { getOpaqueWindowsPref, registerIpcHandlers } from "./ipc-handlers" -import { installLiquidGlass, resolveWindowChrome } from "./liquid-glass" +import { installLiquidGlass, resolveStartupWindowBackground, resolveWindowChrome } from "./liquid-glass" import { createLogger } from "./logger" import { stopServer } from "./devo-manager" import { getSessionStates } from "./notification-watcher" @@ -227,10 +227,12 @@ async function createWindow(): Promise { // Resolve window chrome tier: liquid glass > vibrancy > Windows transparency > opaque const isOpaque = getOpaqueWindowsPref() const colorScheme = getSettings().appearance.colorScheme + const isDarkMode = colorScheme === "dark" || (colorScheme === "system" && nativeTheme.shouldUseDarkColors) const chrome = await resolveWindowChrome({ isOpaque, - isDarkMode: colorScheme === "dark" || (colorScheme === "system" && nativeTheme.shouldUseDarkColors), + isDarkMode, }) + const startupWindowBackground = resolveStartupWindowBackground(isDarkMode) // Resolve the window icon for Linux/Windows. macOS uses the .app bundle icon. // Linux: use 256x256 icon — GTK's GdkPixbuf can choke on the full 1024x1024 @@ -253,7 +255,9 @@ async function createWindow(): Promise { autoHideMenuBar: process.platform === "win32", // Transparent background for macOS glass/vibrancy tiers. Windows acrylic // keeps a non-transparent BrowserWindow so native resize/maximize work. - backgroundColor: chrome.usesTransparentBackground ? "#00000000" : "#000000", + // Product requirement: the Windows startup titlebar should match the + // splash/opening page background instead of flashing as a separate black strip. + backgroundColor: chrome.usesTransparentBackground ? "#00000000" : startupWindowBackground, // Don't show the window until the renderer has painted its first frame. // Prevents a flash of transparent/empty content, especially on Wayland. show: false, diff --git a/apps/desktop/src/main/liquid-glass.test.ts b/apps/desktop/src/main/liquid-glass.test.ts index 7504ea5e..ee9b9ac1 100644 --- a/apps/desktop/src/main/liquid-glass.test.ts +++ b/apps/desktop/src/main/liquid-glass.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { getResolvedChromeTier, + resolveStartupWindowBackground, resolveTitleBarOverlay, resolveWindowChrome, } from "./liquid-glass" @@ -104,4 +105,14 @@ describe("resolveWindowChrome", () => { height: 40, }) }) + + test("matches the native startup background to the splash theme", () => { + expect({ + dark: resolveStartupWindowBackground(true), + light: resolveStartupWindowBackground(false), + }).toEqual({ + dark: "#181818", + light: "#ffffff", + }) + }) }) diff --git a/apps/desktop/src/main/liquid-glass.ts b/apps/desktop/src/main/liquid-glass.ts index ff05d07d..302f295e 100644 --- a/apps/desktop/src/main/liquid-glass.ts +++ b/apps/desktop/src/main/liquid-glass.ts @@ -37,6 +37,8 @@ const TITLE_BAR_OVERLAY_HEIGHT = 40 const TITLE_BAR_OVERLAY_COLOR = "#00000000" const TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR = "#111111" const TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR = "#f4f4f5" +const STARTUP_WINDOW_DARK_BACKGROUND = "#181818" +const STARTUP_WINDOW_LIGHT_BACKGROUND = "#ffffff" export function resolveTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { return { @@ -48,6 +50,10 @@ export function resolveTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { } } +export function resolveStartupWindowBackground(isDarkMode: boolean): string { + return isDarkMode ? STARTUP_WINDOW_DARK_BACKGROUND : STARTUP_WINDOW_LIGHT_BACKGROUND +} + // ============================================================ // Liquid glass support detection (cached singleton) // ============================================================ diff --git a/apps/desktop/src/renderer/components/sidebar-layout.test.ts b/apps/desktop/src/renderer/components/sidebar-layout.test.ts index 02f3e227..197b51ea 100644 --- a/apps/desktop/src/renderer/components/sidebar-layout.test.ts +++ b/apps/desktop/src/renderer/components/sidebar-layout.test.ts @@ -41,4 +41,20 @@ describe("sidebar layout window controls", () => { usesCompactPanelIcon: true, }) }) + + test("opening routes mark the root for Windows titlebar blending", async () => { + const source = await readFile(sourcePath, "utf8") + + expect({ + detectsRootRoute: source.includes('pathname === "/"'), + detectsProjectOpeningRoute: source.includes("^\\/project\\/[^/]+\\/?$"), + setsOpeningRouteMarker: source.includes('root.dataset.openingRoute = "true"'), + clearsOpeningRouteMarker: source.includes("delete root.dataset.openingRoute"), + }).toEqual({ + detectsRootRoute: true, + detectsProjectOpeningRoute: true, + setsOpeningRouteMarker: true, + clearsOpeningRouteMarker: true, + }) + }) }) diff --git a/apps/desktop/src/renderer/components/sidebar-layout.tsx b/apps/desktop/src/renderer/components/sidebar-layout.tsx index 85155fc1..01643e20 100644 --- a/apps/desktop/src/renderer/components/sidebar-layout.tsx +++ b/apps/desktop/src/renderer/components/sidebar-layout.tsx @@ -227,6 +227,24 @@ export function SidebarLayout() { pathname.includes("/session/") || /^\/automations\/[^/]+\/runs\/[^/]+$/.test(pathname) const transcriptFillsTitlebar = isMac && isTranscriptRoute const transcriptTitlebarFillAttr = transcriptFillsTitlebar ? "true" : undefined + const isOpeningRoute = pathname === "/" || /^\/project\/[^/]+\/?$/.test(pathname) + + useEffect(() => { + if (typeof document === "undefined") return + const root = document.documentElement + + // Product requirement: on Windows, the startup New Chat surface should + // blend into the topbar instead of showing a separate chrome strip. + if (isOpeningRoute) { + root.dataset.openingRoute = "true" + } else { + delete root.dataset.openingRoute + } + + return () => { + delete root.dataset.openingRoute + } + }, [isOpeningRoute]) const handleRenameSession = useCallback( async (agent: Agent, title: string) => { diff --git a/apps/desktop/src/renderer/desktop-chrome.css b/apps/desktop/src/renderer/desktop-chrome.css index b3834100..d5d55bf3 100644 --- a/apps/desktop/src/renderer/desktop-chrome.css +++ b/apps/desktop/src/renderer/desktop-chrome.css @@ -114,6 +114,16 @@ transition: background-color var(--duration-fast, 150ms) ease; } +:root[data-platform="win32"][data-opening-route="true"] body::before { + background-color: var(--devo-transcript-background); +} + +:root[data-platform="win32"][data-opening-route="true"] [data-slot="app-bar"] { + background: var(--devo-transcript-background); + background-color: var(--devo-transcript-background); + border-bottom-color: transparent; +} + :root[data-platform="win32"][data-window-focused="true"] [data-slot="sidebar-wrapper"], :root[data-platform="win32"][data-window-focused="true"] [data-slot="sidebar"], diff --git a/apps/desktop/src/renderer/desktop-chrome.test.ts b/apps/desktop/src/renderer/desktop-chrome.test.ts index 1ee73601..b2a3bc29 100644 --- a/apps/desktop/src/renderer/desktop-chrome.test.ts +++ b/apps/desktop/src/renderer/desktop-chrome.test.ts @@ -172,6 +172,27 @@ describe("desktop chrome CSS", () => { expect(windowsContentAreaDeclarations).toEqual({}); }); + test("Windows opening route blends the titlebar into the content surface", async () => { + const css = await readFile(cssPath, "utf8"); + const titlebarDeclarations = declarationsForSelector( + css, + ':root[data-platform="win32"][data-opening-route="true"] body::before', + ); + const appBarDeclarations = declarationsForSelector( + css, + ':root[data-platform="win32"][data-opening-route="true"] [data-slot="app-bar"]', + ); + + expect(titlebarDeclarations).toEqual({ + "background-color": "var(--devo-transcript-background)", + }); + expect(appBarDeclarations).toEqual({ + background: "var(--devo-transcript-background)", + "background-color": "var(--devo-transcript-background)", + "border-bottom-color": "transparent", + }); + }); + test("macOS glass sidebar inset extends to the right and bottom window edges", async () => { const css = await readFile(cssPath, "utf8"); const selectors = [ From d0d16d5d908acb4bc53d3359cb26afaaaf3c4091 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 19:11:43 -1000 Subject: [PATCH 08/36] fix: keep windows topbar aligned with sidebar --- .../components/sidebar-layout.test.ts | 16 ------------- .../renderer/components/sidebar-layout.tsx | 18 --------------- apps/desktop/src/renderer/desktop-chrome.css | 12 ++-------- .../src/renderer/desktop-chrome.test.ts | 23 +++++++++---------- 4 files changed, 13 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/renderer/components/sidebar-layout.test.ts b/apps/desktop/src/renderer/components/sidebar-layout.test.ts index 197b51ea..02f3e227 100644 --- a/apps/desktop/src/renderer/components/sidebar-layout.test.ts +++ b/apps/desktop/src/renderer/components/sidebar-layout.test.ts @@ -41,20 +41,4 @@ describe("sidebar layout window controls", () => { usesCompactPanelIcon: true, }) }) - - test("opening routes mark the root for Windows titlebar blending", async () => { - const source = await readFile(sourcePath, "utf8") - - expect({ - detectsRootRoute: source.includes('pathname === "/"'), - detectsProjectOpeningRoute: source.includes("^\\/project\\/[^/]+\\/?$"), - setsOpeningRouteMarker: source.includes('root.dataset.openingRoute = "true"'), - clearsOpeningRouteMarker: source.includes("delete root.dataset.openingRoute"), - }).toEqual({ - detectsRootRoute: true, - detectsProjectOpeningRoute: true, - setsOpeningRouteMarker: true, - clearsOpeningRouteMarker: true, - }) - }) }) diff --git a/apps/desktop/src/renderer/components/sidebar-layout.tsx b/apps/desktop/src/renderer/components/sidebar-layout.tsx index 01643e20..85155fc1 100644 --- a/apps/desktop/src/renderer/components/sidebar-layout.tsx +++ b/apps/desktop/src/renderer/components/sidebar-layout.tsx @@ -227,24 +227,6 @@ export function SidebarLayout() { pathname.includes("/session/") || /^\/automations\/[^/]+\/runs\/[^/]+$/.test(pathname) const transcriptFillsTitlebar = isMac && isTranscriptRoute const transcriptTitlebarFillAttr = transcriptFillsTitlebar ? "true" : undefined - const isOpeningRoute = pathname === "/" || /^\/project\/[^/]+\/?$/.test(pathname) - - useEffect(() => { - if (typeof document === "undefined") return - const root = document.documentElement - - // Product requirement: on Windows, the startup New Chat surface should - // blend into the topbar instead of showing a separate chrome strip. - if (isOpeningRoute) { - root.dataset.openingRoute = "true" - } else { - delete root.dataset.openingRoute - } - - return () => { - delete root.dataset.openingRoute - } - }, [isOpeningRoute]) const handleRenameSession = useCallback( async (agent: Agent, title: string) => { diff --git a/apps/desktop/src/renderer/desktop-chrome.css b/apps/desktop/src/renderer/desktop-chrome.css index d5d55bf3..42159677 100644 --- a/apps/desktop/src/renderer/desktop-chrome.css +++ b/apps/desktop/src/renderer/desktop-chrome.css @@ -111,19 +111,11 @@ height: var(--devo-titlebar-height, 40px); pointer-events: none; background-color: var(--devo-windows-chrome-bg); + /* Product requirement - after the splash enters the app, the Windows topbar + should use the same chrome surface as the left side panel. */ transition: background-color var(--duration-fast, 150ms) ease; } -:root[data-platform="win32"][data-opening-route="true"] body::before { - background-color: var(--devo-transcript-background); -} - -:root[data-platform="win32"][data-opening-route="true"] [data-slot="app-bar"] { - background: var(--devo-transcript-background); - background-color: var(--devo-transcript-background); - border-bottom-color: transparent; -} - :root[data-platform="win32"][data-window-focused="true"] [data-slot="sidebar-wrapper"], :root[data-platform="win32"][data-window-focused="true"] [data-slot="sidebar"], diff --git a/apps/desktop/src/renderer/desktop-chrome.test.ts b/apps/desktop/src/renderer/desktop-chrome.test.ts index b2a3bc29..42d6a53a 100644 --- a/apps/desktop/src/renderer/desktop-chrome.test.ts +++ b/apps/desktop/src/renderer/desktop-chrome.test.ts @@ -172,25 +172,24 @@ describe("desktop chrome CSS", () => { expect(windowsContentAreaDeclarations).toEqual({}); }); - test("Windows opening route blends the titlebar into the content surface", async () => { + test("Windows titlebar stays aligned with the side panel chrome after startup", async () => { const css = await readFile(cssPath, "utf8"); const titlebarDeclarations = declarationsForSelector( css, - ':root[data-platform="win32"][data-opening-route="true"] body::before', + ':root[data-platform="win32"][data-window-focused="true"] body::before', ); - const appBarDeclarations = declarationsForSelector( + const sidebarInsetDeclarations = declarationsForSelector( css, - ':root[data-platform="win32"][data-opening-route="true"] [data-slot="app-bar"]', + ':root[data-platform="win32"][data-window-focused="true"] [data-slot="sidebar-inset"]', ); - expect(titlebarDeclarations).toEqual({ - "background-color": "var(--devo-transcript-background)", - }); - expect(appBarDeclarations).toEqual({ - background: "var(--devo-transcript-background)", - "background-color": "var(--devo-transcript-background)", - "border-bottom-color": "transparent", - }); + expect(titlebarDeclarations["background-color"]).toBe( + "var(--devo-windows-chrome-bg)", + ); + expect(sidebarInsetDeclarations["background-color"]).toBe( + "var(--devo-windows-chrome-bg) !important", + ); + expect(css).not.toContain("data-opening-route"); }); test("macOS glass sidebar inset extends to the right and bottom window edges", async () => { From 537e1db2d82ba9e4632b32868829813228ad2d33 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 19:36:16 -1000 Subject: [PATCH 09/36] fix: avoid single-option research clarification --- crates/core/prompts/research/clarify.md | 10 +++--- crates/core/src/tools/handlers/question.rs | 39 ++++++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/core/prompts/research/clarify.md b/crates/core/prompts/research/clarify.md index 703b825e..a0a2e584 100644 --- a/crates/core/prompts/research/clarify.md +++ b/crates/core/prompts/research/clarify.md @@ -26,16 +26,16 @@ Question policy: Using `request_user_input`: - Follow the research Language policy for all user-visible tool fields, - including question text, option labels, option descriptions, and any - free-form prompt. + including question text, option labels, and option descriptions. - Ask one concise question unless multiple independent answers are truly needed. - Offer only meaningful multiple-choice options; do not include filler choices that are obviously wrong or irrelevant. +- Do not provide exactly one multiple-choice option. If there are not at least + two meaningful, mutually exclusive options, do not ask a question; continue + with a reasonable default and make the assumed scope explicit in the next + stage. - If one option is the recommended default, put it first and add `(Recommended)` at the end of the label. -- In rare cases where an unavoidable, important clarification cannot be - expressed with meaningful multiple-choice options, ask a direct free-form - question through the tool. - Do not ask whether the user wants research to proceed. If no clarification is needed, respond with one concise sentence confirming the diff --git a/crates/core/src/tools/handlers/question.rs b/crates/core/src/tools/handlers/question.rs index b5d343c2..9c809b4e 100644 --- a/crates/core/src/tools/handlers/question.rs +++ b/crates/core/src/tools/handlers/question.rs @@ -26,7 +26,7 @@ impl QuestionHandler { pub fn new() -> Self { let mut spec = ToolSpec::new( "request_user_input", - "Use this tool when you need to ask the user questions during execution. This allows you to gather user preferences or requirements, clarify ambiguous instructions, get decisions on implementation choices as you work, or offer choices to the user about what direction to take.\n\nUsage notes:\n- Users will always be able to select Other to provide custom text input when the UI supports it.\n- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label.\n- In Plan Mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan.\n- Do NOT use this tool to ask \"Is my plan ready?\" or \"Should I proceed?\".\n- IMPORTANT: Do not reference \"the plan\" in your questions because the user cannot see the plan in the UI until plan approval is requested through the appropriate Plan Mode flow.", + "Use this tool when you need to ask the user questions during execution. This allows you to gather user preferences or requirements, clarify ambiguous instructions, get decisions on implementation choices as you work, or offer choices to the user about what direction to take.\n\nUsage notes:\n- Users will always be able to select Other to provide custom text input when the UI supports it.\n- Only provide `options` when there are at least two meaningful, mutually exclusive choices. Do not provide exactly one option; follow the surrounding task instructions if fewer than two meaningful choices exist.\n- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label.\n- In Plan Mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan.\n- Do NOT use this tool to ask \"Is my plan ready?\" or \"Should I proceed?\".\n- IMPORTANT: Do not reference \"the plan\" in your questions because the user cannot see the plan in the UI until plan approval is requested through the appropriate Plan Mode flow.", JsonSchema::object( std::collections::BTreeMap::from([("questions".to_string(), questions_schema())]), Some(vec!["questions".to_string()]), @@ -124,7 +124,12 @@ fn questions_schema() -> JsonSchema { ), ( "options".to_string(), - JsonSchema::array(option_schema, Some("Mutually exclusive answer options")), + JsonSchema::array( + option_schema, + Some( + "Mutually exclusive answer options. Provide this only when there are at least two meaningful choices; do not provide exactly one option.", + ), + ), ), ]), Some(vec![ @@ -160,3 +165,33 @@ fn request_user_input_args( )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn question_tool_spec_discourages_single_option_questions() { + let handler = QuestionHandler::new(); + let spec = handler.spec(); + + assert!(spec.description.contains("at least two meaningful")); + assert!( + spec.description + .contains("Do not provide exactly one option") + ); + assert!( + !spec + .description + .contains("ask a free-form question instead") + ); + + let schema = spec.input_schema.to_json_value(); + let options_description = + schema["properties"]["questions"]["items"]["properties"]["options"]["description"] + .as_str() + .expect("options description"); + assert!(options_description.contains("at least two meaningful choices")); + assert!(options_description.contains("do not provide exactly one option")); + } +} From b3932c45c1f05c5f48dbb9019d80b89e4c17807e Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 19:45:39 -1000 Subject: [PATCH 10/36] feat: add desktop slash command triggers --- .../renderer/components/chat/chat-input.tsx | 4 - .../renderer/components/chat/chat-view.tsx | 209 +++++++++++------- .../chat/slash-command-popover.test.ts | 60 +++++ .../components/chat/slash-command-popover.tsx | 147 +++--------- .../runtime/handlers/acp_slash_commands.rs | 127 ++++++++++- 5 files changed, 344 insertions(+), 203 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts diff --git a/apps/desktop/src/renderer/components/chat/chat-input.tsx b/apps/desktop/src/renderer/components/chat/chat-input.tsx index edd28b20..054d3284 100644 --- a/apps/desktop/src/renderer/components/chat/chat-input.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-input.tsx @@ -48,7 +48,6 @@ interface ChatInputProps { providers?: ProvidersData | null config?: ConfigData | null devoAgents?: SdkAgent[] - onSkillsOpen: () => void onScrollToBottom: (behavior?: "instant" | "smooth") => void handleSlashCommand: (text: string) => Promise } @@ -162,7 +161,6 @@ export function ChatInput({ providers, config, devoAgents, - onSkillsOpen, onScrollToBottom, handleSlashCommand, }: ChatInputProps) { @@ -317,7 +315,6 @@ export function ChatInput({ query={slashQuery} open={slashOpen} enabled={isConnected} - directory={agent.directory} onSelect={(cmd) => { setSlashOpen(false) // Use the command string directly instead of setText + setTimeout @@ -337,7 +334,6 @@ export function ChatInput({ slashCommandRef.current?.setText(cmd) } }} - onSkillsOpen={onSkillsOpen} onClose={() => setSlashOpen(false)} /> void +}) { + const isPlan = trigger === "plan" + const Icon = isPlan ? ListTodoIcon : GoalIcon + const label = isPlan ? "Plan" : "Goal" + const description = isPlan ? "Create a plan" : "Set a goal" + + return ( + + + } + > + + + {label} + + +

{description}
+ {isPlan &&
Shift + Tab to toggle
} + + + ) +} + /** * Instant-scroll when session content finishes loading. * @@ -897,12 +939,10 @@ export function ChatView({ onReplyQuestion={onReplyQuestion} onRejectQuestion={onRejectQuestion} canRedo={canRedo} - onUndo={onUndo} onRedo={onRedo} isReverted={isReverted} scrollRef={scrollRef} reviewPanelOpen={reviewPanelOpen} - onForkFromTurn={onForkFromTurn} /> )} @@ -934,13 +974,10 @@ interface ChatInputSectionProps { onReplyQuestion?: ChatViewProps["onReplyQuestion"] onRejectQuestion?: ChatViewProps["onRejectQuestion"] canRedo?: boolean - onUndo?: () => Promise onRedo?: () => Promise isReverted?: boolean scrollRef: React.RefObject reviewPanelOpen?: boolean - /** Fork the current session (full fork, no cutoff) */ - onForkFromTurn?: (messageId?: string) => Promise } function ChatInputSection({ @@ -958,14 +995,17 @@ function ChatInputSection({ onReplyQuestion, onRejectQuestion, canRedo, - onUndo, onRedo, isReverted, scrollRef, reviewPanelOpen, - onForkFromTurn, }: ChatInputSectionProps) { const [sending, setSending] = useState(false) + const [activeTrigger, setActiveTrigger] = useState(null) + + useEffect(() => { + setActiveTrigger(null) + }, [agent.sessionId]) // Tree-scoped interactive requests — bubbles up from sub-agent sessions. // These replace the direct `agent.permissions` / `agent.questions` arrays @@ -1188,18 +1228,12 @@ function ChatInputSection({ const spaceIndex = trimmed.indexOf(" ") const cmdName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex) - const cmdArgs = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim() - // Client-only commands that don't go through the server + // Product requirement: Desktop slash commands are limited to first-party + // entries. Compact executes immediately; Goal/Plan become footer trigger + // chips; Research stays as slash text so ACP can run it after a question. switch (cmdName.toLowerCase()) { - case "undo": - if (onUndo) await onUndo() - return true - case "redo": - if (onRedo) await onRedo() - return true case "compact": - case "summarize": if (agent.directory && effectiveModel) { const client = getProjectClient(agent.directory) if (client) { @@ -1215,29 +1249,53 @@ function ChatInputSection({ } } return true + case "goal": + setActiveTrigger("goal") + return true + case "plan": + setActiveTrigger("plan") + return true + case "research": + return false default: - break + return false } + }, + [agent.directory, agent.sessionId, effectiveModel], + ) - if (agent.directory) { - const client = getProjectClient(agent.directory) - if (client) { - try { - await client.session.command({ - sessionID: agent.sessionId, - command: cmdName, - arguments: cmdArgs, - }) - return true - } catch { - // Not a recognized server command - } + const submitTriggeredPrompt = useCallback( + async (trigger: ComposerTrigger, text: string, files?: FileAttachment[]) => { + if (!agent.directory) throw new Error("No project directory for slash trigger") + const client = getProjectClient(agent.directory) + if (!client) throw new Error("Not connected to Devo server") + const parts: Array< + { type: "text"; text: string } | { + type: "file" + mime: string + filename?: string + url: string } + > = [{ type: "text", text: `/${trigger} ${text.trim()}` }] + for (const file of files ?? []) { + parts.push({ + type: "file", + mime: file.mediaType ?? "application/octet-stream", + filename: file.filename, + url: file.url, + }) } - - return false + await client.session.promptAsync({ + sessionID: agent.sessionId, + parts, + model: effectiveModel + ? { providerID: effectiveModel.providerID, modelID: effectiveModel.modelID } + : undefined, + agent: selectedAgent || undefined, + variant: selectedVariant, + }) }, - [agent, onUndo, onRedo, effectiveModel], + [agent.directory, agent.sessionId, effectiveModel, selectedAgent, selectedVariant], ) const handleSend = useCallback( @@ -1248,7 +1306,7 @@ function ChatInputSection({ sending, sessionId: agent.sessionId, }) - if (!text.trim() || !onSendMessage || sending) { + if (!text.trim() || (!onSendMessage && !activeTrigger) || sending) { log.warn("handleSend bailed", { emptyText: !text.trim(), noOnSendMessage: !onSendMessage, @@ -1257,7 +1315,7 @@ function ChatInputSection({ return } - if (text.trim().startsWith("/")) { + if (!activeTrigger && text.trim().startsWith("/")) { const handled = await handleSlashCommand(text) if (handled) { slashCommandRef.current?.setText("") @@ -1293,15 +1351,24 @@ function ChatInputSection({ const commentPrefix = serializeCommentsForChat(diffComments) const finalText = commentPrefix ? `${commentPrefix}${text.trim()}` : text.trim() - await onSendMessage(agent, finalText, { - model: effectiveModel ?? undefined, - agentName: selectedAgent || undefined, - variant: selectedVariant, - files, - }) - log.debug("handleSend onSendMessage completed", { sessionId: agent.sessionId }) + if (activeTrigger) { + await submitTriggeredPrompt(activeTrigger, finalText, files) + log.debug("handleSend triggered prompt completed", { + sessionId: agent.sessionId, + trigger: activeTrigger, + }) + } else { + await onSendMessage?.(agent, finalText, { + model: effectiveModel ?? undefined, + agentName: selectedAgent || undefined, + variant: selectedVariant, + files, + }) + log.debug("handleSend onSendMessage completed", { sessionId: agent.sessionId }) + } clearDraft() setMentions([]) + setActiveTrigger(null) // Clear diff comments after successful send if (diffComments.length > 0) { setDiffComments([]) @@ -1323,6 +1390,8 @@ function ChatInputSection({ selectedAgent, selectedVariant, clearDraft, + activeTrigger, + submitTriggeredPrompt, handleSlashCommand, scrollRef, diffComments, @@ -1360,35 +1429,7 @@ function ChatInputSection({ const [mentionOpen, setMentionOpen] = useState(false) const [mentionQuery, setMentionQuery] = useState("") - // --- Skills picker dialog --- - const [skillsDialogOpen, setSkillsDialogOpen] = useState(false) - - const handleForkViaSlash = useCallback(async () => { - const ctrl = slashCommandRef.current - if (ctrl) ctrl.setText("") - await onForkFromTurn?.() - }, [onForkFromTurn]) - - const handleSkillsOpen = useCallback(() => { - const ctrl = slashCommandRef.current - if (ctrl) ctrl.setText("") - setSkillsDialogOpen(true) - }, []) - const handleSkillSelect = useCallback((skillName: string) => { - const ctrl = slashCommandRef.current - if (ctrl) { - ctrl.setText(`/${skillName} `) - } - requestAnimationFrame(() => { - const ta = document.querySelector("textarea[data-prompt-input]") - if (ta) { - ta.focus() - const len = `/${skillName} `.length - ta.setSelectionRange(len, len) - } - }) - }, []) const slashPopoverRef = useRef(null) const mentionPopoverRef = useRef(null) @@ -1491,6 +1532,14 @@ function ChatInputSection({ const handleTextareaKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.key === "Tab" && e.shiftKey) { + e.preventDefault() + handleSlashClose() + handleMentionClose() + setActiveTrigger((current) => (current === "plan" ? null : "plan")) + return + } + // Always delegate to popovers first — they guard on their own `open` prop // internally, so we don't need to check slashOpen/mentionOpen here. // This avoids stale-closure issues where the parent's boolean lags behind @@ -1502,7 +1551,7 @@ function ChatInputSection({ handleEscapeAbort() } }, - [handleEscapeAbort], + [handleEscapeAbort, handleSlashClose, handleMentionClose], ) // Width constraint class: remove max-w when review panel is open @@ -1582,10 +1631,7 @@ function ChatInputSection({ query={slashQuery} open={slashOpen} enabled={isConnected} - directory={agent.directory} onSelect={handleSlashSelect} - onSkillsOpen={handleSkillsOpen} - onFork={handleForkViaSlash} onClose={handleSlashClose} /> + {activeTrigger && ( + setActiveTrigger(null)} + /> + )} - {/* Skills picker dialog — triggered by /skills command */} - ) } diff --git a/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts b/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts new file mode 100644 index 00000000..757912fe --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const popoverSource = readFileSync(new URL("./slash-command-popover.tsx", import.meta.url), "utf8") +const chatViewSource = readFileSync(new URL("./chat-view.tsx", import.meta.url), "utf8") +const clientCommandsBlock = popoverSource.match(/const CLIENT_COMMANDS[\s\S]*?\n\]/)?.[0] ?? "" + +describe("Desktop slash command composer", () => { + test("limits the popover to first-party composer commands", () => { + expect({ + showsCompact: clientCommandsBlock.includes('name: "compact"'), + showsGoal: clientCommandsBlock.includes('name: "goal"'), + showsPlan: clientCommandsBlock.includes('name: "plan"'), + showsResearch: clientCommandsBlock.includes('name: "research"'), + omitsUndo: !clientCommandsBlock.includes('name: "undo"'), + omitsSkills: !clientCommandsBlock.includes('name: "skills"'), + omitsServerCommands: !popoverSource.includes("useServerCommands"), + omitsUserCommandDispatch: !chatViewSource.includes("session.command"), + }).toEqual({ + showsCompact: true, + showsGoal: true, + showsPlan: true, + showsResearch: true, + omitsUndo: true, + omitsSkills: true, + omitsServerCommands: true, + omitsUserCommandDispatch: true, + }) + }) + + test("turns goal and plan selections into footer trigger chips", () => { + expect({ + requirementComment: chatViewSource.includes( + "Desktop slash commands are limited to first-party", + ), + chipComponent: chatViewSource.includes("function ComposerTriggerChip"), + footerChip: chatViewSource.includes(" void - /** Called when the /skills entry is selected — opens the skills picker */ - onSkillsOpen?: () => void - /** Called when the /fork entry is selected — forks the session */ - onFork?: () => Promise /** Called when Escape is pressed */ onClose: () => void } @@ -84,97 +64,50 @@ interface SlashCommandPopoverProps { // ============================================================ const CLIENT_COMMANDS: SlashCommand[] = [ - { - name: "undo", - description: "Undo the last turn", - icon: Undo2Icon, - source: "client", - shortcut: formatShortcut(["mod", "Z"]), - }, - { - name: "redo", - description: "Redo previously undone turn", - icon: Redo2Icon, - source: "client", - shortcut: formatShortcut(["shift", "mod", "Z"]), - }, { name: "compact", description: "Summarize conversation to save context", icon: SparklesIcon, - source: "client", }, { - name: "fork", - description: "Fork conversation from this point", - icon: GitForkIcon, - source: "client", - action: "fork", + name: "goal", + description: "Set a goal from the next message", + icon: GoalIcon, }, { - name: "skills", - description: "Browse and use skills", - icon: BookOpenIcon, - source: "client", - action: "skills", + name: "plan", + description: "Create a plan from the next message", + icon: ListTodoIcon, + }, + { + name: "research", + description: "Run deep research on a question", + icon: MicroscopeIcon, + insertText: "/research ", }, ] -function getCommandIcon(name: string): LucideIcon { - switch (name) { - case "init": - return SettingsIcon - case "review": - return CodeIcon - case "feedback": - return MessageSquareIcon - case "mcp": - return WrenchIcon - default: - return TerminalIcon - } -} - // ============================================================ // SlashCommandPopover // ============================================================ export const SlashCommandPopover = memo( forwardRef(function SlashCommandPopover( - { query, open, directory, onSelect, onSkillsOpen, onFork, onClose }, + { query, open, enabled, onSelect, onClose }, ref, ) { const [activeIndex, setActiveIndex] = useState(0) const listRef = useRef(null) - // --- Server commands (skills excluded, matching TUI pattern) --- - const rawServerCommands = useServerCommands(directory) - const serverCommands = useMemo( - () => - rawServerCommands - .filter((c) => c.source !== "skill") - .map((c) => ({ - name: c.name, - description: c.description ?? `Run /${c.name}`, - icon: getCommandIcon(c.name), - source: "server" as const, - serverSource: c.source, - })), - [rawServerCommands], - ) - - // --- Merge: server commands first, then built-in (matching TUI ordering) --- - const allCommands = useMemo(() => [...serverCommands, ...CLIENT_COMMANDS], [serverCommands]) - // --- Fuzzy filter --- const flatList = useMemo(() => { - if (!query) return allCommands - const results = fuzzysort.go(query, allCommands, { + if (!query) return CLIENT_COMMANDS + const results = fuzzysort.go(query, CLIENT_COMMANDS, { keys: ["name", "description"], threshold: 0.3, }) return results.map((r) => r.obj) - }, [allCommands, query]) + }, [query]) // Reset active index when options or query change // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset on options/query change @@ -193,26 +126,18 @@ export const SlashCommandPopover = memo( } }, [activeIndex]) - // --- Handle selection (regular commands or special actions) --- + // --- Handle selection --- const handleSelect = useCallback( (cmd: SlashCommand) => { - if (cmd.action === "skills") { - onClose() - onSkillsOpen?.() - } else if (cmd.action === "fork") { - onClose() - onFork?.() - } else { - onSelect(`/${cmd.name}`) - } + onSelect(cmd.insertText ?? `/${cmd.name}`) }, - [onSelect, onClose, onSkillsOpen, onFork], + [onSelect], ) // --- Keyboard handler --- const handleKeyDown = useCallback( (e: React.KeyboardEvent): boolean => { - if (!open || flatList.length === 0) return false + if (!open || !enabled || flatList.length === 0) return false switch (e.key) { case "ArrowDown": { @@ -241,12 +166,12 @@ export const SlashCommandPopover = memo( return false } }, - [open, flatList, activeIndex, handleSelect, onClose], + [open, enabled, flatList, activeIndex, handleSelect, onClose], ) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) - if (!open) return null + if (!open || !enabled) return null return (
- Search + Commands
{/* Results */} @@ -320,16 +245,6 @@ const CommandItem = memo(function CommandItem({ {command.description} )} -
- {command.serverSource === "mcp" && ( - - mcp - - )} - {command.shortcut && ( - {command.shortcut} - )} -
) }) diff --git a/crates/server/src/runtime/handlers/acp_slash_commands.rs b/crates/server/src/runtime/handlers/acp_slash_commands.rs index c64adf14..a9279a79 100644 --- a/crates/server/src/runtime/handlers/acp_slash_commands.rs +++ b/crates/server/src/runtime/handlers/acp_slash_commands.rs @@ -68,6 +68,19 @@ impl ServerRuntime { let Some((command_name, argument)) = acp_slash_command_text(prompt) else { return AcpSlashCommandPromptResult::NotCommand; }; + // Desktop requirement: /plan is a composer trigger chip, not a user-defined + // command. Keep it on the ACP prompt path so the client waits for the plan turn. + if command_name == "plan" { + return self + .handle_acp_plan_slash_command( + connection_id, + request_id, + session_id, + argument, + prompt, + ) + .await; + } let Ok(command) = command_name.parse::() else { return AcpSlashCommandPromptResult::NotCommand; }; @@ -416,6 +429,85 @@ impl ServerRuntime { AcpSlashCommandPromptResult::Pending } + async fn handle_acp_plan_slash_command( + self: &Arc, + connection_id: u64, + request_id: serde_json::Value, + session_id: SessionId, + argument: &str, + prompt: &[AcpContentBlock], + ) -> AcpSlashCommandPromptResult { + let input = match input_items_from_argument_slash_prompt( + argument, + prompt, + "Usage: /plan ", + ) { + Ok(input) => input, + Err(error) => { + return AcpSlashCommandPromptResult::Response(acp_error_response( + request_id, + AcpErrorCode::InvalidParams, + error, + )); + } + }; + let legacy_response = self + .handle_turn_start_with_queue_policy( + Some(connection_id), + request_id.clone(), + TurnStartParams { + session_id, + input, + model: None, + model_binding_id: None, + reasoning_effort_selection: None, + sandbox: None, + approval_policy: None, + cwd: None, + collaboration_mode: CollaborationMode::Plan, + execution_mode: TurnExecutionMode::Regular, + }, + TurnStartQueuePolicy::RejectActive, + ) + .await; + let legacy: SuccessResponse = + match serde_json::from_value(legacy_response.clone()) { + Ok(legacy) => legacy, + Err(_) => { + return AcpSlashCommandPromptResult::Response(legacy_error_to_acp( + request_id, + legacy_response, + )); + } + }; + let Some(turn_id) = legacy.result.turn_id() else { + return AcpSlashCommandPromptResult::Response(acp_error_response( + request_id, + AcpErrorCode::ServerError, + "session/prompt cannot queue behind an active turn", + )); + }; + let runtime = Arc::clone(self); + tokio::spawn(async move { + let stop_reason = runtime + .wait_for_acp_prompt_stop_reason(session_id, turn_id) + .await; + runtime + .send_raw_to_connection( + connection_id, + acp_success_response( + request_id, + AcpPromptResult { + stop_reason, + meta: None, + }, + ), + ) + .await; + }); + AcpSlashCommandPromptResult::Pending + } + async fn send_acp_agent_message( &self, connection_id: u64, @@ -477,6 +569,14 @@ fn acp_slash_command_text(prompt: &[AcpContentBlock]) -> Option<(&str, &str)> { fn input_items_from_research_prompt( argument: &str, prompt: &[AcpContentBlock], +) -> Result, String> { + input_items_from_argument_slash_prompt(argument, prompt, "Usage: /research ") +} + +fn input_items_from_argument_slash_prompt( + argument: &str, + prompt: &[AcpContentBlock], + usage: &str, ) -> Result, String> { let mut input = Vec::new(); let trimmed = argument.trim(); @@ -489,7 +589,7 @@ fn input_items_from_research_prompt( input.extend(block.into_input_items()?); } if input.is_empty() { - return Err("Usage: /research ".to_string()); + return Err(usage.to_string()); } Ok(input) } @@ -568,4 +668,29 @@ mod tests { ] ); } + + #[test] + fn plan_prompt_uses_command_argument_and_preserves_extra_content() { + let input = input_items_from_argument_slash_prompt( + "desktop slash triggers", + &[ + AcpContentBlock::text("/plan desktop slash triggers"), + AcpContentBlock::text("include footer chip behavior"), + ], + "Usage: /plan ", + ) + .expect("plan input"); + + assert_eq!( + input, + vec![ + InputItem::Text { + text: "desktop slash triggers".to_string() + }, + InputItem::Text { + text: "include footer chip behavior".to_string() + }, + ] + ); + } } From 329b1a59f09e41b7574bd7d123b385a07f8661ed Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 29 Jun 2026 19:58:11 -1000 Subject: [PATCH 11/36] fix: polish desktop slash command popover --- .../renderer/components/chat/chat-view.tsx | 4 +- .../chat/slash-command-popover.test.ts | 26 ++++ .../components/chat/slash-command-popover.tsx | 11 +- .../src/renderer/components/new-chat.tsx | 122 +++++++++++++----- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 959fda2d..9a52c911 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -144,7 +144,9 @@ function ComposerTriggerChip({ type="button" aria-label={`Remove ${label} trigger`} onClick={onRemove} - className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full bg-muted-foreground/45 text-background transition-colors hover:bg-foreground" + // User requirement: reveal the close affordance only while hovering + // the trigger chip, with focus reveal preserved for keyboard users. + className="pointer-events-none inline-flex size-3.5 shrink-0 items-center justify-center rounded-full bg-muted-foreground/45 text-background opacity-0 transition-[background-color,color,opacity] group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-foreground" > diff --git a/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts b/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts index 757912fe..7d057b9b 100644 --- a/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts +++ b/apps/desktop/src/renderer/components/chat/slash-command-popover.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test" const popoverSource = readFileSync(new URL("./slash-command-popover.tsx", import.meta.url), "utf8") const chatViewSource = readFileSync(new URL("./chat-view.tsx", import.meta.url), "utf8") +const newChatSource = readFileSync(new URL("../new-chat.tsx", import.meta.url), "utf8") const clientCommandsBlock = popoverSource.match(/const CLIENT_COMMANDS[\s\S]*?\n\]/)?.[0] ?? "" describe("Desktop slash command composer", () => { @@ -16,6 +17,8 @@ describe("Desktop slash command composer", () => { omitsSkills: !clientCommandsBlock.includes('name: "skills"'), omitsServerCommands: !popoverSource.includes("useServerCommands"), omitsUserCommandDispatch: !chatViewSource.includes("session.command"), + omitsSearchHeader: + !popoverSource.includes("SearchIcon") && !popoverSource.includes("Commands"), }).toEqual({ showsCompact: true, showsGoal: true, @@ -25,6 +28,7 @@ describe("Desktop slash command composer", () => { omitsSkills: true, omitsServerCommands: true, omitsUserCommandDispatch: true, + omitsSearchHeader: true, }) }) @@ -38,8 +42,13 @@ describe("Desktop slash command composer", () => { goalIcon: chatViewSource.includes("GoalIcon"), planIcon: chatViewSource.includes("ListTodoIcon"), researchIcon: popoverSource.includes("MicroscopeIcon"), + goalInsertText: popoverSource.includes('insertText: "/goal "'), + planInsertText: popoverSource.includes('insertText: "/plan "'), researchInsertText: popoverSource.includes('insertText: "/research "'), researchPromptPath: chatViewSource.includes('case "research":'), + closeOnlyOnHover: + chatViewSource.includes("opacity-0") && + chatViewSource.includes("group-hover:opacity-100"), hoverBackground: chatViewSource.includes("hover:bg-muted"), shiftTabToggle: chatViewSource.includes('e.key === "Tab" && e.shiftKey'), triggeredPrompt: chatViewSource.includes("text: `/${trigger} ${text.trim()}`"), @@ -50,11 +59,28 @@ describe("Desktop slash command composer", () => { goalIcon: true, planIcon: true, researchIcon: true, + goalInsertText: true, + planInsertText: true, researchInsertText: true, researchPromptPath: true, + closeOnlyOnHover: true, hoverBackground: true, shiftTabToggle: true, triggeredPrompt: true, }) }) + + test("shows slash command suggestions on the new-session composer", () => { + expect({ + importsPopover: newChatSource.includes("SlashCommandPopover"), + detectsSlashTrigger: newChatSource.includes("onSlashChange"), + delegatesSlashKeys: newChatSource.includes("slashPopoverRef.current?.handleKeyDown"), + rendersPopover: newChatSource.includes(" e.preventDefault()} > - {/* Search header */} -
- - Commands -
- - {/* Results */} + {/* User requirement: keep this as a plain command list, without a search/header row. */}
{flatList.length === 0 && ( diff --git a/apps/desktop/src/renderer/components/new-chat.tsx b/apps/desktop/src/renderer/components/new-chat.tsx index a524c99b..ec4988a8 100644 --- a/apps/desktop/src/renderer/components/new-chat.tsx +++ b/apps/desktop/src/renderer/components/new-chat.tsx @@ -15,6 +15,7 @@ import { createFileMention, insertMentionIntoText, } from "./chat/prompt-mentions" +import { SlashCommandPopover, type SlashCommandPopoverHandle } from "./chat/slash-command-popover" import { Tooltip, TooltipContent, TooltipTrigger } from "@devo/ui/components/tooltip" import { useNavigate, useParams } from "@tanstack/react-router" import { useAtomValue } from "jotai" @@ -115,12 +116,13 @@ function WorktreeToggle({ } // ============================================================ -// Mention support helpers (mirrors the pattern in ChatInput) +// Prompt trigger support helpers (mirrors the pattern in ChatInput) // ============================================================ /** * Exposes the PromptInputProvider's text controller to outside components - * via a ref — needed to insert mention text without going through React state. + * via a ref — needed to insert slash command and mention text without going + * through React state. */ function MentionBridge({ controllerRef, @@ -145,12 +147,14 @@ function MentionBridge({ } /** - * Detects `@` trigger patterns in the prompt textarea and notifies the parent - * so the MentionPopover can open/close and filter results. + * User requirement: the new-session composer must support `/` slash popover + * behavior in addition to existing `@` mentions. */ -function MentionTrigger({ +function TriggerDetector({ + onSlashChange, onMentionChange, }: { + onSlashChange: (open: boolean, query: string) => void onMentionChange: (open: boolean, query: string) => void }) { const controller = usePromptInputController() @@ -159,13 +163,21 @@ function MentionTrigger({ const textarea = document.querySelector("textarea[data-prompt-input]") const cursorPos = textarea?.selectionStart ?? inputText.length const textBeforeCursor = inputText.slice(0, cursorPos) + const slashMatch = inputText.match(/^\/(\S*)$/) + if (slashMatch) { + onSlashChange(true, slashMatch[1]) + onMentionChange(false, "") + return + } const atMatch = textBeforeCursor.match(/@(\S*)$/) if (atMatch) { onMentionChange(true, atMatch[1]) + onSlashChange(false, "") return } + onSlashChange(false, "") onMentionChange(false, "") - }, [inputText, onMentionChange]) + }, [inputText, onSlashChange, onMentionChange]) return null } @@ -228,12 +240,15 @@ export function NewChat() { const [selectedAgent, setSelectedAgent] = useState(null) const [selectedVariant, setSelectedVariant] = useState(undefined) - // Mention popover state + // Slash command and mention popover state + const [slashOpen, setSlashOpen] = useState(false) + const [slashQuery, setSlashQuery] = useState("") const [mentionOpen, setMentionOpen] = useState(false) const [mentionQuery, setMentionQuery] = useState("") const controllerRef = useRef<{ setText: (text: string) => void; getText: () => string } | null>( null, ) + const slashPopoverRef = useRef(null) const mentionPopoverRef = useRef(null) // Project model preferences are a UI fallback for older local state. @@ -337,34 +352,65 @@ export function NewChat() { [reloadVcs], ) - // Insert a selected mention into the prompt textarea - const handleMentionSelect = useCallback((option: MentionOption) => { + const handleSlashClose = useCallback(() => { + setSlashOpen(false) + setSlashQuery("") + }, []) + + const handleMentionClose = useCallback(() => { setMentionOpen(false) - const ctrl = controllerRef.current - if (!ctrl) return - const currentText = ctrl.getText() - const textarea = document.querySelector("textarea[data-prompt-input]") - const cursorPos = textarea?.selectionStart ?? currentText.length - const mention = - option.type === "file" ? createFileMention(option.path) : createAgentMention(option.name) - const { text: newText, cursorPosition: newCursor } = insertMentionIntoText( - currentText, - cursorPos, - mention, - ) - ctrl.setText(newText) - requestAnimationFrame(() => { - const ta = document.querySelector("textarea[data-prompt-input]") - if (ta) { - ta.focus() - ta.setSelectionRange(newCursor, newCursor) - } - }) + setMentionQuery("") }, []) - // Delegate keyboard events to the mention popover when it's open + const handleSlashSelect = useCallback( + (command: string) => { + handleSlashClose() + const ctrl = controllerRef.current + if (!ctrl) return + ctrl.setText(command) + requestAnimationFrame(() => { + const ta = document.querySelector("textarea[data-prompt-input]") + if (ta) { + ta.focus() + ta.setSelectionRange(command.length, command.length) + } + }) + }, + [handleSlashClose], + ) + + // Insert a selected mention into the prompt textarea + const handleMentionSelect = useCallback( + (option: MentionOption) => { + handleMentionClose() + const ctrl = controllerRef.current + if (!ctrl) return + const currentText = ctrl.getText() + const textarea = document.querySelector("textarea[data-prompt-input]") + const cursorPos = textarea?.selectionStart ?? currentText.length + const mention = + option.type === "file" ? createFileMention(option.path) : createAgentMention(option.name) + const { text: newText, cursorPosition: newCursor } = insertMentionIntoText( + currentText, + cursorPos, + mention, + ) + ctrl.setText(newText) + requestAnimationFrame(() => { + const ta = document.querySelector("textarea[data-prompt-input]") + if (ta) { + ta.focus() + ta.setSelectionRange(newCursor, newCursor) + } + }) + }, + [handleMentionClose], + ) + + // Delegate keyboard events to open popovers before the composer handles Enter. const handleTextareaKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (slashPopoverRef.current?.handleKeyDown(e)) return if (mentionPopoverRef.current?.handleKeyDown(e)) return }, [], @@ -644,13 +690,25 @@ export function NewChat() { - { + setSlashOpen(open) + setSlashQuery(query) + }} onMentionChange={(open, query) => { setMentionOpen(open) setMentionQuery(query) }} />
+ setMentionOpen(false)} + onClose={handleMentionClose} /> Date: Mon, 29 Jun 2026 20:09:51 -1000 Subject: [PATCH 12/36] fix: refine desktop slash command visuals --- apps/desktop/AGENTS.md | 6 ++++ .../renderer/components/chat/chat-view.tsx | 16 +++++++---- .../chat/slash-command-popover.test.ts | 28 +++++++++++++++++++ .../components/chat/slash-command-popover.tsx | 4 ++- .../src/renderer/components/new-chat.tsx | 5 +++- apps/desktop/src/renderer/desktop-chrome.css | 6 ++++ 6 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/AGENTS.md diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md new file mode 100644 index 00000000..0fecef62 --- /dev/null +++ b/apps/desktop/AGENTS.md @@ -0,0 +1,6 @@ +# AGENTS.md + +## Renderer Iconography + +- Keep desktop renderer icons visually consistent with the left sidebar. For inline controls, menu rows, composer chips, and popover rows, prefer Lucide icons at `size-3.5` with `stroke-[1.5]` unless the surrounding sidebar/navigation pattern clearly uses a different established size. +- When an icon changes state on hover, keep the icon slot dimensions stable and swap the glyph in place instead of adding a new icon that shifts adjacent text. diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 9a52c911..0dc81553 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -144,13 +144,19 @@ function ComposerTriggerChip({ type="button" aria-label={`Remove ${label} trigger`} onClick={onRemove} - // User requirement: reveal the close affordance only while hovering - // the trigger chip, with focus reveal preserved for keyboard users. - className="pointer-events-none inline-flex size-3.5 shrink-0 items-center justify-center rounded-full bg-muted-foreground/45 text-background opacity-0 transition-[background-color,color,opacity] group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-foreground" + // User requirement: hover replaces the trigger icon in-place with + // the close affordance, so the chip text never shifts. + className="pointer-events-none relative inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground transition-colors group-focus-within:pointer-events-auto group-focus-within:text-foreground group-hover:pointer-events-auto group-hover:text-foreground focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - +