diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs
--- a/apps/soundcraft/src/config.rs
+++ b/apps/soundcraft/src/config.rs
@@ -115,6 +115,7 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
     ui.mix_docking.validate_mix()?;
     let mut saved = ui.clone();
     saved.plugin_windows.clear();
+    saved.mix_plugin_windows.clear();
     saved.status.clear();
     let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
     if bytes.len() as u64 > MAX_UI_BYTES {
@@ -142,6 +143,21 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
 mod config_tests {
     use super::*;
 
+    #[test]
+    fn open_plugin_windows_are_not_saved_with_the_preferences() {
+        // They name the session's tracks, which the next session doesn't have.
+        let directory = std::env::temp_dir().join(format!("soundcraft-plugin-window-prefs-{}", std::process::id()));
+        let path = directory.join("ui.json");
+        let _ = std::fs::remove_dir_all(&directory);
+        let ui: soundcraft_ui_egui::UiState =
+            serde_json::from_value(serde_json::json!({"plugin_windows": [[7, 0]], "mix_plugin_windows": [[7, 0]]})).unwrap();
+        assert_eq!(ui.mix_plugin_windows.len(), 1);
+        save_ui(&path, &ui).unwrap();
+        let saved = load_ui(&path).unwrap().unwrap();
+        assert!(saved.plugin_windows.is_empty() && saved.mix_plugin_windows.is_empty());
+        let _ = std::fs::remove_dir_all(&directory);
+    }
+
     #[test]
     fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() {
         use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel};
diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs
--- a/apps/soundcraft/src/main.rs
+++ b/apps/soundcraft/src/main.rs
@@ -79,7 +79,7 @@ impl eframe::App for App {
                 "{}{} — SoundCraft — {}",
                 self.0.engine.session().name,
                 if self.0.engine.is_dirty() { " *" } else { "" },
-                if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
+                if self.0.main_window_shows() == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
             )
         } else {
             format!("{} — SoundCraft", self.0.engine.session().name)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -53,6 +53,15 @@ pub enum MainWindow {
     Mix,
 }
 
+/// The native window a floating window (a plugin or Send window) lives in.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Host {
+    /// The main window, which also draws a Mix window embedded in it.
+    Main,
+    /// The separate Mix window (Window › Mix in Separate Window).
+    Mix,
+}
+
 /// UI state (serde, so agents can read and set it).
 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 #[serde(default)]
@@ -80,12 +89,20 @@ pub struct UiState {
     /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
     pub edit_views: Vec<String>,
     /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip).
+    #[serde(deserialize_with = "saved::track_tint")]
     pub track_tint: f32,
     /// Open plugin windows (track, slot).
     pub plugin_windows: Vec<(TrackId, usize)>,
+    /// The plugin windows (of `plugin_windows`) opened from the separate Mix window: they are
+    /// drawn there while it is open, in the main window otherwise. Like `plugin_windows`, it can
+    /// be read and set (`ui.set`) but is not saved with the preferences.
+    pub mix_plugin_windows: Vec<(TrackId, usize)>,
     /// The send in the Send window (track, slot).
     #[serde(skip)]
     pub send_window: Option<(TrackId, usize)>,
+    /// The Send window was opened from the separate Mix window (see `mix_plugin_windows`).
+    #[serde(skip)]
+    pub send_window_in_mix: bool,
     pub audiosuite: Option<String>,
     pub status: String,
     pub show_automation: bool,
@@ -104,6 +121,12 @@ pub struct UiState {
     /// The Mix window lives in its own native window (Window › Mix in Separate Window); the main
     /// window then always shows Edit.
     pub mix_separate: bool,
+    /// The separate Mix window's outer position and inner size (logical pixels) as last seen; it
+    /// reopens there. No position: the system places it.
+    #[serde(deserialize_with = "saved::mix_window_pos")]
+    pub mix_window_pos: Option<[f32; 2]>,
+    #[serde(deserialize_with = "saved::mix_window_size")]
+    pub mix_window_size: [f32; 2],
     pub show_io_setup: bool,
     pub show_shortcuts: bool,
     pub show_clip_effects: bool,
@@ -124,11 +147,51 @@ pub struct UiState {
     pub theme: theme::ThemeMode,
     /// The main window's size (logical points) and maximized state, and the interface scale, as
     /// the desktop app last saw them; it restores them at launch.
+    #[serde(deserialize_with = "saved::window_size")]
     pub window_size: [f32; 2],
     pub window_maximized: bool,
+    #[serde(deserialize_with = "saved::ui_scale")]
     pub ui_scale: f32,
 }
 
+/// Reading saved sizes and scales. JSON reads a number too big for an `f32` (1e39) as infinity,
+/// which serde_json writes back as `null`, and the whole preferences file would no longer load:
+/// such values, and ones out of range, are replaced as they are read (preferences and `ui.set`).
+mod saved {
+    use serde::{Deserialize, Deserializer};
+
+    /// `v` as an `f32`, if it is a finite one.
+    fn finite(v: f64) -> Option<f32> {
+        (v.is_finite() && v.abs() <= f64::from(f32::MAX)).then_some(v as f32)
+    }
+
+    fn pair([x, y]: [f64; 2]) -> Option<[f32; 2]> {
+        Some([finite(x)?, finite(y)?])
+    }
+
+    pub fn track_tint<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
+        Ok(finite(f64::deserialize(d)?).map_or(0.5, |v| v.clamp(0.0, 1.0)))
+    }
+
+    pub fn ui_scale<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
+        Ok(finite(f64::deserialize(d)?).map_or(1.0, |v| v.clamp(0.75, 1.5)))
+    }
+
+    pub fn window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {
+        Ok(pair(<[f64; 2]>::deserialize(d)?).unwrap_or([1600.0, 1000.0]))
+    }
+
+    pub fn mix_window_pos<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[f32; 2]>, D::Error> {
+        let pos = Option::<[f64; 2]>::deserialize(d)?.and_then(pair);
+        Ok(crate::mix_window::sane_placement(pos, crate::mix_window::DEFAULT_SIZE).0)
+    }
+
+    pub fn mix_window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {
+        let size = pair(<[f64; 2]>::deserialize(d)?).unwrap_or(crate::mix_window::DEFAULT_SIZE);
+        Ok(crate::mix_window::sane_placement(None, size).1)
+    }
+}
+
 impl Default for UiState {
     fn default() -> Self {
         UiState {
@@ -152,7 +215,9 @@ impl Default for UiState {
             edit_views: vec!["color".into()],
             track_tint: 0.5,
             plugin_windows: Vec::new(),
+            mix_plugin_windows: Vec::new(),
             send_window: None,
+            send_window_in_mix: false,
             audiosuite: None,
             status: String::new(),
             show_automation: false,
@@ -168,6 +233,8 @@ impl Default for UiState {
             show_playback_engine: false,
             audio: soundcraft_playback::AudioConfig::default(),
             mix_separate: false,
+            mix_window_pos: None,
+            mix_window_size: mix_window::DEFAULT_SIZE,
             show_io_setup: false,
             show_shortcuts: false,
             show_clip_effects: false,
@@ -297,19 +364,32 @@ pub struct SoundApp {
     pub palette: palette::PaletteState,
     /// Bring the separate Mix window to the front on the next frame.
     pub(crate) focus_mix: bool,
+    /// Bring the main (Edit) window to the front on the next frame.
+    pub(crate) focus_edit: bool,
+    /// The separate Mix window has keyboard focus (as of its last frame).
+    pub(crate) mix_focused: bool,
+    /// The separate Mix window is a native window of its own (as of its last pass), not drawn
+    /// inside the main one.
+    pub(crate) mix_native: bool,
+    /// Files dropped on the separate Mix window that none of its samplers took, handed to the
+    /// shell with the main window's (`SoundApp::take_dropped_files`).
+    pub(crate) mix_drops: Vec<egui::DroppedFileHandle>,
+    /// Where the separate Mix window opened, while it is open (see `mix_window::show_separate`).
+    pub(crate) mix_placement: Option<mix_window::Placement>,
     /// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
     /// next draws, so a window not on screen catches up when it is shown.
     pub(crate) edit_scroll_into_view: Option<TrackId>,
     pub(crate) mix_scroll_into_view: Option<TrackId>,
     pub synthetic: Vec<egui::Event>,
-    /// Where a dropped audio file loads into a track's sampler (last frame's rects).
-    pub sample_drop_targets: Vec<(egui::Rect, TrackId)>,
+    /// Where a dropped audio file loads into a track's sampler: the window (viewport) and rect,
+    /// as each window last drew them.
+    pub sample_drop_targets: Vec<(egui::ViewportId, egui::Rect, TrackId)>,
     sample_reads: Vec<sampler_window::SampleRead>,
     synthetic_grace: u32,
     control_rx: Option<Receiver<ControlRequest>>,
     pending_shots: Vec<control::PendingShot>,
     /// An insert just picked from its menu, opened once the audio engine has its instance.
-    pub(crate) pending_open: Option<(TrackId, usize)>,
+    pub(crate) pending_open: Option<(TrackId, usize, Host)>,
     last_rev: u64,
     fonts_ready: bool,
     fonts_installed: bool,
@@ -414,6 +494,11 @@ impl SoundApp {
             extra: extra_windows::ExtraState::default(),
             palette: palette::PaletteState::default(),
             focus_mix: false,
+            focus_edit: false,
+            mix_focused: false,
+            mix_native: false,
+            mix_drops: Vec::new(),
+            mix_placement: None,
             edit_scroll_into_view: None,
             mix_scroll_into_view: None,
             synthetic: Vec::new(),
@@ -683,7 +768,7 @@ impl SoundApp {
     /// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in
     /// plugin, no audio engine, or an editor that can't open here) its plugin window.
     fn open_inserted(&mut self) {
-        let Some((track, slot)) = self.pending_open.take() else { return };
+        let Some((track, slot, host)) = self.pending_open.take() else { return };
         let Some(plugin) = self.engine.session().track(track).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten()).map(|i| i.plugin) else {
             return;
         };
@@ -696,11 +781,43 @@ impl SoundApp {
                 Err(e) => self.ui.status = format!("{}: {e}", mix_window::plugin_info(&plugin).map_or(plugin.as_str(), |i| i.name)),
             }
         }
-        if !self.ui.plugin_windows.contains(&(track, slot)) {
-            self.ui.plugin_windows.push((track, slot));
+        self.open_plugin_window(track, slot, host);
+    }
+
+    /// Opens the plugin window of `track`'s `slot` in `host`; one already open moves there.
+    pub(crate) fn open_plugin_window(&mut self, track: TrackId, slot: usize, host: Host) {
+        let w = (track, slot);
+        if !self.ui.plugin_windows.contains(&w) {
+            self.ui.plugin_windows.push(w);
+        }
+        self.ui.mix_plugin_windows.retain(|x| *x != w);
+        if host == Host::Mix {
+            self.ui.mix_plugin_windows.push(w);
         }
     }
 
+    /// The window `ctx` is drawing, where a floating window asked for from it opens: the separate
+    /// Mix window, or the main window (also for a Mix window embedded in it).
+    pub(crate) fn host_of(ctx: &egui::Context) -> Host {
+        if ctx.viewport_id() == mix_window::viewport_id() { Host::Mix } else { Host::Main }
+    }
+
+    /// Where the floating windows asked for from the separate Mix window are drawn: there while it
+    /// is a native window of its own, else (it is off, or embedded) in the main window.
+    pub(crate) fn mix_hosts_floating(&self) -> bool {
+        self.ui.mix_separate && self.mix_native
+    }
+
+    /// The window the plugin window of `w` (track, slot) is drawn in.
+    pub(crate) fn plugin_window_host(&self, w: (TrackId, usize)) -> Host {
+        if self.mix_hosts_floating() && self.ui.mix_plugin_windows.contains(&w) { Host::Mix } else { Host::Main }
+    }
+
+    /// The window the Send window is drawn in.
+    pub(crate) fn send_window_host(&self) -> Host {
+        if self.mix_hosts_floating() && self.ui.send_window_in_mix { Host::Mix } else { Host::Main }
+    }
+
     /// Parameter edits made in plugin editors go into the session (one undo step per gesture).
     /// Hosted instruments keep their parameters in their own state, which the session takes
     /// instead, as it does for plugins that said their state changed: either way the session is
@@ -1322,18 +1439,26 @@ impl SoundApp {
             self.arrange_request = false;
             ctx.memory_mut(|m| m.reset_areas());
         }
+        // The main window has the keys, so the Mix window no longer does (it learns that only on its next pass).
+        if ctx.input(|i| i.focused) {
+            self.mix_focused = false;
+        }
         shortcuts::handle(self, &ctx);
-        self.sample_drop_targets.clear();
+        self.sample_drop_targets.retain(|(v, ..)| *v != egui::ViewportId::ROOT);
         if !self.native_menu_bar {
             menus::menu_bar(self, ui);
         }
-        // With the Mix window in its own native window, the main window keeps Edit.
-        match if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window } {
+        match self.main_window_shows() {
             MainWindow::Edit => edit_window::show(self, ui),
             MainWindow::Mix => mix_window::show(self, ui),
         }
         if self.ui.mix_separate {
             mix_window::show_separate(self, &ctx);
+        } else {
+            mix_window::separate_hidden(self);
+        }
+        if std::mem::take(&mut self.focus_edit) {
+            ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
         }
         panels::floating(self, &ctx);
         windows::show(self, &ctx);
@@ -1355,6 +1480,11 @@ impl SoundApp {
         }
     }
 
+    /// What the main window shows: with the Mix window in its own native window, it keeps Edit.
+    pub fn main_window_shows(&self) -> MainWindow {
+        if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window }
+    }
+
     /// UI state as JSON (control channel `ui.inspect`).
     pub fn inspect(&self, ctx: &egui::Context) -> Value {
         let size = ctx.content_rect().size();
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -12,7 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
     ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
     ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
     ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
-    ("window.mix_separate", "Mix in Separate Window", "", None),
+    ("window.mix_separate", "Mix in Separate Window", "Window > Mix in Separate Window", None),
     ("window.transport", "Transport", "Window > Transport", Some("Cmd+1")),
     ("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")),
     ("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")),
@@ -203,6 +203,15 @@ pub fn tree() -> Vec<MenuNode> {
         insert_path(&mut roots, line);
     }
     insert_path(&mut roots, "Window > Reset Panel Layout");
+    // Not in the observed menu catalog; it belongs with Mix and Edit.
+    insert_path(&mut roots, "Window > Mix in Separate Window");
+    if let Some(window) = roots.iter_mut().find(|n| n.label == "Window")
+        && let Some(from) = window.children.iter().position(|n| n.path == "Window > Mix in Separate Window")
+        && let Some(edit) = window.children.iter().position(|n| n.path == "Window > Edit")
+    {
+        let item = window.children.remove(from);
+        window.children.insert((edit + 1).min(window.children.len()), item);
+    }
     // Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point.
     if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
         let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
@@ -257,18 +266,6 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
                             ui.separator();
                             ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
                         }
-                        if root.label == "Window" {
-                            ui.separator();
-                            let mut separate = app.ui.mix_separate;
-                            if ui
-                                .checkbox(&mut separate, "Mix in Separate Window")
-                                .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
-                                .changed()
-                            {
-                                let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
-                                ui.close();
-                            }
-                        }
                     });
                 }
                 let title = format!(
@@ -317,7 +314,11 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
     let label = if checked { format!("✔ {label}") } else { label };
     let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
     let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
-    if ui.add_enabled(enabled, btn).clicked()
+    let mut response = ui.add_enabled(enabled, btn);
+    if id.as_deref() == Some("window.mix_separate") {
+        response = response.on_hover_text("Show Edit and Mix at once, e.g. one per monitor");
+    }
+    if response.clicked()
         && let Some(id) = id
     {
         invoke_menu(app, &id, &n.path);
@@ -364,6 +365,7 @@ fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
         Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r),
         Some("window.mix") => app.ui.window == MainWindow::Mix,
         Some("window.edit") => app.ui.window == MainWindow::Edit,
+        Some("window.mix_separate") => app.ui.mix_separate,
         Some("window.track_list") => app.ui.show_tracks_list,
         Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list,
         Some("window.narrow_mix") => app.ui.narrow_mix,
@@ -529,16 +531,19 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
         }
         "window.edit" => {
             app.ui.window = MainWindow::Edit;
+            app.focus_edit = app.ui.mix_separate;
             json!({"window": "edit"})
         }
         "window.toggle_mix_edit" => {
             if app.ui.mix_separate {
-                // Both are on screen: bring the Mix window forward instead.
-                app.focus_mix = true;
+                // Both are on screen: bring the other one forward instead (Edit from the Mix window).
+                app.focus_edit = app.mix_focused;
+                app.focus_mix = !app.mix_focused;
+                json!({"window": if app.mix_focused { MainWindow::Edit } else { MainWindow::Mix }})
             } else {
                 app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit };
+                json!({"window": app.ui.window})
             }
