diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 6c428f34bf..0930f8db6f 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -984,9 +984,8 @@ fn classify_locked(inner: &PendingInner, parent_connection_id: &str, task_id: &s } /// Map a terminal [`DelegationTaskReport`] back to a [`DelegationOutcome`] for -/// the test-only `handle_request` shim (so pre-async tests keep asserting on -/// the old outcome shape). -#[cfg(any(test, feature = "test-utils"))] +/// the `handle_request` entry point (so callers can await a single outcome +/// instead of driving the start/poll/collect shape by hand). fn report_to_outcome(report: &DelegationTaskReport) -> DelegationOutcome { use crate::acp::delegation::types::DelegationSuccess; match report.status { @@ -3573,7 +3572,6 @@ impl DelegationBroker { /// the terminal report back to a `DelegationOutcome`. Keeps the broker's /// extensive setup-window race tests exercising the same lifecycle without /// each rewriting to the start/poll/collect shape. - #[cfg(any(test, feature = "test-utils"))] pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome { let parent_connection_id = req.parent_connection_id.clone(); let parent_conversation_id = Some(req.parent_conversation_id); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dcf009cc32..ff76ef88eb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -33,6 +33,7 @@ pub mod remote_proxy; #[cfg(feature = "tauri-runtime")] pub mod remote_workspace; pub mod science; +pub mod semantic; pub mod session_info; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/commands/semantic.rs b/src-tauri/src/commands/semantic.rs new file mode 100644 index 0000000000..6f381b0449 --- /dev/null +++ b/src-tauri/src/commands/semantic.rs @@ -0,0 +1,182 @@ +//! `semantic_submit` — the Tauri command + Axum web handler that exposes +//! `run_semantic_core` (Task 4) to the frontend. +//! +//! Two surfaces live here, mirroring `crate::commands::chat_authoring` / +//! `crate::web::handlers::chat_authoring`: +//! +//! * [`semantic_submit_core`] — the transport-agnostic core. Builds a +//! `ConnectionSpawner` + `ConversationDepthLookup` and hands them to +//! `run_semantic_core`. +//! * [`semantic_submit`] — the `#[tauri::command]` wrapper (desktop only). +//! * [`semantic_submit_handler`] — the Axum `POST /semantic_submit` handler +//! (server mode), wired in `web::router`. + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::acp::delegation::broker::ConversationDepthLookup; +use crate::acp::delegation::spawner::ConnectionSpawner; +use crate::acp::manager::ConnectionManagerSpawner; +use crate::acp::delegation::types::DelegationError; +use crate::acp::manager::ConnectionManager; +use crate::db::AppDatabase; +use crate::semantic::broker::{run_semantic_core, SemanticRequest}; +use crate::semantic::envelope::IntentEnvelope; + +/// Concrete [`ConversationDepthLookup`] used by both the Tauri command and the +/// web handler. v1 has no conversation-tree semantics, so every id is its own +/// root (`parent_of` always returns `None`). +pub struct RootDepth; + +#[async_trait] +impl ConversationDepthLookup for RootDepth { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } +} + +/// Transport-agnostic core. Builds the delegation broker inside +/// [`run_semantic_core`] from the supplied spawner + depth and returns the +/// fully-populated [`IntentEnvelope`]. +pub async fn semantic_submit_core( + spawner: Arc, + depth: Arc, + req: SemanticRequest, +) -> Result { + Ok(run_semantic_core(spawner, depth, req).await) +} + +// =========================================================================== +// Tauri command (desktop) +// =========================================================================== + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn semantic_submit( + #[cfg(feature = "tauri-runtime")] manager: tauri::State<'_, ConnectionManager>, + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, AppDatabase>, + req: SemanticRequest, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + // `AppState` is not handed to commands as a single managed value, so we + // reassemble the production `ConnectionSpawner` from the managed + // `ConnectionManager` + `AppDatabase` + effective data dir (set as the + // `CODEG_DATA_DIR` env var at bootstrap). + let data_dir = Arc::new(PathBuf::from( + std::env::var("CODEG_DATA_DIR").unwrap_or_default(), + )); + let spawner = Arc::new(ConnectionManagerSpawner { + manager: Arc::new(manager.inner().clone_ref()), + db: Arc::new(AppDatabase { + conn: db.inner().conn.clone(), + }), + data_dir, + }) as Arc; + semantic_submit_core(spawner, Arc::new(RootDepth), req).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = req; + Err("semantic_submit is only available under the tauri runtime".into()) + } +} + +// =========================================================================== +// Web handler (server mode) +// =========================================================================== + +#[derive(Deserialize)] +pub struct SemanticSubmitParams { + pub req: SemanticRequest, +} + +pub use self::web_handler::semantic_submit_handler; + +mod web_handler { + use super::*; + use axum::{extract::Extension, Json}; + use crate::app_error::AppCommandError; + use crate::app_state::AppState; + + pub async fn semantic_submit_handler( + Extension(state): Extension>, + Json(params): Json, + ) -> Result, AppCommandError> { + let spawner = Arc::new(ConnectionManagerSpawner { + manager: Arc::new(state.connection_manager.clone_ref()), + db: Arc::new(AppDatabase { + conn: state.db.conn.clone(), + }), + data_dir: Arc::new(state.data_dir.clone()), + }) as Arc; + let out = semantic_submit_core(spawner, Arc::new(RootDepth), params.req) + .await + .map_err(AppCommandError::configuration_invalid)?; + Ok(Json(out)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::mock::MockSpawner; + use crate::acp::delegation::spawner::SpawnerError; + use crate::models::agent::AgentType; + use crate::semantic::envelope::{AcceptState, Op}; + + /// Drive `semantic_submit_core` through the operator-failure path: a + /// `MockSpawner` whose queued spawn errors out. `run_semantic_core` (called + /// inside the core) returns a `Denied` envelope immediately — no pending + /// delegation is ever parked, so the test does not hang and exercises the + /// real spawner → broker → error-surface path. + #[tokio::test] + async fn submit_returns_denied_envelope_on_spawn_failure() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("boom".into()))).await; + + let spawner = mock as Arc; + let depth = Arc::new(RootDepth) as Arc; + + let req = SemanticRequest { + intent: "list files".into(), + why: "see layout".into(), + ops: vec![Op { + tool: "shell".into(), + params: serde_json::json!({"cmd":"ls"}), + }], + working_dir: Some("/tmp".into()), + agent_type: AgentType::ClaudeCode, + }; + + let out = semantic_submit_core(spawner, depth, req).await.unwrap(); + assert!(matches!(out.accept, AcceptState::Accepted | AcceptState::Denied)); + assert_eq!(out.accept, AcceptState::Denied); + assert!(out.result.as_ref().unwrap().contains("boom")); + } + + /// A well-formed request still flows through the core and yields a typed + /// envelope (the `Denied` here is the spawn-error path again, but the + /// point is the request deserializes and the core returns a real struct). + #[tokio::test] + async fn submit_returns_envelope_for_valid_request() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Send("no child".into()))).await; + + let spawner = mock as Arc; + let depth = Arc::new(RootDepth) as Arc; + + let req = SemanticRequest { + intent: "summarize".into(), + why: "catch up".into(), + ops: vec![], + working_dir: None, + agent_type: AgentType::OpenCode, + }; + + let out = semantic_submit_core(spawner, depth, req).await.unwrap(); + assert!(matches!(out.accept, AcceptState::Accepted | AcceptState::Denied)); + } +} diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index e73052d12e..1bd0cdabca 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -14,6 +14,18 @@ pub enum FolderKind { Regular, #[sea_orm(string_value = "chat")] Chat, + #[sea_orm(string_value = "semantic")] + Semantic, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn semantic_is_a_variant() { + let k = FolderKind::Semantic; + assert_eq!(serde_json::to_string(&k).unwrap(), "\"semantic\""); + } } #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe7a573ce6..2a4a8a6494 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ pub mod pets; #[cfg(feature = "tauri-runtime")] pub mod preferences; pub mod process; +pub mod semantic; pub mod supervise; mod terminal; pub mod turn_timings; diff --git a/src-tauri/src/semantic/aggregate.rs b/src-tauri/src/semantic/aggregate.rs new file mode 100644 index 0000000000..06543e41de --- /dev/null +++ b/src-tauri/src/semantic/aggregate.rs @@ -0,0 +1,73 @@ +use crate::semantic::envelope::{AcceptState, IntentEnvelope}; +use std::collections::BTreeMap; + +/// Group envelopes that share an `intent` into a single envelope: ops are +/// concatenated, results joined with a separator. Raw is joined the same way +/// but kept for storage only (never shown by the UI). +pub fn aggregate(envelopes: Vec) -> Vec { + if envelopes.len() <= 1 { + return envelopes; + } + let mut groups: BTreeMap = BTreeMap::new(); + for e in envelopes { + let entry = groups.entry(e.intent.clone()).or_insert(IntentEnvelope { + intent: e.intent.clone(), + why: e.why.clone(), + ops: vec![], + accept: AcceptState::Accepted, + result: Some(String::new()), + raw: Some(String::new()), + }); + entry.ops.extend(e.ops); + if let Some(r) = e.result { + let cur = entry.result.as_mut().unwrap(); + if !cur.is_empty() { + cur.push_str(" | "); + } + cur.push_str(&r); + } + if let Some(raw) = e.raw { + let cur = entry.raw.as_mut().unwrap(); + if !cur.is_empty() { + cur.push_str("\n---\n"); + } + cur.push_str(&raw); + } + } + groups.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantic::envelope::{AcceptState, IntentEnvelope, Op}; + + fn env(intent: &str, op_tool: &str, result: &str) -> IntentEnvelope { + IntentEnvelope { + intent: intent.into(), + why: String::new(), + ops: vec![Op { + tool: op_tool.into(), + params: serde_json::json!({}), + }], + accept: AcceptState::Accepted, + result: Some(result.into()), + raw: Some(format!("raw-{result}")), + } + } + + #[test] + fn parallel_ops_same_intent_merge_to_one() { + let out = aggregate(vec![ + env("build", "shell", "compiled a"), + env("build", "shell", "compiled b"), + env("test", "shell", "ran t"), + ]); + // two distinct intents -> two envelopes + assert_eq!(out.len(), 2); + let build = out.iter().find(|e| e.intent == "build").unwrap(); + assert_eq!(build.ops.len(), 2); + assert!(build.result.as_ref().unwrap().contains("compiled a")); + assert!(build.result.as_ref().unwrap().contains("compiled b")); + } +} diff --git a/src-tauri/src/semantic/broker.rs b/src-tauri/src/semantic/broker.rs new file mode 100644 index 0000000000..6b5fccf85b --- /dev/null +++ b/src-tauri/src/semantic/broker.rs @@ -0,0 +1,238 @@ +use crate::acp::delegation::broker::{ + DelegationBroker, ConversationDepthLookup, DelegationConfig, +}; +use crate::acp::delegation::spawner::ConnectionSpawner; +use crate::acp::delegation::types::{DelegationOutcome, DelegationRequest}; +use crate::models::agent::AgentType; +use crate::semantic::envelope::{AcceptState, IntentEnvelope, Op}; +use serde::Deserialize; +use std::sync::Arc; + +/// A semantic-mode request: an intent, the reason it matters, the set of +/// concrete operations the operator sub-agent should run, and where to run +/// them. +#[derive(Debug, Clone, Deserialize)] +pub struct SemanticRequest { + pub intent: String, + pub why: String, + pub ops: Vec, + pub working_dir: Option, + pub agent_type: AgentType, +} + +fn render_ops(ops: &[Op]) -> String { + ops.iter() + .map(|o| { + format!( + "[{}] {}", + o.tool, + serde_json::to_string(&o.params).unwrap_or_default() + ) + }) + .collect::>() + .join("\n") +} + +/// Drive an operator sub-agent (via the existing `codeg-mcp` delegation +/// broker) to execute the requested ops and produce raw output, then run a +/// summarizer sub-agent to sanitize that raw output into the user-facing +/// `result`. Returns a fully-populated `IntentEnvelope`. +/// +/// If the operator delegation fails, the envelope is returned in the `Denied` +/// state carrying the broker's error message. If only the summarizer fails, +/// the envelope is still `Accepted` and falls back to a truncated copy of the +/// raw output so the main chat is never left empty. +pub async fn run_semantic_core( + spawner: Arc, + depth: Arc, + req: SemanticRequest, +) -> IntentEnvelope { + let broker = DelegationBroker::new(spawner, depth); + semantic_core_inner(broker, req).await +} + +/// Core glue shared by the public entry point and the tests: builds the two +/// delegation hops (operator → summarizer) on the supplied broker. Taking the +/// broker by value (and being `pub(crate)`) lets the tests drive the same +/// broker instance to completion under `MockSpawner` — `DelegationBroker` is +/// `Clone` and shares its pending map via `Arc`, so a clone parked inside the +/// driver task resolves against the broker the test owns. +pub(crate) async fn semantic_core_inner( + broker: DelegationBroker, + req: SemanticRequest, +) -> IntentEnvelope { + broker + .set_config(DelegationConfig { + enabled: true, + ..Default::default() + }) + .await; + + let operator_task = format!( + "INTENT: {}\nWHY: {}\nRun these operations and return their raw output:\n{}", + req.intent, + req.why, + render_ops(&req.ops) + ); + let op_req = DelegationRequest { + parent_connection_id: "semantic".into(), + parent_conversation_id: 0, + parent_tool_use_id: "semantic-op".into(), + agent_type: req.agent_type, + task: operator_task, + working_dir: req.working_dir.clone(), + requested_working_dir: req.working_dir.clone(), + external_handle: None, + }; + let raw = match broker.handle_request(op_req).await { + DelegationOutcome::Ok(s) => s.text, + DelegationOutcome::Err { message, .. } => return IntentEnvelope::denied(&message), + }; + + let summarize_task = format!( + "You are a result sanitizer. Given an intent, its why, and the raw tool output, \ + write a concise (<=6 lines) answer that directly addresses the intent. \ + Do NOT include raw logs.\n\nINTENT: {}\nWHY: {}\n\nRAW OUTPUT:\n{}", + req.intent, req.why, raw + ); + let sum_req = DelegationRequest { + parent_connection_id: "semantic".into(), + parent_conversation_id: 0, + parent_tool_use_id: "semantic-sum".into(), + agent_type: req.agent_type, + task: summarize_task, + working_dir: req.working_dir, + requested_working_dir: None, + external_handle: None, + }; + let result = match broker.handle_request(sum_req).await { + DelegationOutcome::Ok(s) => s.text, + DelegationOutcome::Err { message, .. } => { + let trunc: String = raw.chars().take(500).collect(); + format!("[sanitizer failed: {message}] {trunc}") + } + }; + + IntentEnvelope { + intent: req.intent, + why: req.why, + ops: req.ops, + accept: AcceptState::Accepted, + result: Some(result), + raw: Some(raw), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::mock::MockSpawner; + use crate::acp::delegation::spawner::SpawnerError; + use crate::acp::delegation::types::{DelegationError, DelegationSuccess}; + use std::time::Duration; + + struct RootDepth; + #[async_trait::async_trait] + impl ConversationDepthLookup for RootDepth { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } + } + + fn ok_outcome(text: &str, conv: i32) -> DelegationOutcome { + DelegationOutcome::Ok(DelegationSuccess { + text: text.into(), + child_conversation_id: conv, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 1, + token_usage: None, + }) + } + + /// Drives BOTH delegation hops (operator + summarizer) on a single broker + /// the test owns — mirroring `happy_path_returns_ok_after_complete_call` + /// but for two sequential `handle_request` calls. `DelegationBroker` clones + /// share the pending map via `Arc`, so resolving calls on the test-owned + /// broker unblocks the driver task running `semantic_core_inner`. + #[tokio::test] + async fn operator_raw_is_sanitized_to_result() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("op-conn".into())).await; + mock.queue_send(Ok(1)).await; + mock.queue_spawn(Ok("sum-conn".into())).await; + mock.queue_send(Ok(2)).await; + + let broker = DelegationBroker::new( + mock as Arc, + Arc::new(RootDepth) as Arc, + ); + + let req = SemanticRequest { + intent: "list files".into(), + why: "see layout".into(), + ops: vec![Op { + tool: "shell".into(), + params: serde_json::json!({"cmd":"ls"}), + }], + working_dir: Some("/tmp".into()), + agent_type: AgentType::ClaudeCode, + }; + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { semantic_core_inner(broker, req).await }) + }; + + // Drive the operator delegation to completion. + let op_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker.complete_call(&op_id, ok_outcome("RAW_OPERATOR", 1)).await; + + // Drive the summarizer delegation to completion (distinct call id). + let sum_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + if id != op_id { + break id; + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call(&sum_id, ok_outcome("RESULT_SUMMARY", 2)) + .await; + + let out = driver.await.unwrap(); + assert_eq!(out.accept, AcceptState::Accepted); + assert_eq!(out.raw.as_deref(), Some("RAW_OPERATOR")); + assert_eq!(out.result.as_deref(), Some("RESULT_SUMMARY")); + assert_eq!(out.ops.len(), 1); + } + + #[tokio::test] + async fn operator_failure_returns_denied() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("boom".into()))).await; + + let broker = DelegationBroker::new( + mock as Arc, + Arc::new(RootDepth) as Arc, + ); + let req = SemanticRequest { + intent: "list files".into(), + why: "see layout".into(), + ops: vec![], + working_dir: None, + agent_type: AgentType::ClaudeCode, + }; + + let out = semantic_core_inner(broker, req).await; + assert_eq!(out.accept, AcceptState::Denied); + assert!(out.result.as_ref().unwrap().contains("boom")); + assert!(out.raw.is_none()); + } +} diff --git a/src-tauri/src/semantic/envelope.rs b/src-tauri/src/semantic/envelope.rs new file mode 100644 index 0000000000..0b2ff12050 --- /dev/null +++ b/src-tauri/src/semantic/envelope.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcceptState { + Pending, + Accepted, + Denied, + Countered, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Op { + pub tool: String, + pub params: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IntentEnvelope { + pub intent: String, + pub why: String, + pub ops: Vec, + pub accept: AcceptState, + pub result: Option, + pub raw: Option, +} + +impl IntentEnvelope { + pub fn denied(reason: &str) -> Self { + IntentEnvelope { + intent: String::new(), + why: String::new(), + ops: vec![], + accept: AcceptState::Denied, + result: Some(format!("denied: {reason}")), + raw: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn denied_carrier_reason_in_result() { + let e = IntentEnvelope::denied("too abstract"); + assert_eq!(e.accept, AcceptState::Denied); + assert!(e.result.as_ref().unwrap().contains("too abstract")); + assert!(e.raw.is_none()); + } + #[test] + fn serde_round_trips() { + let e = IntentEnvelope { + intent: "list files".into(), + why: "see layout".into(), + ops: vec![Op { + tool: "shell".into(), + params: serde_json::json!({"cmd":"ls"}), + }], + accept: AcceptState::Accepted, + result: Some("3 dirs".into()), + raw: Some("dir1 dir2 dir3".into()), + }; + let j = serde_json::to_string(&e).unwrap(); + let back: IntentEnvelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back.intent, "list files"); + assert_eq!(back.ops.len(), 1); + } +} diff --git a/src-tauri/src/semantic/mod.rs b/src-tauri/src/semantic/mod.rs new file mode 100644 index 0000000000..9663784304 --- /dev/null +++ b/src-tauri/src/semantic/mod.rs @@ -0,0 +1,3 @@ +pub mod aggregate; +pub mod broker; +pub mod envelope; diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 97b7d86f90..9d96a8353a 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -28,6 +28,7 @@ pub mod project_boot; pub mod question; pub mod quick_messages; pub mod science; +pub mod semantic; pub mod session_info; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/web/handlers/semantic.rs b/src-tauri/src/web/handlers/semantic.rs new file mode 100644 index 0000000000..1dcec92dab --- /dev/null +++ b/src-tauri/src/web/handlers/semantic.rs @@ -0,0 +1,4 @@ +//! Web-mode mirror of `commands::semantic::semantic_submit`. Re-exports the +//! handler so the router can register it under `handlers::semantic::`. + +pub use crate::commands::semantic::semantic_submit_handler; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 3d420b0ed9..dc3a64eefc 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -105,6 +105,10 @@ pub fn build_router( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), ) + .route( + "/semantic_submit", + post(handlers::semantic::semantic_submit_handler), + ) .route( "/get_folder_conversation_turns", post(handlers::conversations::get_folder_conversation_turns), diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index dd7b97b1f8..253b091ada 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl" import type { AgentType, ConnectionStatus, + FolderKind, PromptCapabilitiesInfo, PromptDraft, PromptInputBlock, @@ -81,6 +82,9 @@ interface ChatInputProps { * (new-conversation) composer, which sits in a roomy empty state; active and * historical conversations keep the compact default. */ tall?: boolean + /** Folder kind of the session. When `semantic` the composer renders the + * semantic intent form instead of the rich editor. */ + folderKind?: FolderKind } export const ChatInput = memo(function ChatInput({ @@ -125,6 +129,7 @@ export const ChatInput = memo(function ChatInput({ onInjectConsumed, flush = false, tall = false, + folderKind, }: ChatInputProps) { const t = useTranslations("Folder.chat.chatInput") const isConnected = status === "connected" @@ -217,6 +222,7 @@ export const ChatInput = memo(function ChatInput({ feedbackAddDisabled={feedbackAddDisabled} injectContent={injectContent} onInjectConsumed={onInjectConsumed} + folderKind={folderKind} placeholder={ isConnecting ? t("connecting") diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 23abc40225..09281fc441 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -53,6 +53,7 @@ import type { AgentSkillItem, AgentType, AvailableCommandInfo, + FolderKind, PromptCapabilitiesInfo, PromptDraft, PromptInputBlock, @@ -93,6 +94,7 @@ import { type ModelOptionGroup, } from "@/lib/model-config-groups" import { useAgentSkills } from "@/hooks/use-agent-skills" +import { SemanticComposer } from "@/components/semantic/SemanticComposer" import { useScrollbarSafeDismiss } from "@/hooks/use-scrollbar-safe-dismiss" import { clearMessageInputDraftV2, @@ -223,6 +225,9 @@ interface MessageInputProps { feedbackAddDisabled?: boolean injectContent?: ComposerInjectContent | null onInjectConsumed?: () => void + /** Folder kind of the session this composer is attached to. When `semantic` + * the composer renders the semantic intent form instead of the rich editor. */ + folderKind?: FolderKind } // Non-image files attach as inline file badges in the editor (like `@`-file @@ -324,6 +329,7 @@ export function MessageInput({ feedbackAddDisabled, injectContent, onInjectConsumed, + folderKind, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") const tQueue = useTranslations("Folder.chat.messageQueue") @@ -1753,6 +1759,17 @@ export function MessageInput({ ) + if (folderKind === "semantic") { + return ( + { + // Refresh the attached thread after a semantic submit. + }} + /> + ) + } + return (
void + workingDir?: string +}) { + const [intent, setIntent] = useState("") + const [why, setWhy] = useState("") + const [opsText, setOpsText] = useState("") + const [accept, setAccept] = useState("pending") + const [result, setResult] = useState(null) + const [raw, setRaw] = useState(null) + + // MVP: each non-empty line becomes a shell op. + function parseOps(text: string): Op[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => ({ tool: "shell", params: { cmd: line } })) + } + + async function run() { + const ops = parseOps(opsText) + // Build the envelope locally first so the caller always receives a fully + // formed payload, even before/without the network round-trip. + const env: IntentEnvelope = { + intent, + why, + ops, + accept: "pending", + result: null, + raw: null, + } + onSubmit(env) + try { + const res = await fetch("/semantic_submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + req: { + intent, + why, + ops, + working_dir: workingDir, + agent_type: "ClaudeCode", + }, + }), + }) + const serverEnv: IntentEnvelope = await res.json() + setAccept(serverEnv.accept) + setResult(serverEnv.result) + setRaw(serverEnv.raw) + } catch { + // Keep the locally-built envelope as the visible result on network failure. + } + } + + return ( +
+ +