Skip to content

Commit 60fed83

Browse files
committed
Make Claude sessions optionally project-wide (Option<StackId>)
Allow Claude sessions and related APIs to be associated with either a specific stack or a general project-wide session by making StackId optional (Option<StackId>). This update propagates the optional stack_id through request structs, internal APIs, request maps, event naming, rule creation/update logic, and the system prompt/branch-info generation. Changes include: - Change param types in API structs and functions to Option<StackId> for send/get/cancel/compact/is_active and internal handlers. - Store requests keyed by Option<StackId> and update checks, cancellations, and active checks accordingly. - Emit different frontend event names for stack-specific vs general sessions (".../claude/{id}/message_recieved" vs ".../claude/general/message_recieved"). - Update rule structures and functions so assignment rules accept Option<StackId>, mapping None to a project-wide/default target when creating/updating workspace rules. - Enhance system prompt branch info to handle both stack-specific sessions and general sessions; add helpers to list all stacks and assigned files for general sessions. This was needed to support a general project-wide Claude session mode in addition to stack-scoped sessions and to ensure UI/system prompt, eventing, and rule handling adapt to the presence or absence of a stackId.
1 parent 0e06cda commit 60fed83

File tree

7 files changed

+254
-72
lines changed

7 files changed

+254
-72
lines changed

crates/but-api/src/commands/claude.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use crate::{App, error::Error};
2222
#[serde(rename_all = "camelCase")]
2323
pub struct SendMessageParams {
2424
pub project_id: ProjectId,
25-
pub stack_id: StackId,
25+
pub stack_id: Option<StackId>,
2626
#[serde(flatten)]
2727
pub user_params: ClaudeUserParams,
2828
}
@@ -48,7 +48,7 @@ pub async fn claude_send_message(app: &App, params: SendMessageParams) -> Result
4848
#[serde(rename_all = "camelCase")]
4949
pub struct GetMessagesParams {
5050
pub project_id: ProjectId,
51-
pub stack_id: StackId,
51+
pub stack_id: Option<StackId>,
5252
}
5353