-            json!({"window": app.ui.window})
         }
         "window.transport" => toggle(&mut app.ui.show_transport),
         "window.big_counter" => toggle(&mut app.ui.show_big_counter),
@@ -626,6 +631,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
             app.ui.show_audio_health = false;
             app.ui.show_automation = false;
             app.ui.plugin_windows.clear();
+            app.ui.mix_plugin_windows.clear();
             app.ui.audiosuite = None;
             json!({})
         }
diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs
--- a/crates/ui-egui/src/mix_window.rs
+++ b/crates/ui-egui/src/mix_window.rs
@@ -19,28 +19,138 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     crate::docking::show(app, ui, crate::MainWindow::Mix);
 }
 
+/// The separate Mix window's size when nothing sensible was saved (logical pixels).
+pub const DEFAULT_SIZE: [f32; 2] = [1100.0, 720.0];
+const MIN_SIZE: [f32; 2] = [480.0, 320.0];
+
+/// The separate Mix window's viewport.
+pub fn viewport_id() -> egui::ViewportId {
+    egui::ViewportId::from_hash_of("soundcraft.mix")
+}
+
+/// Where the separate Mix window opened (points). It is handed to egui unchanged on every frame:
+/// a builder that changed would move the window back to it.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct Placement {
+    pos: Option<egui::Pos2>,
+    size: egui::Vec2,
+    min_size: egui::Vec2,
+    /// Frames drawn in the native window since it opened.
+    passes: u32,
+}
+
+/// A saved Mix window position and size, or the defaults where the preferences hold nonsense
+/// (NaN, negative or tiny sizes, positions far off any desktop such as Windows' -32000 for a
+/// minimized window). Logical pixels.
+pub fn sane_placement(pos: Option<[f32; 2]>, [w, h]: [f32; 2]) -> (Option<[f32; 2]>, [f32; 2]) {
+    let size = if w.is_finite() && h.is_finite() && w >= MIN_SIZE[0] && h >= MIN_SIZE[1] { [w.min(16_384.0), h.min(16_384.0)] } else { DEFAULT_SIZE };
+    (pos.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 16_384.0)), size)
+}
+
 /// The Mix window in its own native window (Window › Mix in Separate Window), so Edit and Mix can
 /// be seen at once, e.g. one per monitor. Closing that window brings Mix back into the main one.
