diff --git a/crates/but-api/src/commands/claude.rs b/crates/but-api/src/commands/claude.rs index 46adcfb2bb..9ece229c6c 100644 --- a/crates/but-api/src/commands/claude.rs +++ b/crates/but-api/src/commands/claude.rs @@ -22,7 +22,7 @@ use crate::{App, error::Error}; #[serde(rename_all = "camelCase")] pub struct SendMessageParams { pub project_id: ProjectId, - pub stack_id: StackId, + pub stack_id: Option, #[serde(flatten)] pub user_params: ClaudeUserParams, } @@ -48,7 +48,7 @@ pub async fn claude_send_message(app: &App, params: SendMessageParams) -> Result #[serde(rename_all = "camelCase")] pub struct GetMessagesParams { pub project_id: ProjectId, - pub stack_id: StackId, + pub stack_id: Option, } pub fn claude_get_messages( @@ -137,7 +137,7 @@ pub fn claude_update_permission_request( #[serde(rename_all = "camelCase")] pub struct CancelSessionParams { pub project_id: ProjectId, - pub stack_id: StackId, + pub stack_id: Option, } pub async fn claude_cancel_session(app: &App, params: CancelSessionParams) -> Result { @@ -157,7 +157,7 @@ pub async fn claude_check_available() -> Result { #[serde(rename_all = "camelCase")] pub struct IsStackActiveParams { pub project_id: ProjectId, - pub stack_id: StackId, + pub stack_id: Option, } pub async fn claude_is_stack_active(app: &App, params: IsStackActiveParams) -> Result { @@ -169,7 +169,7 @@ pub async fn claude_is_stack_active(app: &App, params: IsStackActiveParams) -> R #[serde(rename_all = "camelCase")] pub struct CompactHistoryParams { pub project_id: ProjectId, - pub stack_id: StackId, + pub stack_id: Option, } pub async fn claude_compact_history(app: &App, params: CompactHistoryParams) -> Result<(), Error> { diff --git a/crates/but-claude/src/bridge.rs b/crates/but-claude/src/bridge.rs index 3073c46653..baec2da15d 100644 --- a/crates/but-claude/src/bridge.rs +++ b/crates/but-claude/src/bridge.rs @@ -53,11 +53,11 @@ use crate::{ send_claude_message, }; -/// Holds the CC instances. Currently keyed by stackId, since our current model -/// assumes one CC per stack at any given time. +/// Holds the CC instances. Keyed by optional stackId - None represents a general +/// project-wide session, Some(StackId) represents a stack-specific session. pub struct Claudes { /// A set that contains all the currently running requests - pub(crate) requests: Mutex>>, + pub(crate) requests: Mutex, Arc>>, } pub struct Claude { @@ -75,12 +75,14 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, user_params: ClaudeUserParams, ) -> Result<()> { if self.requests.lock().await.contains_key(&stack_id) { + let mode = stack_id.map_or("project", |_| "stack"); bail!( - "Claude is currently thinking, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application." + "Claude is currently thinking for this {}, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application.", + mode ); } else { self.spawn_claude(ctx.clone(), broadcaster.clone(), stack_id, user_params) @@ -95,11 +97,13 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, ) -> Result<()> { if self.requests.lock().await.contains_key(&stack_id) { + let mode = stack_id.map_or("project", |_| "stack"); bail!( - "Claude is currently thinking, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application." + "Claude is currently thinking for this {}, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application.", + mode ) } else { self.compact(ctx, broadcaster, stack_id).await @@ -111,7 +115,7 @@ impl Claudes { pub fn get_messages( &self, ctx: &mut CommandContext, - stack_id: StackId, + stack_id: Option, ) -> Result> { let rule = list_claude_assignment_rules(ctx)? .into_iter() @@ -124,8 +128,8 @@ impl Claudes { } } - /// Cancel a running Claude session for the given stack - pub async fn cancel_session(&self, stack_id: StackId) -> Result { + /// Cancel a running Claude session for the given stack (or general session if None) + pub async fn cancel_session(&self, stack_id: Option) -> Result { let requests = self.requests.lock().await; if let Some(claude) = requests.get(&stack_id) { // Send the kill signal @@ -139,8 +143,8 @@ impl Claudes { } } - /// Check if there is an active Claude session for the given stack ID - pub async fn is_stack_active(&self, stack_id: StackId) -> bool { + /// Check if there is an active Claude session for the given stack ID (or general session if None) + pub async fn is_stack_active(&self, stack_id: Option) -> bool { let requests = self.requests.lock().await; requests.contains_key(&stack_id) } @@ -149,7 +153,7 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, user_params: ClaudeUserParams, ) -> () { let res = self @@ -182,7 +186,7 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, user_params: ClaudeUserParams, ) -> Result<()> { // Capture the start time to filter messages created during this session @@ -303,7 +307,7 @@ impl Claudes { // Broadcast each new message for message in new_messages { broadcaster.lock().await.send(FrontendEvent { - name: format!("project://{project_id}/claude/{stack_id}/message_recieved"), + name: crate::claude_event_name(project_id, stack_id), payload: serde_json::json!(message), }); } @@ -325,7 +329,7 @@ impl Claudes { async fn handle_exit( ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, session_id: uuid::Uuid, mut read_stderr: PipeReader, mut handle: Child, @@ -397,7 +401,7 @@ async fn spawn_command( ctx: Arc>, user_params: ClaudeUserParams, summary_to_resume: Option, - stack_id: StackId, + stack_id: Option, ) -> Result { // Write and obtain our own claude hooks path. let settings = fmt_claude_settings()?; @@ -509,7 +513,7 @@ async fn spawn_command( // Format branch information for the system prompt let branch_info = { let mut ctx = ctx.lock().await; - format_branch_info(&mut ctx, stack_id) + format_branch_info(&mut ctx, stack_id).await }; let system_prompt = format!("{}\n\n{}", SYSTEM_PROMPT, branch_info); command.args(["--append-system-prompt", &system_prompt]); @@ -579,23 +583,43 @@ Sorry, this project is managed by GitButler so you must integrate upstream upstr "; /// Formats branch information for the system prompt -fn format_branch_info(ctx: &mut CommandContext, stack_id: StackId) -> String { - let mut output = String::from( - "\n\ - This repository uses GitButler for branch management. While git shows you are on\n\ +async fn format_branch_info(ctx: &mut CommandContext, stack_id: Option) -> String { + let mut output = String::from("\n"); + + output.push_str( + "This repository uses GitButler for branch management. While git shows you are on\n\ the `gitbutler/workspace` branch, this is actually a merge commit containing one or\n\ - more independent stacks of branches being worked on simultaneously.\n\n\ - This session is specific to a particular branch within that workspace. When asked about\n\ - the current branch or what changes have been made, you should focus on the current working\n\ - branch listed below, not the workspace branch itself.\n\n\ - Changes and diffs should be understood relative to the target branch (upstream), as that\n\ - represents the integration point for this work.\n\n\ - When asked about uncommitted changes you must only consider changes assigned to the stack.\n\n", + more independent stacks of branches being worked on simultaneously.\n\n", ); - append_target_branch_info(&mut output, ctx); - append_stack_branches_info(&mut output, stack_id, ctx); - append_assigned_files_info(&mut output, stack_id, ctx); + match stack_id { + Some(stack_id) => { + output.push_str( + "This session is specific to a particular branch within that workspace. When asked about\n\ + the current branch or what changes have been made, you should focus on the current working\n\ + branch listed below, not the workspace branch itself.\n\n\ + Changes and diffs should be understood relative to the target branch (upstream), as that\n\ + represents the integration point for this work.\n\n\ + When asked about uncommitted changes you must only consider changes assigned to the stack.\n\n", + ); + + append_target_branch_info(&mut output, ctx); + append_stack_branches_info(&mut output, stack_id, ctx); + append_assigned_files_info(&mut output, stack_id, ctx); + } + None => { + output.push_str( + "This is a general project-wide session. You can see and work with all stacks and branches.\n\ + When the user asks about changes or branches without specifying which one, you should consider\n\ + all active stacks in the workspace.\n\n\ + You have access to all files and changes across all stacks.\n\n", + ); + + append_target_branch_info(&mut output, ctx); + append_all_stacks_info(&mut output, ctx); + append_all_assigned_files_info(&mut output, ctx); + } + } output.push_str(""); output @@ -732,6 +756,113 @@ fn format_file_with_line_ranges( } } +/// Appends information about all stacks in the workspace (for general sessions) +fn append_all_stacks_info(output: &mut String, ctx: &mut CommandContext) { + let Ok(repo) = ctx.gix_repo() else { + tracing::warn!("Failed to get repository"); + output.push_str("Unable to fetch repository information.\n"); + return; + }; + + let stacks = match but_workspace::legacy::stacks( + ctx, + &ctx.project().gb_dir(), + &repo, + but_workspace::legacy::StacksFilter::InWorkspace, + ) { + Ok(stacks) if !stacks.is_empty() => stacks, + Ok(_) => { + output.push_str("There are no stacks currently in the workspace.\n"); + return; + } + Err(e) => { + tracing::warn!("Failed to fetch stacks: {e}"); + output.push_str("Unable to fetch stack information.\n"); + return; + } + }; + + output.push_str("The following stacks are currently in the workspace:\n"); + for stack in stacks { + let (Some(stack_id), Some(name)) = (stack.id, stack.name()) else { + continue; + }; + + output.push_str(&format!( + "- {} (stack_id: {stack_id})\n", + name.to_str_lossy() + )); + + // List branches in this stack + if !stack.heads.is_empty() { + output.push_str(" Branches:\n"); + for head in &stack.heads { + let checkout_marker = if head.is_checked_out { + " (checked out)" + } else { + "" + }; + output.push_str(&format!( + " - {}{checkout_marker}\n", + head.name.to_str_lossy() + )); + } + } + } +} + +/// Appends information about all assigned files across all stacks (for general sessions) +fn append_all_assigned_files_info(output: &mut String, ctx: &mut CommandContext) { + let Ok((assignments, _error)) = but_hunk_assignment::assignments_with_fallback( + ctx, + false, + None::>, + None, + ) else { + tracing::warn!("Failed to fetch hunk assignments"); + return; + }; + + // Group assignments by stack_id + let mut stacks_with_files: HashMap, Vec<&but_hunk_assignment::HunkAssignment>> = + HashMap::new(); + for assignment in &assignments { + stacks_with_files + .entry(assignment.stack_id) + .or_default() + .push(assignment); + } + + if stacks_with_files.is_empty() { + return; + } + + output.push_str("\nFile assignments across all stacks:\n"); + + // Show unassigned files first if any + if let Some(unassigned) = stacks_with_files.get(&None) { + output.push_str("Unassigned files:\n"); + for assignment in unassigned { + output.push_str(&format!(" - {}\n", assignment.path)); + } + output.push('\n'); + } + + // Show files grouped by stack + let mut stack_ids: Vec<_> = stacks_with_files.keys().copied().flatten().collect(); + stack_ids.sort(); + + for stack_id in stack_ids { + if let Some(files) = stacks_with_files.get(&Some(stack_id)) { + output.push_str(&format!("Stack {stack_id} files:\n")); + for assignment in files { + output.push_str(&format!(" - {}\n", assignment.path)); + } + output.push('\n'); + } + } +} + fn format_message_with_summary( summary: &str, message: &str, @@ -775,7 +906,7 @@ fn format_message(message: &str, thinking_level: ThinkingLevel) -> String { async fn upsert_session( ctx: Arc>, session_id: uuid::Uuid, - stack_id: StackId, + stack_id: Option, ) -> Result { let mut ctx = ctx.lock().await; let session = if let Some(session) = db::get_session_by_id(&mut ctx, session_id)? { @@ -796,7 +927,7 @@ fn spawn_response_streaming( broadcaster: Arc>, read_stdout: PipeReader, session_id: uuid::Uuid, - stack_id: StackId, + stack_id: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/crates/but-claude/src/compact.rs b/crates/but-claude/src/compact.rs index 510f1616ea..6afc59cc47 100644 --- a/crates/but-claude/src/compact.rs +++ b/crates/but-claude/src/compact.rs @@ -79,7 +79,7 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, ) -> () { let res = self .compact_inner(ctx.clone(), broadcaster.clone(), stack_id) @@ -111,7 +111,7 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, ) -> Result<()> { let (send_kill, mut _recv_kill) = unbounded_channel(); self.requests @@ -165,7 +165,7 @@ impl Claudes { &self, ctx: Arc>, broadcaster: Arc>, - stack_id: StackId, + stack_id: Option, ) -> Result<()> { let rule = { let mut ctx = ctx.lock().await; diff --git a/crates/but-claude/src/hooks/mod.rs b/crates/but-claude/src/hooks/mod.rs index 59d4a7a8b3..1ed637bf3d 100644 --- a/crates/but-claude/src/hooks/mod.rs +++ b/crates/but-claude/src/hooks/mod.rs @@ -508,21 +508,33 @@ pub fn get_or_create_session( .into_iter() .find(|r| r.session_id.to_string() == session_id) { - if let Some(stack_id) = stacks.iter().find_map(|s| { - let id = s.id?; - (id == rule.stack_id).then_some(id) - }) { - stack_id + if let Some(rule_stack_id) = rule.stack_id { + // Rule has a specific stack ID + if let Some(stack_id) = stacks.iter().find_map(|s| { + let id = s.id?; + (id == rule_stack_id).then_some(id) + }) { + stack_id + } else { + let stack_id = create_stack(ctx, vb_state, perm)?; + crate::rules::update_claude_assignment_rule_target(ctx, rule.id, Some(stack_id))?; + stack_id + } } else { + // Rule is for general session, create/use a stack for this hook let stack_id = create_stack(ctx, vb_state, perm)?; - crate::rules::update_claude_assignment_rule_target(ctx, rule.id, stack_id)?; + crate::rules::update_claude_assignment_rule_target(ctx, rule.id, Some(stack_id))?; stack_id } } else { // If the session is not in the list of sessions, then create a new stack + session entry // Create a new stack let stack_id = create_stack(ctx, vb_state, perm)?; - crate::rules::create_claude_assignment_rule(ctx, Uuid::parse_str(session_id)?, stack_id)?; + crate::rules::create_claude_assignment_rule( + ctx, + Uuid::parse_str(session_id)?, + Some(stack_id), + )?; stack_id }; Ok(stack_id) diff --git a/crates/but-claude/src/lib.rs b/crates/but-claude/src/lib.rs index 11df60a485..9d3b95342f 100644 --- a/crates/but-claude/src/lib.rs +++ b/crates/but-claude/src/lib.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use anyhow::Result; use but_broadcaster::{Broadcaster, FrontendEvent}; +use but_core::ref_metadata::StackId; use gitbutler_command_context::CommandContext; +use gitbutler_project::ProjectId; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; use uuid::Uuid; pub mod bridge; pub use bridge::ClaudeCheckResult; -use but_core::ref_metadata::StackId; pub(crate) mod claude_config; pub mod claude_mcp; @@ -467,18 +468,26 @@ pub struct ClaudeUserParams { pub attachments: Option>, } +/// Generates the event name for broadcasting Claude messages +pub(crate) fn claude_event_name(project_id: ProjectId, stack_id: Option) -> String { + match stack_id { + Some(id) => format!("project://{project_id}/claude/{id}/message_recieved"), + None => format!("project://{project_id}/claude/general/message_recieved"), + } +} + pub async fn send_claude_message( ctx: &mut CommandContext, broadcaster: Arc>, session_id: uuid::Uuid, - stack_id: StackId, + stack_id: Option, content: MessagePayload, ) -> Result<()> { let message = db::save_new_message(ctx, session_id, content.clone())?; let project_id = ctx.project().id; broadcaster.lock().await.send(FrontendEvent { - name: format!("project://{project_id}/claude/{stack_id}/message_recieved"), + name: claude_event_name(project_id, stack_id), payload: json!(message), }); Ok(()) diff --git a/crates/but-claude/src/rules.rs b/crates/but-claude/src/rules.rs index 4bf9077196..50b8416c43 100644 --- a/crates/but-claude/src/rules.rs +++ b/crates/but-claude/src/rules.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; /// A simplified subset of a `but_rules::WorkspaceRule` representing a rule for assigning a Claude Code session to a stack. +/// If `stack_id` is None, this represents a general project-wide session. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub(crate) struct ClaudeSessionAssignmentRule { @@ -18,18 +19,18 @@ pub(crate) struct ClaudeSessionAssignmentRule { pub enabled: bool, /// The original Claude Code session id. pub session_id: Uuid, - /// The Stack ID to which the session should be assigned. - pub stack_id: StackId, + /// The Stack ID to which the session should be assigned. None for general project-wide sessions. + pub stack_id: Option, } impl TryFrom for ClaudeSessionAssignmentRule { type Error = anyhow::Error; fn try_from(rule: but_rules::WorkspaceRule) -> Result { + // Stack ID is now optional - None means general project-wide session let stack_id = rule .target_stack_id() - .and_then(|id| StackId::from_str(&id).ok()) - .ok_or_else(|| anyhow::anyhow!("Rule does not have a target stack ID"))?; + .and_then(|id| StackId::from_str(&id).ok()); let session_id = rule .session_id() @@ -59,17 +60,24 @@ pub(crate) fn list_claude_assignment_rules( } /// Updates the target stack ID of an existing Claude session assignment rule. +/// If stack_id is None, updates to a general project-wide session. pub(crate) fn update_claude_assignment_rule_target( ctx: &mut CommandContext, rule_id: String, - stack_id: StackId, + stack_id: Option, ) -> anyhow::Result { let mut req: UpdateRuleRequest = but_rules::get_rule(ctx, &rule_id)?.into(); req.action = req.action.and_then(|a| match a { but_rules::Action::Explicit(but_rules::Operation::Assign { target: _ }) => { - Some(but_rules::Action::Explicit(but_rules::Operation::Assign { - target: but_rules::StackTarget::StackId(stack_id.to_string()), - })) + match stack_id { + Some(id) => Some(but_rules::Action::Explicit(but_rules::Operation::Assign { + target: but_rules::StackTarget::StackId(id.to_string()), + })), + // For general sessions, use Leftmost as a default target + None => Some(but_rules::Action::Explicit(but_rules::Operation::Assign { + target: but_rules::StackTarget::Leftmost, + })), + } } _ => None, }); @@ -77,19 +85,23 @@ pub(crate) fn update_claude_assignment_rule_target( rule.try_into() } -/// Creates a new Claude session assignment rule for a given session ID and stack ID. +/// Creates a new Claude session assignment rule for a given session ID and optional stack ID. +/// If stack_id is None, creates a general project-wide session. /// Errors out if there is another rule with a ClaudeCodeHook trigger referencing the same stack ID in the action. /// Errors out if there is another rule referencing the same session ID in a filter. pub(crate) fn create_claude_assignment_rule( ctx: &mut CommandContext, session_id: Uuid, - stack_id: StackId, + stack_id: Option, ) -> anyhow::Result { let existing_rules = list_claude_assignment_rules(ctx)?; if existing_rules.iter().any(|rule| rule.stack_id == stack_id) { + let stack_desc = stack_id.map_or_else( + || "general session".to_string(), + |id| format!("stack_id: {id}"), + ); return Err(anyhow::anyhow!( - "There is an existing WorkspaceRule triggered on ClaudeCodeHook which references stack_id: {}", - stack_id + "There is an existing WorkspaceRule triggered on ClaudeCodeHook which references {stack_desc}" )); } if existing_rules @@ -97,19 +109,22 @@ pub(crate) fn create_claude_assignment_rule( .any(|rule| rule.session_id == session_id) { return Err(anyhow::anyhow!( - "Thes is an existing WorkspaceRule triggered on ClaudeCodeHook with filter on session_id: {}", - session_id + "There is an existing WorkspaceRule triggered on ClaudeCodeHook with filter on session_id: {session_id}" )); } + let target = match stack_id { + Some(id) => but_rules::StackTarget::StackId(id.to_string()), + // For general sessions, use Leftmost as a default target + None => but_rules::StackTarget::Leftmost, + }; + let req = CreateRuleRequest { trigger: but_rules::Trigger::ClaudeCodeHook, filters: vec![but_rules::Filter::ClaudeCodeSessionId( session_id.to_string(), )], - action: but_rules::Action::Explicit(but_rules::Operation::Assign { - target: but_rules::StackTarget::StackId(stack_id.to_string()), - }), + action: but_rules::Action::Explicit(but_rules::Operation::Assign { target }), }; let rule = but_rules::create_rule(ctx, req)?; ClaudeSessionAssignmentRule::try_from(rule) diff --git a/crates/gitbutler-tauri/src/claude.rs b/crates/gitbutler-tauri/src/claude.rs index 140e472fd3..da9b162d16 100644 --- a/crates/gitbutler-tauri/src/claude.rs +++ b/crates/gitbutler-tauri/src/claude.rs @@ -20,7 +20,7 @@ use tracing::instrument; pub async fn claude_send_message( app: State<'_, App>, project_id: ProjectId, - stack_id: StackId, + stack_id: Option, message: String, thinking_level: ThinkingLevel, model: ModelType, @@ -53,7 +53,7 @@ pub async fn claude_send_message( pub fn claude_get_messages( app: State<'_, App>, project_id: ProjectId, - stack_id: StackId, + stack_id: Option, ) -> Result, Error> { claude::claude_get_messages( &app, @@ -69,7 +69,7 @@ pub fn claude_get_messages( pub async fn claude_cancel_session( app: State<'_, App>, project_id: ProjectId, - stack_id: StackId, + stack_id: Option, ) -> Result { claude::claude_cancel_session( &app, @@ -86,7 +86,7 @@ pub async fn claude_cancel_session( pub async fn claude_is_stack_active( app: State<'_, App>, project_id: ProjectId, - stack_id: StackId, + stack_id: Option, ) -> Result { claude::claude_is_stack_active( &app, @@ -103,7 +103,7 @@ pub async fn claude_is_stack_active( pub async fn claude_compact_history( app: State<'_, App>, project_id: ProjectId, - stack_id: StackId, + stack_id: Option, ) -> Result<(), Error> { claude::claude_compact_history( &app,