From 2dee480486576c9deede3b2ed3808a275b8fd35c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Apr 2026 11:49:51 +1000 Subject: [PATCH] feat(diff): add commit session launcher to diff modal Add a commit session launcher to the diff modal sidebar, allowing users to kick off commit sessions directly from the diff view. The launcher composes a prompt with launch context (commit SHA, review ID, branch scope) embedded as XML metadata, which is extracted at drain time to provide the agent with relevant diff context. Key changes: - Commit session launcher component pinned to diff modal sidebar - Launch context embedding/extraction in session queue pipeline - Shared stripXmlTags utility to deduplicate XML block stripping - Queue state awareness with live session status updates - Commit prompt prefill extracted for reuse across entry points Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src-tauri/src/session_commands.rs | 162 +++++++++++- apps/staged/src/lib/commands.ts | 9 +- .../lib/features/branches/BranchCard.svelte | 3 +- .../features/branches/commitSessionPrefill.ts | 3 + .../diff/DiffCommitSessionLauncher.svelte | 240 ++++++++++++++++++ .../src/lib/features/diff/DiffModal.svelte | 112 ++++---- .../lib/features/sessions/SessionModal.svelte | 15 +- .../features/sessions/sessionModalHelpers.ts | 10 +- .../features/timeline/BranchTimeline.svelte | 6 +- .../lib/features/timeline/liveSessionHints.ts | 7 +- apps/staged/src/lib/types.ts | 7 + 11 files changed, 505 insertions(+), 69 deletions(-) create mode 100644 apps/staged/src/lib/features/branches/commitSessionPrefill.ts create mode 100644 apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 437442ecc..c89790f73 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -448,6 +448,15 @@ pub enum BranchSessionType { Review, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BranchSessionLaunchContext { + pub source: String, + pub scope: String, + pub commit_sha: String, + pub review_id: Option, +} + /// Response from starting a branch session. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -623,6 +632,7 @@ pub async fn start_branch_session( session_type: BranchSessionType, provider: Option, image_ids: Option>, + launch_context: Option, ) -> Result { let store = get_store(&store)?; @@ -711,6 +721,7 @@ pub async fn start_branch_session( &project_information, &branch_context, &session_type, + launch_context.as_ref(), ); // Create the session @@ -840,6 +851,7 @@ pub async fn start_branch_session( /// (commit or note), but does NOT resolve working directory, git context, /// or spawn an agent. The session will be started later via `drain_queued_sessions`. #[tauri::command(rename_all = "camelCase")] +#[allow(clippy::too_many_arguments)] pub fn queue_branch_session( store: tauri::State<'_, Mutex>>>, registry: tauri::State<'_, Arc>, @@ -848,6 +860,7 @@ pub fn queue_branch_session( session_type: BranchSessionType, provider: Option, image_ids: Option>, + launch_context: Option, ) -> Result { let store = get_store(&store)?; @@ -866,7 +879,8 @@ pub fn queue_branch_session( // Create the queued session — prompt is stored raw (not enriched with // context) since context will be built when the session is drained. - let mut session = store::Session::new_queued(&prompt); + let queued_prompt = embed_launch_context(&prompt, launch_context.as_ref())?; + let mut session = store::Session::new_queued(&queued_prompt); if let Some(ref p) = provider { session = session.with_provider(p); } @@ -975,7 +989,7 @@ pub async fn drain_queued_sessions_for_branch( }; // Use the original prompt from the queued session. - let prompt = session.prompt.clone(); + let (prompt, launch_context) = extract_launch_context(&session.prompt)?; let session_id = session.id.clone(); // Resolve branch → project (same as start_branch_session). @@ -1050,6 +1064,7 @@ pub async fn drain_queued_sessions_for_branch( &project_information, &branch_context, &session_type, + launch_context.as_ref(), ); // Atomically transition session from queued to running. @@ -1290,6 +1305,7 @@ pub async fn trigger_auto_review( &project_information, &branch_context, &BranchSessionType::Review, + None, ); // Create the session @@ -2318,6 +2334,7 @@ fn build_full_prompt( project_information: &str, branch_context: &str, session_type: &BranchSessionType, + launch_context: Option<&BranchSessionLaunchContext>, ) -> String { let action_instructions = match session_type { BranchSessionType::Note => { @@ -2432,16 +2449,88 @@ Rules: let action_tag = format!( "\n{action_instructions}\n\nProject information:\n{project_information}\n" ); + let branch_history = render_branch_history(branch_context, launch_context); format!( "{action_tag}\n\n\ \n\ - {branch_context}\n\ + {branch_history}\n\ \n\n\ {user_prompt}" ) } +fn render_branch_history( + branch_context: &str, + launch_context: Option<&BranchSessionLaunchContext>, +) -> String { + let mut parts = Vec::new(); + if !branch_context.trim().is_empty() { + parts.push(branch_context.trim_end().to_string()); + } + if let Some(entry) = render_launch_context_entry(launch_context) { + parts.push(entry); + } + parts.join("\n\n") +} + +fn render_launch_context_entry( + launch_context: Option<&BranchSessionLaunchContext>, +) -> Option { + let context = launch_context?; + if context.source != "diff_viewer" { + return None; + } + + let scope_suffix = match context.scope.as_str() { + "branch" => String::new(), + _ => format!(" (scope: {})", context.scope), + }; + + let mut entry = format!( + "Viewed diff before starting this session: commit {}{}.", + context.commit_sha, scope_suffix + ); + if let Some(review_id) = context.review_id.as_deref() { + entry = format!( + "Viewed diff before starting this session: review {} on commit {}{}.", + review_id, context.commit_sha, scope_suffix + ); + } + Some(entry) +} + +fn embed_launch_context( + prompt: &str, + launch_context: Option<&BranchSessionLaunchContext>, +) -> Result { + let Some(context) = launch_context else { + return Ok(prompt.to_string()); + }; + let json = serde_json::to_string(context).map_err(|e| e.to_string())?; + Ok(format!( + "{json}\n\n{}", + prompt.trim_start() + )) +} + +fn extract_launch_context( + prompt: &str, +) -> Result<(String, Option), String> { + const OPEN: &str = ""; + const CLOSE: &str = ""; + + let Some(rest) = prompt.strip_prefix(OPEN) else { + return Ok((prompt.to_string(), None)); + }; + let Some((json, remainder)) = rest.split_once(CLOSE) else { + return Err("Queued session prompt had malformed launch context".to_string()); + }; + let context = + serde_json::from_str::(json).map_err(|e| e.to_string())?; + Ok((remainder.trim_start().to_string(), Some(context))) +} + #[cfg(test)] mod tests { use super::*; @@ -2527,6 +2616,7 @@ mod tests { "project info", "branch context", &BranchSessionType::Review, + None, ); assert!( @@ -2589,4 +2679,70 @@ mod tests { let review = store.get_review(&review.id).unwrap().unwrap(); assert!(review.is_auto); } + + #[test] + fn commit_prompt_appends_diff_viewer_context_to_branch_history() { + let prompt = build_full_prompt( + "user prompt", + "project info", + "branch context", + &BranchSessionType::Commit, + Some(&BranchSessionLaunchContext { + source: "diff_viewer".to_string(), + scope: "commit".to_string(), + commit_sha: "abc123".to_string(), + review_id: Some("review-42".to_string()), + }), + ); + + assert!(prompt.contains( + "Viewed diff before starting this session: review review-42 on commit abc123 (scope: commit)." + )); + } + + #[test] + fn commit_prompt_omits_branch_scope_from_diff_viewer_context() { + let prompt = build_full_prompt( + "user prompt", + "project info", + "branch context", + &BranchSessionType::Commit, + Some(&BranchSessionLaunchContext { + source: "diff_viewer".to_string(), + scope: "branch".to_string(), + commit_sha: "abc123".to_string(), + review_id: None, + }), + ); + + assert!(prompt.contains("Viewed diff before starting this session: commit abc123.")); + assert!(!prompt.contains("(scope: branch)")); + } + + #[test] + fn queued_prompt_round_trips_launch_context() { + let prompt = embed_launch_context( + "Implement plan", + Some(&BranchSessionLaunchContext { + source: "diff_viewer".to_string(), + scope: "branch".to_string(), + commit_sha: "deadbeef".to_string(), + review_id: None, + }), + ) + .unwrap(); + + let (decoded_prompt, launch_context) = extract_launch_context(&prompt).unwrap(); + + assert_eq!(decoded_prompt, "Implement plan"); + assert_eq!( + launch_context, + Some(BranchSessionLaunchContext { + source: "diff_viewer".to_string(), + scope: "branch".to_string(), + commit_sha: "deadbeef".to_string(), + review_id: None, + }) + ); + } } diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index cee6d7b50..4936b5b58 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -13,6 +13,7 @@ import type { Branch, BranchTimeline, BranchRef, + BranchSessionLaunchContext, BranchSessionType, BranchSessionResponse, StoreIncompatibility, @@ -557,7 +558,8 @@ export function startBranchSession( prompt: string, sessionType: BranchSessionType, provider?: string, - imageIds?: string[] + imageIds?: string[], + launchContext?: BranchSessionLaunchContext ): Promise { return invoke('start_branch_session', { branchId, @@ -565,6 +567,7 @@ export function startBranchSession( sessionType, provider: provider ?? null, imageIds: imageIds ?? null, + launchContext: launchContext ?? null, }); } @@ -574,7 +577,8 @@ export function queueBranchSession( prompt: string, sessionType: BranchSessionType, provider?: string, - imageIds?: string[] + imageIds?: string[], + launchContext?: BranchSessionLaunchContext ): Promise { return invoke('queue_branch_session', { branchId, @@ -582,6 +586,7 @@ export function queueBranchSession( sessionType, provider: provider ?? null, imageIds: imageIds ?? null, + launchContext: launchContext ?? null, }); } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 3f0c426a8..47bba165a 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -38,6 +38,7 @@ import ReasonBanner from './ReasonBanner.svelte'; import RemoteWorkspaceStatusBadge from './RemoteWorkspaceStatusBadge.svelte'; import RemoteWorkspaceStatusView from './RemoteWorkspaceStatusView.svelte'; + import { getCommitPrefillFromReviewComments } from './commitSessionPrefill'; import { alerts } from '../../shared/alerts.svelte'; interface Props { @@ -236,7 +237,7 @@ const latest = all[0]; if (latest.kind === 'review' && latest.commentCount > 0) { - return 'Resolve code review comments'; + return getCommitPrefillFromReviewComments(latest.commentCount); } if (latest.kind === 'note' && latest.title.toLowerCase().includes('plan')) { return 'Implement plan'; diff --git a/apps/staged/src/lib/features/branches/commitSessionPrefill.ts b/apps/staged/src/lib/features/branches/commitSessionPrefill.ts new file mode 100644 index 000000000..adcbd2d15 --- /dev/null +++ b/apps/staged/src/lib/features/branches/commitSessionPrefill.ts @@ -0,0 +1,3 @@ +export function getCommitPrefillFromReviewComments(userVisibleCommentCount: number): string { + return userVisibleCommentCount > 0 ? 'Resolve code review comments' : ''; +} diff --git a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte new file mode 100644 index 000000000..bbb4f7101 --- /dev/null +++ b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte @@ -0,0 +1,240 @@ + + +
+ + +
+ + diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index 3b99f59bd..8b40a6a7a 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -20,6 +20,7 @@ import { DiffViewer, CrossFileSearchBar } from '@builderbot/diff-viewer/components'; import DiffCommentsSection from './DiffCommentsSection.svelte'; import DiffFileTreeSection from './DiffFileTreeSection.svelte'; + import DiffCommitSessionLauncher from './DiffCommitSessionLauncher.svelte'; import DiffReferenceSection from './DiffReferenceSection.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; import { createDiffViewerState } from './diffViewerState.svelte'; @@ -248,8 +249,9 @@ let readonlyTree = $derived(compactTree(buildTree(fileEntries))); let needsReviewTree = $derived(compactTree(buildTree(needsReview))); let reviewedTree = $derived(compactTree(buildTree(reviewed))); - /** Flatten tree nodes depth-first to get the visual file order in the sidebar. */ + let revealedAnnotations = $derived(annotationsRevealed ? currentAnnotations : []); + function flattenTreeFiles(nodes: TreeNode[]): FileEntry[] { const result: FileEntry[] = []; for (const node of nodes) { @@ -543,7 +545,7 @@ loading={diffViewer.state.loadingFile !== null} {beforeLabel} {afterLabel} - annotations={currentAnnotations} + annotations={revealedAnnotations} {annotationsRevealed} searchState={searchState.state} onAddComment={readonly ? undefined : handleAddComment} @@ -569,49 +571,62 @@ {:else} @@ -761,16 +776,23 @@ width: 240px; flex-shrink: 0; border-left: none; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden; } .sidebar-content { display: flex; flex-direction: column; + height: 100%; padding: 0; } + .sidebar-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + } + .sidebar-loading, .sidebar-error, .sidebar-empty { diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 923c8aa3e..b711aa95f 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -67,6 +67,7 @@ verbGroupSummary, hasXmlBlocks, stripCodeFences, + stripXmlTags, } from './sessionModalHelpers'; import InContentSearch from '../../shared/InContentSearch.svelte'; import { highlightMatches, clearHighlights, scrollToMatch } from '../../shared/textHighlight'; @@ -594,7 +595,7 @@ const segments: ContentSegment[] = []; let remaining = content; - const tagPattern = /<(action|branch-history)>([\s\S]*?)<\/\1>/g; + const tagPattern = /<(action|branch-history|launch-context)>([\s\S]*?)<\/\1>/g; let lastIndex = 0; let match: RegExpExecArray | null; @@ -606,7 +607,12 @@ } const tag = match[1]; - const label = tag === 'action' ? 'Action instructions' : 'Branch history'; + const label = + tag === 'action' + ? 'Action instructions' + : tag === 'branch-history' + ? 'Branch history' + : 'Launch context'; const icon = tag === 'action' ? Zap : GitBranch; segments.push({ type: 'xml-block', tag, label, content: match[2].trim(), icon }); @@ -840,10 +846,7 @@