You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/23 12:35:07 UTC

(maka) 01/02: feat(tui): branch selected turns with durable recovery

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 8d4efeefd4d6fec073772eae9a8efa4d86b19d40
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Wed Sep 23 20:18:12 2026 +0800

    feat(tui): branch selected turns with durable recovery
    
    Add a focused, localized confirmation with safe mouse dismissal, checkpoint-gated creation, exact receipt reconciliation, and explicit navigation preserving source drafts. Verify native Host history boundaries and restart behavior through PTY tests.
    
    Generated-by: Codex
---
 crates/cli/tests/integration/tui.rs            |   1 +
 crates/cli/tests/integration/tui/branch.rs     | 176 +++++++
 crates/cli/tests/integration/tui/reading.rs    |   2 +-
 crates/tui/locales/en.ftl                      |  12 +
 crates/tui/locales/zh-CN.ftl                   |  12 +
 crates/tui/locales/zh-TW.ftl                   |  12 +
 crates/tui/src/app.rs                          |  19 +
 crates/tui/src/lib.rs                          |  36 ++
 crates/tui/src/pages.rs                        |   1 +
 crates/tui/src/pages/branch.rs                 | 623 +++++++++++++++++++++++++
 crates/tui/src/pages/branch/view.rs            | 212 +++++++++
 crates/tui/src/pages/chat/render/navigation.rs |   9 +
 crates/tui/src/state.rs                        | 109 ++++-
 crates/tui/src/state/snapshot.rs               |  18 +-
 crates/tui/src/view.rs                         |   6 +
 15 files changed, 1242 insertions(+), 6 deletions(-)

diff --git a/crates/cli/tests/integration/tui.rs b/crates/cli/tests/integration/tui.rs
index ad95f3b8f3..fb5c604b93 100644
--- a/crates/cli/tests/integration/tui.rs
+++ b/crates/cli/tests/integration/tui.rs
@@ -19,6 +19,7 @@
 
 #![cfg(unix)]
 
+mod branch;
 mod changes;
 mod connection_test;
 mod connections;
diff --git a/crates/cli/tests/integration/tui/branch.rs b/crates/cli/tests/integration/tui/branch.rs
new file mode 100644
index 0000000000..ca4f931b05
--- /dev/null
+++ b/crates/cli/tests/integration/tui/branch.rs
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_protocol::{
+    Operation,
+    session::{copy, decode_session_create_input},
+};
+use serde_json::json;
+
+#[test]
+fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt() {
+    let directory = tempfile::tempdir().unwrap();
+    let mut host = super::super::candidate::CandidateFixture::new(directory.path().join("root"));
+    host.child = Some(
+        Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args(["host", "serve", "--root"])
+            .arg(&host.root)
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::inherit())
+            .spawn()
+            .unwrap(),
+    );
+    host.wait_for_registration();
+    let runtime = tokio::runtime::Runtime::new().unwrap();
+    let client = runtime.block_on(async {
+        use tokio::{net::TcpListener, io::AsyncWriteExt};
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let client = support::model_client(&host.root,&format!("http://{}/v1",listener.local_addr().unwrap())).await;
+        client.create_session(decode_session_create_input(&json!({
+            "sessionId":"branch-source","name":"Branch source","workspace":{"kind":"host_path","path":directory.path()},"modelTarget":{"kind":"default"}
+        })).unwrap()).await.unwrap();
+        let model = tokio::spawn(async move {
+            for content in ["First reply.","Later reply."] {
+                let (mut stream,_) = model_request(&listener).await;
+                let frame = json!({"id":"branch-fixture","object":"chat.completion.chunk","model":"fixture-model",
+                    "choices":[{"index":0,"delta":{"content":content},"finish_reason":"stop"}]});
+                stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {frame}\n\ndata: [DONE]\n\n").as_bytes()).await.unwrap();
+            }
+        });
+        for (turn,text) in [("first-turn","Selected branch point"),("later-turn","Later excluded prompt")] {
+            client.request(Operation::TurnStart,json!({"sessionId":"branch-source","turnId":turn,"content":{"text":text},"maxSteps":1})).await.unwrap();
+            tokio::time::timeout(Duration::from_secs(15),async {
+                loop {
+                    let output = client.request(Operation::TurnQuery,json!({"sessionId":"branch-source","turnId":turn})).await.unwrap();
+                    match output["status"].as_str() {
+                        Some("completed") => break,
+                        Some("failed" | "cancelled") => panic!("fixture turn failed"),
+                        _ => tokio::time::sleep(Duration::from_millis(20)).await,
+                    }
+                }
+            }).await.unwrap();
+        }
+        model.await.unwrap();
+        client
+    });
+    let before = runtime
+        .block_on(client.session("branch-source"))
+        .unwrap()
+        .unwrap();
+    let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+    tui.wait_for("Branch source");
+    tui.click_text("Branch source");
+    tui.wait_for("Later reply.");
+    tui.send(b"Preserved branch draft");
+    tui.wait_for("Preserved branch draft");
+    tui.click_text("Selected branch point");
+    tui.send(b"\x10");
+    tui.wait_for("Branch from this turn");
+    tui.click_text("Branch from this turn");
+    tui.wait_for("same working directory.");
+    tui.send(b"\r"); // Initial focus is Cancel.
+    tui.wait_until(|s| !s.contains("same working directory."));
+    tui.send(b"\x10");
+    tui.wait_for("Branch from this turn");
+    tui.click_text("Branch from this turn");
+    tui.wait_for("same working directory.");
+    tui.click_text("Create branch");
+    tui.wait_for("Branch is ready.");
+    tui.send(b"\x11");
+    tui.finish();
+    let checkpoint = directory
+        .path()
+        .join("tui-state")
+        .join(&client.identity.root_id)
+        .join("default/state.json");
+    let saved: serde_json::Value =
+        serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
+    let request: copy::Input = serde_json::from_value(saved["branch"]["input"].clone()).unwrap();
+    assert_eq!(request.source_session_id, "branch-source");
+    assert_eq!(
+        request.purpose,
+        copy::Purpose::Branch {
+            turn_id: Some("first-turn".into()),
+            side_conversation: false
+        }
+    );
+    runtime.block_on(async {
+        let receipt = client
+            .query_session_copy(request.clone())
+            .await
+            .unwrap()
+            .receipt
+            .unwrap();
+        assert_eq!(receipt.request, request);
+        let original = client.session("branch-source").await.unwrap().unwrap();
+        assert_eq!(
+            original.revision, before.revision,
+            "branching normal source does not rewrite it"
+        );
+        let target = client
+            .session(&request.target_session_id)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            target.workspace, original.workspace,
+            "branch is not a filesystem copy"
+        );
+    });
+    let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+    reopened.wait_for("Preserved branch draft");
+    reopened.send(b"\x10");
+    reopened.wait_for("Check session branch");
+    reopened.click_text("Check session branch");
+    reopened.wait_for("Result not yet confirmed.");
+    reopened.click_text("Check result");
+    reopened.wait_for("Branch is ready.");
+    reopened.click_text("Open branch");
+    reopened.wait_until(|screen| {
+        screen.contains("First reply.")
+            && !screen.contains("Branch is ready.")
+            && !screen.contains("Preserved branch draft")
+    });
+    let screen = reopened.screen.snapshot().unwrap().screen;
+    assert!(screen.contains("Selected branch point"));
+    assert!(
+        !screen.contains("Later excluded prompt") && !screen.contains("Later reply."),
+        "branch includes only selected history: {screen}"
+    );
+    assert!(
+        !screen.contains("Preserved branch draft"),
+        "branch does not adopt original draft"
+    );
+    reopened.send(b"\x11");
+    reopened.finish();
+    let saved: serde_json::Value =
+        serde_json::from_slice(&std::fs::read(checkpoint).unwrap()).unwrap();
+    assert!(saved["branch"].is_null());
+    assert_eq!(saved["route"]["session"], request.target_session_id);
+    let receipt = runtime
+        .block_on(client.query_session_copy(request))
+        .unwrap()
+        .receipt
+        .unwrap();
+    assert_eq!(receipt.request.source_session_id, "branch-source");
+    client.disconnect();
+    host.retire_registered();
+}
diff --git a/crates/cli/tests/integration/tui/reading.rs b/crates/cli/tests/integration/tui/reading.rs
index 9deb32c62f..8516afc8ed 100644
--- a/crates/cli/tests/integration/tui/reading.rs
+++ b/crates/cli/tests/integration/tui/reading.rs
@@ -151,7 +151,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
         .unwrap(),
     )
     .unwrap();