5454
pub fn claude_get_messages(
@@ -137,7 +137,7 @@ pub fn claude_update_permission_request(
137137
#[serde(rename_all = "camelCase")]
138138
pub struct CancelSessionParams {
139139
pub project_id: ProjectId,
140-
pub stack_id: StackId,
140+
pub stack_id: Option<StackId>,
141141
}
142142

143143
pub async fn claude_cancel_session(app: &App, params: CancelSessionParams) -> Result<bool, Error> {
@@ -157,7 +157,7 @@ pub async fn claude_check_available() -> Result<ClaudeCheckResult, Error> {
157157
#[serde(rename_all = "camelCase")]
158158
pub struct IsStackActiveParams {
159159
pub project_id: ProjectId,
160-
pub stack_id: StackId,
160+
pub stack_id: Option<StackId>,
161161
}
162162

163163
pub async fn claude_is_stack_active(app: &App, params: IsStackActiveParams) -> Result<bool, Error> {
@@ -169,7 +169,7 @@ pub async fn claude_is_stack_active(app: &App, params: IsStackActiveParams) -> R
169169
#[serde(rename_all = "camelCase")]
170170
pub struct CompactHistoryParams {
171171
pub project_id: ProjectId,
172-
pub stack_id: StackId,
172+
pub stack_id: Option<StackId>,
173173
}
174174

175175
pub async fn claude_compact_history(app: &App, params: CompactHistoryParams) -> Result<(), Error> {

crates/but-claude/src/bridge.rs

Lines changed: 183 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ use crate::{
5353
send_claude_message,
5454
};
5555

56-
/// Holds the CC instances. Currently keyed by stackId, since our current model
57-
/// assumes one CC per stack at any given time.
56+
/// Holds the CC instances. Keyed by optional stackId - None represents a general
57+
/// project-wide session, Some(StackId) represents a stack-specific session.
5858
pub struct Claudes {
5959
/// A set that contains all the currently running requests
60-
pub(crate) requests: Mutex<HashMap<StackId, Arc<Claude>>>,
60+
pub(crate) requests: Mutex<HashMap<Option<StackId>, Arc<Claude>>>,
6161
}
6262

6363
pub struct Claude {
@@ -75,12 +75,18 @@ impl Claudes {
7575
&self,
7676
ctx: Arc<Mutex<CommandContext>>,
7777
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
78-
stack_id: StackId,
78+
stack_id: Option<StackId>,
7979
user_params: ClaudeUserParams,
8080
) -> Result<()> {
8181
if self.requests.lock().await.contains_key(&stack_id) {
82+
let mode = if stack_id.is_some() {
83+
"stack"
84+
} else {
85+
"project"
86+
};
8287
bail!(
83-
"Claude is currently thinking, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application."
88+
"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.",
89+
mode
8490
);
8591
} else {
8692
self.spawn_claude(ctx.clone(), broadcaster.clone(), stack_id, user_params)
@@ -95,11 +101,17 @@ impl Claudes {
95101
&self,
96102
ctx: Arc<Mutex<CommandContext>>,
97103
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
98-
stack_id: StackId,
104+
stack_id: Option<StackId>,
99105
) -> Result<()> {
100106
if self.requests.lock().await.contains_key(&stack_id) {
107+
let mode = if stack_id.is_some() {
108+
"stack"
109+
} else {
110+
"project"
111+
};
101112
bail!(
102-
"Claude is currently thinking, please wait for it to complete before sending another message.\n\nIf claude is stuck thinking, try restarting the application."
113+
"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.",
114+
mode
103115
)
104116
} else {
105117
self.compact(ctx, broadcaster, stack_id).await
@@ -111,7 +123,7 @@ impl Claudes {
111123
pub fn get_messages(
112124
&self,
113125
ctx: &mut CommandContext,
114-
stack_id: StackId,
126+
stack_id: Option<StackId>,
115127
) -> Result<Vec<ClaudeMessage>> {
116128
let rule = list_claude_assignment_rules(ctx)?
117129
.into_iter()
@@ -124,8 +136,8 @@ impl Claudes {
124136
}
125137
}
126138

127-
/// Cancel a running Claude session for the given stack
128-
pub async fn cancel_session(&self, stack_id: StackId) -> Result<bool> {
139+
/// Cancel a running Claude session for the given stack (or general session if None)
140+
pub async fn cancel_session(&self, stack_id: Option<StackId>) -> Result<bool> {
129141
let requests = self.requests.lock().await;
130142
if let Some(claude) = requests.get(&stack_id) {
131143
// Send the kill signal
@@ -139,8 +151,8 @@ impl Claudes {
139151
}
140152
}
141153

142-
/// Check if there is an active Claude session for the given stack ID
143-
pub async fn is_stack_active(&self, stack_id: StackId) -> bool {
154+
/// Check if there is an active Claude session for the given stack ID (or general session if None)
155+
pub async fn is_stack_active(&self, stack_id: Option<StackId>) -> bool {
144156
let requests = self.requests.lock().await;
145157
requests.contains_key(&stack_id)
146158
}
@@ -149,7 +161,7 @@ impl Claudes {
149161
&self,
150162
ctx: Arc<Mutex<CommandContext>>,
151163
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
152-
stack_id: StackId,
164+
stack_id: Option<StackId>,
153165
user_params: ClaudeUserParams,
154166
) -> () {
155167
let res = self
@@ -182,7 +194,7 @@ impl Claudes {
182194
&self,
183195
ctx: Arc<Mutex<CommandContext>>,
184196
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
185-
stack_id: StackId,
197+
stack_id: Option<StackId>,
186198
user_params: ClaudeUserParams,
187199
) -> Result<()> {
188200
// Capture the start time to filter messages created during this session
@@ -302,8 +314,12 @@ impl Claudes {
302314

303315
// Broadcast each new message
304316
for message in new_messages {
317+
let event_name = match stack_id {
318+
Some(id) => format!("project://{project_id}/claude/{id}/message_recieved"),
319+
None => format!("project://{project_id}/claude/general/message_recieved"),
320+
};
305321
broadcaster.lock().await.send(FrontendEvent {
306-
name: format!("project://{project_id}/claude/{stack_id}/message_recieved"),
322+
name: event_name,
307323
payload: serde_json::json!(message),
308324
});
309325
}
@@ -325,7 +341,7 @@ impl Claudes {
325341
async fn handle_exit(
326342
ctx: Arc<Mutex<CommandContext>>,
327343
broadcaster: Arc<Mutex<Broadcaster>>,
328-
stack_id: StackId,
344+
stack_id: Option<StackId>,
329345
session_id: uuid::Uuid,
330346
mut read_stderr: PipeReader,
331347
mut handle: Child,
@@ -397,7 +413,7 @@ async fn spawn_command(
397413
ctx: Arc<Mutex<CommandContext>>,
398414
user_params: ClaudeUserParams,
399415
summary_to_resume: Option<String>,
400-
stack_id: StackId,
416+
stack_id: Option<StackId>,
401417
) -> Result<Child> {
402418
// Write and obtain our own claude hooks path.
403419
let settings = fmt_claude_settings()?;
@@ -509,7 +525,7 @@ async fn spawn_command(
509525
// Format branch information for the system prompt
510526
let branch_info = {
511527
let mut ctx = ctx.lock().await;
512-
format_branch_info(&mut ctx, stack_id)
528+
format_branch_info(&mut ctx, stack_id).await
513529
};
514530
let system_prompt = format!("{}\n\n{}", SYSTEM_PROMPT, branch_info);
515531
command.args(["--append-system-prompt", &system_prompt]);
@@ -579,23 +595,43 @@ Sorry, this project is managed by GitButler so you must integrate upstream upstr
579595
</git-usage>";
580596

581597
/// Formats branch information for the system prompt
582-
fn format_branch_info(ctx: &mut CommandContext, stack_id: StackId) -> String {
583-
let mut output = String::from(
584-
"<branch-info>\n\
585-
This repository uses GitButler for branch management. While git shows you are on\n\
598+
async fn format_branch_info(ctx: &mut CommandContext, stack_id: Option<StackId>) -> String {
599+
let mut output = String::from("<branch-info>\n");
600+
601+
output.push_str(
602+
"This repository uses GitButler for branch management. While git shows you are on\n\
586603
the `gitbutler/workspace` branch, this is actually a merge commit containing one or\n\
587-
more independent stacks of branches being worked on simultaneously.\n\n\
588-
This session is specific to a particular branch within that workspace. When asked about\n\
589-
the current branch or what changes have been made, you should focus on the current working\n\
590-
branch listed below, not the workspace branch itself.\n\n\
591-
Changes and diffs should be understood relative to the target branch (upstream), as that\n\
592-
represents the integration point for this work.\n\n\
593-
When asked about uncommitted changes you must only consider changes assigned to the stack.\n\n",
604+
more independent stacks of branches being worked on simultaneously.\n\n",
594605
);
595606

596-
append_target_branch_info(&mut output, ctx);
597-
append_stack_branches_info(&mut output, stack_id, ctx);
598-
append_assigned_files_info(&mut output, stack_id, ctx);
607+
match stack_id {
608+
Some(stack_id) => {
609+
output.push_str(
610+
"This session is specific to a particular branch within that workspace. When asked about\n\
611+
the current branch or what changes have been made, you should focus on the current working\n\
612+
branch listed below, not the workspace branch itself.\n\n\
613+
Changes and diffs should be understood relative to the target branch (upstream), as that\n\
614+
represents the integration point for this work.\n\n\
615+
When asked about uncommitted changes you must only consider changes assigned to the stack.\n\n",
616+
);
617+
618+
append_target_branch_info(&mut output, ctx);
619+
append_stack_branches_info(&mut output, stack_id, ctx);
620+
append_assigned_files_info(&mut output, stack_id, ctx);
621+
}
622+
None => {
623+
output.push_str(
624+
"This is a general project-wide session. You can see and work with all stacks and branches.\n\
625+
When the user asks about changes or branches without specifying which one, you should consider\n\
626+
all active stacks in the workspace.\n\n\
627+
You have access to all files and changes across all stacks.\n\n",
628+
);
629+
630+
append_target_branch_info(&mut output, ctx);
631+
append_all_stacks_info(&mut output, ctx);
632+
append_all_assigned_files_info(&mut output, ctx);
633+
}
634+
}
599635

600636
output.push_str("</branch-info>");
601637
output
@@ -732,6 +768,119 @@ fn format_file_with_line_ranges(
732768
}
733769
}
734770

771+
/// Appends information about all stacks in the workspace (for general sessions)
772+
fn append_all_stacks_info(output: &mut String, ctx: &mut CommandContext) {
773+
let repo = match ctx.gix_repo() {
774+
Ok(repo) => repo,
775+
Err(e) => {
776+
tracing::warn!("Failed to get repository: {}", e);
777+
output.push_str("Unable to fetch repository information.\n");
778+
return;
779+
}
780+
};
781+
782+
match but_workspace::legacy::stacks(
783+
ctx,
784+
&ctx.project().gb_dir(),
785+
&repo,
786+
but_workspace::legacy::StacksFilter::InWorkspace,
787+
) {
788+
Ok(stacks) if !stacks.is_empty() => {
789+
output.push_str("The following stacks are currently in the workspace:\n");
790+
for stack in stacks {
791+
if let Some(stack_id) = stack.id {
792+
if let Some(name) = stack.name() {
793+
output.push_str(&format!(
794+
"- {} (stack_id: {})\n",
795+
name.to_str_lossy(),
796+
stack_id
797+
));
798+
799+
// List branches in this stack
800+
if !stack.heads.is_empty() {
801+
output.push_str(" Branches:\n");
802+
for head in &stack.heads {
803+
let checkout_marker = if head.is_checked_out {
804+
" (checked out)"
805+
} else {
806+
""
807+
};
808+
output.push_str(&format!(
809+
" - {}{}\n",
810+
head.name.to_str_lossy(),
811+
checkout_marker
812+
));
813+
}
814+
}
815+
}
816+
}
817+
}
818+
}
819+
Ok(_) => {
820+
output.push_str("There are no stacks currently in the workspace.\n");
821+
}
822+
Err(e) => {
823+
tracing::warn!("Failed to fetch stacks: {}", e);
824+
output.push_str("Unable to fetch stack information.\n");
825+
}
826+
}
827+
}
828+
829+
/// Appends information about all assigned files across all stacks (for general sessions)
830+
fn append_all_assigned_files_info(output: &mut String, ctx: &mut CommandContext) {
831+
let assignments = match but_hunk_assignment::assignments_with_fallback(
832+
ctx,
833+
false,
834+
None::<Vec<but_core::TreeChange>>,
835+
None,
836+
) {
837+
Ok((assignments, _error)) => assignments,
838+
Err(e) => {
839+
tracing::warn!("Failed to fetch hunk assignments: {}", e);
840+
return;
841+
}
842+
};
843+
844+
// Group assignments by stack_id
845+
let mut stacks_with_files: HashMap<Option<StackId>, Vec<&but_hunk_assignment::HunkAssignment>> =
846+
HashMap::new();
847+
for assignment in &assignments {
848+
stacks_with_files
849+
.entry(assignment.stack_id)
850+
.or_default()
851+
.push(assignment);
852+
}
853+
854+
if stacks_with_files.is_empty() {
855+
return;
856+
}
857+
858+
output.push_str("\nFile assignments across all stacks:\n");
859+
860+
// Show unassigned files first if any
861+
if let Some(unassigned) = stacks_with_files.get(&None) {
862+
output.push_str("Unassigned files:\n");
863+
for assignment in unassigned {
864+
output.push_str(&format!(" - {}\n", assignment.path));
865+
}
866+
output.push('\n');
867+
}
868+
869+
// Show files grouped by stack
870+
let mut stack_ids: Vec<_> = stacks_with_files.keys().filter_map(|k| *k).collect();
871+
stack_ids.sort();
872+
873+
for stack_id in stack_ids {
874+
if let Some(files) = stacks_with_files.get(&Some(stack_id)) {
875+
output.push_str(&format!("Stack {} files:\n", stack_id));
876+
for assignment in files {
877+
output.push_str(&format!(" - {}\n", assignment.path));
878+
}
879+
output.push('\n');
880+
}
881+
}
882+
}
883+
735884
fn format_message_with_summary(
736885
summary: &str,
737886
message: &str,
@@ -775,7 +924,7 @@ fn format_message(message: &str, thinking_level: ThinkingLevel) -> String {
775924
async fn upsert_session(
776925
ctx: Arc<Mutex<CommandContext>>,
777926
session_id: uuid::Uuid,
778-
stack_id: StackId,
927+
stack_id: Option<StackId>,
779928
) -> Result<crate::ClaudeSession> {
780929
let mut ctx = ctx.lock().await;
781930
let session = if let Some(session) = db::get_session_by_id(&mut ctx, session_id)? {
@@ -796,7 +945,7 @@ fn spawn_response_streaming(
796945
broadcaster: Arc<Mutex<Broadcaster>>,
797946
read_stdout: PipeReader,
798947
session_id: uuid::Uuid,
799-
stack_id: StackId,
948+
stack_id: Option<StackId>,
800949
) -> tokio::task::JoinHandle<()> {
801950
tokio::spawn(async move {
802951
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();

0 commit comments

Comments
 (0)