feat(ui): local windows restore by asking the daemon's tree

The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.

The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.

Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
This commit is contained in:
l0ng-ai
2026-07-29 22:30:49 +08:00
parent 1e605afb55
commit 779a32805d
5 changed files with 399 additions and 8 deletions
+12
View File
@@ -101,6 +101,14 @@ pub struct SessionTab {
/// model does not permit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sidebar_group: Option<std::path::PathBuf>,
/// The tab's identity in the daemon's machine tree, when this session was
/// derived *from* that tree — so a window rebuilt from it addresses the
/// daemon's tabs rather than minting new ids and churning them. **Never
/// persisted**: the tree is the authority on its own ids, and a stale one
/// written to disk would collide with a tab the daemon has since reused it
/// for. `None` (every other source) mints a fresh id.
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
}
/// One workspace's contents: the open tabs and which one was active.
@@ -978,6 +986,7 @@ mod tests {
tabs: vec![
SessionTab {
name: Some("build".into()),
tree_id: None,
sidebar_group: None,
pane: SessionPane::Leaf {
cwd: Some(PathBuf::from("/work")),
@@ -990,6 +999,7 @@ mod tests {
},
SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: SessionPane::Split {
axis: SessionAxis::Vertical,
@@ -1128,6 +1138,7 @@ mod tests {
active: 0,
tabs: vec![SessionTab {
name: Some("main".into()),
tree_id: None,
sidebar_group: None,
pane: SessionPane::Leaf {
cwd: Some(PathBuf::from("/home/u")),
@@ -1165,6 +1176,7 @@ mod tests {
fn tab(pane: SessionPane, group: Option<&str>) -> SessionTab {
SessionTab {
name: None,
tree_id: None,
sidebar_group: group.map(PathBuf::from),
pane,
}
+1
View File
@@ -541,6 +541,7 @@ mod tests {
Session {
tabs: vec![SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf("/Users/me/work"),
}],
+36 -6
View File
@@ -907,6 +907,17 @@ impl Tty7App {
let restore = cx.global::<Config>().restore_session;
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
let (workspace, saved) = WorkspaceStore::claim(cx, id);
// A local workspace's layout now lives in the daemon's machine tree,
// so a restore *asks* rather than reads: the window opens empty and
// `hydrate_window_from_tree` rebuilds it the moment the pull answers —
// against the local daemon that is milliseconds, so the empty state is
// effectively one frame. A remote workspace already worked this way
// (its machine has to answer first); it keeps its existing connect-
// driven rebuild for now.
let is_remote = WorkspaceStore::all(cx)
.get(workspace)
.is_some_and(|w| w.is_remote());
let hydrate = known && restore && !is_remote;
// A workspace that was already on file restores its tab/split layout and
// each pane's cwd, unless the user turned restore off — then it starts
// fresh. A *brand-new* one has no tabs to restore, so what it comes up
@@ -916,16 +927,25 @@ impl Tty7App {
// through instead lands on the home page, for the launch that exists to
// show the workspace picker.
let session = match (known, fresh) {
(true, _) if hydrate => Some(Session::default()),
(true, _) => restore.then_some(saved),
(false, crate::ui::windows::FreshStart::Shell) => None,
(false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()),
};
let app = Self::with_session(Some(workspace), session, window, cx);
// Persist right away. The leaves just spawned (or reattached) now carry
// daemon pane ids, and nothing else writes them until the next
// *structural* change — so a crash before the user happens to open a
// tab would strand every one of those panes in the daemon.
app.save_session(cx);
if hydrate {
// No immediate save: the window is deliberately empty, and
// recording that would both clobber the cached layout the
// hydration may fall back to and race the pull with a diff that
// reads as "close everything".
crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace);
} else {
// Persist right away. The leaves just spawned (or reattached) now
// carry daemon pane ids, and nothing else writes them until the
// next *structural* change — so a crash before the user happens to
// open a tab would strand every one of those panes in the daemon.
app.save_session(cx);
}
// If startup reused a daemon that speaks a different wire protocol
// (an app upgrade while the old service kept running), the sessions
// just restored above are living on that old dialect. Surface the
@@ -7152,6 +7172,10 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
sidebar_group: tab.sidebar_group.borrow().clone(),
// Deliberately not the live tab's tree id. This snapshot outlives the
// daemon tab it mirrors (the closed-tab stack, the session file), and
// rebuilding from it is a *new* tab everywhere it matters.
tree_id: None,
}
}
@@ -7355,7 +7379,13 @@ fn tabs_from_session(
// renders grouped on the first frame; the first landed probe
// corrects it if the tab's repo changed while we were gone.
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
// A session lowered from the machine's tree names its daemon tabs;
// keeping those ids is what stops the first save from closing and
// recreating every one of them.
tree_id: std::cell::Cell::new(
st.tree_id
.unwrap_or_else(tty7_core::core::machine::TabId::new),
),
});
}
// Clamp the saved active index into the rebuilt range (which can be empty
+7
View File
@@ -318,6 +318,7 @@ mod tests {
fn closed_tab_label_prefers_the_user_set_name() {
let tab = SessionTab {
name: Some("build".into()),
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -328,6 +329,7 @@ mod tests {
fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -336,6 +338,7 @@ mod tests {
// Whitespace-only names don't count as names.
let tab = SessionTab {
name: Some(" ".into()),
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -346,6 +349,7 @@ mod tests {
fn closed_tab_label_searches_splits_for_the_first_cwd() {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: SessionPane::Split {
axis: crate::core::session::SessionAxis::Horizontal,
@@ -362,12 +366,14 @@ mod tests {
// No name, no cwd — and "/" has no file name either.
let unnamed = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(None),
};
assert_eq!(closed_tab_label(&unnamed), None);
let root = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/")),
};
@@ -378,6 +384,7 @@ mod tests {
fn closed_tab_label_clamps_runaway_names() {
let tab = SessionTab {
name: Some("a".repeat(40)),
tree_id: None,
sidebar_group: None,
pane: leaf(None),
};
+343 -2
View File
@@ -52,12 +52,13 @@ use std::sync::Arc;
use gpui::{App, Global};
use tty7_core::core::machine::{
AgentFacts, Axis as TreeAxis, PaneNode, PaneSeed, Side, Tab as TreeTab, TabId,
AgentFacts, Axis as TreeAxis, Machine, PaneNode, PaneRecord, PaneSeed, Side, Tab as TreeTab,
TabId,
};
use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk};
use tty7_core::host::HostId;
use crate::core::session::{WorkspaceId, WorkspaceStore};
use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore};
use crate::ui::app::Tty7App;
use crate::ui::pane::{Pane, PaneSlot};
@@ -1025,6 +1026,250 @@ fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) {
start_prime(cx, client_ws);
}
// ---------------------------------------------------------------------------
// The read path: a window rebuilt from the machine's tree
// ---------------------------------------------------------------------------
/// One workspace of a pulled [`Machine`], lowered into the `Session` shape the
/// window builder already consumes — the tree's leaves joined with their pane
/// registry records.
///
/// The lowering *is* the revival decision, made per leaf by the daemon's own
/// liveness fact: a `live` pane keeps its id (the builder re-attaches), a dead
/// one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent
/// resume — exactly the leaf shape that makes the builder spawn a successor.
/// The save that follows then diffs the successor's id against the mirror and
/// sends the `PaneReplace` that spends the old record.
pub(crate) fn session_from_tree(
ws: &tty7_core::core::machine::Workspace,
panes: &[PaneRecord],
) -> Session {
let tabs: Vec<SessionTab> = ws
.tabs
.iter()
.map(|tab| SessionTab {
name: tab.name.clone(),
tree_id: Some(tab.id),
sidebar_group: tab.sidebar_group.clone().map(std::path::PathBuf::from),
pane: session_pane_from_node(&tab.root, panes),
})
.collect();
let active = ws
.active_tab
.and_then(|id| ws.tabs.iter().position(|t| t.id == id))
.unwrap_or(0);
Session { active, tabs }
}
fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane {
match node {
PaneNode::Leaf { pane } => {
let record = panes.iter().find(|p| p.id == *pane);
let live = record.is_some_and(|r| r.live);
let (cwd, ssh_spec, agent) = match record {
Some(r) => (
r.cwd.clone().map(std::path::PathBuf::from),
r.ssh_spec.clone(),
r.agent.clone(),
),
None => (None, None, None),
};
SessionPane::Leaf {
cwd,
// The daemon's liveness fact is the whole of the revival
// decision: an id is only worth keeping if the daemon holds a
// PTY for it *right now*.
pane_id: live.then_some(*pane),
ssh_spec,
agent: agent.as_ref().map(|a| a.agent),
agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()),
agent_launch_argv: agent.as_ref().and_then(|a| a.launch_argv.clone()),
}
}
PaneNode::Split { axis, ratio, a, b } => SessionPane::Split {
axis: match axis {
TreeAxis::Horizontal => crate::core::session::SessionAxis::Horizontal,
TreeAxis::Vertical => crate::core::session::SessionAxis::Vertical,
},
ratio: *ratio,
a: Box::new(session_pane_from_node(a, panes)),
b: Box::new(session_pane_from_node(b, panes)),
},
}
}
/// How long an opening window waits for its machine's link before giving up on
/// the pull and staying empty. Generous against a slow daemon start; the local
/// link is normally up within one supervision tick.
const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15);
const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200);
/// Fill an (empty) window from the machine's tree: pull `MachineGet`, prime
/// the mirror with the workspace's tabs, and rebuild the window from them —
/// re-attaching live panes, spawning successors for dead ones.
///
/// The window opens first and this runs behind it, because the pull is a round
/// trip that may have to wait out the link coming up; against the local daemon
/// it lands within milliseconds, so in practice the empty state is one frame.
///
/// A workspace the machine has never heard of is created (empty) — and, as a
/// one-time courtesy to trees that predate the migration, an *empty* pull
/// falls back to the client's cached `session` copy: adopting it re-populates
/// the tree through the ordinary diff, which is the whole import.
pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) {
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
let name = WorkspaceStore::all(cx)
.get(client_ws)
.and_then(|w| w.name.clone());
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
}
cx.spawn(async move |cx| {
// At launch the link is usually still dialing; wait it out briefly
// rather than failing an open the supervisor will fix in a second.
let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE;
let client = loop {
let client = cx.update(|cx| control_for(cx, host));
match client {
Some(client) => break Some(client),
None if std::time::Instant::now() > deadline => break None,
None => cx.background_executor().timer(HYDRATE_LINK_POLL).await,
}
};
let Some(client) = client else {
log::warn!("workspace {client_ws}: no link to its machine; opening empty");
cx.update(|cx| {
if let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) {
if let SyncPhase::Unprimed { priming, .. } = &mut state.sync {
*priming = false;
}
}
});
return;
};
let outcome = cx
.background_executor()
.spawn(async move { pull_workspace(&client, machine_ws, name) })
.await;
cx.update(|cx| finish_hydration(cx, client_ws, outcome));
})
.detach();
}
/// The blocking half: the whole machine (the tree plus the pane registry —
/// `WorkspaceTree` alone answers structure without the pane facts revival
/// needs), reduced to this workspace's mirror and session. A machine that has
/// no such workspace gets it created, empty.
fn pull_workspace(
client: &ControlClient,
machine_ws: WorkspaceId,
name: Option<String>,
) -> io::Result<(WsMirror, Session)> {
let machine: Machine = match client.call(ControlRequest::MachineGet)? {
ReplyOk::MachineTree(m) => *m,
other => return Err(io::Error::other(format!("MachineGet answered {other:?}"))),
};
match machine.workspaces.iter().find(|w| w.id == machine_ws) {
Some(ws) => Ok((
WsMirror {
tabs: ws.tabs.clone(),
active: ws.active_tab,
},
session_from_tree(ws, &machine.panes),
)),
None => {
client.call(ControlRequest::WorkspaceCreate {
name,
workspace: Some(machine_ws),
})?;
Ok((WsMirror::default(), Session::default()))
}
}
}
fn finish_hydration(
cx: &mut App,
client_ws: WorkspaceId,
outcome: io::Result<(WsMirror, Session)>,
) {
let (mirror, session) = match outcome {
Ok(pulled) => pulled,
Err(e) => {
log::warn!("could not hydrate workspace {client_ws} from its machine: {e}");
if let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws)
&& let SyncPhase::Unprimed { priming, .. } = &mut state.sync
{
*priming = false;
}
return;
}
};
let was_dirty = {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. });
state.sync = SyncPhase::Primed(mirror);
dirty
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade())
else {
return;
};
if !app.read(cx).tabs.is_empty() {
// The user got there first (opened a tab into the empty window); their
// window wins, and the sync below reconciles the tree to it.
if was_dirty {
app.update(cx, |app, cx| sync_window(app, cx));
}
return;
}
// The one-time import: a tree with nothing for this workspace, a client
// with a cached layout — adopt the cache, and the adopt's own save
// populates the tree through the ordinary diff.
let session = if session.tabs.is_empty() {
WorkspaceStore::all(cx)
.get(client_ws)
.map(|w| w.session.clone())
.unwrap_or(session)
} else {
session
};
if session.tabs.is_empty() {
if was_dirty
&& let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade())
{
app.update(cx, |app, cx| sync_window(app, cx));
}
return;
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return;
};
log::info!(
"rebuilding {} tab(s) of workspace {client_ws} from its machine's tree",
session.tabs.len()
);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
app.adopt_workspace(client_ws, session, window, cx)
});
});
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1424,6 +1669,102 @@ mod tests {
assert_eq!(diff(ws, &mut mirror, &want, Some(id)), Vec::new());
}
#[test]
fn a_live_leaf_keeps_its_pane_id_and_a_dead_one_lowers_to_a_revival_leaf() {
use tty7_core::core::cli_agent::CLIAgent;
let tab_id = TabId::new();
let ws = tty7_core::core::machine::Workspace {
tabs: vec![TreeTab {
id: tab_id,
name: Some("build".into()),
sidebar_group: Some("/repo".into()),
root: PaneNode::Split {
axis: TreeAxis::Vertical,
ratio: 0.3,
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
}],
active_tab: Some(tab_id),
..Default::default()
};
let panes = vec![
PaneRecord {
id: 1,
cwd: Some("/work".into()),
live: true,
..PaneRecord::new(1)
},
PaneRecord {
id: 2,
cwd: Some("/work/api".into()),
live: false,
agent: Some(AgentFacts {
agent: CLIAgent::Claude,
session_id: Some("sid".into()),
launch_argv: Some(vec!["claude".into()]),
status: None,
}),
..PaneRecord::new(2)
},
];
let session = session_from_tree(&ws, &panes);
assert_eq!(session.tabs.len(), 1);
assert_eq!(session.active, 0);
let tab = &session.tabs[0];
assert_eq!(
tab.tree_id,
Some(tab_id),
"the daemon tab's identity rides along"
);
assert_eq!(tab.name.as_deref(), Some("build"));
let SessionPane::Split { ratio, a, b, .. } = &tab.pane else {
panic!("the split survives the lowering");
};
assert!((ratio - 0.3).abs() < 1e-6);
match &**a {
SessionPane::Leaf { pane_id, cwd, .. } => {
assert_eq!(*pane_id, Some(1), "a live pane re-attaches by its id");
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work")));
}
_ => panic!("leaf"),
}
match &**b {
SessionPane::Leaf {
pane_id,
cwd,
agent,
agent_session_id,
..
} => {
assert_eq!(
*pane_id, None,
"a dead pane's leaf takes the fresh-spawn path — that is the revival"
);
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api")));
assert_eq!(*agent, Some(CLIAgent::Claude));
assert_eq!(agent_session_id.as_deref(), Some("sid"));
}
_ => panic!("leaf"),
}
}
#[test]
fn a_dangling_active_tab_in_the_pulled_tree_falls_back_to_the_first() {
let ws = tty7_core::core::machine::Workspace {
tabs: vec![TreeTab {
id: TabId::new(),
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
}],
active_tab: Some(TabId::new()),
..Default::default()
};
assert_eq!(session_from_tree(&ws, &[]).active, 0);
}
#[test]
fn a_pane_id_reused_in_another_tab_is_never_read_as_a_replace() {
let ws = WorkspaceId::new();