-    assert_eq!(saved["version"], 5);
+    assert_eq!(saved["version"], 6);
     let encoded = serde_json::to_string(&saved).unwrap();
     assert!(
         !encoded.contains("History context line"),
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index 509ca1e89a..874102a104 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -704,3 +704,15 @@ theme-role-number = Number
 theme-role-function = Function
 theme-role-type = Type
 theme-role-operator = Operator
+
+branch-title = Branch from this turn
+branch-resume = Check session branch
+branch-create = Create branch
+branch-query = Check result
+branch-open = Open branch
+branch-note = Keep the conversation through the selected turn. Both sessions share the same working directory.
+branch-wait = Creating branch…
+branch-unknown = Result not yet confirmed. Check this request before creating another branch.
+branch-save-failed = Could not save the recovery identity. No creation request was sent.
+branch-ready = Branch is ready. Your original conversation and draft are unchanged.
+branch-unavailable = The source changed or this branch is unavailable. Close and select the turn again.
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index 35d6673568..6b987aa862 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -704,3 +704,15 @@ theme-role-number = 数字
 theme-role-function = 函数
 theme-role-type = 类型
 theme-role-operator = 运算符
+
+branch-title = 从此轮分支
+branch-resume = 查看会话分支
+branch-create = 创建分支
+branch-query = 查询结果
+branch-open = 打开分支
+branch-note = 保留到所选轮次的对话。两个会话共用同一个工作目录。
+branch-wait = 正在创建分支…
+branch-unknown = 结果尚未确认,请先查询本次请求,不要重复创建。
+branch-save-failed = 无法保存恢复信息,尚未发送创建请求。
+branch-ready = 分支已就绪,原对话和草稿保持不变。
+branch-unavailable = 来源已变化或分支不可用,请关闭后重新选择轮次。
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index f83de9cb7d..95e98aa249 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -704,3 +704,15 @@ theme-role-number = 數字
 theme-role-function = 函式
 theme-role-type = 類型
 theme-role-operator = 運算子
+
+branch-title = 從此輪分支
+branch-resume = 檢視對話分支
+branch-create = 建立分支
+branch-query = 查詢結果
+branch-open = 開啟分支
+branch-note = 保留到所選輪次的對話。兩個對話共用同一個工作目錄。
+branch-wait = 正在建立分支…
+branch-unknown = 結果尚未確認,請先查詢本次請求,不要重複建立。
+branch-save-failed = 無法儲存復原資訊,尚未送出建立請求。
+branch-ready = 分支已就緒,原對話與草稿保持不變。
+branch-unavailable = 來源已變更或分支無法使用,請關閉後重新選擇輪次。
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index a1403ee07e..f05eb4bab7 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -60,6 +60,7 @@ pub enum Action {
     Search(crate::pages::chat::render::search::Command),
     Copy(crate::pages::chat::render::selection::CopyMode),
     CopyFile(String),
+    Branch(crate::pages::branch::Command),
     ToggleSymbols,
     ToggleMotion,
     RefreshSessions,
@@ -124,6 +125,7 @@ pub struct App {
     pub sessions: crate::pages::sessions::Sessions,
     pub inbox: crate::pages::sessions::Sessions,
     pub management: crate::pages::manage::Management,
+    pub branch: crate::pages::branch::State,
     pub onboarding: crate::pages::onboarding::Onboarding,
     pub projects: crate::pages::projects::Projects,
     pub connections: crate::pages::connections::Connections,
@@ -167,6 +169,7 @@ impl App {
             sessions: Default::default(),
             inbox: crate::pages::sessions::Sessions::inbox(),
             management: Default::default(),
+            branch: Default::default(),
             onboarding: Default::default(),
             projects: Default::default(),
             connections: Default::default(),
@@ -216,6 +219,7 @@ impl App {
             (Action::Quit, "command-quit"),
         ];
         commands.extend(self.management_commands());
+        commands.extend(self.branch_commands());
         commands.extend(self.oauth_commands());
         if let Some(action) = self.default_model_action() {
             commands.push((action, "default-model-title"));
@@ -417,6 +421,7 @@ impl App {
                 || self.inbox.error.is_some())
     }
     pub fn begin_frame(&mut self, area: Rect) {
+        self.branch.invalidate_geometry();
         for item in &self.sessions.items {
             self.tabs.rename(&item.id, &item.name);
         }
@@ -439,6 +444,7 @@ impl App {
     }
     pub fn tooltip_wait(&self) -> Option<Duration> {
         if self.theme.editor.is_some()
+            || self.branch.visible
             || !self.has_tooltip()
             || self.palette.is_some()
             || self.interactions.visible
@@ -452,6 +458,7 @@ impl App {
     }
     pub fn selection_wait(&self, now: Instant) -> Option<Duration> {
         if self.theme.editor.is_some()
+            || self.branch.visible
             || !matches!(self.navigation.current(), Route::Session(_))
             || self.palette.is_some()
             || self.chrome.details
@@ -473,6 +480,7 @@ impl App {
     }
     pub fn tooltip_visible(&self) -> bool {
         self.theme.editor.is_none()
+            && !self.branch.visible
             && self.palette.is_none()
             && self.management.dialog.is_none()
             && self.onboarding.dialog.is_none()
@@ -543,6 +551,7 @@ impl App {
                 self.hover = None;
             }
             Action::Manage(command) => return self.management_action(command),
+            Action::Branch(command) => return self.branch_action(command),
             Action::Onboard(command) => return self.onboarding_action(command),
             Action::Project(command) => return self.project_action(command),
             Action::Connection(command) => self.connection_action(command),
@@ -725,6 +734,9 @@ impl App {
         None
     }
     pub fn enabled(&self, action: &Action) -> bool {
+        if let Action::Branch(command) = action {
+            return self.branch_enabled(command);
+        }
         if let Action::Connection(command) = action {
             return self.connection_enabled(command);
         }
@@ -968,6 +980,7 @@ impl App {
         }
         self.management.invalidate_geometry();
         self.management.oauth.invalidate_identity_geometry();
+        self.branch.invalidate_geometry();
         self.onboarding.invalidate_geometry();
         self.tabs.invalidate_geometry();
         if let Some(reader) = self.chat.reader_mut() {
@@ -1053,6 +1066,7 @@ impl App {
         if let Event::Mouse(mouse) = &event
             && mouse.kind == MouseEventKind::Down(MouseButton::Left)
             && (self.theme.editor.is_some()
+                || self.branch.visible
                 || self.palette.is_some()
                 || self.onboarding.dialog.is_some()
                 || self.management.dialog.is_some()
@@ -1065,6 +1079,8 @@ impl App {
             // Dismiss only the displayed overlay. Never forward this press to the page.
             let action = if self.theme.editor.is_some() {
                 Some(Action::Theme(crate::theme::editor::Command::Close))
+            } else if self.branch.visible {
+                Some(Action::Branch(crate::pages::branch::Command::Close))
             } else if self.onboarding.dialog.is_some() {
                 Some(Action::Onboard(crate::pages::onboarding::Command::Close))
             } else if self.management.dialog.is_some() {
@@ -1089,6 +1105,9 @@ impl App {
         if self.theme.editor.is_some() && !matches!(event, Event::Resize(_, _)) {
             return self.theme_input(event);
         }
+        if self.branch.visible && !matches!(event, Event::Resize(_, _)) {
+            return self.branch_input(event);
+        }
         if self.onboarding.dialog.is_some() && !matches!(event, Event::Resize(_, _)) {
             return self.onboarding_input(event);
         }
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index b091585964..cbe44bfeca 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -48,6 +48,10 @@ pub struct Options {
 }
 
 enum Completed {
+    Branched(
+        pages::branch::Request,
+        Result<pages::branch::Output, maka_client::RequestFailure>,
+    ),
     Removal(
         pages::manage::removal::Request,
         Result<pages::manage::removal::Output, maka_client::RequestFailure>,
@@ -197,6 +201,23 @@ pub async fn run(options: Options) -> Result<(), Error> {
             state.start(&app);
         }
         if let Some(client) = &client {
+            if let Some(request) = app.branch_request() {
+                if request.query {
+                    let client = client.clone();
+                    jobs.spawn(async move {
+                        let result = pages::branch::execute(&client, &request).await;
+                        Completed::Branched(request, result)
+                    });
+                } else if let Some(state) = &mut state {
+                    state.submit_branch(request);
+                } else {
+                    app.branch_after_checkpoint(
+                        &request,
+                        &Err("TUI checkpoint unavailable".into()),
+                    );
+                }
+                dirty = true;
+            }
             if let Some(request) = app.oauth_request() {
                 if request.needs_checkpoint() {
                     if let Some(state) = &mut state {
@@ -495,6 +516,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                     continue;
                 }
                 Action::Quit => {
+                    app.branch.disconnect();
                     if let Some(state) = &mut state {
                         app.oauth_abandon_checkpoint();
                         for request in state.cancel_requests() {
@@ -508,6 +530,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                     break;
                 }
                 Action::Connect => {
+                    app.branch.disconnect();
                     if let Some(state) = &mut state {
                         state.cancel_requests();
                     }
@@ -677,6 +700,14 @@ pub async fn run(options: Options) -> Result<(), Error> {
                     None => std::future::pending().await,
                 }
             } => {
+                if let Some(request) = written.branch
+                    && app.branch_after_checkpoint(&request, &written.result)
+                    && let Some(client) = client.clone() {
+                    jobs.spawn(async move {
+                        let result = pages::branch::execute(&client, &request).await;
+                        Completed::Branched(request, result)
+                    });
+                }
                 if let Some(request) = written.oauth
                     && app.oauth_after_checkpoint(&request, &written.result)
                     && let Some(client) = client.clone() {
@@ -763,6 +794,10 @@ pub async fn run(options: Options) -> Result<(), Error> {
             }
             completed = jobs.join_next(), if !jobs.is_empty() => {
                 match completed {
+                    Some(Ok(Completed::Branched(request, result))) => {
+                        app.branch_completed(request, result);
+                        if let Some(state) = &mut state { state.changed(); }
+                    }
                     Some(Ok(Completed::Oauth(request, result))) => {
                         if let Some(service) = app.oauth_completed(request, result) {
                             oauth_service = Some(service);
@@ -947,6 +982,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                 app.abandon_interaction();
                 app.abandon_pending_submissions();
                 app.abandon_management();
+                app.branch.disconnect();
                 app.management.oauth.abandon();
                 app.abandon_onboarding();
                 app.creating = false;
diff --git a/crates/tui/src/pages.rs b/crates/tui/src/pages.rs
index 336676c9b5..d24c707f4e 100644
--- a/crates/tui/src/pages.rs
+++ b/crates/tui/src/pages.rs
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+pub mod branch;
 pub mod chat;
 pub mod connections;
 pub mod interactions;
diff --git a/crates/tui/src/pages/branch.rs b/crates/tui/src/pages/branch.rs
new file mode 100644
index 0000000000..35fb88ffd9
--- /dev/null
+++ b/crates/tui/src/pages/branch.rs
@@ -0,0 +1,623 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod view;
+use crate::{
+    app::{Action, App, ConnectionState},
+    navigation::Route,
+};
+use maka_client::{Client, RequestFailure};
+use maka_protocol::{
+    Operation,
+    session::{SessionCatalogProjection, copy},
+};
+use serde::{Deserialize, Serialize};
+pub use view::draw;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Basis {
+    root: String,
+    epoch: String,
+    source: String,
+    revision: u64,
+    turn: String,
+    name: String,
+    excerpt: String,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Command {
+    Open(Basis),
+    Resume,
+    Confirm,
+    Query,
+    Visit,
+    Close,
+}
+impl Command {
+    pub fn label(&self) -> &'static str {
+        match self {
+            Self::Open(_) => "branch-title",
+            Self::Resume => "branch-resume",
+            Self::Confirm => "branch-create",
+            Self::Query => "branch-query",
+            Self::Visit => "branch-open",
+            Self::Close => "session-cancel",
+        }
+    }
+}
+
+/// Persist the original request, never a stale claim that the target exists.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Checkpoint {
+    root: String,
+    input: copy::Input,
+}
+impl Checkpoint {
+    pub fn validate(&self, root: &str) -> Result<(), String> {
+        let copy::Purpose::Branch {
+            turn_id: Some(turn),
+            side_conversation: false,
+        } = &self.input.purpose
+        else {
+            return Err("Invalid saved branch purpose".into());
+        };
+        if self.root != root {
+            return Err("Saved branch belongs to another Root".into());
+        }
+        copy::decode_input(
+            Operation::SessionBranchCreate,
+            &serde_json::json!({
+                "sourceSessionId":self.input.source_session_id,
+                "targetSessionId":self.input.target_session_id,
+                "expectedSourceRevision":self.input.expected_source_revision,
+                "sourceTurnId":turn
+            }),
+        )
+        .map_err(|e| e.to_string())?;
+        Ok(())
+    }
+}
+#[derive(Clone, Copy, Default, PartialEq, Eq)]
+enum Phase {
+    #[default]
+    Confirm,
+    Saving,
+    Pending,
+    Unknown,
+    Ready,
+    Failed,
+}
+#[derive(Default)]
+pub struct State {
+    pub visible: bool,
+    rendered: bool,
+    focus: usize,
+    basis: Option<Basis>,
+    saved: Option<Checkpoint>,
+    phase: Phase,
+    error: Option<&'static str>,
+    pending: Option<Request>,
+    requested: Option<bool>,
+    sequence: u64,
+}
+impl State {
+    pub fn checkpoint(&self) -> Option<Checkpoint> {
+        self.saved.clone()
+    }
+    pub fn restore(&mut self, saved: Checkpoint) {
+        self.saved = Some(saved);
+        self.phase = Phase::Unknown;
+        self.visible = false;
+    }
+    pub fn invalidate_geometry(&mut self) {
+        self.rendered = false;
+    }
+    pub fn disconnect(&mut self) {
+        self.pending = None;
+        self.requested = None;
+        if self.saved.is_some() {
+            self.phase = Phase::Unknown;
+            self.error = None;
+        }
+    }
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Request {
+    root: String,
+    epoch: String,
+    input: copy::Input,
+    pub query: bool,
+    sequence: u64,
+}
+pub enum Output {
+    Ready(Box<SessionCatalogProjection>),
+    Conflict,
+    Missing,
+    Removed,
+}
+pub async fn execute(client: &Client, request: &Request) -> Result<Output, RequestFailure> {
+    if request.query {
+        let result = client.query_session_copy(request.input.clone()).await?;
+        if result.receipt.is_none() {
+            return Ok(Output::Missing);
+        }
+        return Ok(
+            match client.session(&request.input.target_session_id).await? {
+                Some(session) => Output::Ready(session),
+                None => Output::Removed,
+            },
+        );
+    }
+    Ok(match client.copy_session(request.input.clone()).await? {
+        copy::Output::Committed { session } => Output::Ready(session),
+        copy::Output::SourceRevisionConflict { .. } => Output::Conflict,
+    })
+}
+
+impl App {
+    pub fn branch_commands(&self) -> Vec<(Action, &'static str)> {
+        if self.branch.saved.is_some() {
+            return vec![(Action::Branch(Command::Resume), "branch-resume")];
+        }
+        let Some(basis) = self.branch_basis() else {
+            return vec![];
+        };
+        vec![(Action::Branch(Command::Open(basis)), "branch-title")]
+    }
+    fn branch_basis(&self) -> Option<Basis> {
+        let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+            return None;
+        };
+        let Route::Session(id) = self.navigation.current() else {
+            return None;
+        };
+        if self.chat.removed || self.chat.session.as_ref() != Some(&id) || self.chrome.details {
+            return None;
+        }
+        // Search previews have their own transcript; use only the canonical page.
+        if self.chat.view.search.is_some() {
+            return None;
+        }
+        let (turn, text) = self.chat.view.selected_durable_message()?;
+        let super::sessions::Detail::Ready(item) = &self.sessions.detail else {
+            return None;
+        };
+        if item.id != id {
+            return None;
+        }
+        Some(Basis {
+            root: root_id.clone(),
+            epoch: epoch.clone(),
+            source: id,
+            revision: item.revision,
+            turn: turn.into(),
+            name: item.name.clone(),
+            excerpt: text.chars().take(120).collect(),
+        })
+    }
+    pub fn branch_enabled(&self, command: &Command) -> bool {
+        let state = &self.branch;
+        let connected = |root: &str, epoch: Option<&str>| {
+            matches!(&self.connection,
+            ConnectionState::Connected {root_id, epoch: actual} if root_id == root && epoch.is_none_or(|e| e == actual))
+        };
+        match command {
+            Command::Open(basis) => {
+                state.saved.is_none()
+                    && !state.visible
+                    && self.branch_basis().as_ref() == Some(basis)
+            }
+            Command::Resume => state.saved.is_some() && !state.visible,
+            Command::Close => state.visible,
+            Command::Confirm => {
+                state.visible
+                    && state.rendered
+                    && state.phase == Phase::Confirm
+                    && state
+                        .basis
+                        .as_ref()
+                        .is_some_and(|basis| connected(&basis.root, Some(&basis.epoch)))
+                    && self.tabs.entries.len() < crate::navigation::tabs::LIMIT
+                    && self.drafts.len() < crate::navigation::tabs::LIMIT
+            }
+            Command::Query => {
+                state.visible
+                    && state.rendered
+                    && state.phase == Phase::Unknown
+                    && state.pending.is_none()
+                    && state.requested.is_none()
+                    && state
+                        .saved
+                        .as_ref()
+                        .is_some_and(|saved| connected(&saved.root, None))
+            }
+            Command::Visit => {
+                state.visible
+                    && state.rendered
+                    && state.phase == Phase::Ready
+                    && state.saved.as_ref().is_some_and(|saved| {
+                        connected(&saved.root, None)
+                            && (self.tabs.contains(&saved.input.target_session_id)
+                                || self.tabs.entries.len() < crate::navigation::tabs::LIMIT)
+                            && (self.drafts.contains_key(&saved.input.target_session_id)
+                                || self.drafts.len() < crate::navigation::tabs::LIMIT)
+                    })
+            }
+        }
+    }
+    pub fn branch_action(&mut self, command: Command) -> Option<Action> {
+        if !self.branch_enabled(&command) {
+            return None;
+        }
+        let state = &mut self.branch;
+        match command {
+            Command::Open(basis) => {
+                state.basis = Some(basis);
+                state.phase = Phase::Confirm;
+                state.error = None;
+                state.focus = 0;
+                state.visible = true;
+                state.rendered = false;
+            }
+            Command::Resume => {
+                state.visible = true;
+                state.focus = 0;
+                state.rendered = false;
+            }
+            Command::Close => {
+                state.visible = false;
+                state.rendered = false;
+                if state.saved.is_none() {
+                    state.basis = None;
+                }
+            }
+            Command::Confirm => {
+                let basis = state.basis.as_ref()?;
+                state.saved = Some(Checkpoint {
+                    root: basis.root.clone(),
+                    input: copy::Input {
+                        source_session_id: basis.source.clone(),
+                        target_session_id: uuid::Uuid::new_v4().to_string(),
+                        expected_source_revision: basis.revision,
+                        purpose: copy::Purpose::Branch {
+                            turn_id: Some(basis.turn.clone()),
+                            side_conversation: false,
+                        },
+                    },
+                });
+                state.requested = Some(false);
+                state.phase = Phase::Saving;
+                state.focus = 0;
+            }
+            Command::Query => {
+                state.requested = Some(true);
+                state.error = None;
+            }
+            Command::Visit => {
+                let target = state.saved.take()?.input.target_session_id;
+                state.visible = false;
+                state.basis = None;
+                return self.apply(Action::Visit(Route::Session(target)));
+            }
+        }
+        None
+    }
+    pub fn branch_request(&mut self) -> Option<Request> {
+        let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+            return None;
+        };
+        let state = &mut self.branch;
+        let saved = state.saved.as_ref()?;
+        if saved.root != *root_id || state.pending.is_some() || self.closing {
+            return None;
+        }
+        let query = state.requested.take()?;
+        if !query
+            && state
+                .basis
+                .as_ref()
+                .is_none_or(|basis| basis.epoch != *epoch)
+        {
+            state.phase = Phase::Unknown;
+            return None;
+        }
+        state.sequence += 1;
+        let request = Request {
+            root: root_id.clone(),
+            epoch: epoch.clone(),
+            input: saved.input.clone(),
+            query,
+            sequence: state.sequence,
+        };
+        state.pending = Some(request.clone());
+        state.phase = if query { Phase::Pending } else { Phase::Saving };
+        Some(request)
+    }
+    pub fn branch_after_checkpoint(
+        &mut self,
+        request: &Request,
+        result: &Result<(), String>,
+    ) -> bool {
+        let state = &mut self.branch;
+        if request.query || state.phase != Phase::Saving || state.pending.as_ref() != Some(request)
+        {
+            return false;
+        }
+        if result.is_ok()
+            && !self.closing
+            && matches!(&self.connection,
+            ConnectionState::Connected {root_id, epoch} if *root_id == request.root && *epoch == request.epoch)
+        {
+            state.phase = Phase::Pending;
+            return true;
+        }
+        state.pending = None;
+        // This particular attempt has not been dispatched. A later restart may
+        // still recover an older checkpoint conservatively, but this live UI
+        // knows it can let the user make a fresh explicit choice.
+        state.phase = Phase::Failed;
+        state.saved = None;
+        state.error = Some("branch-save-failed");
+        false
+    }
+    pub fn branch_completed(&mut self, request: Request, result: Result<Output, RequestFailure>) {
+        let state = &mut self.branch;
+        if state.pending.as_ref() != Some(&request) {
+            return;
+        }
+        state.pending = None;
+        if !matches!(&self.connection, ConnectionState::Connected {root_id, epoch} if *root_id == request.root && *epoch == request.epoch)
+        {
+            state.phase = Phase::Unknown;
+            return;
+        }
+        state.focus = 0;
+        match result {
+            Ok(Output::Ready(session)) => {
+                state.phase = Phase::Ready;
+                state.error = None;
+                self.sessions.updated(session);
+                self.inbox.refresh();
+            }
+            Ok(Output::Conflict | Output::Removed) => {
+                state.phase = Phase::Failed;
+                state.saved = None;
+                state.error = Some("branch-unavailable");
+            }
+            Ok(Output::Missing) => {
+                state.phase = Phase::Unknown;
+                state.error = Some("branch-unknown");
+            }
+            Err(error) => {
+                let uncertain = request.query
+                    || matches!(&error, RequestFailure::Unknown(_))
+                    || matches!(&error, RequestFailure::Rejected(maka_client::ClientError::Rejected(e))
+                        if e.code == maka_protocol::OperationErrorCode::CommitOutcomeUnknown);
+                state.phase = if uncertain {
+                    Phase::Unknown
+                } else {
+                    Phase::Failed
+                };
+                if !uncertain {
+                    state.saved = None;
+                }
+                state.error = Some(if uncertain {
+                    "branch-unknown"
+                } else {
+                    "branch-unavailable"
+                });
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{Locale, LocalePreference, i18n::I18n};
+    use crossterm::event::{
+        Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
+    };
+    use ratatui::{Terminal, backend::TestBackend};
+    use serde_json::json;
+
+    fn frame(app: &mut App, width: u16, height: u16) {
+        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+        terminal
+            .draw(|f| {
+                app.hits.clear();
+                draw(f, app, f.area(), app.theme.colors().base());
+            })
+            .unwrap();
+    }
+    fn fixture() -> (App, Basis) {
+        let mut app = App::new(
+            "/unused".into(),
+            I18n::new(LocalePreference::Auto, Locale::En),
+        );
+        app.connection = ConnectionState::Connected {
+            root_id: "root".into(),
+            epoch: "epoch".into(),
+        };
+        app.apply(Action::Visit(Route::Session("source".into())));
+        app.chat.select(&app.navigation.current());
+        app.sessions.detail = super::super::sessions::Detail::Ready(Box::new(
+            super::super::sessions::tests::item("source"),
+        ));
+        let rows = std::collections::BTreeMap::from([(
+            1,
+            json!({"type":"user","id":"original","turnId":"turn","text":"Selected prompt 中文"}),
+        )]);
+        app.chat.view.sync(&rows, &[], 0, &app.i18n, false);
+        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
+        terminal
+            .draw(|f| {
+                app.chat.view.draw(f, f.area(), false).unwrap();
+            })
+            .unwrap();
+        assert!(
+            app.branch_commands().is_empty(),
+            "implicit first-visible is not a selected boundary"
+        );
+        app.chat.view.enter();
+        let basis = app.branch_basis().unwrap();
+        (app, basis)
+    }
+    #[test]
+    fn branch_confirmation_checkpoint_and_recovery_preserve_the_exact_target_and_draft() {
+        let (mut app, basis) = fixture();
+        app.drafts
+            .get_mut("source")
+            .unwrap()
+            .insert("unsubmitted draft");
+        app.apply(Action::Branch(Command::Open(basis.clone())));
+        app.apply(Action::Branch(Command::Confirm));
+        assert!(
+            app.branch_request().is_none(),
+            "must render before confirming"
+        );
+        frame(&mut app, 80, 24);
+        app.input(Event::Key(KeyEvent::new(
+            KeyCode::Enter,
+            KeyModifiers::NONE,
+        )));
+        assert!(!app.branch.visible, "cancel is default");
+        app.apply(Action::Branch(Command::Open(basis.clone())));
+        frame(&mut app, 80, 24);
+        app.input(Event::Mouse(MouseEvent {
+            kind: MouseEventKind::Down(MouseButton::Left),
+            column: 0,
+            row: 0,
+            modifiers: KeyModifiers::NONE,
+        }));
+        assert!(!app.branch.visible, "outside click only dismisses");
+        app.apply(Action::Branch(Command::Open(basis.clone())));
+        frame(&mut app, 32, 10);
+        assert!(!app.branch_enabled(&Command::Confirm));
+        frame(&mut app, 80, 24);
+        app.apply(Action::Branch(Command::Confirm));
+        let request = app.branch_request().unwrap();
+        assert!(!request.query);
+        let saved = serde_json::to_value(app.branch.checkpoint()).unwrap();
+        let mut stale = request.clone();
+        stale.sequence += 1;
+        assert!(!app.branch_after_checkpoint(&stale, &Ok(())));
+        assert!(app.branch_after_checkpoint(&request, &Ok(())));
+        assert!(
+            !app.branch_after_checkpoint(&request, &Ok(())),
+            "never dispatch twice"
+        );
+        app.apply(Action::Branch(Command::Close));
+        app.apply(Action::Visit(Route::Settings));
+        app.branch_completed(
+            request.clone(),
+            Err(RequestFailure::Unknown(maka_client::ClientError::Protocol(
+                "lost reply".into(),
+            ))),
+        );
+        assert_eq!(app.navigation.current(), Route::Settings);
+        assert_eq!(
+            serde_json::to_value(app.branch.checkpoint()).unwrap(),
+            saved
+        );
+        let mut recovered = State::default();
+        recovered.restore(serde_json::from_value(saved.clone()).unwrap());
+        app.branch = recovered;
+        app.connection = ConnectionState::Connected {
+            root_id: "root".into(),
+            epoch: "new-epoch".into(),
+        };
+        assert!(
+            app.branch_request().is_none(),
+            "reopen neither writes nor polls"
+        );
+        app.apply(Action::Branch(Command::Resume));
+        frame(&mut app, 80, 24);
+        app.apply(Action::Branch(Command::Query));
+        let query = app.branch_request().unwrap();
+        assert!(query.query);
+        assert_eq!(query.input, request.input);
+        assert_eq!(query.epoch, "new-epoch");
+        app.branch_completed(query.clone(), Ok(Output::Missing));
+        assert!(!app.branch_enabled(&Command::Confirm));
+        assert_eq!(
+            serde_json::to_value(app.branch.checkpoint()).unwrap(),
+            saved
+        );
+        frame(&mut app, 80, 24);
+        app.apply(Action::Branch(Command::Query));
+        let retry = app.branch_request().unwrap();
+        app.branch_completed(query, Ok(Output::Removed));
+        assert!(
+            app.branch.pending.is_some(),
+            "obsolete read cannot remove the next request"
+        );
+        app.branch_completed(
+            retry,
+            Ok(Output::Ready(Box::new(
+                super::super::sessions::tests::item(&request.input.target_session_id),
+            ))),
+        );
+        assert_eq!(
+            app.navigation.current(),
+            Route::Settings,
+            "late success does not steal the route"
+        );
+        frame(&mut app, 80, 24);
+        app.apply(Action::Branch(Command::Visit));
+        assert_eq!(
+            app.navigation.current(),
+            Route::Session(request.input.target_session_id)
+        );
+        assert_eq!(app.drafts["source"].text(), "unsubmitted draft");
+        assert!(app.branch.saved.is_none());
+
+        let (mut app, basis) = fixture();
+        app.apply(Action::Branch(Command::Open(basis)));
+        frame(&mut app, 80, 24);
+        app.apply(Action::Branch(Command::Confirm));
+        let request = app.branch_request().unwrap();
+        assert!(!app.branch_after_checkpoint(&request, &Err("disk unavailable".into())));
+        assert!(
+            app.branch.saved.is_none(),
+            "known non-dispatch permits a fresh explicit selection"
+        );
+        assert!(app.branch_request().is_none());
+        let valid: Checkpoint = serde_json::from_value(saved.clone()).unwrap();
+        valid.validate("root").unwrap();
+        assert!(valid.validate("other-root").is_err());
+        for (pointer, value) in [
+            ("/input/targetSessionId", json!("source")),
+            ("/input/expectedSourceRevision", json!(0)),
+            ("/input/purpose/turnId", json!(null)),
+            ("/input/purpose/sideConversation", json!(true)),
+        ] {
+            let mut invalid = saved.clone();
+            *invalid.pointer_mut(pointer).unwrap() = value;
+            assert!(
+                serde_json::from_value::<Checkpoint>(invalid)
+                    .unwrap()
+                    .validate("root")
+                    .is_err(),
+                "{pointer}"
+            );
+        }
+    }
+}
diff --git a/crates/tui/src/pages/branch/view.rs b/crates/tui/src/pages/branch/view.rs
new file mode 100644
index 0000000000..9456de8b7c
--- /dev/null
+++ b/crates/tui/src/pages/branch/view.rs
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Action, App, Command, Phase};
+use crate::{
+    pages::manage::view::note_lines,
+    view::{button, safe},
+};
+use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
+use ratatui::{
+    Frame,
+    layout::{Alignment, Margin, Rect},
+    style::Style,
+    widgets::{Block, BorderType, Paragraph, Wrap},
+};
+use unicode_width::UnicodeWidthStr;
+
+impl App {
+    pub fn branch_input(&mut self, event: Event) -> (bool, Option<Action>) {
+        let command = match event {
+            Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
+                KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+                    return (true, Some(Action::Quit));
+                }
+                KeyCode::Esc => Some(Command::Close),
+                KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
+                    self.branch.focus ^= 1;
+                    None
+                }
+                KeyCode::Enter => {
+                    if self.branch.focus == 0 {
+                        Some(Command::Close)
+                    } else {
+                        primary(self.branch.phase)
+                    }
+                }
+                _ => None,
+            },
+            Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
+                self.hits.iter().find_map(|hit| match &hit.action {
+                    Action::Branch(command)
+                        if hit.area.contains((mouse.column, mouse.row).into()) =>
+                    {
+                        Some(command.clone())
+                    }
+                    _ => None,
+                })
+            }
+            _ => None,
+        };
+        (
+            true,
+            command.and_then(|command| self.apply(Action::Branch(command))),
+        )
+    }
+}
+fn primary(phase: Phase) -> Option<Command> {
+    match phase {
+        Phase::Confirm => Some(Command::Confirm),
+        Phase::Unknown => Some(Command::Query),
+        Phase::Ready => Some(Command::Visit),
+        _ => None,
+    }
+}
+pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
+    let width = area.width.saturating_sub(2).min(62);
+    let state = &app.branch;
+    let name = state
+        .basis
+        .as_ref()
+        .map(|basis| basis.name.as_str())
+        .or_else(|| {
+            let source = &state.saved.as_ref()?.input.source_session_id;
+            app.sessions
+                .items
+                .iter()
+                .find(|item| item.id == *source)
+                .map(|item| item.name.as_str())
+                .or_else(|| {
+                    app.tabs
+                        .entries
+                        .iter()
+                        .find(|tab| tab.id == *source)
+                        .and_then(|tab| tab.name.as_deref())
+                })
+        })
+        .unwrap_or("");
+    let mut heading = safe(name);
+    if let Some(basis) = &state.basis {
+        heading.push_str("\n\n");
+        heading.push_str(&safe(&basis.excerpt));
+    }
+    let title = note_lines(&heading, width.saturating_sub(4));
+    let key = state.error.unwrap_or(match state.phase {
+        Phase::Confirm => "branch-note",
+        Phase::Saving | Phase::Pending => "branch-wait",
+        Phase::Unknown => "branch-unknown",
+        Phase::Ready => "branch-ready",
+        Phase::Failed => "branch-unavailable",
+    });
+    let note = note_lines(&app.i18n.text(key), width.saturating_sub(4));
+    let height = 7 + title.len() as u16 + note.len() as u16;
+    if width < 36 || height > area.height.saturating_sub(2) {
+        app.branch.rendered = false;
+        crate::view::clear_overlay(frame, area);
+        frame.render_widget(
+            Paragraph::new(app.i18n.text("terminal-small")).wrap(Wrap { trim: false }),
+            area.inner(Margin::new(1, 1)),
+        );
+        return;
+    }
+    let phase = state.phase;
+    let focus = state.focus;
+    let error = state.error.is_some() || phase == Phase::Unknown;
+    app.branch.rendered = true;
+    let popup = Rect::new(
+        area.x + (area.width - width) / 2,
+        area.y + (area.height - height) / 2,
+        width,
+        height,
+    );
+    let block = Block::bordered()
+        .title(app.i18n.text("branch-title"))
+        .title_alignment(Alignment::Center)
+        .style(base)
+        .border_style(Style::default().fg(app.theme.colors().accent))
+        .border_type(if app.chrome.ascii {
+            BorderType::Plain
+        } else {
+            BorderType::Rounded
+        });
+    let inner = block.inner(popup).inner(Margin::new(1, 0));
+    app.modal_area = Some(popup);
+    crate::view::clear_overlay(frame, popup);
+    frame.render_widget(block, popup);
+    let count = title.len() as u16;
+    frame.render_widget(
+        Paragraph::new(title),
+        Rect::new(inner.x, inner.y + 1, inner.width, count),
+    );
+    frame.render_widget(
+        Paragraph::new(note).style(Style::default().fg(if error {
+            app.theme.colors().warning
+        } else {
+            app.theme.colors().muted
+        })),
+        Rect::new(
+            inner.x,
+            inner.y + count + 2,
+            inner.width,
+            height - count - 5,
+        ),
+    );
+    let cancel = app.i18n.text(if phase == Phase::Confirm {
+        "session-cancel"
+    } else {
+        "session-remove-close"
+    });
+    let action = primary(phase);
+    let label = action
+        .as_ref()
+        .map(|command| app.i18n.text(command.label()));
+    let primary_width = label
+        .as_ref()
+        .map_or(0, |label| label.width() as u16 + 2)
+        .min(inner.width / 2);
+    let cancel_width = (cancel.width() as u16 + 2).min(inner.width / 2);
+    button(
+        frame,
+        app,
+        Rect::new(
+            inner.right() - primary_width - cancel_width - u16::from(primary_width > 0),
+            inner.bottom() - 1,
+            cancel_width,
+            1,
+        ),
+        &cancel,
+        Action::Branch(Command::Close),
+        focus == 0,
+    );
+    if let Some(action) = action {
+        button(
+            frame,
+            app,
+            Rect::new(
+                inner.right() - primary_width,
+                inner.bottom() - 1,
+                primary_width,
+                1,
+            ),
+            label.as_ref().unwrap(),
+            Action::Branch(action),
+            focus == 1,
+        );
+    }
+}
diff --git a/crates/tui/src/pages/chat/render/navigation.rs b/crates/tui/src/pages/chat/render/navigation.rs
index cbdec7ec0c..1c34081aa1 100644
--- a/crates/tui/src/pages/chat/render/navigation.rs
+++ b/crates/tui/src/pages/chat/render/navigation.rs
@@ -20,6 +20,15 @@
 use super::*;
 
 impl Transcript {
+    /// A branch boundary must come from an explicitly selected durable message,
+    /// not the implicit first-visible fallback or a live-only stream block.
+    pub fn selected_durable_message(&self) -> Option<(&str, &str)> {
+        let key = self.selected.as_ref()?;
+        let block = self.blocks.get(key)?;
+        (self.order.contains(key) && matches!(block.revision, Revision::Durable(_)))
+            .then_some((key.turn.as_str(), block.text.as_str()))
+    }
+
     pub fn selection(&self) -> Option<MessageKey> {
         self.selected
             .as_ref()
diff --git a/crates/tui/src/state.rs b/crates/tui/src/state.rs
index 69a1129dec..4344827b68 100644
--- a/crates/tui/src/state.rs
+++ b/crates/tui/src/state.rs
@@ -22,7 +22,7 @@ mod store;
 
 use crate::{
     app::App,
-    pages::{manage::oauth, sending::Submission},
+    pages::{branch, manage::oauth, sending::Submission},
 };
 use maka_client::Error;
 use snapshot::Snapshot;
@@ -38,6 +38,7 @@ pub struct State {
     deadline: Option<Instant>,
     requests: Vec<Submission>,
     oauth: Option<oauth::Request>,
+    branch: Option<branch::Request>,
     generation: u64,
     job: Option<Writing>,
 }
@@ -46,6 +47,7 @@ struct Writing {
     task: tokio::task::JoinHandle<Result<(), String>>,
     requests: Vec<Submission>,
     oauth: Option<oauth::Request>,
+    branch: Option<branch::Request>,
     generation: u64,
 }
 
@@ -53,6 +55,7 @@ pub struct Written {
     pub result: Result<(), String>,
     pub requests: Vec<Submission>,
     pub oauth: Option<oauth::Request>,
+    pub branch: Option<branch::Request>,
 }
 impl State {
     pub async fn open(
@@ -83,6 +86,7 @@ impl State {
                     deadline: None,
                     requests: Vec::new(),
                     oauth: None,
+                    branch: None,
                     generation: 0,
                     job: None,
                 },
@@ -114,6 +118,7 @@ impl State {
     }
     pub fn cancel_requests(&mut self) -> Vec<Submission> {
         self.oauth = None;
+        self.branch = None;
         let mut requests = std::mem::take(&mut self.requests);
         if let Some(job) = &self.job
             && job.generation == self.generation
@@ -127,6 +132,10 @@ impl State {
         self.oauth = Some(request);
         self.force();
     }
+    pub fn submit_branch(&mut self, request: branch::Request) {
+        self.branch = Some(request);
+        self.force();
+    }
     pub fn idle(&self) -> bool {
         self.job.is_none() && self.deadline.is_none()
     }
@@ -148,6 +157,7 @@ impl State {
             }),
             requests,
             oauth: self.oauth.take(),
+            branch: self.branch.take(),
             generation,
         });
     }
@@ -172,6 +182,11 @@ impl State {
             } else {
                 None
             },
+            branch: if job.generation == self.generation {
+                job.branch
+            } else {
+                None
+            },
         }
     }
     /// Terminal EOF/signals have no interactive loop to keep responsive.
@@ -206,6 +221,7 @@ mod tests {
             deadline: None,
             requests: vec![],
             oauth: None,
+            branch: None,
             generation: 0,
             job: None,
         };
@@ -244,6 +260,97 @@ mod tests {
         .unwrap()
     }
 
+    #[tokio::test]
+    async fn branch_dispatch_waits_for_its_own_snapshot_and_reopen_only_recovers_identity() {
+        use crate::pages::branch::Command;
+        let (directory, mut state, mut app) = fixture();
+        app.chat.select(&app.navigation.current());
+        app.sessions.detail = crate::pages::sessions::Detail::Ready(Box::new(
+            crate::pages::sessions::tests::item("a"),
+        ));
+        app.chat.view.sync(&std::collections::BTreeMap::from([(1,serde_json::json!({"type":"user","id":"message","turnId":"turn","text":"Branch point"}))]),&[],0,&app.i18n,false);
+        let mut terminal =
+            ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
+        terminal
+            .draw(|f| {
+                app.chat.view.draw(f, f.area(), false).unwrap();
+            })
+            .unwrap();
+        app.chat.view.enter();
+        let open = app.branch_commands()[0].0.clone();
+        app.apply(open);
+        terminal
+            .draw(|f| {
+                crate::pages::branch::draw(f, &mut app, f.area(), ratatui::style::Style::default())
+            })
+            .unwrap();
+        app.apply(Action::Branch(Command::Confirm));
+        let request = app.branch_request().unwrap();
+        let (release, blocked) = gate(&state).await;
+        state.submit_branch(request.clone());
+        state.start(&app);
+        assert!(
+            tokio::time::timeout(Duration::from_millis(20), state.completed())
+                .await
+                .is_err()
+        );
+        app.apply(Action::Branch(Command::Close));
+        app.apply(Action::Visit(Route::Settings));
+        state.changed();
+        release.send(()).unwrap();
+        blocked.await.unwrap();
+        let written = written(&mut state).await;
+        assert_eq!(written.branch, Some(request.clone()));
+        assert!(written.result.is_ok());
+        let bytes = read(&directory);
+        assert_eq!(
+            bytes["branch"],
+            serde_json::to_value(app.branch.checkpoint()).unwrap()
+        );
+        assert!(app.branch_after_checkpoint(&request, &written.result));
+        assert!(!app.branch_after_checkpoint(&request, &written.result));
+        let mut reopened = App::new(
+            "/unused".into(),
+            I18n::new(LocalePreference::Auto, Locale::En),
+        );
+        serde_json::from_value::<Snapshot>(bytes.clone())
+            .unwrap()
+            .restore(&mut reopened, false)
+            .unwrap();
+        assert!(!reopened.branch.visible);
+        assert!(reopened.branch_request().is_none());
+        assert_eq!(
+            serde_json::to_value(reopened.branch.checkpoint()).unwrap(),
+            bytes["branch"]
+        );
+        let mut obsolete = bytes.clone();
+        obsolete["version"] = serde_json::json!(5);
+        assert!(
+            serde_json::from_value::<Snapshot>(obsolete)
+                .unwrap()
+                .validate(ROOT)
+                .is_err(),
+            "v5 cannot silently discard branch recovery"
+        );
+        let mut foreign = bytes;
+        foreign["branch"]["root"] = serde_json::json!("foreign");
+        assert!(
+            serde_json::from_value::<Snapshot>(foreign)
+                .unwrap()
+                .validate(ROOT)
+                .is_err()
+        );
+        state.submit_branch(request);
+        state.force();
+        state.start(&app);
+        state.cancel_requests();
+        app.branch.disconnect();
+        assert!(
+            self::written(&mut state).await.branch.is_none(),
+            "cancelled IO completion cannot release a write"
+        );
+    }
+
     #[tokio::test]
     async fn slow_writer_keeps_input_live_coalesces_edits_and_never_overwrites_newer_state() {
         let (directory, mut state, mut app) = fixture();
diff --git a/crates/tui/src/state/snapshot.rs b/crates/tui/src/state/snapshot.rs
index 0382f49405..c9476b1627 100644
--- a/crates/tui/src/state/snapshot.rs
+++ b/crates/tui/src/state/snapshot.rs
@@ -52,6 +52,8 @@ pub struct Snapshot {
     pages: Vec<(Route, crate::navigation::state::Saved)>,
     #[serde(default)]
     oauth: Option<crate::pages::manage::oauth::saved::Checkpoint>,
+    #[serde(default)]
+    branch: Option<crate::pages::branch::Checkpoint>,
 }
 
 impl Snapshot {
@@ -64,7 +66,7 @@ impl Snapshot {
             .collect();
         unresolved.sort_by(|left, right| left.session.cmp(&right.session));
         Self {
-            version: 5,
+            version: 6,
             root: root.into(),
             route: app.navigation.current(),
             tabs: app.tabs.entries.iter().map(|tab| tab.id.clone()).collect(),
@@ -85,6 +87,7 @@ impl Snapshot {
             navigation: Some(app.navigation.clone()),
             pages: app.saved_pages(),
             oauth: app.management.oauth.checkpoint(),
+            branch: app.branch.checkpoint(),
         }
     }
 
@@ -92,7 +95,7 @@ impl Snapshot {
         let id = |id: &str| {
             !id.is_empty() && id.encode_utf16().count() <= 256 && !id.chars().any(char::is_control)
         };
-        if !matches!(self.version, 1..=5)
+        if !matches!(self.version, 1..=6)
             || self.root != root
             || self.tabs.len() > LIMIT
             || self.drafts.len() > LIMIT
@@ -103,7 +106,8 @@ impl Snapshot {
             || self.pages.len() > LIMIT + Route::PAGE_COUNT
             || (self.version < 4 && self.oauth.is_some())
             || (self.version < 5 && self.theme.is_some())
-            || (self.version == 5 && self.theme.is_none())
+            || (self.version >= 5 && self.theme.is_none())
+            || (self.version < 6 && self.branch.is_some())
         {
             return Err("Unsupported or mismatched TUI checkpoint".into());
         }
@@ -166,6 +170,9 @@ impl Snapshot {
         if let Some(oauth) = &self.oauth {
             oauth.validate()?;
         }
+        if let Some(branch) = &self.branch {
+            branch.validate(root)?;
+        }
         Ok(())
     }
 
@@ -177,6 +184,9 @@ impl Snapshot {
         if let Some(oauth) = self.oauth {
             app.management.oauth.restore(&self.root, oauth);
         }
+        if let Some(branch) = self.branch {
+            app.branch.restore(branch);
+        }
         for (id, saved) in self.drafts {
             app.drafts.insert(id, Editor::restore(saved)?);
         }
@@ -290,7 +300,7 @@ mod tests {
         assert!(restored.retry_submission().is_none());
         assert!(restored.reconciliation().is_some());
         let mut invalid: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
-        invalid["version"] = serde_json::json!(6);
+        invalid["version"] = serde_json::json!(7);
         assert!(
             serde_json::from_value::<Snapshot>(invalid)
                 .unwrap()
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index e5d56c7d60..4fdd7bc5d6 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -67,6 +67,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
         && app.palette.is_none()
         && !app.interactions.visible
         && app.management.dialog.is_none()
+        && !app.branch.visible
         && app.onboarding.dialog.is_none()
         && app.queue.edit.is_none();
     app.chrome
@@ -288,6 +289,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
     let hint = if app.closing {
         app.i18n.text("state-closing")
     } else if app.theme.editor.is_some()
+        || app.branch.visible
         || app.onboarding.dialog.is_some()
         || app.management.dialog.is_some()
         || app.interactions.visible
@@ -379,6 +381,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
     }
     if app.theme.editor.is_some() {
         crate::theme::editor::draw(frame, app, area);
+    } else if app.branch.visible {
+        crate::pages::branch::draw(frame, app, area, base);
     } else if app.onboarding.dialog.is_some() {
         crate::pages::onboarding::draw(frame, app, area, base);
     } else if app.management.dialog.is_some() {
@@ -534,6 +538,7 @@ fn icon(app: &App, action: &Action) -> &'static str {
             crate::pages::manage::Kind::Locations,
         )) => ("ⓘ", "i"),
         Action::Manage(_) => ("⋯", "."),
+        Action::Branch(_) => ("↳", "+"),
         Action::Onboard(_) => ("⊕", "+"),
         Action::Forward | Action::NextSessions => ("›", ">"),
         Action::Refresh | Action::RefreshSession | Action::RefreshSessions => ("↻", "R"),
@@ -640,6 +645,7 @@ fn action_label(app: &App, action: &Action) -> String {
         Action::RetrySubmission => "chat-retry-original",
         Action::CreateSession => "session-create",
         Action::Manage(command) => command.label(),
+        Action::Branch(command) => command.label(),
         Action::Onboard(command) => command.label(),
         Action::Project(command) => command.label(),
         Action::Connection(command) => command.label(),


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org