[workstation_session] start using graphical presenter

Uses the graphical presenter to launch elements in ermine.

TEST= Added many unit tests. Manual testing can be done by
launching an element and then closing it to see that it
goes away.

Change-Id: I713896ddefbf7ae4fd682f39b63b63f9b5974852
diff --git a/session_shells/ermine/session/BUILD.gn b/session_shells/ermine/session/BUILD.gn
index 926c723..3211b80 100644
--- a/session_shells/ermine/session/BUILD.gn
+++ b/session_shells/ermine/session/BUILD.gn
@@ -44,6 +44,9 @@
     "//third_party/rust_crates:async-trait",
     "//third_party/rust_crates:futures",
     "//third_party/rust_crates:rand",
+
+    # TODO: use the shared library when fxb/370681 lands
+    "//src/connectivity/bluetooth/lib/async-helpers",
   ]
 
   test_deps = []
diff --git a/session_shells/ermine/session/src/element_repository/event_handler.rs b/session_shells/ermine/session/src/element_repository/event_handler.rs
index 8cd2b70..a6220fb 100644
--- a/session_shells/ermine/session/src/element_repository/event_handler.rs
+++ b/session_shells/ermine/session/src/element_repository/event_handler.rs
@@ -3,16 +3,25 @@
 // found in the LICENSE file.
 
 use {
+    anyhow::Error,
     element_management::Element,
+    fidl::encoding::Decodable,
+    fidl::endpoints::create_proxy,
     fidl_fuchsia_session::{
-        AnnotationError, ElementControllerRequest, ElementControllerRequestStream,
+        AnnotationError, Annotations, ElementControllerRequest, ElementControllerRequestStream,
+        GraphicalPresenterProxy, ViewControllerMarker, ViewControllerProxy, ViewSpec,
     },
-    fuchsia_async as fasync,
-    futures::TryStreamExt,
+    fidl_fuchsia_ui_app::ViewProviderMarker,
+    fuchsia_async as fasync, fuchsia_scenic as scenic,
+    fuchsia_syslog::fx_log_info,
+    fuchsia_zircon as zx,
+    fuchsia_zircon::AsHandleRef,
+    futures::{select, stream::StreamExt, FutureExt, TryStreamExt},
+    std::{cell::RefCell, rc::Rc},
 };
 
 /// A trait which is used by the ElementRepository to respond to incoming events.