-/// Backends without multiple windows show it as a floating egui window instead.
+/// It reopens where it was last left. Keyboard shortcuts typed into it are handled here, with the
+/// Mix window as their context. Backends without multiple windows show it as a floating egui
+/// window instead, whose keys the main window already handles.
 pub fn show_separate(app: &mut SoundApp, ctx: &egui::Context) {
     let title = format!("{}  —  Mix", app.engine.session().name);
-    let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([1100.0, 720.0]).with_min_inner_size([480.0, 320.0]);
-    ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("soundcraft.mix"), builder, |ui, _class| {
+    let zoom = ctx.zoom_factor();
+    let placement = *app.mix_placement.get_or_insert_with(|| {
+        let (pos, [w, h]) = sane_placement(app.ui.mix_window_pos, app.ui.mix_window_size);
+        let min_size = egui::vec2(MIN_SIZE[0], MIN_SIZE[1]) / zoom;
+        Placement { pos: pos.map(|[x, y]| egui::pos2(x, y) / zoom), size: egui::vec2(w, h) / zoom, min_size, passes: 0 }
+    });
+    let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size(placement.size).with_min_inner_size(placement.min_size);
+    if let Some(pos) = placement.pos {
+        builder = builder.with_position(pos);
+    }
+    let main_rect = ctx.input(|i| i.viewport().outer_rect);
+    ctx.show_viewport_immediate(viewport_id(), builder, |ui, class| {
         ui.set_style(ui.ctx().global_style());
+        let ctx = ui.ctx().clone();
+        app.mix_native = class == egui::ViewportClass::Immediate;
+        if app.mix_native {
+            app.mix_focused = ctx.input(|i| i.focused);
+            track_placement(app, &ctx, main_rect);
+            // Files dropped here go to this window's samplers (as it last drew them), the rest to
+            // the shell with the main window's.
+            let rest = app.claim_dropped_samples(&ctx);
+            if !rest.is_empty() {
+                app.mix_drops.extend(rest);
+                ctx.request_repaint_of(egui::ViewportId::ROOT);
+            }
+            crate::shortcuts::handle_in(app, &ctx, crate::MainWindow::Mix);
+        } else {
+            app.mix_focused = false;
+        }
+        app.sample_drop_targets.retain(|(v, ..)| *v != viewport_id());
         if std::mem::take(&mut app.focus_mix) {
-            ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
+            ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
         }
         show(app, ui);
-        if ui.ctx().input(|i| i.viewport().close_requested()) {
+        if app.mix_native {
+            // The plugin and Send windows opened from here. Drawn inside the main window, Mix
+            // leaves them to the main window's pass.
+            crate::panels::floating_in_mix(app, &ctx);
+        }
+        if ctx.input(|i| i.viewport().close_requested()) {
             separate_closed(app);
         }
     });
 }
 
+/// Remembers the native Mix window's position and size for the preferences, and on its second
+/// frame (when the system has placed it) fixes a placement that no longer fits: a window on no
+/// monitor (a saved position on a display that is gone) moves next to the main window, and one
+/// bigger than its monitor is maximized.
+fn track_placement(app: &mut SoundApp, ctx: &egui::Context, main_rect: Option<egui::Rect>) {
+    let Some(placement) = app.mix_placement.as_mut() else { return };
+    placement.passes = placement.passes.saturating_add(1);
+    let (outer, inner, monitor, minimized, maximized, fullscreen) = ctx.input(|i| {
+        let v = i.viewport();
+        (v.outer_rect, v.inner_rect, v.monitor_size, v.minimized.unwrap_or(false), v.maximized.unwrap_or(false), v.fullscreen.unwrap_or(false))
+    });
+    if placement.passes == 2 {
+        if outer.is_some() && monitor.is_none() {
+            let to = main_rect.map_or(egui::pos2(48.0, 48.0), |r| r.min + egui::vec2(48.0, 48.0));
+            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(to));
+            return;
+        }
+        if inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
+            return;
+        }
+    }
+    // A minimized, maximized or full-screen window keeps the placement it had before.
+    if minimized || maximized || fullscreen {
+        return;
+    }
+    // Logical pixels (points × zoom). Restored, they are read at the scale the new window starts
+    // with: with monitors at different scales (Windows, X11) a window saved on the other monitor
+    // reopens somewhat offset.
+    let zoom = ctx.zoom_factor();
+    if let Some(r) = outer {
+        app.ui.mix_window_pos = Some((r.min.to_vec2() * zoom).round().into());
+    }
+    if let Some(r) = inner {
+        app.ui.mix_window_size = (r.size() * zoom).round().into();
+    }
+}
+
 /// The separate Mix window was closed: Mix goes back into the main window.
 pub fn separate_closed(app: &mut SoundApp) {
     app.ui.mix_separate = false;
     app.ui.window = crate::MainWindow::Mix;
+    separate_hidden(app);
+}
+
+/// The separate Mix window is not on screen: it no longer has the keys, and opens afresh from the
+/// saved placement next time.
+pub(crate) fn separate_hidden(app: &mut SoundApp) {
+    app.mix_focused = false;
+    app.mix_native = false;
+    app.mix_placement = None;
+    app.sample_drop_targets.retain(|(v, ..)| *v != viewport_id());
 }
 
 pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
@@ -512,8 +622,8 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r:
         None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot),
     }
     let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin));
-    if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {
-        app.ui.plugin_windows.push((id, slot));
+    if resp.clicked() && hosted {
+        app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx()));
     }
     let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() };
     let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) };
@@ -611,8 +721,8 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot:
     if resp.clicked() && ins.is_some() {
         if ui.input(|i| i.modifiers.command) {
             let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot}));
-        } else if !app.ui.plugin_windows.contains(&(id, slot)) {
-            app.ui.plugin_windows.push((id, slot));
+        } else {
+            app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx()));
         }
     }
     let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" };
@@ -678,9 +788,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
     if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) {
         ui.label(egui::RichText::new(info.name).strong());
         if current.as_ref().is_some_and(|i| !soundcraft_mix::is_third_party(&i.plugin)) && ui.button("Edit Parameters…").clicked() {
-            if !app.ui.plugin_windows.contains(&(id, slot)) {
-                app.ui.plugin_windows.push((id, slot));
-            }
+            app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx()));
             ui.close();
         }
         if let Some(p) = &app.player
