diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d8b75c9c..66dd1901 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -163,6 +163,7 @@ pub struct NoteTimelineItem { pub completed_at: Option, pub suggested_next_commit_step: Option, pub suggested_next_note_step: Option, + pub suggested_next_steps: Vec, } /// Review with session status resolved. diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 1725dbff..90c56fef 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -29,6 +29,7 @@ pub fn create_note( completed_at: note.completed_at, suggested_next_commit_step: None, suggested_next_note_step: None, + suggested_next_steps: Vec::new(), }) } diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index c05df8d8..d53bacd6 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -292,24 +292,28 @@ Use the existing conversation context. Do not create commits.\n\ \n\ {standalone_guidance}\n\ \n\ -Your final response must include a suggested-next-steps fenced block followed by the note content after a horizontal rule:\n\ -\n\ -```suggested-next-steps\n\ -{{\"suggestedNextCommitStep\": null, \"suggestedNextNoteStep\": null}}\n\ -```\n\ +Your final response must include the note content after a horizontal rule, followed by a final suggested-next-steps fenced block:\n\ \n\ ---\n\ # \n\ <Body>\n\ \n\ +```suggested-next-steps\n\ +{{\"suggestedNextSteps\": []}}\n\ +```\n\ +\n\ Formatting requirements:\n\ +- The `---` separator must be on its own line.\n\ +- The note content must start immediately after `---` with a markdown H1.\n\ - The opening fence line for suggested-next-steps must be exactly: ```suggested-next-steps\n\ - The closing fence line must be exactly: ```\n\ - Put only a JSON object inside the suggested-next-steps block.\n\ -- Include both nullable string fields: suggestedNextCommitStep and suggestedNextNoteStep.\n\ -- Keep suggested next steps concise; use null when there is no clear next action.\n\ -- The `---` separator must be on its own line.\n\ -- The note content must start immediately after `---` with a markdown H1.\n\ +- Include one suggestedNextSteps array with up to 4 ordered items.\n\ +- Implementation items must be {{\"type\":\"implementation\",\"prompt\":\"...\",\"expectedMultipleCommits\":false}}.\n\ +- Note items must be {{\"type\":\"note\",\"prompt\":\"...\"}}.\n\ +- Keep suggested next steps concise; use an empty array when there is no clear next action.\n\ +- Put the suggested-next-steps block after the note content as the final block.\n\ +- Do not write any prose after the suggested-next-steps block.\n\ - Do not wrap the note in code fences.\n\ </action>\n\ \n\ @@ -4714,42 +4718,39 @@ pub(crate) fn build_full_prompt_with_pikchr_reference( You may use any tools needed to research and gather information, but do NOT create \ any commits.", NOTE_STANDALONE_OUTPUT_GUIDANCE.trim(), - "To return the note, your final response must include the structure shown below. \ -Before the `---` separator, emit a `suggested-next-steps` fenced block that suggests \ -what the user might want to do next. The block must contain a single JSON object with \ -two nullable string fields: - -```suggested-next-steps -{\"suggestedNextCommitStep\": \"Fix the null pointer bug in parser.rs\", \"suggestedNextNoteStep\": \"Make a plan to fix the null pointer bug\"} -``` + r#"To return the note, your final response must include the structure shown below. After the note content, emit a final `suggested-next-steps` fenced block that suggests what the user might want to do next. The block must contain a single JSON object with one ordered suggestedNextSteps array. Guidelines for suggested next steps: -- Keep suggestions very concise (a few words). They are shown alongside the note title, \ -so you can assume the user has already read the title for context. \ -Do NOT repeat information from the title. -- If the note is a plan, suggest a commit to implement it: \ -{\"suggestedNextCommitStep\": \"Implement this plan\", \"suggestedNextNoteStep\": null} -- If the note is a plan with multiple options, pick the best option: \ -{\"suggestedNextCommitStep\": \"Implement option 2: use Redis cache\", \"suggestedNextNoteStep\": null} -- If the note is bug research, suggest both a fix and a deeper plan: \ -{\"suggestedNextCommitStep\": \"Fix this bug\", \"suggestedNextNoteStep\": \"Plan a fix for this bug\"} -- If the note is pure research or informational with no clear next action: \ -{\"suggestedNextCommitStep\": null, \"suggestedNextNoteStep\": null} - -Then, after the suggested-next-steps block, include the note itself: +- Return up to 4 suggested next steps in recommendation order. +- Keep suggestions very concise (a few words). They are shown alongside the note title, so you can assume the user has already read the title for context. Do NOT repeat information from the title. +- Implementation items must include `type`, `prompt`, and `expectedMultipleCommits`. +- Note items must include only `type` and `prompt`. +- If the note is a plan, suggest an implementation step to implement it: {"suggestedNextSteps":[{"type":"implementation","prompt":"Implement this plan","expectedMultipleCommits":false}]} +- If the note is a plan with multiple options, pick the best option: {"suggestedNextSteps":[{"type":"implementation","prompt":"Implement option 2: use Redis cache","expectedMultipleCommits":false}]} +- If the note is bug research, suggest both a fix and a deeper plan in recommendation order: {"suggestedNextSteps":[{"type":"implementation","prompt":"Fix this bug","expectedMultipleCommits":false},{"type":"note","prompt":"Plan a fix for this bug"}]} +- If implementation is likely to require multiple focused commits, set expectedMultipleCommits to true. +- If the note is pure research or informational with no clear next action: {"suggestedNextSteps":[]} + +Use this exact response shape: --- # <Title> <Body> +```suggested-next-steps +{"suggestedNextSteps":[{"type":"implementation","prompt":"Fix the null pointer bug in parser.rs","expectedMultipleCommits":false},{"type":"note","prompt":"Make a plan to fix the null pointer bug"}]} +``` + Formatting requirements: +- `---` must be on its own line, with a newline immediately before and after it. +- The note content must start immediately after `---` with a markdown H1 (`# Title`). - The opening fence line for suggested-next-steps must be exactly: ```suggested-next-steps - The closing fence line must be exactly: ``` - Put only the JSON object inside the suggested-next-steps block (no prose or markdown). - Do not wrap the block in any additional code fences. -- `---` must be on its own line, with a newline immediately before and after it. -- The note content must start immediately after `---` with a markdown H1 (`# Title`). -- Do not wrap the note in code fences.", +- Put the suggested-next-steps block after the note content as the final block. +- Do not write any prose after the suggested-next-steps block. +- Do not wrap the note in code fences."#, ] .join("\n\n"), BranchSessionType::Commit => { @@ -6345,6 +6346,22 @@ mod tests { assert!(prompt.contains(standalone_guidance)); } + fn assert_note_prompt_puts_suggested_steps_after_note(prompt: &str) { + let note_idx = prompt + .find("---\n# <Title>\n<Body>") + .expect("prompt should include the note response shape"); + let steps_idx = prompt[note_idx..] + .find("```suggested-next-steps") + .map(|offset| note_idx + offset) + .expect("prompt should include suggested-next-steps after the note shape"); + + assert!(steps_idx > note_idx); + assert!(prompt.contains( + "Put the suggested-next-steps block after the note content as the final block." + )); + assert!(!prompt.contains("Before the `---` separator")); + } + #[test] fn generated_remote_pikchr_grammar_paths_are_unique_temp_markdown_files() { let first = generated_pikchr_grammar_remote_path(); @@ -6462,6 +6479,7 @@ mod tests { "/Applications/Staged.app/Contents/Resources/resources/pikchr/grammar.md", ); assert_note_standalone_output_guidance(&prompt); + assert_note_prompt_puts_suggested_steps_after_note(&prompt); assert!(prompt.contains("generate_pikchr")); } @@ -6474,6 +6492,7 @@ mod tests { assert!(prompt.contains("Please write the note for this session.")); assert_pikchr_note_guidance(&prompt, &remote_path); assert_note_standalone_output_guidance(&prompt); + assert_note_prompt_puts_suggested_steps_after_note(&prompt); // Remote note sessions can't reach the pikchr server, so it isn't mentioned. assert!(!prompt.contains("generate_pikchr")); } @@ -6533,6 +6552,7 @@ mod tests { assert_note_standalone_output_guidance(&prompt); assert!(prompt.contains("The opening fence line for suggested-next-steps must be exactly: ```suggested-next-steps")); + assert_note_prompt_puts_suggested_steps_after_note(&prompt); } #[test] diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index d511b612..4e9cd0e4 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -54,7 +54,7 @@ use crate::shell_env::ShellEnvCache; use crate::store::{ AcpConfigSelection, Comment, CommentAuthor, CommentType, CompletionReason, FailureStrategy, MessageRole, PipelineExecution, PipelineKind, PipelineStep, SessionMessage, SessionStatus, - StepStatus, StepType, Store, + StepStatus, StepType, Store, SuggestedNextStep, }; const PIPELINE_STEP_PROMPT_OUTPUT_MAX_CHARS: usize = 30_000; @@ -1013,10 +1013,11 @@ fn build_pikchr_correction_prompt( ) -> String { let note_format = if note_format_required { "\n\nThis session is producing a Staged note. Preserve the required final response format:\n\ -- Start with a fenced block whose opening line is exactly: ```suggested-next-steps\n\ -- Put only a JSON object in that block, with nullable string fields `suggestedNextCommitStep` and `suggestedNextNoteStep`.\n\ -- After that block, include a `---` separator on its own line.\n\ +- Include a `---` separator on its own line.\n\ - Start the note content immediately after `---` with a markdown H1 (`# <Title>`).\n\ +- After the note content, include a final fenced block whose opening line is exactly: ```suggested-next-steps\n\ +- Put only a JSON object in that block, with an ordered `suggestedNextSteps` array.\n\ +- Do not write any prose after the suggested-next-steps block.\n\ - Do not wrap the note content in code fences." } else { "" @@ -2403,9 +2404,8 @@ fn run_post_completion_hooks( .find_map(|m| extract_suggested_next_steps(&m.content)); if let Some(ref steps) = suggested_next_steps { log::info!( - "Session {session_id}: extracted suggested next steps — commit: {:?}, note: {:?}", - steps.suggested_next_commit_step, - steps.suggested_next_note_step, + "Session {session_id}: extracted {} suggested next step(s)", + steps.suggested_next_steps.len(), ); } @@ -2425,26 +2425,22 @@ fn run_post_completion_hooks( "extracted" } ); - let sncs = suggested_next_steps + let suggested_steps = suggested_next_steps .as_ref() - .and_then(|s| s.suggested_next_commit_step.as_deref()); - let snns = suggested_next_steps - .as_ref() - .and_then(|s| s.suggested_next_note_step.as_deref()); + .map(|s| s.suggested_next_steps.as_slice()) + .unwrap_or(&[]); let result = match target.kind { NoteKind::Repo => store.update_note_title_and_content( &target.id, &final_title, &body, - sncs, - snns, + suggested_steps, ), NoteKind::Project => store.update_project_note_title_and_content( &target.id, &final_title, &body, - sncs, - snns, + suggested_steps, ), }; if let Err(e) = result { @@ -2534,6 +2530,22 @@ fn auto_review_branch_id_for_terminal_state( }) } +const SUGGESTED_NEXT_STEPS_MARKER: &str = "```suggested-next-steps"; + +#[derive(Debug, Clone, Copy)] +struct NoteSeparator { + start: usize, + content_start: usize, +} + +#[derive(Debug, Clone, Copy)] +struct SuggestedNextStepsBlock { + start: usize, + content_start: usize, + closing_start: usize, + end: usize, +} + /// Extract note content from a single assistant message. /// /// Callers are responsible for choosing which message to pass — typically the @@ -2549,56 +2561,183 @@ fn auto_review_branch_id_for_terminal_state( /// to prior text (for example, `Preamble.---\n# Title`) by accepting inline /// rules only when the remaining content starts with an H1. fn extract_note_content(text: &str) -> Option<String> { - let sanitized = strip_suggested_next_steps_blocks(text); + let sanitized = strip_suggested_next_steps_metadata(text); let text = sanitized.as_deref().unwrap_or(text); extract_note_after_standalone_hr(text).or_else(|| extract_note_after_inline_hr(text)) } /// Remove assistant response metadata before scanning for the note separator. -/// The note body remains normal markdown and can still contain fenced code. -fn strip_suggested_next_steps_blocks(text: &str) -> Option<String> { - let marker = "```suggested-next-steps"; +/// +/// Supported metadata positions: +/// - legacy `suggested-next-steps` blocks before the note separator +/// - the current terminal `suggested-next-steps` block after the note body +/// - an interrupted terminal `suggested-next-steps` block after the note body +/// +/// Other fenced blocks in the note body remain normal markdown. +fn strip_suggested_next_steps_metadata(text: &str) -> Option<String> { + let separator = find_note_separator(text); + let blocks = find_suggested_next_steps_blocks(text); + let terminal_complete_block_start = blocks + .iter() + .rev() + .find(|block| text[block.end..].trim().is_empty()) + .map(|block| block.start); + + let mut ranges: Vec<(usize, usize)> = blocks + .iter() + .filter_map(|block| { + let is_legacy_before_separator = separator + .map(|separator| block.end <= separator.start) + .unwrap_or_else(|| find_note_separator(&text[block.end..]).is_some()); + let is_terminal_metadata = Some(block.start) == terminal_complete_block_start; + + (is_legacy_before_separator || is_terminal_metadata).then_some((block.start, block.end)) + }) + .collect(); + + if let Some(separator) = separator { + if let Some(start) = find_terminal_unclosed_suggested_next_steps_opening(text) { + let already_removed = ranges + .iter() + .any(|(range_start, range_end)| start >= *range_start && start < *range_end); + if start >= separator.content_start && !already_removed { + ranges.push((start, text.len())); + } + } + } + + remove_ranges(text, &mut ranges) +} + +fn remove_ranges(text: &str, ranges: &mut Vec<(usize, usize)>) -> Option<String> { + ranges.retain(|(start, end)| start < end); + if ranges.is_empty() { + return None; + } + + ranges.sort_by_key(|(start, _)| *start); + let mut output = String::new(); let mut last_copied = 0; - let mut search_from = 0; let mut removed_any = false; + for (start, end) in ranges.iter().copied() { + if start < last_copied { + continue; + } + output.push_str(&text[last_copied..start]); + last_copied = end; + removed_any = true; + } + + if removed_any { + output.push_str(&text[last_copied..]); + Some(output) + } else { + None + } +} + +fn find_suggested_next_steps_blocks(text: &str) -> Vec<SuggestedNextStepsBlock> { + let mut blocks = Vec::new(); + let mut search_from = 0; + while search_from < text.len() { - let Some(rel_pos) = find_suggested_next_steps_opening_fence(&text[search_from..], marker) - else { + let Some(rel_pos) = find_suggested_next_steps_opening_fence(&text[search_from..]) else { break; }; - let start_pos = search_from + rel_pos; - let block_start = start_pos + marker.len(); + let start = search_from + rel_pos; + let block_start = start + SUGGESTED_NEXT_STEPS_MARKER.len(); let Some(newline_pos) = text[block_start..].find('\n') else { break; }; let content_start = block_start + newline_pos + 1; let Some(end_pos) = find_closing_fence(&text[content_start..]) else { + if let Some(next_rel_pos) = + find_suggested_next_steps_opening_fence(&text[content_start..]) + { + search_from = content_start + next_rel_pos; + continue; + } break; }; let closing_start = content_start + end_pos; - let after_closing = text[closing_start..] + if let Some(next_rel_pos) = + find_suggested_next_steps_opening_fence(&text[content_start..closing_start]) + { + search_from = content_start + next_rel_pos; + continue; + } + + let end = text[closing_start..] .find('\n') .map(|newline| closing_start + newline + 1) .unwrap_or(text.len()); - output.push_str(&text[last_copied..start_pos]); - last_copied = after_closing; - search_from = after_closing; - removed_any = true; + blocks.push(SuggestedNextStepsBlock { + start, + content_start, + closing_start, + end, + }); + search_from = end; } - if removed_any { - output.push_str(&text[last_copied..]); - Some(output) - } else { - None + blocks +} + +fn find_terminal_unclosed_suggested_next_steps_opening(text: &str) -> Option<usize> { + let mut search_from = 0; + + while search_from < text.len() { + let Some(rel_pos) = find_suggested_next_steps_opening_fence(&text[search_from..]) else { + break; + }; + let start = search_from + rel_pos; + let block_start = start + SUGGESTED_NEXT_STEPS_MARKER.len(); + let Some(newline_pos) = text[block_start..].find('\n') else { + return text[block_start..].trim().is_empty().then_some(start); + }; + let content_start = block_start + newline_pos + 1; + + let Some(end_pos) = find_closing_fence(&text[content_start..]) else { + if let Some(next_rel_pos) = + find_suggested_next_steps_opening_fence(&text[content_start..]) + { + search_from = content_start + next_rel_pos; + continue; + } + return Some(start); + }; + + let closing_start = content_start + end_pos; + if let Some(next_rel_pos) = + find_suggested_next_steps_opening_fence(&text[content_start..closing_start]) + { + search_from = content_start + next_rel_pos; + continue; + } + + search_from = text[closing_start..] + .find('\n') + .map(|newline| closing_start + newline + 1) + .unwrap_or(text.len()); } + + None } fn extract_note_after_standalone_hr(text: &str) -> Option<String> { + let separator = find_standalone_hr_separator(text)?; + Some(text[separator.content_start..].trim().to_string()) +} + +fn find_note_separator(text: &str) -> Option<NoteSeparator> { + find_standalone_hr_separator(text).or_else(|| find_inline_hr_separator(text)) +} + +fn find_standalone_hr_separator(text: &str) -> Option<NoteSeparator> { // Look for --- on its own line (possibly with surrounding whitespace). // We match the same patterns markdown parsers treat as thematic breaks: // a line containing only ---, ***, or ___ (with optional spaces). @@ -2609,12 +2748,12 @@ fn extract_note_after_standalone_hr(text: &str) -> Option<String> { // // We track whether we are inside a fenced code block (``` or ~~~) and // skip any HR that appears inside one. - let lines: Vec<&str> = text.lines().collect(); + let lines: Vec<(usize, &str)> = line_byte_offsets(text).collect(); // Pre-compute which lines are inside a code fence. let mut in_fence = vec![false; lines.len()]; let mut inside = false; - for (i, line) in lines.iter().enumerate() { + for (i, (_, line)) in lines.iter().enumerate() { let trimmed = line.trim(); if trimmed.starts_with("```") || trimmed.starts_with("~~~") { inside = !inside; @@ -2626,12 +2765,18 @@ fn extract_note_after_standalone_hr(text: &str) -> Option<String> { if in_fence[i] { continue; } - let trimmed = lines[i].trim(); + let (line_start, line) = lines[i]; + let trimmed = line.trim(); if trimmed == "---" || trimmed == "***" || trimmed == "___" { - let remaining: String = lines[i + 1..].join("\n"); - let trimmed_remaining = remaining.trim().to_string(); - if !trimmed_remaining.is_empty() { - return Some(trimmed_remaining); + let content_start = lines + .get(i + 1) + .map(|(next_line_start, _)| *next_line_start) + .unwrap_or(text.len()); + if !text[content_start..].trim().is_empty() { + return Some(NoteSeparator { + start: line_start, + content_start, + }); } } } @@ -2639,11 +2784,16 @@ fn extract_note_after_standalone_hr(text: &str) -> Option<String> { } fn extract_note_after_inline_hr(text: &str) -> Option<String> { + let separator = find_inline_hr_separator(text)?; + Some(text[separator.content_start..].to_string()) +} + +fn find_inline_hr_separator(text: &str) -> Option<NoteSeparator> { // Pre-compute a set of byte offsets that fall inside fenced code blocks // so we can skip inline HRs that appear in code examples. let fence_ranges = compute_fence_ranges(text); - let mut best: Option<(usize, String)> = None; + let mut best: Option<NoteSeparator> = None; for marker in ["---", "***", "___"] { let marker_char = marker.chars().next().unwrap(); @@ -2667,17 +2817,23 @@ fn extract_note_after_inline_hr(text: &str) -> Option<String> { if !remaining.starts_with("# ") { continue; } + let content_start = marker_end + text[marker_end..].len() - remaining.len(); // Keep the *first* (earliest) match — the note starts at the // first separator in the message. match best { - Some((best_idx, _)) if idx >= best_idx => {} - _ => best = Some((idx, remaining.to_string())), + Some(separator) if idx >= separator.start => {} + _ => { + best = Some(NoteSeparator { + start: idx, + content_start, + }) + } } } } - best.map(|(_, content)| content) + best } /// Return byte-offset ranges `(start, end)` for content inside fenced code @@ -2866,26 +3022,95 @@ fn extract_review_title(text: &str) -> Option<String> { /// Structured representation of the suggested next steps extracted from a /// `suggested-next-steps` fenced block. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SuggestedNextSteps { + suggested_next_steps: Vec<SuggestedNextStep>, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct SuggestedNextSteps { +struct SuggestedNextStepsPayload { + #[serde(default)] + suggested_next_steps: Vec<SuggestedNextStep>, + suggested_next_commit_step: Option<String>, + suggested_next_note_step: Option<String>, +} + +impl SuggestedNextStepsPayload { + fn into_steps(self) -> SuggestedNextSteps { + let suggested_next_steps = if self.suggested_next_steps.is_empty() { + legacy_suggested_next_steps( + self.suggested_next_commit_step, + self.suggested_next_note_step, + ) + } else { + self.suggested_next_steps + }; + + SuggestedNextSteps { + suggested_next_steps: sanitize_suggested_next_steps(suggested_next_steps), + } + } +} + +fn legacy_suggested_next_steps( suggested_next_commit_step: Option<String>, suggested_next_note_step: Option<String>, +) -> Vec<SuggestedNextStep> { + let mut steps = Vec::new(); + if let Some(prompt) = non_empty_suggested_step_prompt(suggested_next_commit_step) { + steps.push(SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits: false, + }); + } + if let Some(prompt) = non_empty_suggested_step_prompt(suggested_next_note_step) { + steps.push(SuggestedNextStep::Note { prompt }); + } + steps +} + +fn sanitize_suggested_next_steps(steps: Vec<SuggestedNextStep>) -> Vec<SuggestedNextStep> { + steps + .into_iter() + .filter_map(|step| match step { + SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits, + } => non_empty_suggested_step_prompt(Some(prompt)).map(|prompt| { + SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits, + } + }), + SuggestedNextStep::Note { prompt } => non_empty_suggested_step_prompt(Some(prompt)) + .map(|prompt| SuggestedNextStep::Note { prompt }), + }) + .take(4) + .collect() +} + +fn non_empty_suggested_step_prompt(prompt: Option<String>) -> Option<String> { + let prompt = prompt?; + let trimmed = prompt.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } } /// Extract suggested next steps from assistant output. /// -/// Looks for a ```suggested-next-steps fenced block and parses the JSON object -/// inside. Returns `None` if the block is missing or cannot be parsed. +/// Prefer the terminal ```suggested-next-steps fenced block used by the current +/// note contract, while still accepting legacy blocks before the note separator. +/// Returns `None` if the block is missing or cannot be parsed. fn extract_suggested_next_steps(text: &str) -> Option<SuggestedNextSteps> { - let marker = "```suggested-next-steps"; - let start_pos = find_suggested_next_steps_opening_fence(text, marker)?; - let block_start = start_pos + marker.len(); - let content_start = block_start + text[block_start..].find('\n')? + 1; - let end_pos = find_closing_fence(&text[content_start..])?; - let json_str = text[content_start..content_start + end_pos].trim(); - match serde_json::from_str::<SuggestedNextSteps>(json_str) { - Ok(steps) => Some(steps), + let blocks = find_suggested_next_steps_blocks(text); + let block = preferred_suggested_next_steps_block(text, &blocks)?; + let json_str = text[block.content_start..block.closing_start].trim(); + match serde_json::from_str::<SuggestedNextStepsPayload>(json_str) { + Ok(payload) => Some(payload.into_steps()), Err(e) => { log::warn!("Failed to parse suggested-next-steps JSON: {e}"); None @@ -2893,6 +3118,29 @@ fn extract_suggested_next_steps(text: &str) -> Option<SuggestedNextSteps> { } } +fn preferred_suggested_next_steps_block( + text: &str, + blocks: &[SuggestedNextStepsBlock], +) -> Option<SuggestedNextStepsBlock> { + if let Some(block) = blocks + .iter() + .rev() + .find(|block| text[block.end..].trim().is_empty()) + { + return Some(*block); + } + + if let Some(separator) = find_note_separator(text) { + return blocks + .iter() + .rev() + .find(|block| block.end <= separator.start) + .copied(); + } + + blocks.last().copied() +} + /// Find a `suggested-next-steps` opening fence. /// /// The normal fence finder requires markers to appear at the start of a line @@ -2900,19 +3148,19 @@ fn extract_suggested_next_steps(text: &str) -> Option<SuggestedNextSteps> { /// can arrive with this fence attached to the final sentence, so this finder /// accepts inline markers while still requiring the rest of the marker line to /// contain only optional whitespace before the newline. -fn find_suggested_next_steps_opening_fence(text: &str, marker: &str) -> Option<usize> { +fn find_suggested_next_steps_opening_fence(text: &str) -> Option<usize> { let mut pos = 0; while pos < text.len() { - let candidate = text[pos..].find(marker)?; + let candidate = text[pos..].find(SUGGESTED_NEXT_STEPS_MARKER)?; let abs = pos + candidate; - let after_marker = &text[abs + marker.len()..]; + let after_marker = &text[abs + SUGGESTED_NEXT_STEPS_MARKER.len()..]; if after_marker .find('\n') .is_some_and(|newline| after_marker[..newline].trim().is_empty()) { return Some(abs); } - pos = abs + marker.len(); + pos = abs + SUGGESTED_NEXT_STEPS_MARKER.len(); } None } @@ -3034,6 +3282,19 @@ mod tests { } } + fn implementation_step(prompt: &str, expected_multiple_commits: bool) -> SuggestedNextStep { + SuggestedNextStep::Implementation { + prompt: prompt.to_string(), + expected_multiple_commits, + } + } + + fn note_step(prompt: &str) -> SuggestedNextStep { + SuggestedNextStep::Note { + prompt: prompt.to_string(), + } + } + #[test] fn terminal_auto_review_reuses_pending_branch_only_after_successful_turn() { assert_eq!( @@ -3919,6 +4180,59 @@ Keep normal markdown fences in the note body."#; ); } + #[test] + fn note_content_strips_terminal_suggested_steps_after_note() { + let text = r#"Done. +--- +# Harden Note Detection +Strip metadata after the note body. + +```suggested-next-steps +{"suggestedNextCommitStep":"Fix note parsing","suggestedNextNoteStep":null} +```"#; + let content = extract_note_content(text); + assert_eq!( + content, + Some("# Harden Note Detection\nStrip metadata after the note body.".to_string()) + ); + } + + #[test] + fn note_content_preserves_nonterminal_suggested_steps_fence_in_note_body() { + let text = r#"--- +# Parser Notes +This note includes an example: + +```suggested-next-steps +{"suggestedNextCommitStep":"Example only","suggestedNextNoteStep":null} +``` + +That example is part of the note."#; + let content = extract_note_content(text); + assert_eq!( + content, + Some( + "# Parser Notes\nThis note includes an example:\n\n```suggested-next-steps\n{\"suggestedNextCommitStep\":\"Example only\",\"suggestedNextNoteStep\":null}\n```\n\nThat example is part of the note." + .to_string() + ) + ); + } + + #[test] + fn note_content_strips_unterminated_terminal_suggested_steps_after_note() { + let text = r#"--- +# Interrupted Note +The note body finished before metadata started. + +```suggested-next-steps +{"suggestedNextCommitStep":"Fix note parsing""#; + let content = extract_note_content(text); + assert_eq!( + content, + Some("# Interrupted Note\nThe note body finished before metadata started.".to_string()) + ); + } + #[test] fn note_content_inline_hr_without_h1_is_ignored() { let text = "Two reasons:--- this session is read-only."; @@ -4049,26 +4363,100 @@ Body "#; let steps = extract_suggested_next_steps(text).unwrap(); assert_eq!( - steps.suggested_next_commit_step.as_deref(), - Some("Implement the plan") + steps.suggested_next_steps, + vec![ + implementation_step("Implement the plan", false), + note_step("Research alternatives") + ] ); + } + + #[test] + fn extract_steps_accepts_ordered_typed_steps() { + let text = r#"```suggested-next-steps +{"suggestedNextSteps":[{"type":"implementation","prompt":"Fix parser crash","expectedMultipleCommits":false},{"type":"implementation","prompt":"Split parser cleanup","expectedMultipleCommits":true},{"type":"note","prompt":"Research parser coverage"}]} +```"#; + let steps = extract_suggested_next_steps(text).unwrap(); assert_eq!( - steps.suggested_next_note_step.as_deref(), - Some("Research alternatives") + steps.suggested_next_steps, + vec![ + implementation_step("Fix parser crash", false), + implementation_step("Split parser cleanup", true), + note_step("Research parser coverage") + ] ); } #[test] - fn extract_steps_valid_inline_opening_fence() { - let text = "Ready to return the note.```suggested-next-steps\n{\"suggestedNextCommitStep\": \"Reduce churn\", \"suggestedNextNoteStep\": \"Plan IPC fix\"}\n```\n"; + fn extract_steps_prefers_terminal_block_after_note_body_example() { + let text = r#"--- +# Parser Notes +The note body can include examples. + +```suggested-next-steps +{"suggestedNextCommitStep": "Example only", "suggestedNextNoteStep": null} +``` + +Use the real metadata at the end. + +```suggested-next-steps +{"suggestedNextCommitStep": "Fix note parsing", "suggestedNextNoteStep": "Plan parser tests"} +```"#; let steps = extract_suggested_next_steps(text).unwrap(); assert_eq!( - steps.suggested_next_commit_step.as_deref(), - Some("Reduce churn") + steps.suggested_next_steps, + vec![ + implementation_step("Fix note parsing", false), + note_step("Plan parser tests") + ] ); + } + + #[test] + fn extract_steps_ignores_nonterminal_note_body_block_without_metadata() { + let text = r#"--- +# Parser Notes +This is just an example: + +```suggested-next-steps +{"suggestedNextCommitStep": "Example only", "suggestedNextNoteStep": null} +``` + +More note body after the example."#; + assert!(extract_suggested_next_steps(text).is_none()); + } + + #[test] + fn extract_steps_uses_terminal_block_after_unclosed_body_example() { + let text = r#"--- +# Parser Notes +This body example is malformed: + +```suggested-next-steps +{"suggestedNextCommitStep": "Example only", "suggestedNextNoteStep": null} + +The real metadata still comes last. + +```suggested-next-steps +{"suggestedNextCommitStep": "Fix note parsing", "suggestedNextNoteStep": null} +```"#; + let steps = extract_suggested_next_steps(text).unwrap(); + assert_eq!( + steps.suggested_next_steps, + vec![implementation_step("Fix note parsing", false)] + ); + } + + #[test] + fn extract_steps_valid_inline_opening_fence() { + let text = "Ready to return the note.```suggested-next-steps\n{\"suggestedNextCommitStep\": \"Reduce churn\", \"suggestedNextNoteStep\": \"Plan IPC fix\"}\n```\n"; + let steps = extract_suggested_next_steps(text).unwrap(); assert_eq!( - steps.suggested_next_note_step.as_deref(), - Some("Plan IPC fix") + steps.suggested_next_steps, + vec![ + implementation_step("Reduce churn", false), + note_step("Plan IPC fix") + ] ); } @@ -4076,8 +4464,7 @@ Body fn extract_steps_null_fields() { let text = "```suggested-next-steps\n{\"suggestedNextCommitStep\": null, \"suggestedNextNoteStep\": null}\n```\n"; let steps = extract_suggested_next_steps(text).unwrap(); - assert_eq!(steps.suggested_next_commit_step, None); - assert_eq!(steps.suggested_next_note_step, None); + assert!(steps.suggested_next_steps.is_empty()); } #[test] @@ -4085,10 +4472,9 @@ Body let text = "```suggested-next-steps\n{\"suggestedNextCommitStep\": \"Fix the bug\", \"suggestedNextNoteStep\": null}\n```\n"; let steps = extract_suggested_next_steps(text).unwrap(); assert_eq!( - steps.suggested_next_commit_step.as_deref(), - Some("Fix the bug") + steps.suggested_next_steps, + vec![implementation_step("Fix the bug", false)] ); - assert_eq!(steps.suggested_next_note_step, None); } #[test] diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 0fdffa8f..19a04d3f 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 20); + assert_eq!(version, 21); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); @@ -159,6 +159,12 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { "acp_agent_capabilities" )); assert!(column_exists(&conn, "sessions", "acp_config_selection")); + assert!(column_exists(&conn, "notes", "suggested_next_steps")); + assert!(column_exists( + &conn, + "project_notes", + "suggested_next_steps" + )); let trigger_count: i64 = conn .query_row( @@ -210,6 +216,8 @@ fn test_store_repairs_github_comment_tracking_user_version() { github_comment_type TEXT, github_comment_stale INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE notes (id TEXT PRIMARY KEY); + CREATE TABLE project_notes (id TEXT PRIMARY KEY); ", ) .unwrap(); @@ -222,7 +230,7 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 20); + assert_eq!(version, 21); assert!(column_exists(&conn, "sessions", "pipeline")); assert!(column_exists(&conn, "sessions", "acp_config_selection")); assert!(column_exists( @@ -230,6 +238,12 @@ fn test_store_repairs_github_comment_tracking_user_version() { "session_messages", "acp_agent_capabilities" )); + assert!(column_exists(&conn, "notes", "suggested_next_steps")); + assert!(column_exists( + &conn, + "project_notes", + "suggested_next_steps" + )); assert!(table_exists(&conn, "queued_session_messages")); cleanup_db(&path); @@ -268,6 +282,8 @@ fn test_store_repairs_pipeline_user_version() { PRIMARY KEY (github_repo, subpath) ); CREATE TABLE comments (id TEXT PRIMARY KEY); + CREATE TABLE notes (id TEXT PRIMARY KEY); + CREATE TABLE project_notes (id TEXT PRIMARY KEY); ", ) .unwrap(); @@ -280,7 +296,7 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 20); + assert_eq!(version, 21); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); @@ -289,6 +305,12 @@ fn test_store_repairs_pipeline_user_version() { "session_messages", "acp_agent_capabilities" )); + assert!(column_exists(&conn, "notes", "suggested_next_steps")); + assert!(column_exists( + &conn, + "project_notes", + "suggested_next_steps" + )); assert!(table_exists(&conn, "queued_session_messages")); assert!(column_exists(&conn, "sessions", "acp_config_selection")); diff --git a/apps/staged/src-tauri/src/store/migrations/0021-add-ordered-suggested-next-steps/up.sql b/apps/staged/src-tauri/src/store/migrations/0021-add-ordered-suggested-next-steps/up.sql new file mode 100644 index 00000000..2efe2149 --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0021-add-ordered-suggested-next-steps/up.sql @@ -0,0 +1,5 @@ +-- Add ordered suggested next step storage to notes and project_notes. +-- Legacy single-step columns remain for compatibility and fallback conversion. + +ALTER TABLE notes ADD COLUMN suggested_next_steps TEXT; +ALTER TABLE project_notes ADD COLUMN suggested_next_steps TEXT; diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 64cabdd5..e93dd7d1 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -833,6 +833,128 @@ pub struct AcpMessageMetadata { // Notes // ============================================================================= +/// A suggested follow-up action extracted from an AI-generated note. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum SuggestedNextStep { + #[serde(rename_all = "camelCase")] + Implementation { + prompt: String, + expected_multiple_commits: bool, + }, + Note { + prompt: String, + }, +} + +impl SuggestedNextStep { + pub fn prompt(&self) -> &str { + match self { + Self::Implementation { prompt, .. } | Self::Note { prompt } => prompt, + } + } +} + +/// Stored note fields read back for change detection: +/// `(title, content, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps)`. +pub type StoredNoteFields = ( + String, + String, + Option<String>, + Option<String>, + Option<String>, +); + +pub fn suggested_next_steps_from_storage( + json: Option<String>, + legacy_commit_step: Option<String>, + legacy_note_step: Option<String>, +) -> Vec<SuggestedNextStep> { + if let Some(json) = json.filter(|s| !s.trim().is_empty()) { + match serde_json::from_str::<Vec<SuggestedNextStep>>(&json) { + Ok(steps) => return sanitize_suggested_next_steps(steps), + Err(e) => { + log::warn!("Failed to parse stored suggested next steps JSON: {e}"); + } + } + } + + legacy_suggested_next_steps(legacy_commit_step, legacy_note_step) +} + +pub fn suggested_next_steps_to_storage( + steps: &[SuggestedNextStep], +) -> Result<Option<String>, serde_json::Error> { + let steps = sanitize_suggested_next_steps(steps.to_vec()); + if steps.is_empty() { + Ok(None) + } else { + serde_json::to_string(&steps).map(Some) + } +} + +pub fn suggested_next_steps_legacy_commit_step(steps: &[SuggestedNextStep]) -> Option<&str> { + steps.iter().find_map(|step| match step { + SuggestedNextStep::Implementation { prompt, .. } if !prompt.trim().is_empty() => { + Some(prompt.as_str()) + } + _ => None, + }) +} + +pub fn suggested_next_steps_legacy_note_step(steps: &[SuggestedNextStep]) -> Option<&str> { + steps.iter().find_map(|step| match step { + SuggestedNextStep::Note { prompt } if !prompt.trim().is_empty() => Some(prompt.as_str()), + _ => None, + }) +} + +fn legacy_suggested_next_steps( + legacy_commit_step: Option<String>, + legacy_note_step: Option<String>, +) -> Vec<SuggestedNextStep> { + let mut steps = Vec::new(); + if let Some(prompt) = non_empty_prompt(legacy_commit_step) { + steps.push(SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits: false, + }); + } + if let Some(prompt) = non_empty_prompt(legacy_note_step) { + steps.push(SuggestedNextStep::Note { prompt }); + } + steps +} + +fn sanitize_suggested_next_steps(steps: Vec<SuggestedNextStep>) -> Vec<SuggestedNextStep> { + steps + .into_iter() + .filter_map(|step| match step { + SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits, + } => non_empty_prompt(Some(prompt)).map(|prompt| SuggestedNextStep::Implementation { + prompt, + expected_multiple_commits, + }), + SuggestedNextStep::Note { prompt } => { + non_empty_prompt(Some(prompt)).map(|prompt| SuggestedNextStep::Note { prompt }) + } + }) + .take(4) + .collect() +} + +fn non_empty_prompt(prompt: Option<String>) -> Option<String> { + let prompt = prompt?; + let trimmed = prompt.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + /// An AI-generated markdown document tied to a branch. /// /// Notes may be created empty (with a `session_id`) while the AI is still @@ -855,6 +977,8 @@ pub struct Note { pub suggested_next_commit_step: Option<String>, /// AI-suggested prompt for a follow-up note session. pub suggested_next_note_step: Option<String>, + /// Ordered AI-suggested follow-up actions. + pub suggested_next_steps: Vec<SuggestedNextStep>, } impl Note { @@ -872,6 +996,7 @@ impl Note { completed_at: if has_content { Some(now) } else { None }, suggested_next_commit_step: None, suggested_next_note_step: None, + suggested_next_steps: Vec::new(), } } @@ -907,6 +1032,8 @@ pub struct ProjectNote { pub suggested_next_commit_step: Option<String>, /// AI-suggested prompt for a follow-up note session. pub suggested_next_note_step: Option<String>, + /// Ordered AI-suggested follow-up actions. + pub suggested_next_steps: Vec<SuggestedNextStep>, /// Resolved session status (e.g. "running", "completed", "cancelled"). /// Populated at query time via `resolve_session_status()`. #[serde(skip_deserializing)] @@ -931,6 +1058,7 @@ impl ProjectNote { completed_at: if has_content { Some(now) } else { None }, suggested_next_commit_step: None, suggested_next_note_step: None, + suggested_next_steps: Vec::new(), session_status: None, completion_reason: None, } diff --git a/apps/staged/src-tauri/src/store/notes.rs b/apps/staged/src-tauri/src/store/notes.rs index 4f6c8dcd..7f7cd859 100644 --- a/apps/staged/src-tauri/src/store/notes.rs +++ b/apps/staged/src-tauri/src/store/notes.rs @@ -2,7 +2,11 @@ use rusqlite::{params, OptionalExtension}; -use super::models::Note; +use super::models::{ + suggested_next_steps_from_storage, suggested_next_steps_legacy_commit_step, + suggested_next_steps_legacy_note_step, suggested_next_steps_to_storage, Note, StoredNoteFields, + SuggestedNextStep, +}; use super::{now_timestamp, Store, StoreError}; impl Store { @@ -28,9 +32,19 @@ impl Store { } fn insert_note(conn: &rusqlite::Connection, note: &Note) -> Result<(), StoreError> { + let suggested_next_steps = suggested_next_steps_to_storage(¬e.suggested_next_steps) + .map_err(|e| StoreError(format!("Failed to serialize suggested next steps: {e}")))?; + let suggested_next_commit_step = note + .suggested_next_commit_step + .as_deref() + .or_else(|| suggested_next_steps_legacy_commit_step(¬e.suggested_next_steps)); + let suggested_next_note_step = note + .suggested_next_note_step + .as_deref() + .or_else(|| suggested_next_steps_legacy_note_step(¬e.suggested_next_steps)); conn.execute( - "INSERT INTO notes (id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO notes (id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ note.id, note.branch_id, @@ -40,8 +54,9 @@ impl Store { note.created_at, note.updated_at, note.completed_at, - note.suggested_next_commit_step, - note.suggested_next_note_step, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps, ], )?; Ok(()) @@ -79,7 +94,7 @@ impl Store { pub fn get_note(&self, id: &str) -> Result<Option<Note>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM notes WHERE id = ?1", params![id], Self::row_to_note, @@ -91,7 +106,7 @@ impl Store { pub fn list_notes_for_branch(&self, branch_id: &str) -> Result<Vec<Note>, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM notes WHERE branch_id = ?1 ORDER BY COALESCE(completed_at, created_at) DESC, created_at DESC", )?; @@ -103,7 +118,7 @@ impl Store { pub fn get_note_by_session(&self, session_id: &str) -> Result<Option<Note>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM notes WHERE session_id = ?1", params![session_id], Self::row_to_note, @@ -116,7 +131,7 @@ impl Store { pub fn get_empty_note_by_session(&self, session_id: &str) -> Result<Option<Note>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM notes WHERE session_id = ?1 AND content = ''", params![session_id], Self::row_to_note, @@ -131,35 +146,57 @@ impl Store { id: &str, title: &str, content: &str, - suggested_next_commit_step: Option<&str>, - suggested_next_note_step: Option<&str>, + suggested_next_steps: &[SuggestedNextStep], ) -> Result<(), StoreError> { + let suggested_next_steps_json = suggested_next_steps_to_storage(suggested_next_steps) + .map_err(|e| StoreError(format!("Failed to serialize suggested next steps: {e}")))?; + let comparable_next_steps = + suggested_next_steps_from_storage(suggested_next_steps_json.clone(), None, None); + let suggested_next_commit_step = + suggested_next_steps_legacy_commit_step(&comparable_next_steps); + let suggested_next_note_step = + suggested_next_steps_legacy_note_step(&comparable_next_steps); + let conn = self.conn.lock().unwrap(); // The session runner re-runs note extraction at the end of every turn for sessions // with a linked note, even if the assistant didn't rewrite the note. Without this // short-circuit, `updated_at` would advance on every turn, defeating any freshness // comparison that relies on it. - let existing: Option<(String, String, Option<String>, Option<String>)> = conn + let existing: Option<StoredNoteFields> = conn .query_row( - "SELECT title, content, suggested_next_commit_step, suggested_next_note_step + "SELECT title, content, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM notes WHERE id = ?1", params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .optional()?; - if let Some((cur_title, cur_content, cur_sncs, cur_snns)) = existing { - if cur_title == title - && cur_content == content - && cur_sncs.as_deref() == suggested_next_commit_step - && cur_snns.as_deref() == suggested_next_note_step - { + if let Some((cur_title, cur_content, cur_sncs, cur_snns, cur_steps_json)) = existing { + let cur_steps = suggested_next_steps_from_storage(cur_steps_json, cur_sncs, cur_snns); + if cur_title == title && cur_content == content && cur_steps == comparable_next_steps { return Ok(()); } } let now = now_timestamp(); conn.execute( - "UPDATE notes SET title = ?1, content = ?2, updated_at = ?3, completed_at = COALESCE(completed_at, ?4), suggested_next_commit_step = ?5, suggested_next_note_step = ?6 WHERE id = ?7", - params![title, content, now, now, suggested_next_commit_step, suggested_next_note_step, id], + "UPDATE notes SET title = ?1, content = ?2, updated_at = ?3, completed_at = COALESCE(completed_at, ?4), suggested_next_commit_step = ?5, suggested_next_note_step = ?6, suggested_next_steps = ?7 WHERE id = ?8", + params![ + title, + content, + now, + now, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps_json, + id + ], )?; Ok(()) } @@ -191,6 +228,14 @@ impl Store { } fn row_to_note(row: &rusqlite::Row) -> rusqlite::Result<Note> { + let suggested_next_commit_step: Option<String> = row.get(8)?; + let suggested_next_note_step: Option<String> = row.get(9)?; + let suggested_next_steps_json: Option<String> = row.get(10)?; + let suggested_next_steps = suggested_next_steps_from_storage( + suggested_next_steps_json, + suggested_next_commit_step.clone(), + suggested_next_note_step.clone(), + ); Ok(Note { id: row.get(0)?, branch_id: row.get(1)?, @@ -200,8 +245,9 @@ impl Store { created_at: row.get(5)?, updated_at: row.get(6)?, completed_at: row.get(7)?, - suggested_next_commit_step: row.get(8)?, - suggested_next_note_step: row.get(9)?, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps, }) } } diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index 66089256..bfae02e7 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -2,15 +2,29 @@ use rusqlite::{params, OptionalExtension}; -use super::models::ProjectNote; +use super::models::{ + suggested_next_steps_from_storage, suggested_next_steps_legacy_commit_step, + suggested_next_steps_legacy_note_step, suggested_next_steps_to_storage, ProjectNote, + StoredNoteFields, SuggestedNextStep, +}; use super::{now_timestamp, Store, StoreError}; impl Store { pub fn create_project_note(&self, note: &ProjectNote) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); + let suggested_next_steps = suggested_next_steps_to_storage(¬e.suggested_next_steps) + .map_err(|e| StoreError(format!("Failed to serialize suggested next steps: {e}")))?; + let suggested_next_commit_step = note + .suggested_next_commit_step + .as_deref() + .or_else(|| suggested_next_steps_legacy_commit_step(¬e.suggested_next_steps)); + let suggested_next_note_step = note + .suggested_next_note_step + .as_deref() + .or_else(|| suggested_next_steps_legacy_note_step(¬e.suggested_next_steps)); conn.execute( - "INSERT INTO project_notes (id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO project_notes (id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ note.id, note.project_id, @@ -20,8 +34,9 @@ impl Store { note.created_at, note.updated_at, note.completed_at, - note.suggested_next_commit_step, - note.suggested_next_note_step, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps, ], )?; Ok(()) @@ -30,7 +45,7 @@ impl Store { pub fn get_project_note(&self, id: &str) -> Result<Option<ProjectNote>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM project_notes WHERE id = ?1", params![id], Self::row_to_project_note, @@ -42,7 +57,7 @@ impl Store { pub fn list_project_notes(&self, project_id: &str) -> Result<Vec<ProjectNote>, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM project_notes WHERE project_id = ?1 ORDER BY COALESCE(completed_at, created_at) DESC, created_at DESC", @@ -58,7 +73,7 @@ impl Store { ) -> Result<Option<ProjectNote>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM project_notes WHERE session_id = ?1", params![session_id], Self::row_to_project_note, @@ -74,7 +89,7 @@ impl Store { ) -> Result<Option<ProjectNote>, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step + "SELECT id, project_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM project_notes WHERE session_id = ?1 AND content = ''", params![session_id], Self::row_to_project_note, @@ -88,37 +103,59 @@ impl Store { id: &str, title: &str, content: &str, - suggested_next_commit_step: Option<&str>, - suggested_next_note_step: Option<&str>, + suggested_next_steps: &[SuggestedNextStep], ) -> Result<(), StoreError> { + let suggested_next_steps_json = suggested_next_steps_to_storage(suggested_next_steps) + .map_err(|e| StoreError(format!("Failed to serialize suggested next steps: {e}")))?; + let comparable_next_steps = + suggested_next_steps_from_storage(suggested_next_steps_json.clone(), None, None); + let suggested_next_commit_step = + suggested_next_steps_legacy_commit_step(&comparable_next_steps); + let suggested_next_note_step = + suggested_next_steps_legacy_note_step(&comparable_next_steps); + let conn = self.conn.lock().unwrap(); // The session runner re-runs note extraction at the end of every turn for sessions // with a linked note, even if the assistant didn't rewrite the note. Without this // short-circuit, `updated_at` would advance on every turn, defeating any freshness // comparison that relies on it. - let existing: Option<(String, String, Option<String>, Option<String>)> = conn + let existing: Option<StoredNoteFields> = conn .query_row( - "SELECT title, content, suggested_next_commit_step, suggested_next_note_step + "SELECT title, content, suggested_next_commit_step, suggested_next_note_step, suggested_next_steps FROM project_notes WHERE id = ?1", params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .optional()?; - if let Some((cur_title, cur_content, cur_sncs, cur_snns)) = existing { - if cur_title == title - && cur_content == content - && cur_sncs.as_deref() == suggested_next_commit_step - && cur_snns.as_deref() == suggested_next_note_step - { + if let Some((cur_title, cur_content, cur_sncs, cur_snns, cur_steps_json)) = existing { + let cur_steps = suggested_next_steps_from_storage(cur_steps_json, cur_sncs, cur_snns); + if cur_title == title && cur_content == content && cur_steps == comparable_next_steps { return Ok(()); } } let now = now_timestamp(); conn.execute( "UPDATE project_notes - SET title = ?1, content = ?2, updated_at = ?3, completed_at = COALESCE(completed_at, ?4), suggested_next_commit_step = ?5, suggested_next_note_step = ?6 - WHERE id = ?7", - params![title, content, now, now, suggested_next_commit_step, suggested_next_note_step, id], + SET title = ?1, content = ?2, updated_at = ?3, completed_at = COALESCE(completed_at, ?4), suggested_next_commit_step = ?5, suggested_next_note_step = ?6, suggested_next_steps = ?7 + WHERE id = ?8", + params![ + title, + content, + now, + now, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps_json, + id + ], )?; Ok(()) } @@ -147,6 +184,14 @@ impl Store { } fn row_to_project_note(row: &rusqlite::Row) -> rusqlite::Result<ProjectNote> { + let suggested_next_commit_step: Option<String> = row.get(8)?; + let suggested_next_note_step: Option<String> = row.get(9)?; + let suggested_next_steps_json: Option<String> = row.get(10)?; + let suggested_next_steps = suggested_next_steps_from_storage( + suggested_next_steps_json, + suggested_next_commit_step.clone(), + suggested_next_note_step.clone(), + ); Ok(ProjectNote { id: row.get(0)?, project_id: row.get(1)?, @@ -156,8 +201,9 @@ impl Store { created_at: row.get(5)?, updated_at: row.get(6)?, completed_at: row.get(7)?, - suggested_next_commit_step: row.get(8)?, - suggested_next_note_step: row.get(9)?, + suggested_next_commit_step, + suggested_next_note_step, + suggested_next_steps, session_status: None, completion_reason: None, }) diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index bbeef419..71e17883 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -6,6 +6,19 @@ use std::path::Path; use super::models::*; use super::Store; +fn implementation_step(prompt: &str, expected_multiple_commits: bool) -> SuggestedNextStep { + SuggestedNextStep::Implementation { + prompt: prompt.to_string(), + expected_multiple_commits, + } +} + +fn note_step(prompt: &str) -> SuggestedNextStep { + SuggestedNextStep::Note { + prompt: prompt.to_string(), + } +} + #[test] fn test_create_and_get_project() { let store = Store::in_memory().unwrap(); @@ -89,14 +102,14 @@ fn test_project_note_completion_is_write_once() { assert!(before.completed_at.is_none()); store - .update_project_note_title_and_content(¬e.id, "First", "Initial content", None, None) + .update_project_note_title_and_content(¬e.id, "First", "Initial content", &[]) .unwrap(); let completed = store.get_project_note(¬e.id).unwrap().unwrap(); let first_completed_at = completed.completed_at.unwrap(); std::thread::sleep(std::time::Duration::from_millis(2)); store - .update_project_note_title_and_content(¬e.id, "Second", "Updated content", None, None) + .update_project_note_title_and_content(¬e.id, "Second", "Updated content", &[]) .unwrap(); let updated = store.get_project_note(¬e.id).unwrap().unwrap(); @@ -118,8 +131,10 @@ fn test_update_project_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "Body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_first = store.get_project_note(¬e.id).unwrap().unwrap(); @@ -132,8 +147,10 @@ fn test_update_project_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "Body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_second = store.get_project_note(¬e.id).unwrap().unwrap(); @@ -147,8 +164,10 @@ fn test_update_project_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "New body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_third = store.get_project_note(¬e.id).unwrap().unwrap(); @@ -169,11 +188,11 @@ fn test_list_project_notes_orders_by_completion_time() { store.create_project_note(&newer).unwrap(); store - .update_project_note_title_and_content(&newer.id, "Newer", "Completed first", None, None) + .update_project_note_title_and_content(&newer.id, "Newer", "Completed first", &[]) .unwrap(); std::thread::sleep(std::time::Duration::from_millis(2)); store - .update_project_note_title_and_content(&older.id, "Older", "Completed second", None, None) + .update_project_note_title_and_content(&older.id, "Older", "Completed second", &[]) .unwrap(); let notes = store.list_project_notes(&project.id).unwrap(); @@ -1740,11 +1759,11 @@ fn test_list_notes_for_branch_orders_by_completion_time() { store.create_note(&newer).unwrap(); store - .update_note_title_and_content(&newer.id, "Newer", "Completed first", None, None) + .update_note_title_and_content(&newer.id, "Newer", "Completed first", &[]) .unwrap(); std::thread::sleep(std::time::Duration::from_millis(2)); store - .update_note_title_and_content(&older.id, "Older", "Completed second", None, None) + .update_note_title_and_content(&older.id, "Older", "Completed second", &[]) .unwrap(); let notes = store.list_notes_for_branch(&branch.id).unwrap(); @@ -1768,8 +1787,10 @@ fn test_update_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "Body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_first = store.get_note(¬e.id).unwrap().unwrap(); @@ -1782,8 +1803,10 @@ fn test_update_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "Body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_second = store.get_note(¬e.id).unwrap().unwrap(); @@ -1797,8 +1820,10 @@ fn test_update_note_title_and_content_is_noop_when_unchanged() { ¬e.id, "Title", "New body", - Some("commit-step"), - Some("note-step"), + &[ + implementation_step("commit-step", false), + note_step("note-step"), + ], ) .unwrap(); let after_third = store.get_note(¬e.id).unwrap().unwrap(); diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index f7608b47..e4339df8 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -468,6 +468,7 @@ fn build_branch_timeline(store: &Arc<Store>, branch_id: &str) -> Result<BranchTi completed_at: n.completed_at, suggested_next_commit_step: n.suggested_next_commit_step, suggested_next_note_step: n.suggested_next_note_step, + suggested_next_steps: n.suggested_next_steps, } }) .collect(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 915d596e..539cb709 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2373,6 +2373,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val completed_at: note.completed_at, suggested_next_commit_step: None, suggested_next_note_step: None, + suggested_next_steps: Vec::new(), }; Ok(serde_json::to_value(item).unwrap()) } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index f015cde8..b80d87ff 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -49,6 +49,7 @@ HashtagItem, NoteTimelineItem, ProjectRepo, + SuggestedNextStep, WorkspaceStatus, } from '../../types'; import * as commands from '../../api/commands'; @@ -58,6 +59,7 @@ import SessionModal from '../sessions/SessionModal.svelte'; import NewSessionModal from '../sessions/NewSessionModal.svelte'; import NoteModal from '../notes/NoteModal.svelte'; + import { suggestedNextStepsForNote } from '../notes/suggestedNextSteps'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { @@ -79,6 +81,8 @@ import RemoteWorkspaceStatusView from './RemoteWorkspaceStatusView.svelte'; import { branchTimelineReadyKey } from './branchTimelineReady'; import { toast } from 'svelte-sonner'; + import { sessionRegistry } from '../../stores/sessionRegistry.svelte'; + import { projectStateStore } from '../../stores/projectState.svelte'; import { aggregateProjectPrStatus } from '../../shared/utils'; import { timelineToHashtagItems, projectNotesToHashtagItems } from '../sessions/hashtagItems'; import { getPreferredAgent } from '../settings/preferences.svelte'; @@ -183,7 +187,7 @@ sessionId?: string; noteUpdatedAt?: number; chatOpen?: boolean; - nextSteps?: { commitStep: string | null; noteStep: string | null } | null; + nextSteps?: SuggestedNextStep[] | null; }; let timelineReviewDetailsById = $state<Record<string, TimelineReviewDetails>>({}); @@ -375,8 +379,7 @@ id: string; title: string; timestamp: number; - suggestedNextCommitStep: string | null; - suggestedNextNoteStep: string | null; + suggestedNextSteps: SuggestedNextStep[]; }; const candidates: Candidate[] = []; @@ -399,8 +402,7 @@ id: note.id, title: note.title, timestamp: ts, - suggestedNextCommitStep: note.suggestedNextCommitStep, - suggestedNextNoteStep: note.suggestedNextNoteStep, + suggestedNextSteps: suggestedNextStepsForNote(note), }); } @@ -418,17 +420,19 @@ all.sort((a, b) => b.timestamp - a.timestamp); const latest = all[0]; - // If latest item is a note with suggested next steps, use them - if ( - latest.kind === 'note' && - (latest.suggestedNextCommitStep || latest.suggestedNextNoteStep) - ) { + // If latest item is a note with suggested next steps, use the first + // branch-routable suggestion for each draft mode. + if (latest.kind === 'note' && latest.suggestedNextSteps.length > 0) { + const commitStep = latest.suggestedNextSteps.find( + (step) => step.type === 'implementation' && !step.expectedMultipleCommits + ); + const noteStep = latest.suggestedNextSteps.find((step) => step.type === 'note'); const ref = `Re: #note:${latest.id}`; return { - commit: latest.suggestedNextCommitStep ?? '', - note: latest.suggestedNextNoteStep ?? '', - commitRef: latest.suggestedNextCommitStep ? ref : '', - noteRef: latest.suggestedNextNoteStep ? ref : '', + commit: commitStep?.prompt ?? '', + note: noteStep?.prompt ?? '', + commitRef: commitStep ? ref : '', + noteRef: noteStep ? ref : '', }; } @@ -463,18 +467,12 @@ // Compute next-step suggestions for a note. Called once when the note modal // is opened so the result is static and doesn't cause DOM churn from polling. - function computeNoteNextSteps( - noteId: string - ): { commitStep: string | null; noteStep: string | null } | null { + function computeNoteNextSteps(noteId: string): SuggestedNextStep[] | null { if (!timeline) return null; const note = timeline.notes.find((n) => n.id === noteId); if (!note) return null; - if (!note.suggestedNextCommitStep && !note.suggestedNextNoteStep) return null; - - return { - commitStep: note.suggestedNextCommitStep, - noteStep: note.suggestedNextNoteStep, - }; + const steps = suggestedNextStepsForNote(note); + return steps.length > 0 ? steps : null; } // Commit diff modal (opened by clicking a commit in the timeline) @@ -961,6 +959,41 @@ }; } + function withNoteReference(noteId: string | undefined, prompt: string): string { + return noteId ? `Re: #note:${noteId}\n${prompt}` : prompt; + } + + async function startProjectFollowupSession(prompt: string) { + const provider = getPreferredAgent(agentState.providers) ?? undefined; + if (!provider) { + notifyError('Unable to start project session', 'No AI agent available.'); + return; + } + + try { + const response = await commands.startProjectSession(branch.projectId, prompt, provider); + sessionRegistry.register(response.sessionId, branch.projectId, 'note'); + projectStateStore.addRunningSession(branch.projectId, response.sessionId); + window.dispatchEvent(new CustomEvent('project-notes-invalidated')); + } catch (e) { + notifyError('Unable to start project session', e); + } + } + + function handleNoteNextStep(step: SuggestedNextStep) { + const noteId = openNote?.noteId; + const prompt = withNoteReference(noteId, step.prompt); + openNote = null; + + if (step.type === 'implementation' && step.expectedMultipleCommits) { + void startProjectFollowupSession(prompt); + return; + } + + const mode = step.type === 'note' ? 'note' : 'commit'; + void sessionMgr.startOrQueueSession(mode, prompt); + } + async function handleReviewClick(reviewId: string) { const cached = timelineReviewDetailsById[reviewId]; if (cached) { @@ -1901,11 +1934,7 @@ referenceNav={disabledReferenceNav} onClose={() => (openNote = null)} onHashtagClick={handleHashtagClick} - onStartSession={(mode, prefill) => { - const noteRef = openNote?.noteId ? `Re: #note:${openNote.noteId}` : ''; - openNote = null; - void sessionMgr.startOrQueueSession(mode, noteRef ? `${noteRef}\n${prefill}` : prefill); - }} + onStartSession={handleNoteNextStep} /> {/if} diff --git a/apps/staged/src/lib/features/notes/NoteModal.svelte b/apps/staged/src/lib/features/notes/NoteModal.svelte index 98e684c9..f6f60ae0 100644 --- a/apps/staged/src/lib/features/notes/NoteModal.svelte +++ b/apps/staged/src/lib/features/notes/NoteModal.svelte @@ -11,6 +11,7 @@ import Check from '@lucide/svelte/icons/check'; import MessageCircle from '@lucide/svelte/icons/message-circle'; import FileText from '@lucide/svelte/icons/file-text'; + import GitCommitVertical from '@lucide/svelte/icons/git-commit-vertical'; import PanelRightClose from '@lucide/svelte/icons/panel-right-close'; import PanelRightOpen from '@lucide/svelte/icons/panel-right-open'; import * as Dialog from '$lib/components/ui/dialog'; @@ -37,10 +38,11 @@ } from '../../shared/markdown/diagramViewer'; import { loadPikchrRenderer, type PikchrRenderer } from '../../shared/markdown/pikchrRendering'; import { noteMarkdownWithTitle, renderNoteMarkdown } from './noteMarkdown'; - import type { HashtagItem, ProjectRepo, Session } from '../../types'; + import type { HashtagItem, ProjectRepo, Session, SuggestedNextStep } from '../../types'; import { findHashtagItemForReference, renderHashtagTokens } from '../sessions/hashtagItems'; import ReferenceNavControls from '../references/ReferenceNavControls.svelte'; import type { HashtagClickInfo, ReferenceNavState } from '../references/referenceHistory.svelte'; + import { suggestedNextStepButtonLabel } from './suggestedNextSteps'; interface Props { open: boolean; @@ -60,9 +62,9 @@ chatOpen?: boolean; onChatOpenChange?: (open: boolean) => void; /** Suggested next steps to show as action buttons at the bottom. */ - nextSteps?: { commitStep: string | null; noteStep: string | null } | null; + nextSteps?: SuggestedNextStep[] | null; /** Called when the user clicks a next-step button. */ - onStartSession?: (mode: 'commit' | 'note', prefill: string) => void; + onStartSession?: (step: SuggestedNextStep) => void; hashtagItems?: HashtagItem[]; referenceNav?: ReferenceNavState; onHashtagClick?: (click: HashtagClickInfo) => void; @@ -150,6 +152,7 @@ let noteHasPikchr = $derived( extractMarkdownDiagramFences(noteMarkdown).some((diagram) => diagram.language === 'pikchr') ); + let visibleNextSteps = $derived((nextSteps ?? []).filter((step) => step.prompt.trim())); let pikchrRenderer = $state<PikchrRenderer | null>(null); let pikchrRendererLoadKey = $derived(noteMarkdown); let pikchrRendererLoadFailedKey = $state<string | null>(null); @@ -584,7 +587,7 @@ {/if} </div> - {#if showChatInfo || (nextSteps && onStartSession && (nextSteps.noteStep || nextSteps.commitStep))} + {#if showChatInfo || (onStartSession && visibleNextSteps.length > 0)} <div class="next-steps"> {#if showChatInfo} <div class="chat-info-row"> @@ -598,32 +601,26 @@ </Button> </div> {/if} - {#if nextSteps && onStartSession && nextSteps.noteStep} + {#each visibleNextSteps as step} <div class="next-step-row"> - <span class="next-step-prompt">{nextSteps.noteStep}</span> + <span class="next-step-prompt">{step.prompt}</span> <Button variant="outline" size="sm" - class="h-auto shrink-0 rounded-md border-transparent bg-[var(--note-bg)] px-3 py-1 text-xs font-medium text-[var(--note-color)] shadow-none hover:border-transparent hover:bg-[var(--note-bg-emphasis)] hover:text-[var(--note-color)]" - onclick={() => onStartSession('note', nextSteps!.noteStep!)} + class={step.type === 'note' + ? 'h-auto shrink-0 gap-1.5 rounded-md border-transparent bg-[var(--note-bg)] px-3 py-1 text-xs font-medium text-[var(--note-color)] shadow-none hover:border-transparent hover:bg-[var(--note-bg-emphasis)] hover:text-[var(--note-color)]' + : 'h-auto shrink-0 gap-1.5 rounded-md border-transparent bg-[var(--commit-bg)] px-3 py-1 text-xs font-medium text-[var(--commit-color)] shadow-none hover:border-transparent hover:bg-[var(--commit-bg-emphasis)] hover:text-[var(--commit-color)]'} + onclick={() => onStartSession?.(step)} > - Start note + {#if step.type === 'note'} + <FileText size={13} aria-hidden="true" /> + {:else} + <GitCommitVertical size={13} aria-hidden="true" /> + {/if} + {suggestedNextStepButtonLabel(step)} </Button> </div> - {/if} - {#if nextSteps && onStartSession && nextSteps.commitStep} - <div class="next-step-row"> - <span class="next-step-prompt">{nextSteps.commitStep}</span> - <Button - variant="outline" - size="sm" - class="h-auto shrink-0 rounded-md border-transparent bg-[var(--commit-bg)] px-3 py-1 text-xs font-medium text-[var(--commit-color)] shadow-none hover:border-transparent hover:bg-[var(--commit-bg-emphasis)] hover:text-[var(--commit-color)]" - onclick={() => onStartSession('commit', nextSteps!.commitStep!)} - > - Start commit - </Button> - </div> - {/if} + {/each} </div> {/if} </section> diff --git a/apps/staged/src/lib/features/notes/suggestedNextSteps.ts b/apps/staged/src/lib/features/notes/suggestedNextSteps.ts new file mode 100644 index 00000000..a95eb34f --- /dev/null +++ b/apps/staged/src/lib/features/notes/suggestedNextSteps.ts @@ -0,0 +1,33 @@ +import type { SuggestedNextStep } from '../../types'; + +interface SuggestedNextStepSource { + suggestedNextSteps?: SuggestedNextStep[] | null; + suggestedNextCommitStep?: string | null; + suggestedNextNoteStep?: string | null; +} + +export function suggestedNextStepsForNote(source: SuggestedNextStepSource): SuggestedNextStep[] { + const typedSteps = (source.suggestedNextSteps ?? []).filter((step) => step.prompt.trim()); + if (typedSteps.length > 0) return typedSteps; + + const steps: SuggestedNextStep[] = []; + if (source.suggestedNextCommitStep?.trim()) { + steps.push({ + type: 'implementation', + prompt: source.suggestedNextCommitStep.trim(), + expectedMultipleCommits: false, + }); + } + if (source.suggestedNextNoteStep?.trim()) { + steps.push({ + type: 'note', + prompt: source.suggestedNextNoteStep.trim(), + }); + } + return steps; +} + +export function suggestedNextStepButtonLabel(step: SuggestedNextStep): string { + if (step.type === 'note') return 'Start note'; + return step.expectedMultipleCommits ? 'Start series' : 'Start commit'; +} diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 8ae2f295..60f4845e 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -15,6 +15,7 @@ Branch, ProjectNote, HashtagItem, + SuggestedNextStep, } from '../../types'; import * as commands from '../../api/commands'; import { buildProjectHashtagItems } from '../sessions/hashtagItems'; @@ -31,6 +32,7 @@ type TimelineContextMenuAction, } from '../timeline/TimelineContextMenu.svelte'; import NoteModal from '../notes/NoteModal.svelte'; + import { suggestedNextStepsForNote } from '../notes/suggestedNextSteps'; import NewSessionModal from '../sessions/NewSessionModal.svelte'; import { agentState } from '../agents/agent.svelte'; import { getPreferredAgent } from '../settings/preferences.svelte'; @@ -297,6 +299,7 @@ sessionId?: string; noteUpdatedAt?: number; chatOpen?: boolean; + nextSteps?: SuggestedNextStep[] | null; }; let openNote = $state<OpenProjectNoteState | null>(null); @@ -322,6 +325,22 @@ openProjectSessionModal(); } + function computeProjectNoteNextSteps(note: { + suggestedNextSteps?: SuggestedNextStep[] | null; + suggestedNextCommitStep?: string | null; + suggestedNextNoteStep?: string | null; + }): SuggestedNextStep[] | null { + const steps = suggestedNextStepsForNote(note); + return steps.length > 0 ? steps : null; + } + + function handleProjectNoteNextStep(step: SuggestedNextStep) { + const noteRef = openNote?.noteId ? `Re: #project-note:${openNote.noteId}` : ''; + const prompt = noteRef ? `${noteRef}\n${step.prompt}` : step.prompt; + openNote = null; + void handleSubmitProjectSession({ prompt, imageIds: [] }); + } + function projectNoteToOpenState(note: ProjectNote, chatOpen = false): OpenProjectNoteState { return { noteId: note.id, @@ -330,6 +349,7 @@ sessionId: note.sessionId ?? undefined, noteUpdatedAt: note.updatedAt, chatOpen, + nextSteps: computeProjectNoteNextSteps(note), }; } @@ -388,7 +408,12 @@ const onTimelineInvalidated = () => { hashtagVersion++; }; + const onProjectNotesInvalidated = () => { + hashtagVersion++; + void loadProjectNotes(); + }; window.addEventListener('timeline-invalidated', onTimelineInvalidated); + window.addEventListener('project-notes-invalidated', onProjectNotesInvalidated); const unlistenSession = listenToEvent<{ sessionId: string; @@ -430,6 +455,7 @@ return () => { unlistenSession(); window.removeEventListener('timeline-invalidated', onTimelineInvalidated); + window.removeEventListener('project-notes-invalidated', onProjectNotesInvalidated); }; }); </script> @@ -561,6 +587,7 @@ onChatOpenChange={(chatOpen) => { if (openNote) openNote = { ...openNote, chatOpen }; }} + nextSteps={openNote.nextSteps} {hashtagItems} referenceNav={disabledReferenceNav} onClose={() => { @@ -568,6 +595,7 @@ void loadProjectNotes(); }} onHashtagClick={handleHashtagClick} + onStartSession={handleProjectNoteNextStep} /> {/if} diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index 20d0e9a5..21313c27 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -65,6 +65,7 @@ function projectNote(overrides: Partial<ProjectNote> = {}): ProjectNote { completedAt: 0, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + suggestedNextSteps: [], sessionStatus: null, completionReason: null, ...overrides, @@ -124,6 +125,7 @@ describe('timelineToHashtagItems', () => { completedAt: 1000, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + suggestedNextSteps: [], }, { id: 'new-note', @@ -137,6 +139,7 @@ describe('timelineToHashtagItems', () => { completedAt: 5000, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + suggestedNextSteps: [], }, ], commits: [ diff --git a/apps/staged/src/lib/features/sessions/noteIndicators.test.ts b/apps/staged/src/lib/features/sessions/noteIndicators.test.ts index 0800bf06..939eb3ed 100644 --- a/apps/staged/src/lib/features/sessions/noteIndicators.test.ts +++ b/apps/staged/src/lib/features/sessions/noteIndicators.test.ts @@ -37,6 +37,22 @@ Strip metadata before scanning for the note separator.`); }); }); + it('cuts at the horizontal rule when suggested-next-steps trails note markdown', () => { + const split = splitAtNoteIndicator(`I focused the plan on the parser and tests. +--- +# Harden Note Detection +Strip metadata after the note body. + +\`\`\`suggested-next-steps +{"suggestedNextCommitStep":"Fix note parsing","suggestedNextNoteStep":null} +\`\`\``); + + expect(split).toEqual({ + preamble: 'I focused the plan on the parser and tests.\n', + hasNote: true, + }); + }); + it('ignores inline horizontal rules that are not immediately followed by an H1', () => { const text = 'Two reasons:--- this session is read-only.'; diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 03d61c74..a95b527d 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -137,6 +137,17 @@ export interface CommitTimelineItem { isOwnCommit: boolean; } +export type SuggestedNextStep = + | { + type: 'implementation'; + prompt: string; + expectedMultipleCommits: boolean; + } + | { + type: 'note'; + prompt: string; + }; + export interface NoteTimelineItem { id: string; title: string; @@ -149,6 +160,7 @@ export interface NoteTimelineItem { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + suggestedNextSteps: SuggestedNextStep[]; } /** A full branch note record, as returned by `get_branch_note_by_session`. */ @@ -163,6 +175,7 @@ export interface BranchNote { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + suggestedNextSteps: SuggestedNextStep[]; } export interface ReviewTimelineItem { @@ -321,6 +334,7 @@ export interface ProjectNote { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + suggestedNextSteps: SuggestedNextStep[]; sessionStatus: string | null; completionReason: string | null; }