Skip to content

Commit 740d1cd

Browse files
committed
Make stack_id optional for codegen sessions
The absence of a `stack_id` indicates the chat is project wide. In practical terms it just means the system prompt is slightly different, and at the end we don't try to create any commits.
1 parent 0e06cda commit 740d1cd

File tree

7 files changed

+229
-74
lines changed

7 files changed

+229
-74
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: 155 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,14 @@ 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 = stack_id.map_or("project", |_| "stack");
8283
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."
84+
"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.",
85+
mode
8486
);
8587
} else {
8688
self.spawn_claude(ctx.clone(), broadcaster.clone(), stack_id, user_params)
@@ -95,11 +97,13 @@ impl Claudes {
9597
&self,
9698
ctx: Arc<Mutex<CommandContext>>,
9799
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
98-
stack_id: StackId,
100+
stack_id: Option<StackId>,
99101
) -> Result<()> {
100102
if self.requests.lock().await.contains_key(&stack_id) {
103+
let mode = stack_id.map_or("project", |_| "stack");
101104
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."
105+
"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.",
106+
mode
103107
)
104108
} else {
105109
self.compact(ctx, broadcaster, stack_id).await
@@ -111,7 +115,7 @@ impl Claudes {
111115
pub fn get_messages(
112116
&self,
113117
ctx: &mut CommandContext,
114-
stack_id: StackId,
118+
stack_id: Option<StackId>,
115119
) -> Result<Vec<ClaudeMessage>> {
116120
let rule = list_claude_assignment_rules(ctx)?
117121
.into_iter()
@@ -124,8 +128,8 @@ impl Claudes {
124128
}
125129
}
126130

127-
/// Cancel a running Claude session for the given stack
128-
pub async fn cancel_session(&self, stack_id: StackId) -> Result<bool> {
131+
/// Cancel a running Claude session for the given stack (or general session if None)
132+
pub async fn cancel_session(&self, stack_id: Option<StackId>) -> Result<bool> {
129133
let requests = self.requests.lock().await;
130134
if let Some(claude) = requests.get(&stack_id) {
131135
// Send the kill signal
@@ -139,8 +143,8 @@ impl Claudes {
139143
}
140144
}
141145

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 {
146+
/// Check if there is an active Claude session for the given stack ID (or general session if None)
147+
pub async fn is_stack_active(&self, stack_id: Option<StackId>) -> bool {
144148
let requests = self.requests.lock().await;
145149
requests.contains_key(&stack_id)
146150
}
@@ -149,7 +153,7 @@ impl Claudes {
149153
&self,
150154
ctx: Arc<Mutex<CommandContext>>,
151155
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
152-
stack_id: StackId,
156+
stack_id: Option<StackId>,
153157
user_params: ClaudeUserParams,
154158
) -> () {
155159
let res = self
@@ -182,7 +186,7 @@ impl Claudes {
182186
&self,
183187
ctx: Arc<Mutex<CommandContext>>,
184188
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
185-
stack_id: StackId,
189+
stack_id: Option<StackId>,
186190
user_params: ClaudeUserParams,
187191
) -> Result<()> {
188192
// Capture the start time to filter messages created during this session
@@ -303,7 +307,7 @@ impl Claudes {
303307
// Broadcast each new message
304308
for message in new_messages {
305309
broadcaster.lock().await.send(FrontendEvent {
306-
name: format!("project://{project_id}/claude/{stack_id}/message_recieved"),
310+
name: crate::claude_event_name(project_id, stack_id),
307311
payload: serde_json::json!(message),
308312
});
309313
}
@@ -325,7 +329,7 @@ impl Claudes {
325329
async fn handle_exit(
326330
ctx: Arc<Mutex<CommandContext>>,
327331
broadcaster: Arc<Mutex<Broadcaster>>,
328-
stack_id: StackId,
332+
stack_id: Option<StackId>,
329333
session_id: uuid::Uuid,
330334
mut read_stderr: PipeReader,
331335
mut handle: Child,
@@ -397,7 +401,7 @@ async fn spawn_command(
397401
ctx: Arc<Mutex<CommandContext>>,
398402
user_params: ClaudeUserParams,
399403
summary_to_resume: Option<String>,
400-
stack_id: StackId,
404+
stack_id: Option<StackId>,
401405
) -> Result<Child> {
402406
// Write and obtain our own claude hooks path.
403407
let settings = fmt_claude_settings()?;
@@ -509,7 +513,7 @@ async fn spawn_command(
509513
// Format branch information for the system prompt
510514
let branch_info = {
511515
let mut ctx = ctx.lock().await;
512-
format_branch_info(&mut ctx, stack_id)
516+
format_branch_info(&mut ctx, stack_id).await
513517
};
514518
let system_prompt = format!("{}\n\n{}", SYSTEM_PROMPT, branch_info);
515519
command.args(["--append-system-prompt", &system_prompt]);
@@ -579,23 +583,43 @@ Sorry, this project is managed by GitButler so you must integrate upstream upstr
579583
</git-usage>";
580584

581585
/// 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\
586+
async fn format_branch_info(ctx: &mut CommandContext, stack_id: Option<StackId>) -> String {
587+
let mut output = String::from("<branch-info>\n");
588+
589+
output.push_str(
590+
"This repository uses GitButler for branch management. While git shows you are on\n\
586591
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",
592+
more independent stacks of branches being worked on simultaneously.\n\n",
594593
);
595594

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

600624
output.push_str("</branch-info>");
601625
output
@@ -732,6 +756,103 @@ fn format_file_with_line_ranges(
732756
}
733757
}
734758

759+
/// Appends information about all stacks in the workspace (for general sessions)
760+
fn append_all_stacks_info(output: &mut String, ctx: &mut CommandContext) {
761+
let Ok(repo) = ctx.gix_repo() else {
762+
tracing::warn!("Failed to get repository");
763+
output.push_str("Unable to fetch repository information.\n");
764+
return;
765+
};
766+
767+
let stacks = match but_workspace::legacy::stacks(
768+
ctx,
769+
&ctx.project().gb_dir(),
770+
&repo,
771+
but_workspace::legacy::StacksFilter::InWorkspace,
772+
) {
773+
Ok(stacks) if !stacks.is_empty() => stacks,
774+
Ok(_) => {
775+
output.push_str("There are no stacks currently in the workspace.\n");
776+
return;
777+
}
778+
Err(e) => {
779+
tracing::warn!("Failed to fetch stacks: {e}");
780+
output.push_str("Unable to fetch stack information.\n");
781+
return;
782+
}
783+
};
784+
785+
output.push_str("The following stacks are currently in the workspace:\n");
786+
for stack in stacks {
787+
let (Some(stack_id), Some(name)) = (stack.id, stack.name()) else {
788+
continue;
789+
};
790+
791+
output.push_str(&format!("- {} (stack_id: {stack_id})\n", name.to_str_lossy()));
792+
793+
// List branches in this stack
794+
if !stack.heads.is_empty() {
795+
output.push_str(" Branches:\n");
796+
for head in &stack.heads {
797+
let checkout_marker = if head.is_checked_out { " (checked out)" } else { "" };
798+
output.push_str(&format!(" - {}{checkout_marker}\n", head.name.to_str_lossy()));
799+
}
800+
}
801+
}
802+
}
803+
804+
/// Appends information about all assigned files across all stacks (for general sessions)
805+
fn append_all_assigned_files_info(output: &mut String, ctx: &mut CommandContext) {
806+
let Ok((assignments, _error)) = but_hunk_assignment::assignments_with_fallback(
807+
ctx,
808+
false,
809+
None::<Vec<but_core::TreeChange>>,
810+
None,
811+
) else {
812+
tracing::warn!("Failed to fetch hunk assignments");
813+
return;
814+
};
815+
816+
// Group assignments by stack_id
817+
let mut stacks_with_files: HashMap<Option<StackId>, Vec<&but_hunk_assignment::HunkAssignment>> =
818+
HashMap::new();
819+
for assignment in &assignments {
820+
stacks_with_files
821+
.entry(assignment.stack_id)
822+
.or_default()
823+
.push(assignment);
824+
}
825+
826+
if stacks_with_files.is_empty() {
827+
return;
828+
}
829+
830+
output.push_str("\nFile assignments across all stacks:\n");
831+
832+
// Show unassigned files first if any
833+
if let Some(unassigned) = stacks_with_files.get(&None) {
834+
output.push_str("Unassigned files:\n");
835+
for assignment in unassigned {
836+
output.push_str(&format!(" - {}\n", assignment.path));
837+
}
838+
output.push('\n');
839+
}
840+
841+
// Show files grouped by stack
842+
let mut stack_ids: Vec<_> = stacks_with_files.keys().copied().flatten().collect();
843+
stack_ids.sort();
844+
845+
for stack_id in stack_ids {
846+
if let Some(files) = stacks_with_files.get(&Some(stack_id)) {
847+
output.push_str(&format!("Stack {stack_id} files:\n"));
848+
for assignment in files {
849+
output.push_str(&format!(" - {}\n", assignment.path));
850+
}
851+
output.push('\n');
852+
}
853+
}
854+
}
855+
735856
fn format_message_with_summary(
736857
summary: &str,
737858
message: &str,
@@ -775,7 +896,7 @@ fn format_message(message: &str, thinking_level: ThinkingLevel) -> String {
775896
async fn upsert_session(
776897
ctx: Arc<Mutex<CommandContext>>,
777898
session_id: uuid::Uuid,
778-
stack_id: StackId,
899+
stack_id: Option<StackId>,
779900
) -> Result<crate::ClaudeSession> {
780901
let mut ctx = ctx.lock().await;
781902
let session = if let Some(session) = db::get_session_by_id(&mut ctx, session_id)? {
@@ -796,7 +917,7 @@ fn spawn_response_streaming(
796917
broadcaster: Arc<Mutex<Broadcaster>>,
797918
read_stdout: PipeReader,
798919
session_id: uuid::Uuid,
799-
stack_id: StackId,
920+
stack_id: Option<StackId>,
800921
) -> tokio::task::JoinHandle<()> {
801922
tokio::spawn(async move {
802923
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();

crates/but-claude/src/compact.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ impl Claudes {
7979
&self,
8080
ctx: Arc<Mutex<CommandContext>>,
8181
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
82-
stack_id: StackId,
82+
stack_id: Option<StackId>,
8383
) -> () {
8484
let res = self
8585
.compact_inner(ctx.clone(), broadcaster.clone(), stack_id)
@@ -111,7 +111,7 @@ impl Claudes {
111111
&self,
112112
ctx: Arc<Mutex<CommandContext>>,
113113
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
114-
stack_id: StackId,
114+
stack_id: Option<StackId>,
115115
) -> Result<()> {
116116
let (send_kill, mut _recv_kill) = unbounded_channel();
117117
self.requests
@@ -165,7 +165,7 @@ impl Claudes {
165165
&self,
166166
ctx: Arc<Mutex<CommandContext>>,
167167
broadcaster: Arc<tokio::sync::Mutex<Broadcaster>>,
168-
stack_id: StackId,
168+
stack_id: Option<StackId>,
169169
) -> Result<()> {
170170
let rule = {
171171
let mut ctx = ctx.lock().await;

0 commit comments

Comments
 (0)