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 CordesandClaude Opus 4.8 committed 2026-08-07 19:39:57 -04:00
1 parent 1512fc5bdc
commit ee8a11d421
3 files changed
+220 -24

No files matched your search

+47 -19
View File
@@ -1335,6 +1335,9 @@ pub struct ChatHistoryHttpResponse {
pub struct ChatForkInfo {
pub position: usize,
pub total: usize,
/// The divergence tree node. Pass as `node_id` to the branches endpoint
/// for a list scoped to this exact fork.
pub node_id: u64,
}
#[derive(Debug, Serialize)]
@@ -1443,6 +1446,7 @@ pub async fn chat_history_handler(
f.map(|fi| ChatForkInfo {
position: fi.position,
total: fi.total,
node_id: fi.node_id,
})
})
.collect();
@@ -1487,14 +1491,32 @@ pub async fn chat_history_handler(
}
}
/// GET /insights/chat/branches — return the list of branch leaf IDs for a
/// photo's conversation tree. Each branch is a leaf node; the active branch
/// is the one matching `active_leaf_id`. Use with `branch_id` on the
/// history endpoint to load an alternate conversation path.
/// Query for GET /insights/chat/branches.
#[derive(Debug, Deserialize)]
pub struct ChatBranchesQuery {
pub path: String,
#[serde(default)]
pub library: Option<String>,
/// When set (from `ForkInfo.node_id`), scope the list to the sibling
/// branches diverging at this tree node instead of every leaf.
#[serde(default)]
pub node_id: Option<u64>,
/// The leaf the client is currently viewing. Scoped options in the
/// same subtree anchor to it so the client can identify its own branch.
/// Defaults to the active leaf when absent.
#[serde(default)]
pub viewing_branch_id: Option<u64>,
}
/// GET /insights/chat/branches — return the branch list for a photo's
/// conversation tree. Without `node_id`, each entry is a leaf node;
/// with `node_id`, entries are the position-ranked sibling branches at
/// that divergence point. Use an entry's `id` with `branch_id` on the
/// history endpoint (or the switch-branch endpoint) to load that path.
#[get("/insights/chat/branches")]
pub async fn chat_branches_handler(
_claims: Claims,
query: web::Query<ChatHistoryQuery>,
query: web::Query<ChatBranchesQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
@@ -1502,21 +1524,27 @@ pub async fn chat_branches_handler(
.flatten()
.unwrap_or_else(|| app_state.primary_library());
let (branches, active_leaf_id) =
match app_state.insight_chat.get_branches(library.id, &query.path) {
Ok(result) => result,
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
} else if msg.contains("no chat history") {
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
} else {
return HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": msg }));
}
let (branches, active_leaf_id) = match app_state.insight_chat.get_branches(
library.id,
&query.path,
query.node_id,
query.viewing_branch_id,
) {
Ok(result) => result,
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
} else if msg.contains("no chat history") {
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
} else if msg.contains("not found in tree") {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
} else {
return HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": msg }));
}
};
}
};
HttpResponse::Ok().json(serde_json::json!({
"branches": branches,