@@ -720,7 +828,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
 /// it (`SoundApp::open_inserted`).
 fn pick_insert(app: &mut SoundApp, ui: &Ui, id: TrackId, slot: usize, plugin: &str) {
     match app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) {
-        Ok(_) => app.pending_open = Some((id, slot)),
+        Ok(_) => app.pending_open = Some((id, slot, SoundApp::host_of(ui.ctx()))),
         Err(e) => app.ui.status = e,
     }
     ui.close();
@@ -945,6 +1053,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
             used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level));
             let which = (b'A' + slot as u8) as char;
             let shown = app.ui.send_window == Some((id, slot));
+            let in_mix = SoundApp::host_of(ui.ctx()) == crate::Host::Mix;
             let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag());
             level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level")));
             if shown {
@@ -954,7 +1063,10 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
                 if ui.input(|i| i.modifiers.alt) {
                     let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
                 } else {
-                    app.ui.send_window = (!shown).then_some((id, slot));
+                    // Open where it was clicked; open in the other window, it moves here.
+                    let here = shown && app.ui.send_window_in_mix == in_mix;
+                    app.ui.send_window = (!here).then_some((id, slot));
+                    app.ui.send_window_in_mix = in_mix;
                 }
             }
             if level_resp.dragged() {
@@ -1001,7 +1113,11 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
 
 /// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a
 /// send's level opens it, moves it to that send, or closes it, and so does Escape.
-pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) {
+/// It is drawn by the window it was opened from (`host`: the pass drawing it).
+pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context, host: crate::Host) {
+    if app.send_window_host() != host {
+        return;
+    }
     // egui closes menus and drops text focus on Escape before this runs, so whether this Escape
     // was theirs comes from the last frame. A dialog or the search on top takes it too.
     let busy_id = egui::Id::new("send_window_escape");
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1,7 +1,7 @@
 //! Side panels (Tracks, Groups, Clips) and floating windows.
 
-use crate::SoundApp;
 use crate::theme::{Tokens, bold, mono, regular, rgb};
+use crate::{Host, SoundApp};
 use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
 use serde_json::json;
 use soundcraft_model::{ClipId, SourceId};
@@ -309,12 +309,18 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
 pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
     transport_window(app, ctx);
     big_counter(app, ctx);
-    plugin_windows(app, ctx);
-    crate::mix_window::send_window(app, ctx);
+    plugin_windows(app, ctx, Host::Main);
+    crate::mix_window::send_window(app, ctx, Host::Main);
     about(app, ctx);
     session_info(app, ctx);
 }
 
+/// The floating windows asked for from the separate Mix window, drawn in it (see `SoundApp::host_of`).
+pub(crate) fn floating_in_mix(app: &mut SoundApp, ctx: &egui::Context) {
+    plugin_windows(app, ctx, Host::Mix);
+    crate::mix_window::send_window(app, ctx, Host::Mix);
+}
+
 fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_transport;
     if !open {
@@ -498,9 +504,10 @@ pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) {
     });
 }
 
-fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
-    let wins = app.ui.plugin_windows.clone();
-    let mut keep = Vec::new();
+/// The plugin windows that live in `host`, the window this pass draws.
+fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context, host: Host) {
+    let wins: Vec<_> = app.ui.plugin_windows.iter().copied().filter(|w| app.plugin_window_host(*w) == host).collect();
+    let mut closed = Vec::new();
     for (tid, slot) in wins {
         // The instrument slot (an instrument track's instrument) or an insert slot.
         let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;
@@ -508,9 +515,13 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
             let ins = if instrument { t.instrument.clone() } else { t.mixer.inserts.get(slot).cloned().flatten() };
             ins.map(|i| (t.name.clone(), i))
         }) else {
+            closed.push((tid, slot));
+            continue;
+        };
+        let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else {
+            closed.push((tid, slot));
             continue;
         };
-        let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue };
         let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() };
         let mut open = true;
         let shown = egui::Window::new(format!("{tname} · {place} · {}", info.name))
@@ -621,11 +632,12 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
         {
             crate::sampler_window::drop_target(app, ctx, w.response.rect, tid);
         }
-        if open {
-            keep.push((tid, slot));
+        if !open {
+            closed.push((tid, slot));
         }
     }
-    app.ui.plugin_windows = keep;
+    app.ui.plugin_windows.retain(|w| !closed.contains(w));
+    app.ui.mix_plugin_windows.retain(|w| !closed.contains(w) && app.ui.plugin_windows.contains(w));
 }
 
 fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) {
diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs
--- a/crates/ui-egui/src/sampler_window.rs
+++ b/crates/ui-egui/src/sampler_window.rs
@@ -3,8 +3,8 @@
 //! an instrument track. Markers and every other control go through `mix.instrument_param`, so
 //! they undo and script like any edit.
 
-use crate::SoundApp;
 use crate::theme::{Tokens, mono, regular};
+use crate::{Host, SoundApp};
 use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
 use serde_json::json;
 use soundcraft_model::{Insert, TrackId};
@@ -50,10 +50,10 @@ fn param(ins: &Insert, id: &str) -> f32 {
     ins.params.get(id).copied().unwrap_or(default)
 }
 
-/// Remembers `r` as a place where a dropped audio file loads into track `t`'s sampler, and
-/// outlines it while files are dragged over it.
+/// Remembers `r` (in the window `ctx` draws) as a place where a dropped audio file loads into
+/// track `t`'s sampler, and outlines it while files are dragged over it.
 pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId) {
-    app.sample_drop_targets.push((r, t));
+    app.sample_drop_targets.push((ctx.viewport_id(), r, t));
     let over = ctx.input(|i| !i.raw.hovered_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| r.contains(p)));
     if over {
         ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("sample_drop", t.0)))).rect_stroke(
@@ -72,6 +72,8 @@ pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId)
 pub struct SampleRead {
     pub track: TrackId,
     pub file: egui::DroppedFileHandle,
+    /// The window it was dropped on, where the Sampler window opens.
+    host: Host,
 }
 
 impl SampleRead {
@@ -82,16 +84,26 @@ impl SampleRead {
 }
 
 impl SoundApp {
-    /// Takes this frame's dropped files: audio dropped on an instrument track or a Sampler window
+    /// Takes this frame's files dropped on the main window (`ctx`), and those the separate Mix
+    /// window was handed since: audio dropped on an instrument track or a Sampler window
     /// loads into that track's sampler (the sampler becomes its instrument). A file with a full
     /// path loads straight away; one without (the browser) waits in [`SoundApp::take_sample_reads`]
     /// for the shell to read. Everything else is returned for the shell to open or import.
     pub fn take_dropped_files(&mut self, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {
+        let mut rest = self.claim_dropped_samples(ctx);
+        rest.append(&mut self.mix_drops);
+        rest
+    }
+
+    /// Loads audio dropped on a sampler target of the window `ctx` draws (only its own targets:
+    /// each window's drops come with its own pointer) and returns the other files.
+    pub(crate) fn claim_dropped_samples(&mut self, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {
         let (files, pos) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos()));
         if files.is_empty() {
             return files;
         }
-        let Some(track) = self.drop_track(pos) else { return files };
+        let host = SoundApp::host_of(ctx);
+        let Some(track) = self.drop_track(ctx.viewport_id(), host, pos) else { return files };
         let mut rest = Vec::new();
         let mut claimed = false;
         for f in files {
@@ -103,23 +115,25 @@ impl SoundApp {
             if f.path().is_absolute() {
                 let path = f.path().to_string_lossy().into_owned();
                 let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path}));
-                self.sample_loaded(track, &path, r);
+                self.sample_loaded(track, host, &path, r);
             } else {
-                self.sample_reads.push(SampleRead { track, file: f });
+                self.sample_reads.push(SampleRead { track, file: f, host });
             }
         }
         rest
     }
 
