diff --git a/crates/oa-desktop/src/app/checkpoints.rs b/crates/oa-desktop/src/app/checkpoints.rs index 9f5525cd..4aa9a5ca 100644 --- a/crates/oa-desktop/src/app/checkpoints.rs +++ b/crates/oa-desktop/src/app/checkpoints.rs @@ -202,7 +202,16 @@ impl App { } else { "Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into() }; - self.request_lint() + let lint = self.request_lint(); + if self + .architecture + .as_ref() + .is_some_and(architecture_authoring::State::inline) + { + Task::batch([lint, iced::widget::operation::focus("architecture-name")]) + } else { + lint + } } } @@ -331,6 +340,177 @@ mod tests { .unwrap() } + async fn run_recovery_tasks(app: &mut App, task: Task) -> (usize, bool, bool) { + use iced::advanced::{Layout, layout, widget}; + use iced::futures::StreamExt; + // Mount real inputs, with focus initially in another control. This + // verifies the returned operation targets the naming field by its ID. + let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![ + iced::widget::text_input("Engineering name", "Retained name") + .id("architecture-name") + .on_input(|_| ()), + iced::widget::text_input("Other control", "Other value") + .id("unrelated-field") + .on_input(|_| ()) + ] + .into(); + let mut tree = widget::Tree::new(fields.as_widget()); + let node = fields.as_widget_mut().layout( + &mut tree, + &(), + &layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)), + ); + tree.children[1] + .state + .downcast_mut::>() + .focus(); + let mut operations = 0; + let mut lint_completions = 0; + let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint"); + while let Some(action) = stream.next().await { + match action { + iced_runtime::Action::Widget(mut operation) => { + operations += 1; + fields.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &(), + operation.as_mut(), + ); + } + iced_runtime::Action::Output(message @ Message::Linted(_, _)) => { + lint_completions += 1; + let _ = app.update(message); + } + _ => panic!("Unexpected recovery task action"), + } + } + assert_eq!( + lint_completions, 1, + "Autofocus must not replace request_lint" + ); + assert!(app.lint_running.is_none()); + let focused = |index: usize| { + tree.children[index] + .state + .downcast_ref::>() + .is_focused() + }; + (operations, focused(0), focused(1)) + } + + fn architecture_recovery_app() -> App { + let mut app = App::with_project(Project::empty()); + let _ = app.update(Message::AddDiagram); + let _ = app.update(Message::Architecture(architecture_authoring::Event::Start( + Kind::Entity, + Some(Position { x: 80., y: 90. }), + ))); + let _ = app.update(Message::Architecture(architecture_authoring::Event::Name( + "Accepted team".into(), + ))); + let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit)); + assert!(app.architecture.is_none()); + app + } + + #[tokio::test] + async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history() + { + use architecture_authoring::Event; + for rename in [false, true] { + let mut app = architecture_recovery_app(); + let point = Position { x: 439., y: 328.2 }; + let event = if rename { + Event::Rename(app.route_context().unwrap(), app.selected, point) + } else { + Event::Start(Kind::Actor, Some(point)) + }; + let _ = app.update(Message::Architecture(event)); + let _ = app.update(Message::Architecture(Event::Name( + " Recovered engineering name 雨 ".into(), + ))); + let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap(); + let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref()); + let expected = snapshot.clone().restore().unwrap(); + let checkpoint = snapshot.id; + let mut recovered = App::with_project(Project::empty()); + let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot))); + assert_eq!( + run_recovery_tasks(&mut recovered, task).await, + (1, true, false) + ); + assert_eq!(recovered.page, Page::Diagram); + assert!(recovered.architecture.as_ref().unwrap().inline()); + assert_eq!( + serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(), + saved + ); + assert_eq!(recovered.project(), expected.document.project()); + assert_eq!(recovered.document.bundle(), expected.document.bundle()); + assert_eq!(recovered.document.revision(), expected.document.revision()); + assert_eq!(recovered.document.can_undo(), expected.document.can_undo()); + assert_eq!(recovered.document.can_redo(), expected.document.can_redo()); + assert_eq!( + recovered.source_workspace.bundle(), + expected.workspace.bundle() + ); + assert_eq!(recovered.recovery.restored, Some(checkpoint)); + assert!(recovered.path.is_none()); + } + } + + #[tokio::test] + async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() { + use architecture_authoring::Event; + for context in ["accepted", "armed", "picker", "property", "source"] { + let mut app = architecture_recovery_app(); + match context { + "armed" => { + let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None))); + } + "picker" => { + let _ = app.update(Message::Architecture(Event::Existing(None))); + } + "property" => { + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied property".into()); + assert!(app.draft_dirty()); + } + "source" => { + app.source_workspace + .edit("incomplete source 雨".into(), app.source_editor.cursor()); + assert!(app.source_dirty()); + } + _ => {} + } + let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref()); + let expected = snapshot.clone().restore().unwrap(); + let mut recovered = App::with_project(Project::empty()); + let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot))); + assert_eq!( + run_recovery_tasks(&mut recovered, task).await, + (0, false, true), + "{context}" + ); + assert!( + !recovered + .architecture + .as_ref() + .is_some_and(architecture_authoring::State::inline) + ); + assert_eq!(recovered.project(), expected.document.project()); + assert_eq!(recovered.document.bundle(), expected.document.bundle()); + assert_eq!(recovered.document.revision(), expected.document.revision()); + assert_eq!( + recovered.source_workspace.bundle(), + expected.workspace.bundle() + ); + } + } + #[test] fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() { let directory = tempfile::tempdir().unwrap(); diff --git a/harness/release-palette-implementation-20260910.md b/harness/release-palette-implementation-20260910.md index f0001d76..c4e945d0 100644 --- a/harness/release-palette-implementation-20260910.md +++ b/harness/release-palette-implementation-20260910.md @@ -138,3 +138,27 @@ root again. No worker-owned Cargo process remains. Root's existing native stage remains the earlier `1B47C096…` binary and is not overwritten. The full integrated suite is deferred until the other-host behavior and forms changes are integrated; the earlier 588-test scope is preserved above. + +## Recovery autofocus follow-up on integrated source + +Root's native candidate 2 confirmed that recovered inline naming was visible but +needed a click to focus. In `app/checkpoints.rs`, recovery now batches the +unchanged lint task with focus of `architecture-name` only when the restored +architecture state is inline. Armed tools, existing-element pickers and other +recovery contexts retain their existing focus behavior. + +Two tests consume the actual returned Task stream, operate on mounted Iced name +and unrelated TextInputs, require lint completion and compare exact accepted +source/model, raw draft, revision and history. Seven recovery contexts include +Create/Rename and the non-inline/property/source controls. The integrated source +at `057eddd` plus this checkpoint-only patch compiled in 4m 42s; all 13 checkpoint +tests passed in 4.84s, with 605 filtered. Scoped rustfmt and diff check passed. +Log: `.local/checkpoint-autofocus-tests-20260910.log`. + +Test artifact SHA256: +`D504C59BC2A5853F4FBE54A9DFA841DA0AC8DD2475342E45A91DBAA6533372B4`. +Root copied this immutable artifact into +`.local/integration-20260910/tests-057eddd/` and is executing its full 618-test +inventory separately while later scenario edits proceed. That result is pending; +it does not include the later F1 nullable-constructor correction or workspace +layout. No new GUI executable or native autofocus pass is claimed. diff --git a/harness/release-palette-native-20260910.md b/harness/release-palette-native-20260910.md index 1feb3e1a..d67116ed 100644 --- a/harness/release-palette-native-20260910.md +++ b/harness/release-palette-native-20260910.md @@ -116,3 +116,26 @@ No rename action was performed by that stopped call. Native control is paused; F2 rename, the remaining complete basic-editing sequence, transitions and final reopen/runtime acceptance remain pending. Source integration and packaging continue. Recovery autofocus remains a small confirmed usability follow-up. + +## Resumed native F2 check + +The user subsequently explicitly reauthorized Computer Use. Continued on the +same separately staged candidate 2 (`E6D6B1D9…`), not the newly integrated source. +Selecting Rescue team and pressing F2 opened the anchored, focused name editor. +Before applying, an independent expected bundle was copied from the already +oracle-verified Entity snapshot and changed only the unique quoted name in +`oa/participants.arcadia` to `Rescue coordination team`; all other bytes and the +complete file set were retained. Enter accepted the visible name, retained the +same alias/UUID and geometry, and first-submit Ctrl+S saved it. All three saved +files matched that expectation byte-for-byte. One Undo followed by Save passed +the complete Entity model/three-source oracle. One Redo followed by Save matched +the same name-only expectation exactly. Evidence is retained as +`expected-entity-rename`, `checkpoint-entity-rename` and `rename-source-result.json`. +The last Undo/Save returned the working fixture to the original Entity baseline. + +Input limitation observed: after Ctrl+A selected the original name, the first +Computer Use type_text call left an empty field. A fresh observation confirmed +that state; explicit refocus and a second type_text produced the intended name. +The cause is not established, so this is not recorded as a proven application +typing defect or as uninterrupted first-attempt typing. Applying, history and +source persistence were tested only after the correct text was visible.