Scope branch listings to a divergence point via node_id

ForkInfo now carries the divergence node's id, and the branches endpoint
accepts node_id + viewing_branch_id to return only the position-ranked
sibling branches at that fork (snippets taken from after the divergence,
skipping tool scaffolding) instead of every leaf in the tree. Options in
the same subtree as the viewing leaf anchor to that leaf so clients can
reliably identify "the branch I'm on". Tree-wide listing is unchanged
when node_id is absent.

- llm_client: descendant_leaves + branch_options_at helpers; optional
  position rank on BranchLeafInfo.
- insight_chat: ForkInfo.node_id set during render_tree_path; get_branches
  takes the scoping params.
- handlers: dedicated ChatBranchesQuery; unknown node_id maps to 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-08-07 19:39:57 -04:00
parent 1512fc5bdc
commit ee8a11d421
3 changed files with 220 additions and 24 deletions
+36 -5
View File
@@ -717,12 +717,20 @@ impl InsightChatService {
Ok(())
}
/// Return branch leaf metadata (id, snippet, message_count) and the
/// active leaf ID for a photo's conversation tree.
/// Return branch metadata and the active leaf ID for a photo's
/// conversation tree.
///
/// With `node_id = None` this lists every leaf in the tree. With
/// `node_id = Some(fork)` (from `ForkInfo.node_id`) it lists only the
/// sibling branches diverging at that node, position-ranked; options in
/// the same subtree as `viewing_leaf` anchor to that leaf so the client
/// can recognise "the branch I'm on".
pub fn get_branches(
&self,
library_id: i32,
file_path: &str,
node_id: Option<u64>,
viewing_leaf: Option<u64>,
) -> Result<(Vec<BranchLeafInfo>, u64)> {
let normalized = normalize_path(file_path);
let cx = opentelemetry::Context::new();
@@ -745,7 +753,16 @@ impl InsightChatService {
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
};
Ok((store.leaves_with_info(), store.active_leaf_id))
let list = match node_id {
Some(fork_node) => {
if !store.nodes.iter().any(|n| n.id == fork_node) {
bail!("node_id {} not found in tree", fork_node);
}
store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id))
}
None => store.leaves_with_info(),
};
Ok((list, store.active_leaf_id))
}
/// Streaming variant of `chat_turn`. Emits user-facing events as the
@@ -2465,6 +2482,10 @@ pub(crate) fn restore_system_prompt_override(
pub struct ForkInfo {
pub position: usize,
pub total: usize,
/// The divergence node itself (the parent whose children fork). Pass as
/// `node_id` to the branches endpoint to list the sibling branches at
/// this exact point rather than every leaf in the tree.
pub node_id: u64,
}
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
@@ -2498,8 +2519,16 @@ fn render_tree_path(
// Update the carried fork BEFORE rendering: a non-rendered fork child
// (e.g. a tool-dispatch assistant) must colour the rendered message
// that follows it.
if let Some((position, total)) = store.fork_at_node(node.id) {
last_fork = Some(ForkInfo { position, total });
// (fork_at_node only fires for nodes with a parent, so the pair
// pattern below can't drop a real fork.)
if let (Some(node_id), Some((position, total))) =
(node.parent_id, store.fork_at_node(node.id))
{
last_fork = Some(ForkInfo {
position,
total,
node_id,
});
}
let msg = &node.message;
match msg.role.as_str() {
@@ -2839,6 +2868,7 @@ mod tests {
.as_ref()
.expect("fork indicator carried onto the rendered reply");
assert_eq!((f.position, f.total), (2, 2));
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
}
#[test]
@@ -2859,6 +2889,7 @@ mod tests {
.as_ref()
.expect("fork indicator on the divergent rendered reply");
assert_eq!((f.position, f.total), (2, 2));
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
}
#[test]