-pub(crate) trait EventHandler {
+pub trait EventHandler {
     /// Called when a new element is added to the repository.
     fn add_element(&mut self, element: Element, stream: Option<ElementControllerRequestStream>);
 
@@ -21,75 +30,196 @@
     fn shutdown(&mut self) {}
 }
 
-pub(crate) struct ElementEventHandler {
-    elements: Vec<ElementHolder>,
+pub struct ElementEventHandler {
+    proxy: GraphicalPresenterProxy,
+    termination_event: async_helpers::event::Event,
 }
 
 /// The ElementEventHandler is a concrete implementation of the EventHandler trait which
 /// manages the lifecycle of elements.
 impl ElementEventHandler {
-    pub fn new() -> ElementEventHandler {
-        ElementEventHandler { elements: vec![] }
+    /// Creates a new instance of the ElementEventHandler.
+    pub fn new(proxy: GraphicalPresenterProxy) -> ElementEventHandler {
+        ElementEventHandler { proxy, termination_event: async_helpers::event::Event::new() }
+    }
+
+    /// Attempts to connect to the element's view provider and if it does
+    /// expose the view provider will tell the proxy to present the view.
+    fn present_view_for_element(
+        &self,
+        element: &mut Element,
+    ) -> Result<ViewControllerProxy, Error> {
+        let view_provider = element.connect_to_service::<ViewProviderMarker>()?;
+        let token_pair = scenic::ViewTokenPair::new()?;
+
+        // note: this call will never fail since connecting to a service is
+        // always successful and create_view doesn't have a return value.
+        // If there is no view provider, the view_holder_token will be invalidated
+        // and the presenter can choose to close the view controller if it
+        // only wants to allow graphical views.
+        view_provider.create_view(token_pair.view_token.value, None, None)?;
+
+        let annotations = element.get_annotations()?;
+
+        let view_spec = ViewSpec {
+            view_holder_token: Some(token_pair.view_holder_token),
+            annotations: Some(annotations),
+        };
+
+        let (view_controller_proxy, server_end) = create_proxy::<ViewControllerMarker>()?;
+        self.proxy.present_view(view_spec, Some(server_end))?;
+
+        Ok(view_controller_proxy)
     }
 }
 
 impl EventHandler for ElementEventHandler {
-    fn add_element(&mut self, element: Element, stream: Option<ElementControllerRequestStream>) {
-        self.elements.push(ElementHolder::new(element, stream));
-    }
-
-    // We do not implement the shutdown method because we do not have any cleanup to do at this time.
-}
-
-struct ElementHolder {
-    _element: Option<Element>,
-}
-
-/// An ElementHolder helps to manage the lifetime of an Element.
-///
-/// If an element has an associated ElementController it will serve the ElementController protocol.
-/// If not, it will just hold the elemen to keep it alive.
-impl ElementHolder {
-    fn new(element: Element, stream: Option<ElementControllerRequestStream>) -> ElementHolder {
-        //TODO(47078): Watch the element and respond when it closes.
-        if let Some(stream) = stream {
-            ElementHolder::spawn_request_stream_server(element, stream);
-            return ElementHolder::empty();
-        }
-        ElementHolder { _element: Some(element) }
-    }
-
-    fn spawn_request_stream_server(
+    fn add_element(
+        &mut self,
         mut element: Element,
-        mut stream: ElementControllerRequestStream,
+        stream: Option<ElementControllerRequestStream>,
     ) {
-        //TODO(47078): need to shut down the request stream when the component the element represents dies.
+        // Attempt to present the view. For now, if this fails we log the error and continue, In the
+        // future we will want to communicate the failure back to the proposer.
+        let view_controller_proxy = self.present_view_for_element(&mut element).ok();
+
+        let termination_event = self.termination_event.clone();
+        // Hold the element in the spawn_local here. when the call closes all
+        // of the proxies will be closed.
         fasync::spawn_local(async move {
-            while let Ok(Some(request)) = stream.try_next().await {
-                match request {
-                    ElementControllerRequest::SetAnnotations { annotations, responder } => {
-                        let _ = responder.send(
-                            &mut element
-                                .set_annotations(annotations)
-                                .map_err(|_: anyhow::Error| AnnotationError::Rejected),
-                        );
-                    }
-                    ElementControllerRequest::GetAnnotations { responder } => {
-                        let _ = responder.send(
-                            &mut element
-                                .get_annotations()
-                                .map_err(|_: anyhow::Error| AnnotationError::NotFound),
-                        );
-                    }
-                }
-            }
+            let element_close_fut =
+                Box::pin(run_until_closed(element, stream, view_controller_proxy));
+            futures::future::select(element_close_fut, termination_event.wait()).await;
         });
     }
 
-    // An empty ElementHolder is returned to signal that we are managing the lifetime of this
-    // element but we are doing so via request stream loop.
-    fn empty() -> ElementHolder {
-        ElementHolder { _element: None }
+    fn shutdown(&mut self) {
+        self.termination_event.signal();
+    }
+}
+
+/// Runs the Element until it receives a signal to shutdown.
+///
+/// The Element can receive a signal to shut down from any of the
+/// following:
+///   - Element. The component represented by the element can close on its own.
+///   - ElementControllerRequestStream. The element controller can signal that the element should close.
+///   - ViewControllerProxy. The view controller can signal that the element can close.
+///
+/// The Element will shutdown when any of these signals are received.
+///
+/// The Element will also listen for any incoming events from the element controller and
+/// forward them to the view controller.
+async fn run_until_closed(
+    element: Element,
+    stream: Option<ElementControllerRequestStream>,
+    view_controller_proxy: Option<ViewControllerProxy>,
+) {
+    let element = Rc::new(RefCell::new(element));
+
+    select!(
+        _ = await_element_close(element.clone()).fuse() => {
+            // signals that a element has died without being told to close.
+            // We could tell the view to dismiss here but we need to signal
+            // that there was a crash. The current contract is that if the
+            // view controller binding closes without a dismiss then the
+            // presenter should treat this as a crash and respond accordingly.
+            fx_log_info!("An element closed unexpectedly: {:?}", element);
+        },
+        _ = wait_for_optional_view_controller_close(view_controller_proxy.clone()).fuse() =>  {
+            // signals that the presenter would like to close the element.
+            // We do not need to do anything here but exit which will cause
+            // the element to be dropped and will kill the component.
+        },
+        _ = spawn_element_controller_stream(element.clone(), stream, view_controller_proxy.clone()).fuse() => {
+            // the proposer has decided they want to shut down the element.
+            if let Some(proxy) = view_controller_proxy {
+                // We want to allow the presenter the ability to dismiss
+                // the view so we tell it to dismiss and then wait for
+                // the view controller stream to close.
+                let _ = proxy.dismiss();
+                //TODO(47925) introdue a timeout here
+                wait_for_view_controller_close(proxy).await;
+            }
+        }
+    );
+}
+
+/// Waits for the element to signal that it closed
+async fn await_element_close(element: Rc<RefCell<Element>>) {
+    let element = element.borrow();
+    let channel = element.directory_channel();
+    let _ =
+        fasync::OnSignals::new(&channel.as_handle_ref(), zx::Signals::CHANNEL_PEER_CLOSED).await;
+}
+
+/// Waits for the view controller to signal that it wants to close.
+///
+/// if the ViewControllerProxy is None then this future will never resolve.
+async fn wait_for_optional_view_controller_close(proxy: Option<ViewControllerProxy>) {
+    if let Some(proxy) = proxy {
+        wait_for_view_controller_close(proxy).await;
+    } else {
+        // If the view controller is None then we never exit and rely
+        // on the other futures to signal the end of the element.
+        futures::future::pending::<()>().await;
+    }
+}
+
+/// Waits for this view controller to close.
+async fn wait_for_view_controller_close(proxy: ViewControllerProxy) {
+    let stream = proxy.take_event_stream();
+    let _ = stream.collect::<Vec<_>>().await;
+}
+
+/// watches the element controller request stream and responds to requests.
+///
+/// if the ElementControllerRequestStream is None then this future will never resolve.
+async fn spawn_element_controller_stream(
+    element: Rc<RefCell<Element>>,
+    stream: Option<ElementControllerRequestStream>,
+    view_controller_proxy: Option<ViewControllerProxy>,
+) {
+    if let Some(mut stream) = stream {
+        while let Ok(Some(request)) = stream.try_next().await {
+            match request {
+                ElementControllerRequest::SetAnnotations { annotations, responder } => {
+                    let mut element = element.borrow_mut();
+                    let _ = responder.send(
+                        &mut element
+                            .set_annotations(annotations)
+                            .map_err(|_: anyhow::Error| AnnotationError::Rejected),
+                    );
+
+                    if let Some(proxy) = &view_controller_proxy {
+                        // Annotations cannot be cloned so we get them from
+                        // the element which creates a new copy. This must
+                        // be done after the call to set_annotations on
+                        // element.
+                        let annotations = element.get_annotations().unwrap_or_else(|e: Error| {
+                            eprintln!("failed to get annotations from element: {:?}", e);
+                            Annotations::new_empty()
+                        });
+                        let _ =
+                            proxy.annotate(annotations).await.unwrap_or_else(|e: fidl::Error| {
+                                eprintln!("failed to get annotations from element: {:?}", e)
+                            });
+                    }
+                }
+                ElementControllerRequest::GetAnnotations { responder } => {
+                    let mut element = element.borrow_mut();
+                    let _ = responder.send(
+                        &mut element
+                            .get_annotations()
+                            .map_err(|_: anyhow::Error| AnnotationError::NotFound),
+                    );
+                }
+            }
+        }
+    } else {
+        // If the element controller is None then we never exit and rely
+        // on the other futures to signal the end of the element.
+        futures::future::pending::<()>().await;
     }
 }
 
@@ -98,54 +228,276 @@
     use {
         super::*,
         crate::element_repository::testing_utils::{init_logger, make_mock_element},
-        anyhow::Error,
         fidl::endpoints::create_proxy_and_stream,
-        fidl_fuchsia_session::{Annotation, Annotations, ElementControllerMarker, Value},
+        fidl_fuchsia_session::{
+            Annotation, ElementControllerMarker, GraphicalPresenterMarker, Value,
+            ViewControllerMarker, ViewControllerRequest,
+        },
+        fuchsia_async::{DurationExt, Timer},
+        fuchsia_zircon::DurationNum,
+        futures::{future::Either, task::Poll},
     };
-    #[test]
-    fn add_element_adds_to_the_collection() {
-        init_logger();
-        let (element, _channel) = make_mock_element();
-        let mut handler = ElementEventHandler::new();
-        handler.add_element(element, None);
-        assert_eq!(handler.elements.len(), 1);
-    }
 
-    #[test]
-    fn element_holder_without_stream_new() {
-        init_logger();
-        let (element, _channel) = make_mock_element();
-        let holder = ElementHolder::new(element, None);
-        assert!(holder._element.is_some());
-    }
-
-    #[fasync::run_singlethreaded(test)]
-    async fn element_holder_with_stream_manages_lifetime_via_stream() -> Result<(), Error> {
-        init_logger();
-        let (element, _channel) = make_mock_element();
-        let (_proxy, stream) = create_proxy_and_stream::<ElementControllerMarker>()?;
-        let holder = ElementHolder::new(element, Some(stream));
-        assert!(holder._element.is_none());
-        Ok(())
-    }
-
-    #[fasync::run_singlethreaded(test)]
-    async fn element_holder_listens_to_event_stream() -> Result<(), Error> {
-        init_logger();
-        let (element, _channel) = make_mock_element();
-        let (proxy, stream) = create_proxy_and_stream::<ElementControllerMarker>()?;
-        let _holder = ElementHolder::new(element, Some(stream));
-
-        let annotation = Annotation {
-            key: "foo".to_string(),
-            value: Some(Box::new(Value::Text("bar".to_string()))),
+    macro_rules! expect_element_wait_fut_completion {
+        ($element_wait_fut:expr) => {
+            let timeout = Timer::new(500_i64.millis().after_now());
+            let either = futures::future::select(timeout, $element_wait_fut);
+            let resolved = either.await;
+            match resolved {
+                Either::Left(_) => panic!("failed to end element wait"),
+                Either::Right(_) => (),
+            }
         };
-        let _ =
-            proxy.set_annotations(Annotations { custom_annotations: Some(vec![annotation]) }).await;
-        let result = proxy.get_annotations().await?;
+    }
 
-        let key = &result.unwrap().custom_annotations.unwrap()[0].key;
-        assert_eq!(key, "foo");
+    #[test]
+    fn spawn_element_controller_stream_never_resolves_if_none_stream() {
+        init_logger();
+        let mut executor = fasync::Executor::new().unwrap();
+
+        let (element, _channel) = make_mock_element();
+        let mut fut =
+            Box::pin(spawn_element_controller_stream(Rc::new(RefCell::new(element)), None, None));
+
+        assert_eq!(Poll::Pending, executor.run_until_stalled(&mut fut));
+    }
+
+    #[test]
+    fn wait_for_optional_view_controller_close_never_resolves_if_none_proxy() {
+        init_logger();
+        let mut executor = fasync::Executor::new().unwrap();
+
+        let mut fut = Box::pin(wait_for_optional_view_controller_close(None));
+
+        assert_eq!(Poll::Pending, executor.run_until_stalled(&mut fut));
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn add_element_keeps_the_element_alive() {
+        init_logger();
+        let (element, channel) = make_mock_element();
+        let (proxy, _stream) =
+            create_proxy_and_stream::<GraphicalPresenterMarker>().expect("failed to create proxy");
+
+        let mut handler = ElementEventHandler::new(proxy);
+        handler.add_element(element, None);
+
+        let timeout = Timer::new(500_i64.millis().after_now());
+        let handle_ref = channel.as_handle_ref();
+        let element_close_fut =
+            fasync::OnSignals::new(&handle_ref, zx::Signals::CHANNEL_PEER_CLOSED);
+
+        let either = futures::future::select(timeout, element_close_fut);
+        let resolved = either.await;
+        match resolved {
+            Either::Left(_) => (),
+            Either::Right(_) => panic!("channel closed before timeout"),
+        }
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn killing_element_ends_wait() {
+        init_logger();
+        let (element, channel) = make_mock_element();
+
+        let element_wait_fut = Box::pin(run_until_closed(element, None, None));
+
+        // signal that the element should close
+        drop(channel);
+
+        expect_element_wait_fut_completion!(element_wait_fut);
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn dropping_element_controller_ends_wait() {
+        init_logger();
+        let (element, _channel) = make_mock_element();
+        let (element_controller, element_stream) =
+            create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+        let element_wait_fut = Box::pin(run_until_closed(element, Some(element_stream), None));
+
+        drop(element_controller);
+
+        expect_element_wait_fut_completion!(element_wait_fut);
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn dropping_element_controller_ends_wait_after_dismissing_proxy() {
+        init_logger();
+        let (element, _channel) = make_mock_element();
+        let (vc_proxy, vc_server_end) =
+            create_proxy::<ViewControllerMarker>().expect("failed to create proxy");
+
+        let (element_controller, element_stream) =
+            create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+        let element_wait_fut =
+            Box::pin(run_until_closed(element, Some(element_stream), Some(vc_proxy)));
+
+        drop(element_controller);
+
+        // Our API contract is that when the element controller closes we
+        // tell the view controller to dismiss. The view controller then can
+        // decide how it wants to dismiss the view. When it is done presenting
+        // the view it closes the channel to indicate it is done presenting.
+        fasync::spawn_local(async move {
+            let mut vc_stream = vc_server_end.into_stream().unwrap();
+            while let Ok(Some(request)) = vc_stream.try_next().await {
+                match request {
+                    ViewControllerRequest::Dismiss { control_handle } => {
+                        control_handle.shutdown();
+                    }
+                    _ => (),
+                }
+            }
+        });
+
+        expect_element_wait_fut_completion!(element_wait_fut);
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn dropping_view_controller_ends_wait() {
+        init_logger();
+        let (element, _channel) = make_mock_element();
+        let (view_controller, view_controller_stream) =
+            create_proxy_and_stream::<ViewControllerMarker>().expect("failed to create proxy");
+        let element_wait_fut = Box::pin(run_until_closed(element, None, Some(view_controller)));
+
+        drop(view_controller_stream);
+
+        expect_element_wait_fut_completion!(element_wait_fut);
+    }
+    #[fasync::run_singlethreaded(test)]
+    async fn calling_shutdown_on_the_handler_kills_elements() {
+        init_logger();
+        let (element, channel) = make_mock_element();
+        let (proxy, _stream) =
+            create_proxy_and_stream::<GraphicalPresenterMarker>().expect("failed to create proxy");
+
+        let mut handler = ElementEventHandler::new(proxy);
+        handler.add_element(element, None);
+
+        // shutdown should kill elements
+        handler.shutdown();
+
+        let timeout = Timer::new(500_i64.millis().after_now());
+        let handle_ref = channel.as_handle_ref();
+        let element_close_fut =
+            fasync::OnSignals::new(&handle_ref, zx::Signals::CHANNEL_PEER_CLOSED);
+
+        let either = futures::future::select(timeout, element_close_fut);
+        let resolved = either.await;
+        match resolved {
+            Either::Left(_) => panic!("element should have closed before timeout"),
+            Either::Right(_) => (),
+        }
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn spawn_element_controller_stream_set_annotations_updates_element() {
+        init_logger();
+        let (element, _channel) = make_mock_element();
+        let (element_controller, element_stream) =
+            create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+        let element = Rc::new(RefCell::new(element));
+
+        let element_clone = element.clone();
+        fasync::spawn_local(async move {
+            spawn_element_controller_stream(element_clone, Some(element_stream), None).await;
+        });
+
+        let new_annotations = Annotations {
+            custom_annotations: Some(vec![Annotation {
+                key: "foo".to_string(),
+                value: Some(Box::new(Value::Text("bar".to_string()))),
+            }]),
+        };
+        let _ = element_controller.set_annotations(new_annotations).await;
+
+        let mut element = element.borrow_mut();
+        let annotations = element.get_annotations().unwrap();
+        let custom_annotations = annotations.custom_annotations.unwrap();
+
+        assert_eq!(custom_annotations.len(), 1);
+        assert_eq!(custom_annotations[0].key, "foo");
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn spawn_element_controller_stream_set_annotations_updates_view_controller_proxy() {
+        init_logger();
+        let (element, _channel) = make_mock_element();
+        let (element_controller, element_stream) =
+            create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+        let (vc_proxy, mut view_controller_stream) =
+            create_proxy_and_stream::<ViewControllerMarker>().expect("failed to create proxy");
+
+        let element = Rc::new(RefCell::new(element));
+
+        let element_clone = element.clone();
+        fasync::spawn_local(async move {
+            spawn_element_controller_stream(element_clone, Some(element_stream), Some(vc_proxy))
+                .await;
+        });
+
+        fasync::spawn_local(async move {
+            let new_annotations = Annotations {
+                custom_annotations: Some(vec![Annotation {
+                    key: "foo".to_string(),
+                    value: Some(Box::new(Value::Text("bar".to_string()))),
+                }]),
+            };
+            let _ = element_controller.set_annotations(new_annotations).await;
+        });
+
+        let mut got_annotation = false;
+        if let Ok(Some(request)) = view_controller_stream.try_next().await {
+            match request {
+                ViewControllerRequest::Annotate { annotations, responder } => {
+                    let custom_annotations = annotations.custom_annotations.unwrap();
+                    got_annotation = custom_annotations[0].key == "foo";
+
+                    let _ = responder.send();
+                }
+                _ => (),
+            }
+        }
+        assert!(got_annotation);
+    }
+
+    #[fasync::run_singlethreaded(test)]
+    async fn spawn_element_controller_stream_can_get_annotations() -> Result<(), Error> {
+        init_logger();
+
+        let (element, _channel) = make_mock_element();
+        let (element_controller, element_stream) =
+            create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+        let element = Rc::new(RefCell::new(element));
+
+        let new_annotations = Annotations {
+            custom_annotations: Some(vec![Annotation {
+                key: "foo".to_string(),
+                value: Some(Box::new(Value::Text("bar".to_string()))),
+            }]),
+        };
+
+        {
+            let mut element = element.borrow_mut();
+            element.set_annotations(new_annotations).expect("failed to set annotationss");
+        }
+
+        let element_clone = element.clone();
+        fasync::spawn_local(async move {
+            spawn_element_controller_stream(element_clone, Some(element_stream), None).await;
+        });
+
+        let mut got_annotation = false;
+        if let Ok(Ok(annotations)) = element_controller.get_annotations().await {
+            let custom_annotations = annotations.custom_annotations.unwrap();
+            got_annotation = custom_annotations[0].key == "foo";
+        }
+        assert!(got_annotation);
         Ok(())
     }
 }
diff --git a/session_shells/ermine/session/src/element_repository/mod.rs b/session_shells/ermine/session/src/element_repository/mod.rs
index 27de4ef..729845c 100644
--- a/session_shells/ermine/session/src/element_repository/mod.rs
+++ b/session_shells/ermine/session/src/element_repository/mod.rs
@@ -11,7 +11,6 @@
 use {
     anyhow::{Context as _, Error},
     element_management::{Element, ElementManager, ElementManagerError},
-    event_handler::{ElementEventHandler, EventHandler},
     fidl_fuchsia_session::{
         ElementControllerMarker, ElementControllerRequestStream, ElementSpec, ProposeElementError,
     },
@@ -23,7 +22,10 @@
     std::rc::Rc,
 };
 
-pub use element_manager_server::ElementManagerServer;
+pub use {
+    element_manager_server::ElementManagerServer,
+    event_handler::{ElementEventHandler, EventHandler},
+};
 
 /// The child collection to add elements to. This must match a collection name declared in
 /// this session's CML file.
@@ -56,17 +58,14 @@
         ElementRepository { element_manager, sender, receiver }
     }
 
-    /// Starts the event loop which handles incoming events that are handled by the servers
-    pub async fn run(&mut self) -> Result<(), Error> {
-        self.run_with_handler(&mut ElementEventHandler::new()).await;
-        Ok(())
-    }
-
     /// Runs the repository with a given handler.
     ///
     /// The handler is responsible for responding to incoming events and processing them.
     /// A single handler is used for one repository allowing it to be safely mutated.
-    async fn run_with_handler<'a>(&mut self, handler: &'a mut impl EventHandler) {
+    pub async fn run_with_handler<'a>(
+        &mut self,
+        handler: &'a mut impl EventHandler,
+    ) -> Result<(), Error> {
         while let Some(event) = self.receiver.next().await {
             match event {
                 ElementEvent::ElementAdded { element, element_controller_stream } => {
@@ -78,6 +77,7 @@
                 }
             }
         }
+        Ok(())
     }
 
     /// Creates a new ElementManagerServer suitable for handling incoming connections.
@@ -182,7 +182,7 @@
     }
 
     #[fasync::run_singlethreaded(test)]
-    async fn shutdown_event_forwards_to_handler_and_ends_loop() {
+    async fn shutdown_event_forwards_to_handler_and_ends_loop() -> Result<(), Error> {
         init_logger();
         let mut repo = ElementRepository::new_for_test();
         let mut handler = CallCountEventHandler::default();
@@ -192,12 +192,13 @@
             sender.unbounded_send(ElementEvent::Shutdown).expect("failed to send event");
         });
 
-        repo.run_with_handler(&mut handler).await;
+        repo.run_with_handler(&mut handler).await?;
         assert_eq!(handler.shutdown_call_count, 1);
+        Ok(())
     }
 
     #[fasync::run_singlethreaded(test)]
-    async fn element_added_event_forwards_to_handler() {
+    async fn element_added_event_forwards_to_handler() -> Result<(), Error> {
         init_logger();
         let mut repo = ElementRepository::new_for_test();
         let mut handler = CallCountEventHandler::default();
@@ -216,8 +217,9 @@
             sender.unbounded_send(ElementEvent::Shutdown).expect("failed to send event");
         });
 
-        repo.run_with_handler(&mut handler).await;
+        repo.run_with_handler(&mut handler).await?;
         assert_eq!(handler.add_call_count, 1);
+        Ok(())
     }
 
     #[fasync::run_singlethreaded(test)]
diff --git a/session_shells/ermine/session/src/main.rs b/session_shells/ermine/session/src/main.rs
index 9937e38..f99b468 100644
--- a/session_shells/ermine/session/src/main.rs
+++ b/session_shells/ermine/session/src/main.rs
@@ -10,13 +10,15 @@
 
 use {
     crate::{
-        element_repository::{ElementManagerServer, ElementRepository},
+        element_repository::{ElementEventHandler, ElementManagerServer, ElementRepository},
         pointer_hack_server::PointerHackServer,
     },
     anyhow::{Context as _, Error},
     element_management::SimpleElementManager,
     fidl::endpoints::DiscoverableService,
-    fidl_fuchsia_session::{ElementManagerMarker, ElementManagerRequestStream},
+    fidl_fuchsia_session::{
+        ElementManagerMarker, ElementManagerRequestStream, GraphicalPresenterMarker,
+    },
     fidl_fuchsia_sys::LauncherMarker,
     fidl_fuchsia_sys2 as fsys,
     fidl_fuchsia_ui_app::ViewProviderMarker,
@@ -111,6 +113,9 @@
     let (app, pointer_hack_server) = launch_ermine(element_repository.make_server()).await?;
     let view_provider = app.connect_to_service::<ViewProviderMarker>()?;
 
+    let presenter = app.connect_to_service::<GraphicalPresenterMarker>()?;
+    let mut handler = ElementEventHandler::new(presenter);
+
     let scenic = connect_to_service::<ScenicMarker>()?;
     let mut scene_manager = scene_management::FlatSceneManager::new(scenic, None, None).await?;
 
@@ -119,7 +124,7 @@
 
     let services_fut = expose_services(element_repository.make_server());
     let input_fut = workstation_input_pipeline::handle_input(scene_manager, &pointer_hack_server);
-    let element_manager_fut = element_repository.run();
+    let element_manager_fut = element_repository.run_with_handler(&mut handler);
 
     //TODO(47080) monitor the futures to see if they complete in an error.
     let _ = try_join!(services_fut, input_fut, element_manager_fut);
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index 2a42845..18a6d33 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -83,6 +83,7 @@
     "src/models/topbar_model.dart",
     "src/utils/elevations.dart",
     "src/utils/styles.dart",
+    "src/utils/presenter.dart",
     "src/utils/suggestion.dart",
     "src/utils/suggestions.dart",
     "src/utils/utils.dart",
@@ -124,6 +125,7 @@
     "//sdk/fidl/fuchsia.intl",
     "//sdk/fidl/fuchsia.memory",
     "//sdk/fidl/fuchsia.power",
+    "//sdk/fidl/fuchsia.session",
     "//sdk/fidl/fuchsia.ui.app",
     "//sdk/fidl/fuchsia.ui.input",
     "//sdk/fidl/fuchsia.ui.input2",
diff --git a/session_shells/ermine/shell/lib/src/models/app_model.dart b/session_shells/ermine/shell/lib/src/models/app_model.dart
index 3dd0cbb..662a172 100644
--- a/session_shells/ermine/shell/lib/src/models/app_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/app_model.dart
@@ -6,7 +6,6 @@
 import 'dart:io';
 
 import 'package:fidl_fuchsia_intl/fidl_async.dart';
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
 import 'package:fidl_fuchsia_ui_input/fidl_async.dart' as input;
 import 'package:fidl_fuchsia_ui_shortcut/fidl_async.dart' as ui_shortcut
     show RegistryProxy;
@@ -20,6 +19,7 @@
     show KeyboardShortcuts;
 import 'package:lib.widgets/utils.dart' show PointerEventsListener;
 
+import '../utils/presenter.dart';
 import '../utils/suggestions.dart';
 import '../widgets/ask/ask.dart';
 import 'cluster_model.dart';
@@ -32,9 +32,9 @@
   final _pointerEventsListener = PointerEventsListener();
   final _shortcutRegistry = ui_shortcut.RegistryProxy();
   final _intl = PropertyProviderProxy();
-  final _launcher = LauncherProxy();
 
   SuggestionService _suggestionService;
+  PresenterService _presenterService;
 
   /// The [GlobalKey] associated with [Ask] widget.
   final GlobalKey<AskState> askKey = GlobalKey(debugLabel: 'ask');
@@ -62,20 +62,24 @@
     _startupContext.incoming.connectToService(_shortcutRegistry);
     _startupContext.incoming.connectToService(_intl);
     _startupContext.incoming.connectToService(_presentation);
-    _startupContext.incoming.connectToService(_launcher);
 
     _localeStream = LocaleSource(_intl).stream().asBroadcastStream();
 
-    clustersModel = ClustersModel(_launcher);
+    clustersModel = ClustersModel();
 
     _suggestionService = SuggestionService.fromStartupContext(
       startupContext: _startupContext,
-      onSuggestion: clustersModel.storyStarted,
+      onSuggestion: clustersModel.storySuggested,
     );
 
     topbarModel = TopbarModel(appModel: this);
 
     status = StatusModel.fromStartupContext(_startupContext, onLogout);
+
+    // Expose the presenter service to the environment.
+    _presenterService = PresenterService(clustersModel.presentStory);
+    _startupContext.outgoing
+        .addPublicService(_presenterService.bind, PresenterService.serviceName);
   }
 
   SuggestionService get suggestions => _suggestionService;
@@ -236,7 +240,6 @@
     _keyboardShortcuts.dispose();
     _shortcutRegistry.ctrl.close();
     _presentation.ctrl.close();
-    _launcher.ctrl.close();
   }
 
   void injectTap(Offset offset) {
diff --git a/session_shells/ermine/shell/lib/src/models/cluster_model.dart b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
index d857362..fa33de6 100644
--- a/session_shells/ermine/shell/lib/src/models/cluster_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
@@ -4,9 +4,10 @@
 
 import 'package:flutter/material.dart';
 
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
 import 'package:tiler/tiler.dart' show TilerModel, TileModel;
 
+import '../utils/presenter.dart';
 import '../utils/suggestion.dart';
 import 'ermine_shell.dart';
 import 'ermine_story.dart';
@@ -52,10 +53,6 @@
   /// Returns a iterable of all [Story] objects.
   Iterable<ErmineStory> get stories => _storyToCluster.keys.map(getStory);
 
-  final LauncherProxy _launcher;
-
-  ClustersModel(Launcher launcher) : _launcher = launcher;
-
   /// Maximize the story to fullscreen: it's visual state to IMMERSIVE.
   void maximize(String id) {
     if (fullscreenStoryNotifier.value?.id == id) {
@@ -78,18 +75,42 @@
   /// Returns the story given it's id.
   ErmineStory getStory(String id) => _storyToCluster[id]?.getStory(id);
 
+  @override
+  void presentStory(
+    ViewHolderToken token,
+    ViewControllerImpl viewController,
+    String id,
+  ) {
+    // Check to see if a story already exists on screen.
+    ErmineStory story = getStory(id);
+
+    if (story == null) {
+      // This view was created by some external source.
+      // create a new story and present it.
+      story = ErmineStory.fromExternalSource(
+        onDelete: storyDeleted,
+        onChange: storyChanged,
+      );
+      _addErmineStory(story);
+    }
+
+    story.presentView(token, viewController);
+  }
+
   /// Creates and adds a [Story] to the current cluster given it's [StoryInfo],
   /// [SessionShell] and [StoryController]. Returns the created instance.
   @override
-  void storyStarted(Suggestion suggestion) {
+  void storySuggested(Suggestion suggestion) {
     assert(!_storyToCluster.containsKey(suggestion.id));
-    final story = ErmineStory(
+    final story = ErmineStory.fromSuggestion(
       suggestion: suggestion,
-      launcher: _launcher,
       onDelete: storyDeleted,
       onChange: storyChanged,
     );
+    _addErmineStory(story);
+  }
 
+  void _addErmineStory(ErmineStory story) {
     // Add the story to the current cluster
     currentCluster.value ??= clusters.first;
     _storyToCluster[story.id] = currentCluster.value..add(story);
diff --git a/session_shells/ermine/shell/lib/src/models/ermine_shell.dart b/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
index cf3f288..785e430 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+
+import '../utils/presenter.dart';
 import '../utils/suggestion.dart';
 import 'ermine_story.dart';
 
@@ -14,11 +17,27 @@
 /// - programmatically through scripting
 abstract class ErmineShell {
   /// Creates a new [ErmineStory] with a given [Suggestion].
-  void storyStarted(Suggestion suggestion);
+  void storySuggested(Suggestion suggestion);
 
   /// Handles a story removed from the shell.
   void storyDeleted(ErmineStory story);
 
   /// Handles story attributes changing and updating its UX in the shell.
   void storyChanged(ErmineStory story);
+
+  /// Called when a story should be presented. A story is presented after
+  /// it has been launched. The story may have been proposed by ermine or
+  /// an external source.
+  void presentStory(
+    /// The view holder token used to connect the view to the process
+    ViewHolderToken token,
+
+    /// A view controller which can be used to communicate with the running process
+    /// This value may be null
+    ViewControllerImpl viewController,
+
+    /// A string identifying the launched story. This id is only valid if the
+    /// story was launched from ermine.
+    String id,
+  );
 }
diff --git a/session_shells/ermine/shell/lib/src/models/ermine_story.dart b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
index 99bf05b..7ed9502 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_story.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
@@ -2,40 +2,78 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
-import 'package:fidl_fuchsia_ui_app/fidl_async.dart';
+import 'package:fidl_fuchsia_session/fidl_async.dart';
 import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
 import 'package:flutter/material.dart';
+import 'package:fuchsia_logger/logger.dart';
 import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
 import 'package:fuchsia_services/services.dart';
-import 'package:zircon/zircon.dart';
+import 'package:uuid/uuid.dart';
 
+import '../utils/presenter.dart';
 import '../utils/suggestion.dart';
 
+/// A function which can be used to launch the suggestion.
+@visibleForTesting
+typedef LaunchFunction = Future<void> Function(
+    Suggestion, ElementControllerProxy);
+
 /// Defines a class to represent a story in ermine.
 class ErmineStory {
-  final Suggestion suggestion;
   final ValueChanged<ErmineStory> onDelete;
   final ValueChanged<ErmineStory> onChange;
-  final LauncherProxy _launcher;
-  final ComponentControllerProxy _componentController;
+  final String id;
 
+  // An optional view controller which allows the story to communicate with the
+  // process.
+  @visibleForTesting
+  ViewControllerImpl viewController;
+
+  // An optional element controller which allows the story to communicate with
+  // the element. This will only be available if ermine launched this process.
+  ElementControllerProxy _elementController;
+
+  /// Creates a launches an ermine story.
+  @visibleForTesting
   ErmineStory({
-    this.suggestion,
-    Launcher launcher,
+    this.id,
     this.onDelete,
     this.onChange,
-    ComponentControllerProxy componentController,
-  })  : _launcher = launcher,
-        _componentController =
-            componentController ?? ComponentControllerProxy(),
-        nameNotifier = ValueNotifier(suggestion.title),
-        childViewConnectionNotifier = ValueNotifier(null) {
-    launchSuggestion();
-    _componentController.onTerminated.listen((_) => delete());
+    String title = '',
+  })  : nameNotifier = ValueNotifier(title),
+        childViewConnectionNotifier = ValueNotifier(null);
+
+  factory ErmineStory.fromSuggestion({
+    Suggestion suggestion,
+    ValueChanged<ErmineStory> onDelete,
+    ValueChanged<ErmineStory> onChange,
+    LaunchFunction launchFunction = ErmineStory.launchSuggestion,
+  }) {
+    final elementController = ElementControllerProxy();
+    launchFunction(suggestion, elementController);
+    return ErmineStory(
+      id: suggestion.id,
+      title: suggestion.title,
+      onDelete: onDelete,
+      onChange: onChange,
+    ).._elementController = elementController;
   }
 
-  String get id => suggestion.id;
+  /// Creates an ermine story which was proposed from an external source.
+  ///
+  /// This method will not attempt to launch a story but will generate
+  /// a random suggestion
+  factory ErmineStory.fromExternalSource({
+    ValueChanged<ErmineStory> onDelete,
+    ValueChanged<ErmineStory> onChange,
+  }) {
+    final id = Uuid().v4();
+    return ErmineStory(
+      id: 'external:$id',
+      onDelete: onDelete,
+      onChange: onChange,
+    );
+  }
 
   final ValueNotifier<String> nameNotifier;
   String get name => nameNotifier.value ?? id;
@@ -55,9 +93,9 @@
   bool get isImmersive => fullscreenNotifier.value == true;
 
   void delete() {
-    _componentController.kill();
-    _componentController.ctrl.close();
+    viewController?.close();
     onDelete?.call(this);
+    _elementController?.ctrl?.close();
   }
 
   void focus() => onChange?.call(this..focused = true);
@@ -69,34 +107,38 @@
   ValueNotifier<bool> editStateNotifier = ValueNotifier(false);
   void edit() => editStateNotifier.value = !editStateNotifier.value;
 
-  @visibleForTesting
-  Future<void> launchSuggestion() async {
-    final incoming = Incoming();
+  static Future<void> launchSuggestion(
+      Suggestion suggestion, ElementControllerProxy elementController) async {
+    final proxy = ElementManagerProxy();
 
-    await _launcher.createComponent(
-      LaunchInfo(
-        url: suggestion.url,
-        directoryRequest: incoming.request().passChannel(),
+    StartupContext.fromStartupInfo().incoming.connectToService(proxy);
+
+    final annotations = Annotations(customAnnotations: [
+      Annotation(
+        key: ermineSuggestionIdKey,
+        value: Value.withText(suggestion.id),
       ),
-      _componentController.ctrl.request(),
-    );
+    ]);
 
-    ViewProviderProxy viewProvider = ViewProviderProxy();
-    incoming.connectToService(viewProvider);
-    await incoming.close();
+    final spec =
+        ElementSpec(componentUrl: suggestion.url, annotations: annotations);
 
-    final viewTokens = EventPairPair();
-    assert(viewTokens.status == ZX.OK);
-    final viewHolderToken = ViewHolderToken(value: viewTokens.first);
-    final viewToken = ViewToken(value: viewTokens.second);
+    await proxy
+        .proposeElement(spec, elementController.ctrl.request())
+        .catchError((err) {
+      log.shout('$err: Failed to propose elememnt <${suggestion.url}>');
+    });
 
-    await viewProvider.createView(viewToken.value, null, null);
-    viewProvider.ctrl.close();
+    proxy.ctrl.close();
+  }
 
+  void presentView(ViewHolderToken viewHolderToken, ViewControllerImpl vc) {
     childViewConnectionNotifier.value = ChildViewConnection(
       viewHolderToken,
       onAvailable: (_) {},
       onUnavailable: (_) {},
     );
+    viewController = vc;
+    viewController?.didPresent();
   }
 }
diff --git a/session_shells/ermine/shell/lib/src/utils/presenter.dart b/session_shells/ermine/shell/lib/src/utils/presenter.dart
new file mode 100644
index 0000000..c9d3dd1
--- /dev/null
+++ b/session_shells/ermine/shell/lib/src/utils/presenter.dart
@@ -0,0 +1,102 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:fidl_fuchsia_session/fidl_async.dart' as fidl;
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:fidl/fidl.dart';
+import 'package:fuchsia_logger/logger.dart';
+
+import 'suggestion.dart';
+
+/// A callback which is invoked when the presenter is asked to present a view.
+///
+/// The ViewControllerImpl will not be null but it may not be bound if the
+/// requesting source did not provide a channel. The methods will still be safe
+/// to call even if it is not bound.
+typedef PresentViewCallback = void Function(
+    ViewHolderToken, ViewControllerImpl, String);
+
+/// A service which implements the fuchsia.session.GraphicalPresenter protocol.
+class PresenterService extends fidl.GraphicalPresenter {
+  static const String serviceName = fidl.GraphicalPresenter.$serviceName;
+
+  final List<fidl.GraphicalPresenterBinding> _bindings = [];
+  final PresentViewCallback _onPresent;
+
+  PresenterService(this._onPresent);
+
+  /// Binds the request to this model.
+  void bind(InterfaceRequest<fidl.GraphicalPresenter> request) {
+    final binding = fidl.GraphicalPresenterBinding();
+    binding
+      ..bind(this, request)
+      ..whenClosed.then((_) {
+        _bindings.remove(binding);
+      });
+    _bindings.add(binding);
+  }
+
+  @override
+  Future<void> presentView(fidl.ViewSpec viewSpec,
+      InterfaceRequest<fidl.ViewController> viewControllerRequest) async {
+    final viewController = ViewControllerImpl()..bind(viewControllerRequest);
+
+    // Check to see if we have an id that we included in the annotation.
+    final idAnnotation = viewSpec.annotations?.customAnnotations
+        ?.firstWhere((a) => a.key == ermineSuggestionIdKey, orElse: () => null);
+
+    final viewHolderToken = viewSpec.viewHolderToken;
+    if (viewHolderToken != null) {
+      _onPresent(
+          viewHolderToken, viewController, idAnnotation?.value?.text ?? '');
+    } else {
+      viewController.close(fidl.ViewControllerEpitaph.invalidViewSpec);
+    }
+  }
+}
+
+class ViewControllerImpl extends fidl.ViewController {
+  final _binding = fidl.ViewControllerBinding();
+  final StreamController<void> _onPresentedStreamController =
+      StreamController.broadcast();
+
+  void bind(InterfaceRequest<fidl.ViewController> interfaceRequest) {
+    if (interfaceRequest != null && interfaceRequest.channel != null) {
+      _binding.bind(this, interfaceRequest);
+      _binding.whenClosed.then((_) {
+        //TODO(47793) Need to watch our binding for unexpected closures and notify the user
+        log.info('binding closed unexpectedly');
+      });
+    }
+  }
+
+  void close([fidl.ViewControllerEpitaph e]) {
+    if (_binding.isBound) {
+      _binding.close(e?.$value);
+    }
+    _onPresentedStreamController.close();
+  }
+
+  @override
+  Future<void> annotate(fidl.Annotations annotations) async {
+    //TODO(47791) need to implement this method
+  }
+
+  @override
+  Future<void> dismiss() async {
+    // TODO(47792): A call to dismiss indicates that the view should go away we need to
+    // allow the user of this class to asynchronously dismiss the view before closing
+    // the channel.
+    close();
+  }
+
+  @override
+  Stream<void> get onPresented => _onPresentedStreamController.stream;
+
+  void didPresent() {
+    _onPresentedStreamController.add(null);
+  }
+}
diff --git a/session_shells/ermine/shell/lib/src/utils/suggestion.dart b/session_shells/ermine/shell/lib/src/utils/suggestion.dart
index 857019c..7ed5511 100644
--- a/session_shells/ermine/shell/lib/src/utils/suggestion.dart
+++ b/session_shells/ermine/shell/lib/src/utils/suggestion.dart
@@ -10,3 +10,5 @@
 
   const Suggestion({this.id, this.url, this.title});
 }
+
+const String ermineSuggestionIdKey = 'ermine:suggestion:id';
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
index 7985c91..49c685b 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
@@ -53,35 +53,35 @@
     return AnimatedBuilder(
       animation: story.childViewConnectionNotifier,
       builder: (context, child) {
-        return story.childViewConnection != null
-            ? AnimatedBuilder(
-                animation: Listenable.merge([
-                  story.nameNotifier,
-                  story.focusedNotifier,
-                  story.editStateNotifier,
-                ]),
-                builder: (context, child) => TileChrome(
-                  name: story.name,
-                  showTitle: !custom,
-                  titleFieldController: titleFieldController,
-                  editing: story.editStateNotifier.value,
-                  focused: story.focused,
-                  child: AnimatedBuilder(
-                    animation: story.fullscreenNotifier,
-                    builder: (context, child) => story.isImmersive
-                        ? Offstage()
-                        : ChildView(connection: story.childViewConnection),
-                  ),
-                  onTap: story.focus,
-                  onDelete: story.delete,
-                  onFullscreen: story.maximize,
-                  onMinimize: story.restore,
-                  onEdit: story.edit,
-                  onCancelEdit: () => confirmEditNotifier.value = false,
-                  onConfirmEdit: () => confirmEditNotifier.value = true,
-                ),
-              )
-            : Offstage();
+        return AnimatedBuilder(
+          animation: Listenable.merge([
+            story.nameNotifier,
+            story.focusedNotifier,
+            story.editStateNotifier,
+          ]),
+          builder: (context, child) => TileChrome(
+            name: story.name,
+            showTitle: !custom,
+            titleFieldController: titleFieldController,
+            editing: story.editStateNotifier.value,
+            focused: story.focused,
+            child: AnimatedBuilder(
+              animation: story.fullscreenNotifier,
+              builder: (context, child) => story.isImmersive
+                  ? Offstage()
+                  : story.childViewConnection != null
+                      ? ChildView(connection: story.childViewConnection)
+                      : Offstage(), //TODO(47796) show a placeholder until the view loads
+            ),
+            onTap: story.focus,
+            onDelete: story.delete,
+            onFullscreen: story.maximize,
+            onMinimize: story.restore,
+            onEdit: story.edit,
+            onCancelEdit: () => confirmEditNotifier.value = false,
+            onConfirmEdit: () => confirmEditNotifier.value = true,
+          ),
+        );
       },
     );
   }
diff --git a/session_shells/ermine/shell/test/ermine_story_test.dart b/session_shells/ermine/shell/test/ermine_story_test.dart
index 8918f14..e11d5c9 100644
--- a/session_shells/ermine/shell/test/ermine_story_test.dart
+++ b/session_shells/ermine/shell/test/ermine_story_test.dart
@@ -5,111 +5,51 @@
 import 'dart:async';
 
 // ignore_for_file: implementation_imports
-import 'package:fidl/fidl.dart';
 import 'package:ermine_library/src/models/ermine_story.dart';
+import 'package:ermine_library/src/utils/presenter.dart';
 import 'package:ermine_library/src/utils/suggestion.dart';
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
 import 'package:mockito/mockito.dart';
 import 'package:test/test.dart';
 
 void main() {
-  MockComponentController controller;
-  MockProxyController proxy;
-  StreamController<ComponentController$OnTerminated$Response>
-      onTerminatedEventStreamController;
+  test('from suggestion sets id and title', () {
+    final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
+    final story = ErmineStory.fromSuggestion(
+      suggestion: suggestion,
+      launchFunction: (_, __) async {},
+    );
 
-  setUp(() async {
-    controller = MockComponentController();
-    proxy = MockProxyController();
-    when(controller.ctrl).thenReturn(proxy);
-
-    onTerminatedEventStreamController =
-        StreamController<ComponentController$OnTerminated$Response>.broadcast(
-            sync: true);
-    when(controller.onTerminated)
-        .thenAnswer((_) => onTerminatedEventStreamController.stream);
+    expect(story.id, 'id');
+    expect(story.name, 'title');
   });
 
-  tearDown(() async {
-    await onTerminatedEventStreamController.close();
+  test('Creating a story also launches the suggestion', () async {
+    final completer = Completer<bool>();
+    final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
+    ErmineStory.fromSuggestion(
+      suggestion: suggestion,
+      launchFunction: (s, _) async => completer.complete(s.id == 'id'),
+    );
+    expect(await completer.future, true);
+  });
+
+  test('create from external source generates random id', () {
+    final story1 = ErmineStory.fromExternalSource();
+    final story2 = ErmineStory.fromExternalSource();
+
+    expect(story1.id, isNot(story2.id));
   });
 
   test('Delete ErmineStory', () {
-    // Creating a story should also launch it.
-    final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
-    final story = TestErmineStory(
-      suggestion: suggestion,
-      launcher: MockLauncher(),
-      componentController: controller,
-    );
-    expect(story.launched, true);
+    final viewController = MockViewControllerImpl();
+    bool didCallDelete = false;
+    ErmineStory.fromExternalSource(onDelete: (_) => didCallDelete = true)
+      ..viewController = viewController
+      ..delete();
 
-    // Delete should call deleted.
-    story.delete();
-    expect(story.deleted, true);
-    verify(controller.kill()).called(1);
-    verify(proxy.close()).called(1);
-  });
-
-  test('Terminate ErmineStory', () {
-    // Creating a story should also launch it.
-    final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
-    final story = TestErmineStory(
-      suggestion: suggestion,
-      launcher: MockLauncher(),
-      componentController: controller,
-    );
-    expect(story.launched, true);
-
-    // Terminated should call deleted.
-    final response =
-        ComponentController$OnTerminated$Response(0, TerminationReason.exited);
-    onTerminatedEventStreamController.add(response);
-    expect(story.deleted, true);
-    verify(controller.kill()).called(1);
-    verify(proxy.close()).called(1);
+    expect(didCallDelete, isTrue);
+    verify(viewController.close()).called(1);
   });
 }
 
-class TestErmineStory extends ErmineStory {
-  bool deleted;
-  bool changed;
-  bool launched;
-  MockLauncher launcher;
-  MockComponentController componentController;
-
-  TestErmineStory({
-    Suggestion suggestion,
-    this.launcher,
-    this.componentController,
-  }) : super(
-          suggestion: suggestion,
-          launcher: launcher,
-          componentController: componentController,
-          onDelete: _onDelete,
-          onChange: _onChange,
-        );
-
-  static void _onDelete(ErmineStory story) {
-    if (story is TestErmineStory) {
-      story.deleted = true;
-    }
-  }
-
-  static void _onChange(ErmineStory story) {
-    if (story is TestErmineStory) {
-      story.changed = true;
-    }
-  }
-
-  @override
-  Future<void> launchSuggestion() async => launched = true;
-}
-
-class MockLauncher extends Mock implements LauncherProxy {}
-
-class MockComponentController extends Mock implements ComponentControllerProxy {
-}
-
-class MockProxyController extends Mock
-    implements AsyncProxyController<ComponentController> {}
+class MockViewControllerImpl extends Mock implements ViewControllerImpl {}