Skip to content

Commit 555a83d

Browse files
authored
Merge pull request #6680 from loopx-project/codex/desktop-workspace-return-20261017
2 parents f0c7302 + 6df7d25 commit 555a83d

4 files changed

Lines changed: 400 additions & 5 deletions

File tree

‎apps/desktop/loopx-control-plane/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ cargo run --example webcontent_recovery -- --baseline
153153
```
154154

155155
It reads no Goals or accounts and never targets an installed LoopX process.
156+
156157
The fault injector uses a private WebKit process-id getter only in this example;
157158
production recovery uses the public Tauri termination hook.
158159
This controlled regression does not establish long-duration reliability or
@@ -188,6 +189,33 @@ rechecked at most every 30 seconds; an explicit recovery action wakes the
188189
supervisor immediately. It returns to the loading shape as soon as the
189190
snapshot leaves the terminal phase (for example while an explicit repair runs).
190191

192+
### Returning After A Normal Close
193+
194+
The packaged App remembers the selected local Goal, view, Agent and Todo when
195+
its main window closes or the App quits. Reopening uses the newly allocated
196+
loopback origin and returns to that selection without another navigation step.
197+
Selecting **LoopX 管家** before closing instead returns to Manager next time.
198+
This restores a reading position; it never submits a message or starts work.
199+
200+
The optional `workspace-location.json` in the App's private data directory
201+
contains only those selection identifiers. It stores no origin, raw URL, prompt,
202+
credentials or report/session query. Missing, corrupt or obsolete preferences
203+
fall back to the ordinary workspace; unknown views fall back to the frontend's
204+
default. Boot and foreign pages cannot overwrite a saved local selection.
205+
Remote status sources and host-granted workspace contexts are not restored:
206+
they reopen at local Manager, so their Goal identifiers cannot be replayed
207+
against the local source. Vite development navigation is unchanged. Explicit
208+
runtime repair retains its existing root handoff; WebContent crash recovery
209+
continues to retain the current in-process route.
210+
211+
For native qualification, use a separately identified packaged App with the
212+
real backend: select a local Goal and Agent page, quit normally, reopen, and
213+
check the same selection on the new port. Also check window-close, an explicit
214+
return to Manager and a corrupt preference in that disposable App profile.
215+
Retain Session IDs and active-Turn readbacks before and after; navigation is
216+
not evidence that work was resumed or accepted. Cross-platform and sustained
217+
use require their own qualification.
218+
191219
## Known Issues
192220

193221
### Fresh Mac without a usable Python stays on the boot screen

‎apps/desktop/loopx-control-plane/src-tauri/src/lib.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod service_endpoints;
77
mod services;
88
mod update_backup;
99
mod workspace_handoff;
10+
mod workspace_location;
1011

1112
use services::ServiceSet;
1213
use std::sync::{
@@ -15,7 +16,7 @@ use std::sync::{
1516
};
1617
use tauri::{
1718
ipc::CapabilityBuilder, webview::PageLoadEvent, AppHandle, Manager, RunEvent, Url, WebviewUrl,
18-
WebviewWindowBuilder,
19+
WebviewWindowBuilder, WindowEvent,
1920
};
2021
use tauri_plugin_notification::NotificationExt;
2122
use workspace_handoff::{NavigationRetry, WorkspaceHandoff};
@@ -135,6 +136,11 @@ pub fn run() {
135136
.build(),
136137
)
137138
.plugin(tauri_plugin_notification::init())
139+
.on_window_event(|window, event| {
140+
if window.label() == "main" && matches!(event, WindowEvent::CloseRequested { .. }) {
141+
workspace_location::remember_current(window.app_handle());
142+
}
143+
})
138144
.setup(move |app| {
139145
let origin: Url = web_origin.parse()?;
140146
app.add_capability(
@@ -155,7 +161,7 @@ pub fn run() {
155161
};
156162
let handoff = Arc::new(Mutex::new(WorkspaceHandoff::new(
157163
boot_url(app.handle()),
158-
origin.clone(),
164+
workspace_location::launch_destination(app.handle(), &origin),
159165
retry,
160166
)));
161167
app.manage(Arc::clone(&handoff));
@@ -220,7 +226,10 @@ pub fn run() {
220226
}
221227
let healed = started.healed;
222228
*services_for_setup.lock().expect("service state lock") = Some(started);
223-
handoff.lock().expect("workspace handoff lock").reconnect();
229+
handoff
230+
.lock()
231+
.expect("workspace handoff lock")
232+
.services_ready();
224233
WorkspaceHandoff::reconcile(&handoff, &handle);
225234
if healed {
226235
let _ = handle
@@ -279,8 +288,9 @@ pub fn run() {
279288
let app = builder
280289
.build(tauri::generate_context!())
281290
.expect("failed to build LoopX desktop shell");
282-
app.run(move |_app, event| {
291+
app.run(move |app, event| {
283292
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
293+
workspace_location::remember_current(app);
284294
shutting_down.store(true, Ordering::Release);
285295
if let Ok(mut guard) = services.lock() {
286296
if let Some(current) = guard.as_mut() {

‎apps/desktop/loopx-control-plane/src-tauri/src/workspace_handoff.rs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(crate) enum NavigationRetry {
1616
pub(crate) struct WorkspaceHandoff {
1717
boot_url: Url,
1818
workspace_url: Url,
19+
launch_workspace_url: Option<Url>,
1920
last_workspace_url: Option<Url>,
2021
retry: NavigationRetry,
2122
pending: bool,
@@ -25,9 +26,13 @@ pub(crate) struct WorkspaceHandoff {
2526
impl WorkspaceHandoff {
2627
const RETRY_INTERVAL: Duration = Duration::from_secs(2);
2728
pub(crate) fn new(boot_url: Url, workspace_url: Url, retry: NavigationRetry) -> Self {
29+
let mut root = workspace_url.clone();
30+
root.set_query(None);
31+
root.set_fragment(None);
2832
Self {
2933
boot_url,
30-
workspace_url,
34+
workspace_url: root,
35+
launch_workspace_url: Some(workspace_url),
3136
last_workspace_url: None,
3237
retry,
3338
pending: false,
@@ -36,10 +41,21 @@ impl WorkspaceHandoff {
3641
}
3742
pub(crate) fn reconnect(&mut self) {
3843
// Explicit runtime repair retains its established workspace-root handoff.
44+
self.launch_workspace_url = None;
3945
self.last_workspace_url = None;
4046
self.pending = true;
4147
self.last_attempt = None;
4248
}
49+
pub(crate) fn services_ready(&mut self) {
50+
// A normal launch consumes the saved selection once. Explicit runtime
51+
// repair still returns to the root; it must not replay an old selection.
52+
let launch = self.launch_workspace_url.take();
53+
self.reconnect();
54+
self.last_workspace_url = launch;
55+
}
56+
pub(crate) fn origin(&self) -> &Url {
57+
&self.workspace_url
58+
}
4359
pub(crate) fn page_reached(&mut self, current: &Url) {
4460
if current.origin() == self.workspace_url.origin() {
4561
self.last_workspace_url = Some(current.clone());
@@ -128,6 +144,26 @@ pub(crate) fn content_lost(webview: &tauri::Webview) {
128144
mod tests {
129145
use super::*;
130146
#[test]
147+
fn normal_launch_resumes_once_but_explicit_repair_keeps_its_root_handoff() {
148+
let launch: Url = "http://127.0.0.1:49123/chat/?goalId=example&view=tasks"
149+
.parse()
150+
.unwrap();
151+
let root: Url = "http://127.0.0.1:49123/chat/".parse().unwrap();
152+
let mut h = WorkspaceHandoff::new(
153+
"tauri://localhost/".parse().unwrap(),
154+
launch.clone(),
155+
NavigationRetry::UntilNativeCommit,
156+
);
157+
assert!(!h.needs_navigation(Instant::now()));
158+
h.services_ready();
159+
assert_eq!(h.destination(), &launch);
160+
h.page_reached(&launch);
161+
h.reconnect();
162+
assert_eq!(h.destination(), &root);
163+
h.services_ready();
164+
assert_eq!(h.destination(), &root);
165+
}
166+
#[test]
131167
fn failed_workspace_load_retries_until_a_native_commit() {
132168
let boot: Url = "tauri://localhost/".parse().unwrap();
133169
let target: Url = "http://127.0.0.1:8767/chat/".parse().unwrap();

0 commit comments

Comments
 (0)