-    /// The track a drop at `pos` loads into. Without a position (some platforms report none
-    /// during a drag), a single open Sampler window takes it.
-    fn drop_track(&self, pos: Option<egui::Pos2>) -> Option<TrackId> {
+    /// The track a drop at `pos` in `viewport` (the window `host`) loads into. Without a position
+    /// (some platforms report none during a drag), a single Sampler window open in it takes it.
+    fn drop_track(&self, viewport: egui::ViewportId, host: Host, pos: Option<egui::Pos2>) -> Option<TrackId> {
         if let Some(p) = pos {
-            return self.sample_drop_targets.iter().rev().find(|(r, _)| r.contains(p)).map(|(_, t)| *t);
+            return self.sample_drop_targets.iter().rev().find(|(v, r, _)| *v == viewport && r.contains(p)).map(|(.., t)| *t);
         }
         let s = self.engine.session();
-        let mut open = self.ui.plugin_windows.iter().filter(|(t, slot)| {
-            *slot == soundcraft_mix::INSTRUMENT_SLOT && s.track(*t).and_then(|x| x.instrument.as_ref()).is_some_and(|i| i.plugin == SAMPLER)
+        let mut open = self.ui.plugin_windows.iter().filter(|&&(t, slot)| {
+            slot == soundcraft_mix::INSTRUMENT_SLOT
+                && self.plugin_window_host((t, slot)) == host
+                && s.track(t).and_then(|x| x.instrument.as_ref()).is_some_and(|i| i.plugin == SAMPLER)
         });
         match (open.next(), open.next()) {
             (Some((t, _)), None) => Some(*t),
@@ -137,15 +151,15 @@ impl SoundApp {
     pub fn load_sample_bytes(&mut self, read: &SampleRead, bytes: Result<Vec<u8>, String>) {
         let name = read.name();
         let r = bytes.and_then(|b| soundcraft_engine::io::load_sampler_bytes(&mut self.engine, read.track, &name, &b).map_err(|e| e.to_string()));
-        self.sample_loaded(read.track, &name, r);
+        self.sample_loaded(read.track, read.host, &name, r);
     }
 
-    fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result<serde_json::Value, String>) {
+    /// Shows the outcome of a sample load; loaded, the Sampler window opens in `host`, the window
+    /// the file was dropped on (one already open elsewhere moves there).
+    fn sample_loaded(&mut self, track: TrackId, host: Host, what: &str, r: Result<serde_json::Value, String>) {
         match r {
             Ok(v) => {
-                if !self.ui.plugin_windows.contains(&(track, soundcraft_mix::INSTRUMENT_SLOT)) {
-                    self.ui.plugin_windows.push((track, soundcraft_mix::INSTRUMENT_SLOT));
-                }
+                self.open_plugin_window(track, soundcraft_mix::INSTRUMENT_SLOT, host);
                 let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(what);
                 self.ui.status = format!("{name} loaded into the sampler");
             }
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -54,7 +54,17 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
     mac && m.command && m.ctrl
 }
 
+/// The main window's keys (see [`handle_in`]).
 pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
+    let shown = app.main_window_shows();
+    handle_in(app, ctx, shown);
+}
+
+/// Shortcuts typed into the window whose pass `ctx` is in, which shows `window`. Each native
+/// window gets only its own key presses, so a press is handled once, by the window that has the
+/// keyboard focus; Edit-only keys (nudge, Grid/Nudge value, Commands Keyboard Focus) act only
+/// there when that window shows Edit.
+pub fn handle_in(app: &mut SoundApp, ctx: &egui::Context, window: MainWindow) {
     // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
     ctx.options_mut(|o| o.zoom_with_keyboard = false);
     if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
@@ -72,7 +82,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
     // Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
     let mac = ctx.os() == egui::os::OperatingSystem::Mac;
     for (key, mods) in events {
-        if fixed(app, key, mods, mac) {
+        if fixed(app, key, mods, mac, window) {
             continue;
         }
         if let Some(id) = command_for(key, mods, mac) {
@@ -98,7 +108,7 @@ fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
 }
 
 /// Keys with context-dependent meaning. Returns true when handled.
-fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
+fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool, window: MainWindow) -> bool {
     let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
     match key {
         Key::Space if !m.command && !m.alt => {
@@ -123,7 +133,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
             true
         }
         // Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +.
-        Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => {
+        Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && window == MainWindow::Edit => {
             let step = if key == Key::Minus { -1 } else { 1 };
             let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step}));
             true
@@ -165,7 +175,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
         // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it
         // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a
         // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.
-        Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {
+        Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && window == MainWindow::Edit => {
             let dir = if key == Key::Minus { -1 } else { 1 };
             let _ = app.run("edit.nudge", json!({"direction": dir}));
             true
@@ -213,7 +223,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
         k if !m.command
             && !m.alt
             && !m.ctrl
-            && app.ui.window == MainWindow::Edit
+            && window == MainWindow::Edit
             && app.engine.session().edit.keyboard_focus == "commands"
             && commands_focus(app, k, m.shift) =>
         {
diff --git a/crates/ui-egui/tests/it/mix_separate.rs b/crates/ui-egui/tests/it/mix_separate.rs
--- a/crates/ui-egui/tests/it/mix_separate.rs
+++ b/crates/ui-egui/tests/it/mix_separate.rs
@@ -1,9 +1,12 @@
 //! Window › Mix in Separate Window: Edit stays in the main window, and closing the Mix window
-//! brings Mix back into it.
-use egui::vec2;
+//! brings Mix back into it. Keys typed into either window act there, and the Mix window reopens
+//! where it was left.
+use egui::{Key, Modifiers, vec2};
 use egui_kittest::Harness;
 use serde_json::json;
 use soundcraft_ui_egui::{MainWindow, Services, SoundApp, mix_window};
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
 
 #[test]
 fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window() {
@@ -35,3 +38,531 @@ fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window()
     app.run("window.mix_separate", json!({"value": false})).unwrap();
     assert!(!app.ui.mix_separate);
 }
+
+#[test]
+fn mix_in_separate_window_is_a_checked_item_of_the_window_menu() {
+    use soundcraft_ui_egui::menus;
+    let path = "Window > Mix in Separate Window";
+    let tree = menus::tree();
+    let window = tree.iter().find(|n| n.label == "Window").unwrap();
+    let at = window.children.iter().position(|n| n.path == path).expect("in the Window menu (native and in-window)");
+    assert_eq!(window.children.get(at.wrapping_sub(1)).map(|n| n.path.as_str()), Some("Window > Edit"));
+    assert_eq!(menus::command_for_path(path, &menus::ui_aliases()).as_deref(), Some("window.mix_separate"));
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, false));
+    menus::invoke_menu(&mut app, "window.mix_separate", path);
+    assert!(app.ui.mix_separate);
+    assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, true));
+    menus::invoke_menu(&mut app, "window.mix_separate", path);
+    assert!(!app.ui.mix_separate);
+}
+
+/// A main window and the native Mix window, as a multi-window backend drives them: each window's
+/// pass gets only its own input (`set_immediate_viewport_renderer` is how eframe does it).
+struct TwoWindows {
+    ctx: egui::Context,
+    /// Input for the Mix window's next pass, and what it reported.
+    mix_events: Rc<RefCell<Vec<egui::Event>>>,
+    mix_info: Rc<RefCell<egui::ViewportInfo>>,
+    mix_focused: Rc<Cell<bool>>,
+    mix_commands: Rc<RefCell<Vec<egui::ViewportCommand>>>,
+    mix_builder: Rc<RefCell<Option<egui::ViewportBuilder>>>,
+    /// A widget in the Mix window holds the keyboard focus (a text field being typed into).
+    mix_field_focused: Rc<Cell<bool>>,
+    /// The labelled widgets and windows each window drew last (with `ctx.enable_accesskit()`).
+    mix_nodes: Rc<RefCell<Vec<(String, egui::Rect)>>>,
+    main_nodes: RefCell<Vec<(String, egui::Rect)>>,
+    /// Files dropped on each window in its next pass, and those the app left to the shell.
+    mix_dropped: Rc<RefCell<Vec<egui::DroppedFileHandle>>>,
+    main_dropped: RefCell<Vec<egui::DroppedFileHandle>>,
+    shell_drops: RefCell<Vec<egui::DroppedFileHandle>>,
+}
+
+/// The labels (and values) of the accessibility nodes a pass output, with their bounds.
+fn labelled(update: Option<egui::accesskit::TreeUpdate>) -> Vec<(String, egui::Rect)> {
+    let nodes = update.map(|u| u.nodes).unwrap_or_default();
+    nodes
+        .iter()
+        .filter_map(|(_, n)| {
+            let text = n.label().or(n.value())?.to_string();
+            let b = n.bounds()?;
+            Some((text, egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))))
+        })
+        .collect()
+}
+
+impl TwoWindows {
+    fn new() -> Self {
+        let ctx = egui::Context::default();
+        ctx.set_os(egui::os::OperatingSystem::Mac);
+        ctx.set_embed_viewports(false);
+        let w = TwoWindows {
+            ctx,
+            mix_events: Rc::default(),
+            mix_info: Rc::default(),
+            mix_focused: Rc::new(Cell::new(true)),
+            mix_commands: Rc::default(),
+            mix_builder: Rc::default(),
+            mix_field_focused: Rc::default(),
+            mix_nodes: Rc::default(),
+            main_nodes: RefCell::default(),
+            mix_dropped: Rc::default(),
+            main_dropped: RefCell::default(),
+            shell_drops: RefCell::default(),
+        };
+        let (events, info, focused, builder) = (w.mix_events.clone(), w.mix_info.clone(), w.mix_focused.clone(), w.mix_builder.clone());
+        let (field, nodes, dropped) = (w.mix_field_focused.clone(), w.mix_nodes.clone(), w.mix_dropped.clone());
+        egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
+            let id = viewport.ids.this;
+            *builder.borrow_mut() = Some(viewport.builder.clone());
+            let input = egui::RawInput {
+                viewport_id: id,
+                viewports: std::iter::once((id, info.borrow().clone())).collect(),
+                events: events.take(),
+                dropped_files: dropped.take(),
+                focused: focused.get(),
+                ..Default::default()
+            };
+            let mut out = ctx.run_ui(input, |ui| {
+                if field.get() {
+                    ui.memory_mut(|m| m.request_focus(egui::Id::new("mix text field")));
+                }
+                (viewport.viewport_ui_cb)(ui)
+            });
+            out.textures_delta.clear();
+            *nodes.borrow_mut() = labelled(out.platform_output.accesskit_update);
+        });
+        w
+    }
+
+    /// One frame of the main window, with `events` typed into it; returns the main window's commands.
+    fn frame(&self, app: &mut SoundApp, events: Vec<egui::Event>) -> Vec<egui::ViewportCommand> {
+        let root =
+            egui::ViewportInfo { outer_rect: Some(egui::Rect::from_min_size(egui::pos2(100.0, 80.0), vec2(1600.0, 1000.0))), ..Default::default() };
+        let input = egui::RawInput {
+            events,
+            viewports: std::iter::once((egui::ViewportId::ROOT, root)).collect(),
+            dropped_files: self.main_dropped.take(),
+            focused: !self.mix_focused.get(),
+            ..Default::default()
+        };
+        let mut out = self.ctx.run_ui(input, |ui| {
+            app.logic(ui.ctx());
+            // As the shell does: the files no sampler took open or import.
+            self.shell_drops.borrow_mut().extend(app.take_dropped_files(ui.ctx()));
+            app.ui(ui);
+        });
+        out.textures_delta.clear();
+        *self.main_nodes.borrow_mut() = labelled(out.platform_output.accesskit_update.take());
+        // The main window's pass carries the commands of the windows drawn inside it.
+        if let Some(mix) = out.viewport_output.remove(&mix_window::viewport_id()) {
+            self.mix_commands.borrow_mut().extend(mix.commands);
+        }
+        out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
+    }
+
+    fn type_in_mix(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {
+        self.mix_focused.set(true);
+        *self.mix_events.borrow_mut() = key_press(key, modifiers);
+        self.frame(app, Vec::new())
+    }
+
+    /// A click at `at` in the Mix window (`in_mix`) or the main window, over two frames.
+    fn click(&self, app: &mut SoundApp, in_mix: bool, at: egui::Pos2) {
+        let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
+        self.mix_focused.set(in_mix);
+        for events in [vec![egui::Event::PointerMoved(at), button(true)], vec![button(false)]] {
+            if in_mix {
+                *self.mix_events.borrow_mut() = events;
+                self.frame(app, Vec::new());
+            } else {
+                self.frame(app, events);
+            }
+        }
+        self.frame(app, Vec::new());
+    }
+
+    /// Where the leftmost (then topmost) widget or window labelled `label` is in the Mix window or the
+    /// main one: the first track's, in the Mix strips or the Edit window's rows.
+    fn find(&self, in_mix: bool, label: impl Fn(&str) -> bool) -> Option<egui::Pos2> {
+        let nodes = if in_mix { self.mix_nodes.borrow().clone() } else { self.main_nodes.borrow().clone() };
+        nodes.iter().filter(|(l, _)| label(l)).map(|(_, r)| r.center()).min_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)))
+    }
+
+    /// Drops `file` at `at` in the Mix window (`in_mix`) or the main window.
+    fn drop_file(&self, app: &mut SoundApp, in_mix: bool, at: egui::Pos2, file: egui::DroppedFileHandle) {
+        if in_mix {
+            *self.mix_events.borrow_mut() = vec![egui::Event::PointerMoved(at)];
+            self.frame(app, Vec::new());
+            self.mix_dropped.borrow_mut().push(file);
+            self.frame(app, Vec::new());
+        } else {
+            self.frame(app, vec![egui::Event::PointerMoved(at)]);
+            self.main_dropped.borrow_mut().push(file);
+            self.frame(app, Vec::new());
+        }
+        self.frame(app, Vec::new());
+    }
+
+    fn type_in_main(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {
+        self.mix_focused.set(false);
+        self.frame(app, key_press(key, modifiers))
+    }
+}
+
+fn key_press(key: Key, modifiers: Modifiers) -> Vec<egui::Event> {
+    [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec()
+}
+
+fn separate_app() -> SoundApp {
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.run("window.mix_separate", json!({"value": true})).unwrap();
+    app
+}
+
+#[test]
+fn shortcuts_work_in_the_window_that_has_the_keys_and_run_once() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+    // Cmd+1 (Transport) typed into the Mix window toggles it exactly once; so does the main window.
+    assert!(!app.ui.show_transport);
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(app.ui.show_transport, "a shortcut typed into the separate Mix window runs");
+    w.type_in_main(&mut app, Key::Num1, cmd);
+    assert!(!app.ui.show_transport, "and one typed into the main window runs once, not twice");
+
+    // Nudge (bare =) is an Edit window key: not from the Mix window, but from the main window,
+    // which shows Edit even after Window › Mix brought the Mix window forward.
+    let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
+    app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
+    let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;
+    let original = start(&app);
+    app.run("window.mix", json!({})).unwrap();
+    w.type_in_mix(&mut app, Key::Equals, Modifiers::NONE);
+    assert_eq!(start(&app), original, "Edit-only keys don't act from the Mix window");
+    w.type_in_main(&mut app, Key::Equals, Modifiers::NONE);
+    assert!(start(&app) > original, "the main window shows Edit, so its nudge key works");
+
+    // Cmd+= goes to the other window: Edit from the Mix window, Mix from the main one.
+    let focus = |c: &[egui::ViewportCommand]| c.contains(&egui::ViewportCommand::Focus);
+    w.mix_commands.borrow_mut().clear();
+    assert!(focus(&w.type_in_mix(&mut app, Key::Equals, cmd)), "Cmd+= in the Mix window brings Edit forward");
+    w.type_in_main(&mut app, Key::Equals, cmd);
+    w.frame(&mut app, Vec::new());
+    assert!(focus(&w.mix_commands.borrow()), "Cmd+= in the main window brings Mix forward");
+    assert!(app.ui.mix_separate);
+}
+
+#[test]
+fn a_text_field_in_the_mix_window_keeps_its_keys() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+    w.mix_field_focused.set(true);
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(!app.ui.show_transport, "typing into a Mix window field runs no shortcut");
+    // The field goes away (egui drops the focus of a widget no longer drawn).
+    w.mix_field_focused.set(false);
+    for _ in 0..2 {
+        w.frame(&mut app, Vec::new());
+    }
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(app.ui.show_transport);
+}
+
+#[test]
+fn a_mix_window_drawn_inside_the_main_one_handles_its_keys_once() {
+    // Backends without native windows draw it as a window inside the main one, which shares the
+    // main window's input: the main window's pass handles the keys, the Mix window's must not.
+    let mut app = separate_app();
+    let ctx = egui::Context::default();
+    ctx.set_os(egui::os::OperatingSystem::Mac);
+    let frame = |app: &mut SoundApp, events: Vec<egui::Event>| {
+        ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        })
+        .textures_delta
+        .clear();
+    };
+    for _ in 0..3 {
+        frame(&mut app, Vec::new());
+    }
+    frame(&mut app, key_press(Key::Num1, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }));
+    assert!(app.ui.show_transport, "Cmd+1 toggled Transport once, not on and off again");
+}
+
+#[test]
+fn the_mix_window_reopens_where_it_was_left() {
+    reopens_where_it_was_left(1.0);
+    reopens_where_it_was_left(1.5);
+}
+
+fn reopens_where_it_was_left(zoom: f32) {
+    let w = TwoWindows::new();
+    w.ctx.set_zoom_factor(zoom);
+    let mut app = separate_app();
+    assert_eq!(app.ui.mix_window_pos, None);
+    *w.mix_info.borrow_mut() = egui::ViewportInfo {
+        outer_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 120.0), vec2(900.0, 640.0))),
+        inner_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 148.0), vec2(900.0, 612.0))),
+        monitor_size: Some(vec2(1920.0, 1080.0)),
+        ..Default::default()
+    };
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    // Saved in logical pixels: points × zoom.
+    assert_eq!(app.ui.mix_window_pos, Some([1800.0 * zoom, 120.0 * zoom]));
+    assert_eq!(app.ui.mix_window_size, [900.0 * zoom, 612.0 * zoom]);
+    assert!(!w.mix_commands.borrow().iter().any(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))), "an on-screen window stays put");
+
+    // Saved with the preferences, restored at the next launch.
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.ui = ui;
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let builder = w.mix_builder.borrow().clone().unwrap();
+    assert_eq!(builder.position, Some(egui::pos2(1800.0, 120.0)));
+    assert_eq!(builder.inner_size, Some(vec2(900.0, 612.0)));
+}
+
+#[test]
+fn a_mix_window_on_a_display_that_is_gone_moves_next_to_the_main_window() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    app.ui.mix_window_pos = Some([5000.0, 300.0]);
+    // The system puts the window where it was, but on no monitor.
+    *w.mix_info.borrow_mut() = egui::ViewportInfo {
+        outer_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 300.0), vec2(900.0, 640.0))),
+        inner_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 328.0), vec2(900.0, 612.0))),
+        monitor_size: None,
+        ..Default::default()
+    };
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let moves: Vec<_> = w.mix_commands.borrow().iter().filter(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))).cloned().collect();
+    assert_eq!(moves, [egui::ViewportCommand::OuterPosition(egui::pos2(148.0, 128.0))]);
+}
+
+#[test]
+fn nonsense_mix_window_placements_fall_back_to_the_defaults() {
+    assert_eq!(mix_window::sane_placement(Some([10.0, 20.0]), [900.0, 600.0]), (Some([10.0, 20.0]), [900.0, 600.0]));
+    assert_eq!(mix_window::sane_placement(Some([f32::NAN, 0.0]), [f32::INFINITY, 600.0]), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!(mix_window::sane_placement(Some([1e9, 0.0]), [10.0, -5.0]), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!(mix_window::sane_placement(None, [1e9, 1e9]), (None, [16_384.0, 16_384.0]));
+    // Preferences from before the Mix window was remembered.
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(json!({"mix_separate": true})).unwrap();
+    assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));
+}
+
+#[test]
+fn saved_numbers_too_big_for_the_preferences_get_their_defaults() {
+    // JSON reads 1e39 into an f32 as infinity, which would be written back as `null` and make
+    // the whole preferences file unreadable.
+    let saved =
+        json!({"mix_window_pos": [1e39, 0], "mix_window_size": [1e39, 600], "track_tint": 1e39, "window_size": [1e39, 1e39], "ui_scale": -1e39});
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(saved.clone()).unwrap();
+    assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!((ui.track_tint, ui.window_size, ui.ui_scale), (0.5, [1600.0, 1000.0], 1.0));
+    let again: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
+    assert_eq!(again.mix_window_size, mix_window::DEFAULT_SIZE, "the preferences round-trip");
+
+    // The control channel's ui.set goes through the same reading.
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.run("ui.set", saved).unwrap();
+    assert_eq!((app.ui.mix_window_pos, app.ui.mix_window_size, app.ui.track_tint), (None, mix_window::DEFAULT_SIZE, 0.5));
+    app.run("ui.set", json!({"track_tint": 2.0, "mix_window_pos": [-32000, -32000]})).unwrap();
+    assert_eq!((app.ui.track_tint, app.ui.mix_window_pos), (1.0, None), "out of range: clamped, or the minimized-window sentinel dropped");
+    app.run("ui.set", json!({"show_transport": true})).expect("later ui.set calls still round-trip");
+}
+
+#[test]
+fn plugin_and_send_windows_open_in_the_window_they_were_asked_from() {
+    let w = TwoWindows::new();
+    w.ctx.enable_accesskit();
+    let mut app = separate_app();
+    app.run("view.edit_inserts_ae", json!({})).unwrap();
+    let first = app.engine.session().tracks.first().map(|t| (t.id, t.name.clone())).unwrap();
+    app.run("mix.insert", json!({"track": first.0.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let title = format!("{} · a · ", first.1);
+    let window = |l: &str| l.starts_with(&title);
+
+    // Clicking an insert in the Mix window opens its plugin window there, not in the main window.
+    let insert = w.find(true, |l| l == "Insert A").expect("the Mix window shows inserts");
+    w.click(&mut app, true, insert);
+    assert_eq!(app.ui.plugin_windows, [(first.0, 0)]);
+    assert!(w.find(true, window).is_some(), "the plugin window opened in the Mix window");
+    assert!(w.find(false, window).is_none(), "and not in the main window");
+
+    // With the Mix window back inside the main one, so is the plugin window; turned on again,
+    // the plugin window goes back with it.
+    app.run("window.mix_separate", json!({"value": false})).unwrap();
+    w.frame(&mut app, Vec::new());
+    assert!(w.find(false, window).is_some(), "Mix in the main window: the plugin window is there");
+    app.run("window.mix_separate", json!({"value": true})).unwrap();
+    // A window reopened in a new native window takes a few frames to find its size.
+    for _ in 0..4 {
+        w.frame(&mut app, Vec::new());
+    }
+    assert!(w.find(true, window).is_some() && w.find(false, window).is_none(), "back in the separate Mix window");
+
+    // Its close button works there.
+    let close = w.find(true, |l| l == "Close window").expect("a close button");
+    w.click(&mut app, true, close);
+    assert!(app.ui.plugin_windows.is_empty() && app.ui.mix_plugin_windows.is_empty(), "closed from the Mix window");
+
+    // The same insert clicked in the Edit window opens it in the main window; clicked in the Mix
+    // window again, the open window moves there.
+    let insert_in_edit = w.find(false, |l| l == "Insert A").expect("the Edit window shows inserts");
+    w.click(&mut app, false, insert_in_edit);
+    assert!(w.find(false, window).is_some() && w.find(true, window).is_none(), "opened from Edit: in the main window");
+    w.click(&mut app, true, insert);
+    assert!(w.find(true, window).is_some() && w.find(false, window).is_none(), "asked for from Mix: moved there");
+    let close = w.find(true, |l| l == "Close window").unwrap();
+    w.click(&mut app, true, close);
+
+    // A send's level opens the Send window in the window it was clicked in, too.
+    let send = w.find(true, |l| l == "Send A level").expect("a send");
+    w.click(&mut app, true, send);
+    let send_window = |l: &str| l.contains(" · Send A");
+    assert!(app.ui.send_window.is_some());
+    assert!(w.find(true, send_window).is_some() && w.find(false, send_window).is_none(), "the Send window opened in the Mix window");
+    w.click(&mut app, true, send);
+    assert!(app.ui.send_window.is_none(), "clicking the send again closes it");
+}
+
+#[test]
+fn plugin_windows_of_a_mix_window_inside_the_main_one_are_drawn_there() {
+    // Backends without native windows: the Mix window and its plugin windows are in the main one.
+    // So without a renderer for native windows, even where egui is not told to embed them.
+    for embed in [true, false] {
+        plugin_windows_drawn_in_the_main_window(embed);
+    }
+}
+
+fn plugin_windows_drawn_in_the_main_window(embed: bool) {
+    let mut app = separate_app();
+    let kick = app.engine.session().track_by_name("Kick").unwrap().id;
+    app.ui.plugin_windows = vec![(kick, 0)];
+    app.ui.mix_plugin_windows = vec![(kick, 0)];
+    let ctx = egui::Context::default();
+    ctx.set_embed_viewports(embed);
+    ctx.enable_accesskit();
+    let mut nodes = Vec::new();
+    for _ in 0..3 {
+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        });
+        out.textures_delta.clear();
+        nodes = labelled(out.platform_output.accesskit_update);
+    }
+    assert!(nodes.iter().any(|(l, _)| l.starts_with("Kick · a · ")), "the plugin window is drawn (embed: {embed})");
+    assert_eq!(app.ui.plugin_windows, [(kick, 0)], "and stays open");
+}
+
+#[test]
+fn plugin_and_send_windows_come_back_to_the_main_window_when_the_mix_window_is_closed() {
+    let w = TwoWindows::new();
+    w.ctx.enable_accesskit();
+    let mut app = separate_app();
+    let kick = app.engine.session().track_by_name("Kick").unwrap().id;
+    app.ui.plugin_windows = vec![(kick, 0)];
+    app.ui.mix_plugin_windows = vec![(kick, 0)];
+    let sends = app.engine.session().tracks.iter().find(|t| t.mixer.sends.first().is_some_and(Option::is_some)).map(|t| t.id).unwrap();
+    app.ui.send_window = Some((sends, 0));
+    app.ui.send_window_in_mix = true;
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let plugin = |l: &str| l.starts_with("Kick · a · ");
+    let send = |l: &str| l.contains(" · Send A");
+    assert!(w.find(true, plugin).is_some() && w.find(true, send).is_some(), "both in the Mix window");
+    assert!(w.find(false, plugin).is_none() && w.find(false, send).is_none());
+
+    // The system's close button: Mix comes back into the main window, and so do they.
+    w.mix_info.borrow_mut().events = vec![egui::ViewportEvent::Close];
+    w.frame(&mut app, Vec::new());
+    w.mix_info.borrow_mut().events.clear();
+    assert!(!app.ui.mix_separate && app.ui.window == MainWindow::Mix, "closing the Mix window shows Mix in the main one");
+    for _ in 0..2 {
+        w.frame(&mut app, Vec::new());
+    }
+    assert!(w.find(false, plugin).is_some() && w.find(false, send).is_some(), "both in the main window");
+    assert_eq!((app.ui.plugin_windows.as_slice(), app.ui.send_window), ([(kick, 0)].as_slice(), Some((sends, 0))), "and still open");
+
+    // Turned on again, they go back with it.
+    app.run("window.mix_separate", json!({"value": true})).unwrap();
+    for _ in 0..4 {
+        w.frame(&mut app, Vec::new());
+    }
+    assert!(w.find(true, plugin).is_some() && w.find(true, send).is_some(), "back in the separate Mix window");
+    assert!(w.find(false, plugin).is_none() && w.find(false, send).is_none());
+}
+
+#[test]
+fn a_sample_loads_where_it_was_dropped_and_its_window_opens_there() {
+    use crate::sampler_window::{Dropped, wav};
+    use std::sync::Arc;
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    let keys = app.engine.session().track_by_name("Keys").unwrap().id;
+    app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
+    let sampler = (keys, soundcraft_mix::INSTRUMENT_SLOT);
+    app.ui.plugin_windows = vec![sampler];
+    app.ui.mix_plugin_windows = vec![sampler];
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let target = |app: &SoundApp, v: egui::ViewportId| app.sample_drop_targets.iter().find(|(at, _, t)| *at == v && *t == keys).map(|(_, r, _)| *r);
+    let at = target(&app, mix_window::viewport_id()).expect("the Sampler window in the Mix window is a drop target").center();
+    let file = |name: &str| -> egui::DroppedFileHandle { Arc::new(Dropped { path: name.into(), bytes: wav(0.1) }) };
+
+    // Dropped in the main window where the Mix window's Sampler is: not for that Sampler.
+    let edit_track = app.sample_drop_targets.iter().rev().find(|(v, r, _)| *v == egui::ViewportId::ROOT && r.contains(at)).map(|(.., t)| *t);
+    w.drop_file(&mut app, false, at, file("main.wav"));
+    let reads = app.take_sample_reads();
+    assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), Vec::from_iter(edit_track), "only what the main window shows there takes it");
+    assert_eq!(w.shell_drops.take().len(), usize::from(edit_track.is_none()), "else it is the shell's");
+
+    // Dropped on the Sampler window in the Mix window: it loads there.
+    w.drop_file(&mut app, true, at, file("mix.wav"));
+    let reads = app.take_sample_reads();
+    assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), [keys]);
+    for r in &reads {
+        app.load_sample_bytes(r, r.file.bytes());
+    }
+    assert!(app.ui.mix_plugin_windows.contains(&sampler), "its window stays in the Mix window");
+
+    // Dropped on Keys' lane in the Edit window while the Mix window last had the keys: the
+    // Sampler window comes to the main window, where the file was dropped.
+    let lane = target(&app, egui::ViewportId::ROOT).expect("Keys' lane in the Edit window is a drop target").center();
+    w.mix_focused.set(true);
+    w.drop_file(&mut app, false, lane, file("lane.wav"));
+    let reads = app.take_sample_reads();
+    assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), [keys]);
+    for r in &reads {
+        app.load_sample_bytes(r, r.file.bytes());
+    }
+    assert_eq!(app.ui.plugin_windows, [sampler]);
+    assert!(app.ui.mix_plugin_windows.is_empty(), "the Sampler window moved to the main window");
+
+    // Other files dropped on the Mix window go to the shell, as from the main window.
+    w.drop_file(&mut app, true, at, file("song.scraft"));
+    assert_eq!(w.shell_drops.take().iter().map(|f| f.path().to_string_lossy().into_owned()).collect::<Vec<_>>(), ["song.scraft"]);
+    assert!(app.take_sample_reads().is_empty());
+}
diff --git a/crates/ui-egui/tests/it/sampler_window.rs b/crates/ui-egui/tests/it/sampler_window.rs
--- a/crates/ui-egui/tests/it/sampler_window.rs
+++ b/crates/ui-egui/tests/it/sampler_window.rs
@@ -11,9 +11,9 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 #[derive(Debug)]
-struct Dropped {
-    path: PathBuf,
-    bytes: Vec<u8>,
+pub(crate) struct Dropped {
+    pub(crate) path: PathBuf,
+    pub(crate) bytes: Vec<u8>,
 }
 
 impl DroppedFile for Dropped {
@@ -25,7 +25,7 @@ impl DroppedFile for Dropped {
     }
 }
 
-fn wav(secs: f32) -> Vec<u8> {
+pub(crate) fn wav(secs: f32) -> Vec<u8> {
     let n = (48_000.0 * secs) as usize;
     let one: Vec<f32> = (0..n).map(|i| 0.4 * (std::f32::consts::TAU * 220.0 * i as f32 / 48_000.0).sin()).collect();
     let buf = AudioBuffer { sample_rate: 48_000, channels: vec![one; 2] };
@@ -66,7 +66,7 @@ fn instrument(h: &Harness<'_, SoundApp>, t: TrackId) -> soundcraft_model::Insert
 
 /// Drops `file` with the pointer at the centre of the Keys sampler window.
 fn drop_on_window(h: &mut Harness<'_, SoundApp>, keys: TrackId, file: Dropped) {
-    let r = h.state().sample_drop_targets.iter().rev().find(|(_, t)| *t == keys).map(|(r, _)| *r).expect("the sampler window is a drop target");
+    let r = h.state().sample_drop_targets.iter().rev().find(|(.., t)| *t == keys).map(|(_, r, _)| *r).expect("the sampler window is a drop target");
     h.state_mut().synthetic.push(Event::PointerMoved(r.center()));
     for _ in 0..3 {
         step(h);
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (separate Mix window: plugin and Send windows open in it) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
 windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -43,7 +43,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 | Window | Status | Notes |
 |---|---|---|
 | Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) |
-| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window) ✅; Delay Compensation view and HEAT missing |
+| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window, a checked item of the Window menu, also the macOS menu bar) ✅: shortcuts work in whichever window has the keys (Edit-only keys such as nudge only in the Edit window; Cmd+= brings the other window forward), and it reopens where it was left (moved next to Edit if its display is gone, as far as the system reports that: macOS yes, Windows no; with monitors at different scales on Windows/X11 it can reopen somewhat offset); plugin and Send windows open in the window they were asked from (the Sampler window, when a sample loads: the window the file was dropped on; `ui.set`: the main window, or the Mix window for those also in `mix_plugin_windows`) and go back into the main window with Mix; Delay Compensation view and HEAT missing |
 | MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow |
 | Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | |
 | Workspace / sound browser with previews | ❌ | G-16 |
@@ -69,6 +69,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Separate Mix window: plugin and Send windows opened from it open in it, not behind it in the main window. Readiness estimate unchanged |
+| 2026-10-24 | minor | Separate Mix window: shortcuts work while it has the keys, its position and size are saved, and it is in the Window menu on the macOS menu bar. Readiness estimate unchanged |
 | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
 | 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
 | 2026-10-13 | minor | Drag across checkboxes (#399) |
