Skip to main content

tilo_app/
feedback_composer.rs

1//! Shared private feedback presentation. Desktop hosts lend the system-owned
2//! controller into a private component; no draft is a workspace record.
3
4use std::{
5    cell::{Cell, RefCell},
6    rc::Rc,
7};
8
9use crate::feedback::{Composer, Consent, DeliveryState, PickedReport, RecordingScope, Subject};
10use crate::i18n::EmbeddedCatalog;
11use crate::prelude::*;
12
13#[path = "feedback_composer_report.rs"]
14mod report;
15
16static CATALOG: EmbeddedCatalog =
17    EmbeddedCatalog::new("en-US", &[("en-US", include_str!("feedback_composer.ftl"))]);
18
19/// Supplied by the desktop host, never resolved from a URL or draft content.
20#[derive(Clone)]
21pub struct FeedbackServices {
22    controller: Rc<RefCell<Composer>>,
23    presentation: Rc<RefCell<Option<Presentation>>>,
24    mounted: Rc<Cell<bool>>,
25    /// The model the host lends for reviewing reports, if any, and what
26    /// the review calls it.
27    models: Rc<RefCell<(crate::models::Models, String)>>,
28    pass: report::Pass,
29    /// Since when the report on its way waits for its destination's receipt.
30    awaiting: Rc<Cell<Option<crate::time::Instant>>>,
31}
32
33/// How soon a report waiting for its receipt is first looked at again: the
34/// destination may still be taking in its picture.
35const RECEIPT_CHECK_FIRST: crate::time::Duration = crate::time::Duration::from_secs(1);
36/// The longest a waiting report goes without being looked at again.
37const RECEIPT_CHECK_LAST: crate::time::Duration = crate::time::Duration::from_secs(10);
38
39/// What the one mounted composer shows of the controller's state.
40#[derive(Clone)]
41struct Presentation {
42    status: LocalState<String>,
43    subject: LocalState<Option<Subject>>,
44    shown: LocalState<String>,
45    fields: LocalState<Vec<crate::feedback::ContextField>>,
46    pixels: LocalState<Option<Vec<u8>>>,
47    /// What a recording of the window would hold, while one can be made.
48    offer: LocalState<Option<RecordingScope>>,
49    /// The recording the review includes.
50    recorded: LocalState<Option<RecordingScope>>,
51    /// What each picked part is called, while reporting picks.
52    picks: LocalState<Option<Vec<String>>>,
53    review: LocalState<Option<report::ReviewShown>>,
54    /// A note per picked part, by its place.
55    notes: Rc<Vec<LocalState<String>>>,
56    /// What the report sheet shows of the composer: made with the composer
57    /// window, so the desktop's own code reaches it only through this.
58    report: Rc<ReportShown>,
59}
60
61/// The picks' titles and the review, as the report sheet shows them.
62type ReportShown = dyn Fn(&Composer) -> (Option<Vec<String>>, Option<report::ReviewShown>);
63
64impl Presentation {
65    /// Show the controller's current review and subject: a review the
66    /// controller discarded must not stay on screen as what would be sent.
67    fn show(&self, composer: &Composer) {
68        self.subject.set(composer.subject().cloned());
69        self.offer.set(composer.recording_scope().cloned());
70        let (picks, review) = (self.report)(composer);
71        self.picks.set(picks);
72        self.review.set(review);
73        if let Some(review) = composer.preview() {
74            let feedback = review.feedback();
75            self.shown.set(feedback.text.clone());
76            self.fields.set(feedback.context.clone());
77            self.pixels
78                .set(review.screenshot().map(|image| image.png().to_vec()));
79            self.recorded
80                .set(review.recording().map(crate::feedback::Recorded::scope));
81        } else {
82            self.shown.set(String::new());
83            self.fields.set(Vec::new());
84            self.pixels.set(None);
85            self.recorded.set(None);
86        }
87    }
88}
89
90impl FeedbackServices {
91    /// Private outbox writes need a wake independently of the ephemeral draft.
92    /// Pending local persistence polls; a report waiting for its receipt is
93    /// looked at again, less often the longer it waits, so a receipt that
94    /// arrives later is read and the destination is asked again.
95    #[must_use]
96    pub fn next_wake_deadline(&self) -> Option<crate::time::Instant> {
97        if self.pass.try_borrow().is_ok_and(|pass| pass.is_some()) {
98            return crate::time::now().checked_add(crate::time::Duration::from_millis(50));
99        }
100        let controller = self.controller.try_borrow().ok()?;
101        let state = controller.state().ok()?;
102        if state != Some(DeliveryState::AwaitingReceipt) {
103            self.awaiting.set(None);
104        }
105        match state {
106            Some(DeliveryState::Saving) => {
107                crate::time::now().checked_add(crate::time::Duration::from_millis(100))
108            }
109            Some(DeliveryState::AwaitingReceipt) => {
110                let now = crate::time::now();
111                let since = self.awaiting.get().unwrap_or(now);
112                self.awaiting.set(Some(since));
113                let delay = now
114                    .saturating_duration_since(since)
115                    .checked_div(4)
116                    .unwrap_or_default()
117                    .clamp(RECEIPT_CHECK_FIRST, RECEIPT_CHECK_LAST);
118                now.checked_add(delay)
119            }
120            _ => None,
121        }
122    }
123
124    #[must_use]
125    pub fn new(controller: Rc<RefCell<Composer>>) -> Self {
126        Self {
127            controller,
128            presentation: Rc::default(),
129            mounted: Rc::default(),
130            models: Rc::default(),
131            pass: Rc::default(),
132            awaiting: Rc::default(),
133        }
134    }
135
136    /// Lend the model that looks over reports before they are sent, as the
137    /// host's settings chose it, and what the review calls it to the
138    /// reporter: the provider and model ("Anthropic claude-haiku-5-5").
139    pub fn set_models(&self, models: crate::models::Models, name: impl Into<String>) {
140        *self.models.borrow_mut() = (models, name.into());
141    }
142
143    /// Start a report about what the person picked; the composer opens on
144    /// it.
145    pub fn begin_report(&self, picked: PickedReport) -> Result<(), crate::feedback::Error> {
146        let mut controller = self
147            .controller
148            .try_borrow_mut()
149            .map_err(|_| crate::feedback::Error::Invalid("composer busy"))?;
150        controller.begin_report(picked);
151        self.pass.borrow_mut().take();
152        if let Ok(presentation) = self.presentation.try_borrow()
153            && let Some(presentation) = presentation.as_ref()
154        {
155            for note in presentation.notes.iter() {
156                note.set(String::new());
157            }
158            presentation.show(&controller);
159        }
160        Ok(())
161    }
162
163    /// Name the window the next report is about, as the desktop chose it
164    /// when the person asked for Feedback there.
165    pub fn about(&self, subject: Option<Subject>) -> Result<(), crate::feedback::Error> {
166        let mut controller = self
167            .controller
168            .try_borrow_mut()
169            .map_err(|_| crate::feedback::Error::Invalid("composer busy"))?;
170        controller.about(subject);
171        if let Ok(presentation) = self.presentation.try_borrow()
172            && let Some(presentation) = presentation.as_ref()
173        {
174            presentation.show(&controller);
175        }
176        Ok(())
177    }
178
179    /// Pump after a host storage/transport wake, publishing progress to the
180    /// current private presentation. Its `LocalState` retains the component's
181    /// retirement scope, so a late completion cannot wake a closed component.
182    pub fn pump(&self) -> Result<(), crate::feedback::Error> {
183        self.poll_model();
184        let result = self
185            .controller
186            .try_borrow_mut()
187            .map_err(|_| crate::feedback::Error::Invalid("composer busy"))
188            .and_then(|mut controller| controller.pump());
189        if let Ok(presentation) = self.presentation.try_borrow()
190            && let Some(presentation) = presentation.as_ref()
191        {
192            if let Err(error) = &result {
193                presentation.status.set(error_label(error).to_owned());
194            } else {
195                restore_progress(self, &presentation.status);
196            }
197        }
198        result
199    }
200}
201
202impl FeedbackServices {
203    /// Lay a model's answer over the review once it is in.
204    fn poll_model(&self) {
205        let (Ok(mut pass), Ok(mut controller)) =
206            (self.pass.try_borrow_mut(), self.controller.try_borrow_mut())
207        else {
208            return;
209        };
210        let Some(running) = pass.as_mut() else {
211            return;
212        };
213        if running(&mut controller) {
214            *pass = None;
215            if let Ok(presentation) = self.presentation.try_borrow()
216                && let Some(presentation) = presentation.as_ref()
217            {
218                presentation.show(&controller);
219            }
220        }
221    }
222}
223
224/// The host retains the outbox, while exactly one private presentation owns
225/// its raw review. Retirement drops this lease with the actual effect owner.
226struct PresentationLease(FeedbackServices);
227
228impl Drop for PresentationLease {
229    fn drop(&mut self) {
230        self.0.presentation.borrow_mut().take();
231        self.0.mounted.set(false);
232        if let Ok(mut controller) = self.0.controller.try_borrow_mut() {
233            controller.abandon_review();
234        }
235    }
236}
237
238/// Ephemeral CRDT state, admitted only into a private component host. Its
239/// fields are edited at their paths; the effects review and send it.
240#[derive(Clone, Debug, Default, PartialEq, Model)]
241#[model(check = FeedbackDraft::valid)]
242#[allow(
243    clippy::struct_excessive_bools,
244    reason = "each is one checkbox of the composer, set on its own"
245)]
246pub struct FeedbackDraft {
247    /// The issue, in the person's words.
248    pub issue: String,
249    /// Whether contextual information is included.
250    pub include_context: bool,
251    /// Whether a single masked app screenshot is included.
252    pub include_screenshot: bool,
253    /// "This was slow": a report about picked parts carries the last
254    /// frame timings.
255    pub slow: bool,
256    /// Whether a recording of the window's record is included.
257    pub include_recording: bool,
258}
259
260impl FeedbackDraft {
261    fn valid(&self) -> Result<(), tilo_resource::Refusal> {
262        if self.issue.len() > 8_000 {
263            return Err(tilo_resource::Refusal::new("feedback-issue-too-long"));
264        }
265        Ok(())
266    }
267}
268
269#[resource(kind = "tilo.feedback-draft", label = "Feedback")]
270impl FeedbackDraft {}
271
272#[derive(Clone, Debug, Effect)]
273#[effect(operation = "feedback.compose")]
274pub enum FeedbackEffect {
275    /// Inspect exactly what will leave this device.
276    Review,
277    /// Approve the displayed review and durably queue it.
278    Submit,
279    /// Retry the immutable report and read its durable delivery status.
280    Retry,
281    /// Hide a span in the already-masked review.
282    Redact {
283        #[param("Reviewed field key")]
284        key: String,
285        #[param("UTF-8 byte start")]
286        start: usize,
287        #[param("UTF-8 byte end")]
288        end: usize,
289    },
290    /// Remove the screenshot from the review.
291    RemoveScreenshot,
292    /// Leave the recording out of the review.
293    RemoveRecording,
294    /// Show a span the review hid, or hide it again.
295    Toggle {
296        #[param("Which hidden span, by its place in the review")]
297        index: usize,
298    },
299}
300
301/// Mounted inside desktop chrome, never as an overlapping application window.
302pub struct FeedbackComposer;
303
304/// The shared composer definition, instantiated by the SDK once.
305/// Hosts use this entry point to avoid another copy of its generic resource,
306/// history and effect machinery in each host crate.
307#[must_use]
308#[inline(never)]
309pub fn definition() -> &'static crate::application::ApplicationDefinition {
310    crate::framework::definition::<FeedbackComposer>()
311}
312
313/// Prepare the chrome component over an isolated, ephemeral store. The host's
314/// durable outbox is lent separately; raw drafts cannot reach profile records,
315/// history or workspace synchronization through this preparation path.
316pub fn prepare(
317    services: Option<FeedbackServices>,
318    device: &str,
319    author: &str,
320    instance: u64,
321) -> Result<AppComponent, crate::launch::LaunchError> {
322    let definition = definition();
323    let store = Rc::new(crate::feedback::MemoryStore::new());
324    let workspace = "feedback-private-draft";
325    let records = crate::record::RecordOwners::with_store(store.clone())
326        .open(FeedbackComposer::ID, workspace)
327        .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?;
328    let views = Rc::new(
329        InstanceViewStore::with_store(store, device, workspace)
330            .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?,
331    );
332    let target = LaunchTarget::New;
333    let permissions = crate::permissions::SharedPermissions::new(
334        crate::permissions::PermissionContext::member(author, crate::permissions::Role::Owner),
335    );
336    let mut context = HostContext::prepare(
337        definition,
338        HostContextInputs {
339            records,
340            views,
341            device_id: device.to_owned(),
342            instance_id: instance,
343            permissions,
344            author: crate::permissions::UserId(author.to_owned()),
345            action_context: tilo_actions::ActionContext::default(),
346        },
347        &target,
348    )?;
349    context.provide_private();
350    if let Some(services) = services {
351        if services.mounted.replace(true) {
352            return Err(crate::launch::LaunchError::Operation(
353                "feedback-busy".into(),
354            ));
355        }
356        context.lend(PresentationLease(services.clone()));
357        context.lend(services);
358    }
359    definition.instantiate(context, target, RestoreInput::None)
360}
361
362impl ResourceApp for FeedbackComposer {
363    type Resource = FeedbackDraft;
364    const ID: &'static str = "so.tilo.feedback-composer";
365    const TITLE: &'static str = "Feedback";
366    const DOCUMENT_HISTORY: bool = false;
367    const PRIVATE_WINDOWS: bool = true;
368    const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour {
369        capture_protected: true,
370        ..ComponentBehaviour::DEFAULT
371    };
372
373    fn catalog() -> Option<&'static EmbeddedCatalog> {
374        Some(&CATALOG)
375    }
376
377    fn view(cx: &mut ViewCx<FeedbackDraft>) -> Element {
378        let presentation = Presentation {
379            status: LocalState::new("feedback-review-first".to_owned()),
380            subject: LocalState::new(None),
381            shown: LocalState::new(String::new()),
382            fields: LocalState::new(Vec::new()),
383            pixels: LocalState::new(None),
384            offer: LocalState::new(None),
385            recorded: LocalState::new(None),
386            picks: LocalState::new(None),
387            review: LocalState::new(None),
388            notes: Rc::new(
389                (0..crate::feedback::report::MAX_PICKS)
390                    .map(|_| LocalState::new(String::new()))
391                    .collect(),
392            ),
393            report: Rc::new(|composer: &Composer| {
394                (report::titles(composer), report::ReviewShown::of(composer))
395            }),
396        };
397        let screenshots = install_effects(cx, &presentation);
398        let shown = presentation.clone();
399        cx.switch(&presentation.picks, move |part, picks| match picks {
400            Some(titles) => report::sheet(part, &shown, titles),
401            None => typed(part, &shown, screenshots),
402        })
403    }
404}
405
406/// The composer for a report typed out rather than picked.
407fn typed(cx: &ViewCx<FeedbackDraft>, presentation: &Presentation, screenshots: bool) -> Element {
408    let subject = about(cx, &presentation.subject);
409    let fields_view = reviewed_fields(cx, &presentation.fields);
410    let preview = cx.switch(&presentation.shown, |_, shown| {
411        Box::new(
412            Text::new("feedback-reviewed-text")
413                .with_arg("value", shown.clone())
414                .with_id("feedback-preview"),
415        )
416    });
417    // An absent band takes its gap with it (`ui_core::widget::optional`), so
418    // a composer with no attachment has no hole where one would be.
419    let picture = cx.switch(&presentation.pixels, |_, png| {
420        png.as_ref()
421            .and_then(|bytes| crate::widget::Image::from_encoded(bytes).ok())
422            .map_or_else(
423                || ui_core::widget::optional(None),
424                |image| Box::new(image.with_alt("feedback-screenshot-preview")) as Element,
425            )
426    });
427    let mut choices = crate::column![
428        Text::new("feedback-title"),
429        cx.input(FeedbackDraft::paths().issue)
430            .with_id("feedback-issue")
431            .with_placeholder("feedback-describe")
432            .multiline(true),
433    ]
434    .push(subject);
435    // A host that cannot capture is never offered the attachment.
436    if screenshots {
437        choices = choices.push(Box::new(cx.checkbox(
438            "feedback-screenshot",
439            FeedbackDraft::paths().include_screenshot,
440        )));
441    }
442    choices = choices.push(recording_offer(cx, &presentation.offer));
443    let mut sending = group().push(picture);
444    if screenshots {
445        sending = sending.push(Box::new(cx.effect_button(
446            "feedback-remove-screenshot",
447            &FeedbackEffect::RemoveScreenshot,
448        )));
449    }
450    sending = sending.push(cx.switch(&presentation.recorded, |part, scope| {
451        let Some(scope) = scope else {
452            return ui_core::widget::optional(None);
453        };
454        Box::new(
455            group()
456                .push(recording_included(scope))
457                .push(Box::new(part.effect_button(
458                    "feedback-remove-recording",
459                    &FeedbackEffect::RemoveRecording,
460                ))),
461        ) as Element
462    }));
463    // The sheet's one primary action (docs/design-language.md, "Buttons";
464    // HIG ยง7, "a single primary action per screen"): it was a secondary fill
465    // like everything beside it, so the one control that sends read as an
466    // optional step. Checking delivery is the quiet recovery beside it.
467    let sending = sending
468        .push(Box::new(ui_core::widget::When::from_state(
469            &presentation.status,
470            |status| status != "feedback-review-first",
471            Box::new(
472                Text::from_state(&presentation.status, Clone::clone)
473                    .with_id("feedback-status")
474                    .with_live(crate::a11y::Politeness::Polite),
475            ),
476        )))
477        .push(Box::new(
478            cx.effect_button("feedback-submit", &FeedbackEffect::Submit)
479                .role(ui_core::widget::ButtonRole::Primary),
480        ))
481        .push(Box::new(
482            cx.effect_button("feedback-retry", &FeedbackEffect::Retry)
483                .role(ui_core::widget::ButtonRole::Plain),
484        ));
485    Box::new(
486        choices
487            .push(Box::new(Text::new("feedback-privacy")))
488            .push(Box::new(
489                cx.effect_button("feedback-review", &FeedbackEffect::Review),
490            ))
491            .push(preview)
492            .push(fields_view)
493            .push(Box::new(sending)),
494    )
495}
496
497/// What the report is about, and the choice to include it; nothing while
498/// Feedback was opened with no window in front.
499fn about(cx: &ViewCx<FeedbackDraft>, subject: &LocalState<Option<Subject>>) -> Element {
500    cx.switch(subject, |part, subject| {
501        let Some(subject) = subject else {
502            return ui_core::widget::optional(None);
503        };
504        let name = if subject.name.is_empty() {
505            subject.app.clone()
506        } else {
507            subject.name.clone()
508        };
509        Box::new(
510            group()
511                .push(Box::new(
512                    Text::new("feedback-about")
513                        .with_arg("app", name)
514                        .with_id("feedback-about"),
515                ))
516                .push(Box::new(part.checkbox(
517                    "feedback-context",
518                    FeedbackDraft::paths().include_context,
519                ))),
520        ) as Element
521    })
522}
523
524/// The choice to include a recording of the window's record, and what it
525/// would hold; nothing while the window cannot be recorded.
526fn recording_offer(
527    cx: &ViewCx<FeedbackDraft>,
528    offer: &LocalState<Option<RecordingScope>>,
529) -> Element {
530    cx.switch(offer, |part, offer| {
531        let Some(scope) = offer else {
532            return ui_core::widget::optional(None);
533        };
534        Box::new(
535            group()
536                .push(Box::new(part.checkbox(
537                    "feedback-recording",
538                    FeedbackDraft::paths().include_recording,
539                )))
540                .push(Box::new(
541                    with_scope(Text::new("feedback-recording-scope"), scope)
542                        .with_id("feedback-recording-scope"),
543                )),
544        ) as Element
545    })
546}
547
548/// The recording a review includes, as the review shows it.
549fn recording_included(scope: &RecordingScope) -> Element {
550    Box::new(
551        with_scope(Text::new("feedback-recording-included"), scope)
552            .with_id("feedback-recording-included"),
553    )
554}
555
556/// `text` with the recording's app, time range and steps as its arguments.
557fn with_scope(text: Text, scope: &RecordingScope) -> Text {
558    let at = |ms: i64| ui_core::format::time_of(&ui_core::format::UnixSecs(ms.div_euclid(1000)));
559    text.with_arg("app", scope.app_name.clone())
560        .with_arg("from", at(scope.from_ms))
561        .with_arg("to", at(scope.to_ms))
562        .with_arg("steps", scope.steps)
563}
564
565/// Each reviewed context field with its control to hide it.
566fn reviewed_fields(
567    cx: &ViewCx<FeedbackDraft>,
568    fields: &LocalState<Vec<crate::feedback::ContextField>>,
569) -> Element {
570    cx.switch(fields, |part, fields| {
571        let mut controls = group();
572        for field in fields {
573            controls = controls.push(Box::new(
574                Text::new("feedback-reviewed-text").with_arg("value", field.value.clone()),
575            ));
576            controls = controls.push(Box::new(part.effect_button(
577                "feedback-hide-context",
578                &FeedbackEffect::Redact {
579                    key: field.key.clone(),
580                    start: 0,
581                    end: field.value.len(),
582                },
583            )));
584        }
585        Box::new(controls)
586    })
587}
588
589/// A run of the composer's controls inside its one column: as tall as its
590/// contents and aligned with the rest, not a second full-height column.
591fn group() -> Column {
592    Column::new().with_intrinsic_height().with_no_padding()
593}
594
595/// Wire the effects to the lent controller; true when its host can offer a
596/// screenshot at all.
597fn install_effects(cx: &ViewCx<FeedbackDraft>, presentation: &Presentation) -> bool {
598    // Refuse a host accidentally mounting the raw draft in a shared workspace.
599    let services = cx
600        .is_private()
601        .then(|| cx.take_lent::<FeedbackServices>())
602        .flatten();
603    let lease = cx.take_lent::<PresentationLease>();
604    let mut screenshots = false;
605    if let Some(services) = &services {
606        *services.presentation.borrow_mut() = Some(presentation.clone());
607        if let Ok(composer) = services.controller.try_borrow() {
608            screenshots = composer.offers_screenshots();
609            presentation.show(&composer);
610        }
611        restore_progress(services, &presentation.status);
612    }
613    let shown = presentation.clone();
614    cx.effects(move |effect: FeedbackEffect, effect_cx| {
615        let _lease = &lease;
616        let Some(services) = &services else {
617            shown.status.set("feedback-unconfigured".to_owned());
618            return Err(said("feedback-unconfigured"));
619        };
620        let draft = effect_cx.live().with(Clone::clone);
621        let mut composer = services
622            .controller
623            .try_borrow_mut()
624            .map_err(|_| said("feedback-busy"))?;
625        // Context is only offered while there is a window to describe; the
626        // review shows exactly what is attached either way. A screenshot the
627        // host cannot take is refused rather than dropped.
628        let consent = Consent {
629            context: draft.include_context && composer.subject().is_some(),
630            screenshot: draft.include_screenshot,
631            recording: draft.include_recording && composer.recording_scope().is_some(),
632        };
633        // A report about picked parts reads the notes beside each part.
634        let picked = composer.picked().map(|draft| draft.picks.len());
635        let words = picked.map(|picks| report::words(&draft, &shown, picks, consent.recording));
636        let result = match (effect, &words) {
637            (FeedbackEffect::Review, Some(words)) => {
638                let reviewed = composer.review_report(words);
639                if reviewed.is_ok() {
640                    let (models, name) = services.models.borrow().clone();
641                    *services.pass.borrow_mut() =
642                        report::ModelPass::start(&models, &name, &mut composer, words).map(
643                            |mut pass| {
644                                Box::new(move |composer: &mut Composer| pass.poll(composer))
645                                    as Box<report::PassPoll>
646                            },
647                        );
648                }
649                reviewed
650            }
651            (FeedbackEffect::Review, None) => composer.review(&draft.issue, consent),
652            (FeedbackEffect::Submit, _) => {
653                let id = effect_cx
654                    .invocation()
655                    .ok_or_else(|| said("feedback-busy"))?
656                    .0
657                    .simple()
658                    .to_string();
659                match &words {
660                    Some(words) => composer.submit_report(id, words),
661                    None => composer.submit(id, &draft.issue, consent),
662                }
663            }
664            (FeedbackEffect::Retry, _) => composer.pump(),
665            (FeedbackEffect::Redact { key, start, end }, _) => composer.hide(&key, start, end),
666            (FeedbackEffect::RemoveScreenshot, _) => composer.remove_screenshot(),
667            (FeedbackEffect::RemoveRecording, _) => composer.remove_recording(),
668            (FeedbackEffect::Toggle { index }, _) => composer.toggle_redaction(index),
669        };
670        shown.show(&composer);
671        let key = if let Err(error) = &result {
672            error_label(error)
673        } else {
674            match composer
675                .state()
676                .map_err(|_| said("feedback-retry-needed"))?
677            {
678                None => "feedback-reviewed",
679                Some(state) => delivery_label(&state),
680            }
681        };
682        shown.status.set(key.to_owned());
683        result.map_err(|error| {
684            // The person reads what to do; the log keeps why, which names
685            // no content of the report.
686            tracing::warn!(%error, "a feedback action did not run");
687            said(key)
688        })?;
689        Ok(Effected::done())
690    });
691    screenshots
692}
693
694/// Why an action did not run, as the person reads it where it is said.
695fn said(key: &str) -> String {
696    ui_core::i18n::I18nManager::lookup(key, None)
697}
698
699fn error_label(error: &crate::feedback::Error) -> &'static str {
700    match error {
701        crate::feedback::Error::CaptureProtected => "feedback-capture-unavailable",
702        crate::feedback::Error::CaptureChanged => "feedback-capture-changed",
703        crate::feedback::Error::NotApproved => "feedback-review-first",
704        crate::feedback::Error::Store(_) => "feedback-storage-failed",
705        _ => "feedback-retry-needed",
706    }
707}
708
709fn restore_progress(services: &FeedbackServices, status: &LocalState<String>) {
710    match services.controller.try_borrow() {
711        Ok(composer) => match composer.state() {
712            Ok(Some(state)) => status.set(delivery_label(&state).to_owned()),
713            Ok(None) => {}
714            Err(_) => status.set("feedback-retry-needed".to_owned()),
715        },
716        Err(_) => status.set("feedback-busy".to_owned()),
717    }
718}
719
720fn delivery_label(state: &DeliveryState) -> &'static str {
721    match state {
722        DeliveryState::Saving => "feedback-saving",
723        DeliveryState::StorageFailed => "feedback-storage-failed",
724        DeliveryState::AwaitingReceipt => "feedback-awaiting-receipt",
725        DeliveryState::Sent { .. } => "feedback-sent",
726    }
727}
728
729#[cfg(test)]
730#[cfg(not(target_arch = "wasm32"))]
731mod tests {
732    use super::*;
733    use crate::feedback::{
734        Destination, Error, FeedbackHost, MaskedScreenshot, MemoryStore, Outbox, Receipt,
735        RecordingScope, ReviewText, StorageKey, Submission,
736    };
737    use crate::headless::FrameHarness;
738    use serde_json::json;
739
740    #[derive(Default)]
741    struct Transport {
742        submissions: Vec<Submission>,
743        receipt: Option<Receipt>,
744    }
745    struct Host(Rc<RefCell<Transport>>);
746    impl FeedbackHost for Host {
747        fn offers_screenshots(&self) -> bool {
748            false
749        }
750        fn context(&mut self) -> Result<Vec<ReviewText>, Error> {
751            Ok(vec![ReviewText {
752                node: "focused.public.label".into(),
753                sensitivity: crate::feedback::DeclaredSensitivity::Public,
754                kind: None,
755                origin: crate::feedback::TextOrigin::Semantic,
756                text: "feedback-privacy".into(),
757            }])
758        }
759        fn capture(&mut self) -> Result<MaskedScreenshot, Error> {
760            Err(Error::CaptureProtected)
761        }
762        fn persist_screenshot(&mut self, _: &MaskedScreenshot) -> Result<(), Error> {
763            Err(Error::CaptureProtected)
764        }
765        fn send(&mut self, submission: &Submission) -> Result<(), Error> {
766            self.0.borrow_mut().submissions.push(submission.clone());
767            Ok(())
768        }
769        fn receipt(&mut self, _: &Submission) -> Result<Option<Receipt>, Error> {
770            Ok(self.0.borrow().receipt.clone())
771        }
772    }
773
774    /// The arguments of the field edit `edit`, as the door takes them.
775    fn edit(edit: &tilo_resource::Edit<FeedbackDraft>) -> serde_json::Value {
776        json!({ tilo_resource::EDIT_PARAM: edit })
777    }
778
779    /// Type the issue, as the composer's text field does.
780    fn describe(frame: &mut FrameHarness, issue: &str) -> Result<(), tilo_actions::DoorError> {
781        call(
782            frame,
783            tilo_resource::FIELD_EDIT,
784            &edit(&FeedbackDraft::paths().issue.replace(issue)),
785        )
786    }
787
788    fn call(
789        frame: &mut FrameHarness,
790        action: &str,
791        arguments: &serde_json::Value,
792    ) -> Result<(), tilo_actions::DoorError> {
793        frame.relayout();
794        let resource = frame
795            .resources()
796            .into_iter()
797            .find(|resource| resource.kind == FeedbackDraft::KIND)
798            .expect("the real composer resource is published");
799        frame
800            .invoke_bound_action(
801                &crate::bound_dispatch::BoundActionAddress {
802                    instance_id: 42,
803                    address: tilo_actions::ActionAddress::new(resource.id.0, action),
804                },
805                arguments.as_object().expect("typed arguments"),
806            )
807            .map(|_| ())
808    }
809
810    #[test]
811    fn private_storage_wake_sends_only_after_host_acknowledges_approval() {
812        let transport = Rc::new(RefCell::new(Transport::default()));
813        let storage = Rc::new(MemoryStore::queued());
814        let controller = Composer::new(
815            Destination {
816                room: "private-fixture".into(),
817                provider: "record:chat:feedback".into(),
818                channel: "feedback".into(),
819            },
820            Outbox::open(storage.clone(), StorageKey::package("feedback-outbox"))
821                .expect("private outbox"),
822            Box::new(Host(transport.clone())),
823        )
824        .expect("admitted destination");
825        let services = FeedbackServices::new(Rc::new(RefCell::new(controller)));
826        let component =
827            prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
828        let mut frame = FrameHarness::from_component(
829            component,
830            (720, 720),
831            ComponentRunnerInputs::from_native_process(),
832        )
833        .expect("production component");
834        call(
835            &mut frame,
836            tilo_resource::FIELD_EDIT,
837            &edit(
838                &FeedbackDraft::paths()
839                    .issue
840                    .replace("Opening the book failed"),
841            ),
842        )
843        .expect("describe");
844        call(&mut frame, "review", &json!({})).expect("review");
845        call(&mut frame, "submit", &json!({})).expect("queue approval");
846        frame.relayout();
847        assert!(
848            frame
849                .inspection_json()
850                .expect("inspection")
851                .contains("Saving on this device")
852        );
853        assert!(services.next_wake_deadline().is_some());
854        services.pump().expect("storage still pending");
855        assert!(transport.borrow().submissions.is_empty());
856        let pending = storage.drain();
857        assert_eq!(pending.len(), 1);
858        storage
859            .acknowledge(pending[0].revision, Ok(()))
860            .expect("host commit");
861        services.pump().expect("durable approval sends");
862        frame.relayout();
863        assert_eq!(transport.borrow().submissions.len(), 1);
864        // Waiting for the receipt, the composer looks again on its own.
865        let again = services.next_wake_deadline().expect("receipt wake");
866        let now = crate::time::now();
867        assert!(again >= now.checked_add(RECEIPT_CHECK_FIRST / 2).unwrap());
868        assert!(again <= now.checked_add(RECEIPT_CHECK_LAST).unwrap());
869        assert!(
870            frame
871                .inspection_json()
872                .expect("inspection")
873                .contains("Waiting for destination confirmation")
874        );
875        assert!(
876            !frame
877                .inspection_json()
878                .expect("inspection")
879                .contains("Feedback received")
880        );
881        // The receipt arrives later; the wake reads it with no click.
882        let submission = transport.borrow().submissions[0].clone();
883        transport.borrow_mut().receipt = Some(Receipt {
884            id: submission.id.clone(),
885            digest: submission.digest().expect("digest"),
886            destination: submission.destination,
887            report: "chat.message:m_feedback_later".into(),
888        });
889        services.pump().expect("receipt wake");
890        for write in storage.drain() {
891            storage
892                .acknowledge(write.revision, Ok(()))
893                .expect("host commit");
894        }
895        services.pump().expect("receipt kept");
896        frame.relayout();
897        assert!(
898            frame
899                .inspection_json()
900                .expect("inspection")
901                .contains("Feedback received")
902        );
903        assert!(services.next_wake_deadline().is_none());
904    }
905
906    #[test]
907    fn semantic_composer_reviews_queues_and_waits_for_destination_receipt() {
908        let transport = Rc::new(RefCell::new(Transport::default()));
909        let controller = Rc::new(RefCell::new(
910            Composer::new(
911                Destination {
912                    room: "demo".into(),
913                    provider: "record:chat:feedback".into(),
914                    channel: "feedback".into(),
915                },
916                Outbox::open(
917                    Rc::new(MemoryStore::new()),
918                    StorageKey::package("feedback-outbox"),
919                )
920                .expect("private outbox"),
921                Box::new(Host(transport.clone())),
922            )
923            .expect("admitted destination"),
924        ));
925        let services = FeedbackServices::new(controller.clone());
926        let component =
927            prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
928        assert!(
929            prepare(Some(services.clone()), "device", "guest", 43).is_err(),
930            "one approved outbox must not have competing private editors"
931        );
932        let mut frame = FrameHarness::from_component(
933            component,
934            (720, 720),
935            ComponentRunnerInputs::from_native_process(),
936        )
937        .expect("production component");
938        assert!(transport.borrow().submissions.is_empty());
939        describe(&mut frame, "Opening the book failed; token=secret12345")
940            .expect("describe through public action");
941        frame.relayout();
942        assert!(
943            frame.capture_protected(),
944            "the actual private composer paint must protect its enclosing frame"
945        );
946        assert!(
947            call(&mut frame, "submit", &json!({})).is_err(),
948            "sending before review must fail"
949        );
950        call(&mut frame, "review", &json!({})).expect("review");
951        call(&mut frame, "submit", &json!({})).expect("approve exact preview");
952        let submission = transport
953            .borrow()
954            .submissions
955            .first()
956            .expect("existing transport request")
957            .clone();
958        assert!(!submission.feedback.text.contains("secret12345"));
959        assert!(submission.feedback.context.is_empty());
960        assert!(submission.feedback.screenshot.is_none());
961        assert_eq!(
962            controller.borrow().state().expect("state"),
963            Some(DeliveryState::AwaitingReceipt)
964        );
965        call(&mut frame, "retry", &json!({})).expect("stable retry");
966        assert!(
967            transport
968                .borrow()
969                .submissions
970                .iter()
971                .all(|retry| retry == &submission)
972        );
973        transport.borrow_mut().receipt = Some(Receipt {
974            id: submission.id.clone(),
975            digest: submission.digest().expect("digest"),
976            destination: submission.destination,
977            report: "chat.message:m_feedback_test".into(),
978        });
979        services.pump().expect("host receipt wake");
980        frame.relayout();
981        assert!(
982            serde_json::to_string(&frame.inspection())
983                .expect("real component inspection")
984                .contains("Feedback received"),
985            "the private UI must show receipt progress without another click"
986        );
987        assert_eq!(
988            controller.borrow().state().expect("state"),
989            Some(DeliveryState::Sent {
990                report: "chat.message:m_feedback_test".into()
991            })
992        );
993        services
994            .about(Some(Subject::of_app("so.tilo.books", "Books")))
995            .expect("the desktop names the window feedback is about");
996        assert_literal_second_review(&mut frame, &transport);
997        frame.close_components();
998        assert!(
999            controller.borrow().preview().is_none(),
1000            "closing discards the unapproved review"
1001        );
1002        let reopened = prepare(Some(services), "device", "guest", 42)
1003            .expect("closed editor releases its private lease");
1004        drop(reopened);
1005    }
1006
1007    fn find(node: &crate::headless::InspectionNode) -> Option<&crate::headless::InspectionNode> {
1008        if node.test_id.as_deref() == Some("feedback-preview") {
1009            Some(node)
1010        } else {
1011            node.children.iter().find_map(find)
1012        }
1013    }
1014    fn contains_literal_context(node: &crate::headless::InspectionNode) -> bool {
1015        node.semantics
1016            .as_ref()
1017            .and_then(|node| node.name.as_deref())
1018            == Some("feedback-privacy")
1019            || node.children.iter().any(contains_literal_context)
1020    }
1021    fn assert_literal_second_review(frame: &mut FrameHarness, transport: &Rc<RefCell<Transport>>) {
1022        transport.borrow_mut().receipt = None;
1023        describe(frame, "feedback-title").expect("second issue");
1024        call(
1025            frame,
1026            tilo_resource::FIELD_EDIT,
1027            &edit(&FeedbackDraft::paths().include_context.set(true)),
1028        )
1029        .expect("explicit context opt-in");
1030        call(frame, "review", &json!({})).expect("new review after durable receipt");
1031        frame.relayout();
1032        let inspection = frame.inspection();
1033        let preview = find(&inspection.root).expect("displayed approved preview");
1034        assert_eq!(
1035            preview
1036                .semantics
1037                .as_ref()
1038                .and_then(|node| node.name.as_deref()),
1039            Some("feedback-title"),
1040            "user text matching a Fluent key must remain literal"
1041        );
1042        assert!(contains_literal_context(&inspection.root));
1043        call(frame, "submit", &json!({})).expect("second approved submission");
1044        let sent = transport.borrow();
1045        let first = sent.submissions.first().expect("first report");
1046        let second = sent.submissions.last().expect("second report");
1047        assert_ne!(first.id, second.id);
1048        assert_eq!(second.feedback.text, "feedback-title");
1049        let context: Vec<_> = second
1050            .feedback
1051            .context
1052            .iter()
1053            .map(|field| field.value.as_str())
1054            .collect();
1055        assert_eq!(context, ["App: Books (so.tilo.books)", "feedback-privacy"]);
1056    }
1057
1058    /// A host that records the window it is asked about when the window
1059    /// shows a record, and keeps what it records once a report names it.
1060    struct Recording {
1061        sent: Host,
1062        recorded: crate::feedback::Recorded,
1063        kept: Rc<RefCell<Vec<String>>>,
1064    }
1065    impl FeedbackHost for Recording {
1066        fn offers_screenshots(&self) -> bool {
1067            false
1068        }
1069        fn context(&mut self) -> Result<Vec<ReviewText>, Error> {
1070            Ok(Vec::new())
1071        }
1072        fn capture(&mut self) -> Result<MaskedScreenshot, Error> {
1073            Err(Error::CaptureProtected)
1074        }
1075        fn persist_screenshot(&mut self, _: &MaskedScreenshot) -> Result<(), Error> {
1076            Err(Error::CaptureProtected)
1077        }
1078        fn recording_scope(&mut self, subject: &Subject) -> Option<RecordingScope> {
1079            subject.record.as_ref().map(|_| self.recorded.scope())
1080        }
1081        fn record(&mut self, subject: &Subject) -> Result<crate::feedback::Recorded, Error> {
1082            subject.record.as_ref().ok_or(Error::Invalid("recording"))?;
1083            Ok(self.recorded.clone())
1084        }
1085        fn persist_recording(
1086            &mut self,
1087            recording: &crate::feedback::Recorded,
1088        ) -> Result<(), Error> {
1089            self.kept
1090                .borrow_mut()
1091                .push(recording.reference().hash.clone());
1092            Ok(())
1093        }
1094        fn send(&mut self, submission: &Submission) -> Result<(), Error> {
1095            self.sent.send(submission)
1096        }
1097        fn receipt(&mut self, submission: &Submission) -> Result<Option<Receipt>, Error> {
1098            self.sent.receipt(submission)
1099        }
1100    }
1101
1102    /// A recording of a small resource's last three changes.
1103    fn three_changes() -> crate::feedback::Recorded {
1104        use ui_core::automerge::{ROOT, transaction::Transactable as _};
1105        let mut doc = tilo_resource::model::Doc::new();
1106        for (second, body) in [(10, "a"), (20, "ab"), (30, "abc"), (40, "abcd")] {
1107            doc.put(ROOT, "body", body).expect("edit");
1108            tilo_resource::history::commit_stamped_at(&mut doc, second).expect("commit");
1109        }
1110        let heads = doc.get_heads();
1111        let cut = tilo_resource::recording::record(
1112            &doc,
1113            15_000,
1114            &heads,
1115            &crate::feedback::recording::LIMITS,
1116        )
1117        .expect("recording");
1118        crate::feedback::Recorded::new("so.tilo.notes", "Notes", "notes.note", &cut)
1119            .expect("recorded")
1120    }
1121
1122    fn shows_id(node: &crate::headless::InspectionNode, id: &str) -> bool {
1123        node.test_id.as_deref() == Some(id) || node.children.iter().any(|child| shows_id(child, id))
1124    }
1125
1126    #[test]
1127    fn a_recording_is_offered_for_a_window_with_a_record_and_sent_only_when_included() {
1128        let transport = Rc::new(RefCell::new(Transport::default()));
1129        let kept = Rc::new(RefCell::new(Vec::new()));
1130        let recorded = three_changes();
1131        let controller = Rc::new(RefCell::new(
1132            Composer::new(
1133                Destination {
1134                    room: "demo".into(),
1135                    provider: "record:chat:feedback".into(),
1136                    channel: "feedback".into(),
1137                },
1138                Outbox::open(
1139                    Rc::new(MemoryStore::new()),
1140                    StorageKey::package("feedback-outbox"),
1141                )
1142                .expect("private outbox"),
1143                Box::new(Recording {
1144                    sent: Host(transport.clone()),
1145                    recorded: recorded.clone(),
1146                    kept: kept.clone(),
1147                }),
1148            )
1149            .expect("admitted destination"),
1150        ));
1151        let services = FeedbackServices::new(controller);
1152        let component =
1153            prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
1154        let mut frame = FrameHarness::from_component(
1155            component,
1156            (720, 900),
1157            ComponentRunnerInputs::from_native_process(),
1158        )
1159        .expect("production component");
1160        services
1161            .about(Some(Subject::of_app("so.tilo.notes", "Notes")))
1162            .expect("a window that shows no record");
1163        frame.relayout();
1164        assert!(!shows_id(
1165            &frame.inspection().root,
1166            "feedback-recording-scope"
1167        ));
1168        services
1169            .about(Some(Subject {
1170                record: Some(crate::feedback::ShownRecord {
1171                    workspace: "demo".into(),
1172                    record: "note-1".into(),
1173                }),
1174                ..Subject::of_app("so.tilo.notes", "Notes")
1175            }))
1176            .expect("a window that shows a note");
1177        frame.relayout();
1178        assert!(
1179            shows_id(&frame.inspection().root, "feedback-recording-scope"),
1180            "the offer says what a recording holds before it is chosen"
1181        );
1182        describe(&mut frame, "Undo lost a word").expect("describe");
1183        call(&mut frame, "review", &json!({})).expect("review");
1184        frame.relayout();
1185        assert!(!shows_id(
1186            &frame.inspection().root,
1187            "feedback-recording-included"
1188        ));
1189        call(
1190            &mut frame,
1191            tilo_resource::FIELD_EDIT,
1192            &edit(&FeedbackDraft::paths().include_recording.set(true)),
1193        )
1194        .expect("include the recording");
1195        call(&mut frame, "review", &json!({})).expect("review with the recording");
1196        frame.relayout();
1197        assert!(
1198            shows_id(&frame.inspection().root, "feedback-recording-included"),
1199            "the review shows the recording that would be sent"
1200        );
1201        call(&mut frame, "submit", &json!({})).expect("send");
1202        let sent = transport
1203            .borrow()
1204            .submissions
1205            .last()
1206            .cloned()
1207            .expect("sent");
1208        assert_eq!(sent.feedback.recording.as_ref(), Some(recorded.reference()));
1209        assert_eq!(*kept.borrow(), vec![recorded.reference().hash.clone()]);
1210    }
1211
1212    /// A composer reporting one picked chat row, its message by someone here.
1213    fn picked_services(transport: &Rc<RefCell<Transport>>) -> FeedbackServices {
1214        use crate::feedback::report::{Area, Pick, PickedText, ReportDraft, TextKind};
1215        let controller = Rc::new(RefCell::new(
1216            Composer::new(
1217                Destination {
1218                    room: "lounge".into(),
1219                    provider: "record:so.tilo.chat:".into(),
1220                    channel: "c_feedback".into(),
1221                },
1222                Outbox::open(
1223                    Rc::new(MemoryStore::new()),
1224                    StorageKey::package("feedback-outbox"),
1225                )
1226                .expect("private outbox"),
1227                Box::new(Host(Rc::clone(transport))),
1228            )
1229            .expect("admitted destination"),
1230        ));
1231        let services = FeedbackServices::new(controller);
1232        let pick = Pick {
1233            app: "so.tilo.chat".into(),
1234            app_name: "Chat".into(),
1235            build: Some("abc123".into()),
1236            path: "Chat[0]/Row[1]".into(),
1237            role: "ListItem".into(),
1238            kind: None,
1239            caption: "A message in Chat".into(),
1240            label: None,
1241            record: None,
1242            area: Area {
1243                x: 0.0,
1244                y: 0.0,
1245                width: 100.0,
1246                height: 20.0,
1247            },
1248            texts: vec![
1249                PickedText {
1250                    kind: TextKind::Content,
1251                    text: "Thanks Ada Lovelace".into(),
1252                    area: None,
1253                },
1254                PickedText {
1255                    kind: TextKind::Interface,
1256                    text: "Reply".into(),
1257                    area: None,
1258                },
1259            ],
1260        };
1261        services
1262            .begin_report(PickedReport {
1263                draft: ReportDraft {
1264                    picks: vec![pick],
1265                    names: vec!["Ada Lovelace".into()],
1266                    ..ReportDraft::default()
1267                },
1268                crops: Vec::new(),
1269            })
1270            .expect("report begins");
1271        services
1272    }
1273
1274    #[test]
1275    fn a_picked_report_shows_exactly_what_is_sent_and_toggles_hidden_spans() {
1276        let transport = Rc::new(RefCell::new(Transport::default()));
1277        let services = picked_services(&transport);
1278        let component = prepare(Some(services), "device", "guest", 42).expect("private composer");
1279        let mut frame = FrameHarness::from_component(
1280            component,
1281            (720, 900),
1282            ComponentRunnerInputs::from_native_process(),
1283        )
1284        .expect("production component");
1285        frame.relayout();
1286        let sheet = frame.inspection_json().expect("inspection");
1287        assert!(sheet.contains("Report a problem"), "the report sheet shows");
1288        assert!(sheet.contains("1. A message in Chat"), "{sheet}");
1289        describe(&mut frame, "The row is cut off").expect("summary");
1290        call(&mut frame, "review", &json!({})).expect("review");
1291        frame.relayout();
1292        let preview = frame.inspection();
1293        let shown = find(&preview.root)
1294            .and_then(|node| node.semantics.as_ref())
1295            .and_then(|node| node.name.clone())
1296            .expect("the outgoing report is shown");
1297        assert!(shown.contains("The row is cut off"), "{shown}");
1298        assert!(shown.contains("Reply"), "{shown}");
1299        assert!(!shown.contains("Ada"), "content is hidden: {shown}");
1300        call(&mut frame, "toggle", &json!({"index": 0})).expect("show the message");
1301        frame.relayout();
1302        let preview = frame.inspection();
1303        let shown = find(&preview.root)
1304            .and_then(|node| node.semantics.as_ref())
1305            .and_then(|node| node.name.clone())
1306            .expect("the outgoing report is shown");
1307        assert!(shown.contains("Thanks \u{2039}name\u{203A}"), "{shown}");
1308        assert!(
1309            frame
1310                .inspection_json()
1311                .expect("inspection")
1312                .contains("Checked on this device by rules"),
1313            "with no model, the review says the rules checked it"
1314        );
1315        call(&mut frame, "submit", &json!({})).expect("send");
1316        let sent = transport.borrow();
1317        let submission = sent.submissions.first().expect("sent");
1318        assert_eq!(submission.feedback.selections.len(), 1);
1319        assert!(
1320            !serde_json::to_string(submission)
1321                .unwrap()
1322                .contains("Lovelace")
1323        );
1324    }
1325
1326    /// A model the host lends looks over the review: it is asked the model
1327    /// view alone, and the review says by name which model checked it.
1328    #[test]
1329    fn a_lent_model_reviews_the_model_view_and_the_review_names_it() {
1330        use crate::models::{
1331            CompletionFuture, Content, Message, ModelBackend, ModelRequest, ModelResponse, Role,
1332            StopReason, Usage,
1333        };
1334        struct Reviewer(Rc<RefCell<Vec<String>>>);
1335        impl ModelBackend for Reviewer {
1336            fn complete<'a>(&'a self, request: &'a ModelRequest) -> CompletionFuture<'a> {
1337                let asked: String = request
1338                    .messages
1339                    .iter()
1340                    .flat_map(|message| &message.content)
1341                    .filter_map(|content| match content {
1342                        Content::Text { text } => Some(text.as_str()),
1343                        _ => None,
1344                    })
1345                    .collect();
1346                let index = asked
1347                    .lines()
1348                    .find_map(|line| line.strip_suffix("] Reply")?.strip_prefix('['))
1349                    .unwrap_or("0")
1350                    .to_owned();
1351                self.0.borrow_mut().push(asked);
1352                Box::pin(async move {
1353                    Ok(ModelResponse {
1354                        message: Message::text(
1355                            Role::Assistant,
1356                            format!("{{\"hide\":[{{\"text\":{index},\"quote\":\"Reply\"}}]}}"),
1357                        ),
1358                        stop_reason: StopReason::EndTurn,
1359                        usage: Usage::default(),
1360                        cost_micros: 0,
1361                    })
1362                })
1363            }
1364        }
1365        let transport = Rc::new(RefCell::new(Transport::default()));
1366        let services = picked_services(&transport);
1367        let asked = Rc::new(RefCell::new(Vec::new()));
1368        services.set_models(
1369            crate::models::Models::new(Rc::new(Reviewer(Rc::clone(&asked)))),
1370            "Anthropic claude-haiku-5-5",
1371        );
1372        let component =
1373            prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
1374        let mut frame = FrameHarness::from_component(
1375            component,
1376            (720, 900),
1377            ComponentRunnerInputs::from_native_process(),
1378        )
1379        .expect("production component");
1380        frame.relayout();
1381        describe(&mut frame, "Ada Lovelace says the row is cut off").expect("summary");
1382        call(&mut frame, "review", &json!({})).expect("review");
1383        services
1384            .pump()
1385            .expect("the model's answer is laid over the review");
1386        frame.relayout();
1387        let asked = asked.borrow();
1388        assert_eq!(asked.len(), 1, "one pass per review");
1389        assert!(
1390            !asked[0].contains("Lovelace") && !asked[0].contains("Thanks"),
1391            "the model sees placeholders for personal values: {}",
1392            asked[0]
1393        );
1394        let sheet = frame.inspection_json().expect("inspection");
1395        assert!(
1396            sheet.contains("Checked by rules and Anthropic claude-haiku-5-5, which hid more."),
1397            "{sheet}"
1398        );
1399        call(&mut frame, "submit", &json!({})).expect("send");
1400        let sent = transport.borrow();
1401        let submission = serde_json::to_string(sent.submissions.first().expect("sent")).unwrap();
1402        assert!(!submission.contains("Reply"), "the model's span is hidden");
1403        assert!(!submission.contains("Lovelace"), "the floor stands");
1404    }
1405}