diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs
--- a/crates/engine/src/activity.rs
+++ b/crates/engine/src/activity.rs
@@ -7,6 +7,11 @@
 //! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the
 //! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a
 //! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.
+//!
+//! A job that says how it ended ([`TaskGuard::finish`]) leaves a row behind when there is something to come back
+//! to: it ran long enough to walk away from, or it did not go well. That row stays until it is dismissed
+//! (`activity.finished`, `activity.dismiss`), for this session. A guard dropped by a panic leaves "stopped
+//! unexpectedly" instead of nothing.
 
 use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
 use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
@@ -15,6 +20,19 @@ use serde::Serialize;
 
 /// The longest label or detail kept, in characters (a file name can be anything).
 const MAX_TEXT: usize = 200;
+/// The longest sentence kept of how a task ended, or of why a file failed.
+const MAX_SENTENCE: usize = 400;
+/// A task that ended well leaves a row once it has run this long ([`Activity::keep_after`]); a shorter one was
+/// watched to its end.
+pub const KEEP_AFTER_MS: u64 = 10_000;
+/// The most tasks that ended well kept: the oldest give way.
+const MAX_DONE: usize = 50;
+/// The most ended tasks kept in all. Those that did not go well are kept past [`MAX_DONE`], up to here.
+const MAX_FINISHED: usize = 200;
+/// The most files a task keeps the reason of ([`Finished::failed`] still counts them all).
+pub const MAX_FAILURES: usize = 500;
+/// The longest path kept of where a task's results are.
+const MAX_PATH: usize = 4096;
 
 /// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it.
 #[derive(Clone, Default)]
@@ -22,13 +40,118 @@ pub struct Activity {
     inner: Arc<Inner>,
 }
 
-#[derive(Default)]
 struct Inner {
     /// Oldest first.
     tasks: Mutex<Vec<Arc<Entry>>>,
+    /// The tasks that ended and left a row, oldest first.
+    finished: Mutex<Vec<Kept>>,
+    keep_after_ms: AtomicU64,
     next: AtomicU64,
 }
 
+impl Default for Inner {
+    fn default() -> Self {
+        Inner { tasks: Mutex::default(), finished: Mutex::default(), keep_after_ms: AtomicU64::new(KEEP_AFTER_MS), next: AtomicU64::new(0) }
+    }
+}
+
+/// How a task ended.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Ending {
+    /// It did all it was asked to.
+    #[default]
+    Done,
+    /// It ran to its end, but not for every file or photo.
+    Problems,
+    /// It could not do its work.
+    Failed,
+    /// It was stopped: ✕, `activity.cancel`, quitting.
+    Stopped,
+    /// Its worker died (a bug): the guard was dropped by a panic.
+    Died,
+}
+
+impl Ending {
+    /// Whether the person should be told: everything but a clean end.
+    pub fn needs_attention(self) -> bool {
+        self != Ending::Done
+    }
+}
+
+/// A file or photo a task could not do, and why.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+pub struct Failure {
+    pub name: String,
+    pub reason: String,
+}
+
+/// What a job says of itself when it ends ([`TaskGuard::finish`]).
+#[derive(Clone, Debug, Default)]
+pub struct Outcome {
+    pub ending: Ending,
+    /// What happened, as the frontend shows it ("Exported 24 of 25 photos"). Frontend code passes it translated;
+    /// engine code leaves it empty, and a frontend words the ending from the task's label.
+    pub summary: String,
+    /// What else needs reading: what was skipped, or the reason it failed.
+    pub detail: String,
+    /// The files or photos it could not do. The first [`MAX_FAILURES`] are kept.
+    pub failures: Vec<Failure>,
+    /// How many it could not do, for a job that counts them without naming them (`failures` counts when it holds
+    /// more).
+    pub failed: usize,
+    /// One of the files it made (or the folder they are in), for showing where they are in the file manager.
+    pub path: Option<String>,
+}
+
+impl Outcome {
+    /// An end that says nothing more than how it went (the frontend words it from the task's name).
+    pub fn ended(ending: Ending) -> Self {
+        Outcome { ending, ..Default::default() }
+    }
+
+    /// The job could not do its work, and this is why.
+    pub fn failed(why: impl Into<String>) -> Self {
+        Outcome { ending: Ending::Failed, detail: why.into(), ..Default::default() }
+    }
+}
+
+/// A task that ended, as the frontends see it (`activity.finished`), until it is dismissed.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Finished {
+    /// The id it had while it ran.
+    pub id: u64,
+    pub kind: &'static str,
+    /// English; the frontend translates it.
+    pub label: String,
+    pub ending: Ending,
+    pub summary: String,
+    pub detail: String,
+    pub failures: Vec<Failure>,
+    /// How many files or photos failed (`failures` may hold fewer, or none when the job only counted them).
+    pub failed: usize,
+    /// One of the files it made (or their folder), if it made any.
+    pub path: Option<String>,
+    /// How long it ran.
+    pub ran_ms: u64,
+    /// How long ago it ended.
+    pub age_ms: u64,
+}
+
+struct Kept {
+    row: Finished,
+    ended: web_time::Instant,
+}
+
+fn millis(d: std::time::Duration) -> u64 {
+    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
+}
+
+fn sentence(text: &str) -> String {
+    text.chars().take(MAX_SENTENCE).collect()
+}
+
 /// How a task can be cancelled.
 pub enum Cancel {
     /// Not at all (no ✕): the face scan, AI Denoise.
@@ -162,7 +285,7 @@ impl Entry {
             detail: lock(&self.detail).clone(),
             cancellable: self.is_cancellable(),
             cancelling: self.is_cancelled(),
-            age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
+            age_ms: millis(self.started.elapsed()),
         }
     }
 }
@@ -191,7 +314,48 @@ impl Activity {
             gate: Mutex::new(()),
         });
         lock(&self.inner.tasks).push(entry.clone());
-        TaskGuard { activity: self.clone(), entry }
+        TaskGuard { activity: self.clone(), entry, reported: false }
+    }
+
+    /// The tasks that ended and left a row, newest first.
+    pub fn finished(&self) -> Vec<Finished> {
+        lock(&self.inner.finished).iter().rev().map(|k| Finished { age_ms: millis(k.ended.elapsed()), ..k.row.clone() }).collect()
+    }
+
+    /// Let go of the row of the ended task `id`. An unknown id (it may have been dismissed already) is an error.
+    pub fn dismiss(&self, id: u64) -> Result<(), String> {
+        let mut finished = lock(&self.inner.finished);
+        let at = finished.iter().position(|k| k.row.id == id).ok_or_else(|| format!("no finished task {id}"))?;
+        finished.remove(at);
+        Ok(())
+    }
+
+    /// Let go of every ended task's row; returns how many there were.
+    pub fn dismiss_all(&self) -> usize {
+        let mut finished = lock(&self.inner.finished);
+        let n = finished.len();
+        finished.clear();
+        n
+    }
+
+    /// How long a task has to run for a clean end to leave a row ([`KEEP_AFTER_MS`] unless set).
+    pub fn keep_after(&self, ms: u64) {
+        self.inner.keep_after_ms.store(ms, Ordering::Relaxed);
+    }
+
+    /// Keep the row of a task that ended, and make room: of those that ended well the oldest give way first.
+    fn keep(&self, row: Finished) {
+        let mut finished = lock(&self.inner.finished);
+        finished.push(Kept { row, ended: web_time::Instant::now() });
+        while finished.iter().filter(|k| k.row.ending == Ending::Done).count() > MAX_DONE {
+            let Some(at) = finished.iter().position(|k| k.row.ending == Ending::Done) else { break };
+            finished.remove(at);
+        }
+        while finished.len() > MAX_FINISHED {
+            // (a clean one goes before one that did not go well, as long as there is one)
+            let at = finished.iter().position(|k| k.row.ending == Ending::Done).unwrap_or(0);
+            finished.remove(at);
+        }
     }
 
     /// Every task in flight, oldest first.
@@ -223,6 +387,8 @@ impl Activity {
 pub struct TaskGuard {
     activity: Activity,
     entry: Arc<Entry>,
+    /// The job said how it ended ([`TaskGuard::finish`]).
+    reported: bool,
 }
 
 impl TaskGuard {
@@ -261,12 +427,61 @@ impl TaskGuard {
     pub fn handle(&self) -> TaskHandle {
         TaskHandle { entry: self.entry.clone() }
     }
+
+    /// The job ended, and this is how. Its row among the running tasks goes; a row among the finished ones takes
+    /// its place when there is something to come back to: the task ran for [`KEEP_AFTER_MS`] or more, or it did
+    /// not end well. Returns whether that row was kept. A frontend still says at once how the job went (a toast):
+    /// the row is what stays, not the announcement.
+    pub fn finish(mut self, outcome: Outcome) -> bool {
+        self.report(outcome)
+    }
+
+    /// Say how the job ended, as [`TaskGuard::finish`] does, but keep its running row until the guard is dropped: for a
+    /// job that marks itself finished in between, so that whoever sees the row go finds it finished, and whoever finds
+    /// it finished finds the row. Only the first report counts.
+    pub fn report(&mut self, outcome: Outcome) -> bool {
+        if std::mem::replace(&mut self.reported, true) {
+            return false;
+        }
+        self.leave(outcome)
+    }
+
+    fn leave(&self, outcome: Outcome) -> bool {
+        let ran_ms = millis(self.entry.started.elapsed());
+        if !outcome.ending.needs_attention() && ran_ms < self.activity.inner.keep_after_ms.load(Ordering::Relaxed) {
+            return false;
+        }
+        let failed = outcome.failed.max(outcome.failures.len());
+        self.activity.keep(Finished {
+            id: self.entry.id,
+            kind: self.entry.kind,
+            label: self.entry.label.clone(),
+            ending: outcome.ending,
+            summary: sentence(&outcome.summary),
+            detail: sentence(&outcome.detail),
+            failures: outcome
+                .failures
+                .into_iter()
+                .take(MAX_FAILURES)
+                .map(|f| Failure { name: capped(&f.name), reason: sentence(&f.reason) })
+                .collect(),
+            failed,
+            path: outcome.path.map(|path| path.chars().take(MAX_PATH).collect()),
+            ran_ms,
+            age_ms: 0,
+        });
+        true
+    }
 }
 
 impl Drop for TaskGuard {
     fn drop(&mut self) {
         let id = self.entry.id;
         lock(&self.activity.inner.tasks).retain(|e| e.id != id);
+        // a worker that died never said how it ended: say that it stopped, rather than nothing
+        if !self.reported && std::thread::panicking() {
+            self.leave(Outcome { ending: Ending::Died, ..Default::default() });
+        }
     }
 }
 
@@ -309,6 +524,11 @@ mod tests {
     use std::sync::Arc;
     use std::sync::atomic::{AtomicBool, Ordering};
 
+    /// How many ended tasks did not end well.
+    fn problems(a: &Activity) -> usize {
+        a.finished().iter().filter(|f| f.ending.needs_attention()).count()
+    }
+
     #[test]
     fn guard_drop_removes_the_row() {
         let a = Activity::default();
@@ -318,6 +538,127 @@ mod tests {
         assert!(a.list().is_empty());
     }
 
+    #[test]
+    fn a_long_task_that_ends_well_leaves_a_row_and_a_short_one_does_not() {
+        let a = Activity::default();
+        // Given a task that ran for less than it takes to walk away
+        let quick = a.start("export", "Exporting", Cancel::Yes);
+        // When it ends well, Then nothing is kept: it was watched to its end
+        assert!(!quick.finish(Outcome { summary: "Exported 2 photos".into(), ..Default::default() }));
+        assert!(a.finished().is_empty() && a.list().is_empty());
+        // Given a task that ran long enough (here: any time at all)
+        a.keep_after(0);
+        let long = a.start("export", "Exporting", Cancel::Yes);
+        let id = long.id();
+        assert!(long.finish(Outcome { summary: "Exported 5,000 photos".into(), path: Some("/out/a.jpg".into()), ..Default::default() }));
+        // Then its row among the running tasks is gone, and one among the finished took its place
+        assert!(a.list().is_empty());
+        let rows = a.finished();
+        assert_eq!(rows.len(), 1);
+        assert_eq!((rows[0].id, rows[0].kind, rows[0].ending, rows[0].summary.as_str()), (id, "export", Ending::Done, "Exported 5,000 photos"));
+        assert_eq!(rows[0].path.as_deref(), Some("/out/a.jpg"));
+        assert_eq!(a.finished().first().map(|f| (f.id, f.summary.clone())), Some((id, "Exported 5,000 photos".to_string())));
+        // And a clean end asks for nobody's attention
+        assert_eq!(problems(&a), 0);
+    }
+
+    #[test]
+    fn a_task_that_did_not_end_well_always_leaves_a_row_and_asks_for_attention_until_dismissed() {
+        let a = Activity::default();
+        for (ending, n) in [(Ending::Problems, 1), (Ending::Failed, 2), (Ending::Stopped, 3)] {
+            let g = a.start("import", "Importing", Cancel::Yes);
+            assert!(g.finish(Outcome { ending, ..Default::default() }), "{ending:?} is kept however short the task");
+            assert_eq!(problems(&a), n);
+        }
+        // newest first
+        let rows = a.finished();
+        assert_eq!(rows.iter().map(|f| f.ending).collect::<Vec<_>>(), vec![Ending::Stopped, Ending::Failed, Ending::Problems]);
+        // each asks until it is dismissed, not until it has been looked at
+        a.dismiss(rows[1].id).unwrap();
+        assert_eq!(problems(&a), 2);
+        a.dismiss_all();
+        assert_eq!(problems(&a), 0);
+    }
+
+    #[test]
+    fn finished_rows_are_dismissed_one_by_one_or_all_at_once() {
+        let a = Activity::default();
+        let ids: Vec<u64> = (0..3)
+            .map(|_| {
+                let g = a.start("export", "Exporting", Cancel::Yes);
+                let id = g.id();
+                g.finish(Outcome { ending: Ending::Failed, ..Default::default() });
+                id
+            })
+            .collect();
+        a.dismiss(ids[1]).unwrap();
+        assert_eq!(a.finished().iter().map(|f| f.id).collect::<Vec<_>>(), vec![ids[2], ids[0]]);
+        assert!(a.dismiss(ids[1]).is_err(), "an error, not a panic, for a row already gone");
+        assert_eq!(a.dismiss_all(), 2);
+        assert!(a.finished().is_empty());
+        assert_eq!(a.dismiss_all(), 0);
+    }
+
+    #[test]
+    fn what_is_kept_of_an_ending_is_bounded() {
+        let a = Activity::default();
+        a.keep_after(0);
+        // a job with more failures than are kept, long names and long reasons
+        let g = a.start("export", "Exporting", Cancel::Yes);
+        let failures: Vec<Failure> =
+            (0..MAX_FAILURES + 40).map(|i| Failure { name: format!("{i}{}", "é".repeat(300)), reason: "x".repeat(1000) }).collect();
+        g.finish(Outcome {
+            ending: Ending::Problems,
+            summary: "s".repeat(5000),
+            detail: "d".repeat(5000),
+            failures,
+            path: Some("p".repeat(9000)),
+            ..Default::default()
+        });
+        let row = &a.finished()[0];
+        assert_eq!((row.failed, row.failures.len()), (MAX_FAILURES + 40, MAX_FAILURES), "all counted, the first kept");
+        assert_eq!((row.summary.chars().count(), row.detail.chars().count()), (MAX_SENTENCE, MAX_SENTENCE));
+        assert_eq!(row.path.as_ref().map(|p| p.chars().count()), Some(MAX_PATH));
+        // a job that only counts what failed (an import) is believed
+        let counted = a.start("import", "Importing", Cancel::Yes);
+        counted.finish(Outcome { ending: Ending::Problems, failed: 3, ..Default::default() });
+        assert_eq!((a.finished()[0].failed, a.finished()[0].failures.len()), (3, 0));
+        a.dismiss(a.finished()[0].id).unwrap();
+        assert!(row.failures.iter().all(|f| f.name.chars().count() == MAX_TEXT && f.reason.chars().count() == MAX_SENTENCE));
+        // many clean endings: the oldest give way, the one that did not go well stays
+        for i in 0..MAX_DONE + 10 {
+            a.start("export", &format!("Task {i}"), Cancel::Yes).finish(Outcome::default());
+        }
+        let rows = a.finished();
+        assert_eq!(rows.iter().filter(|f| f.ending == Ending::Done).count(), MAX_DONE);
+        assert_eq!(rows.first().map(|f| f.label.as_str()), Some(format!("Task {}", MAX_DONE + 9).as_str()), "the newest is kept");
+        assert!(rows.iter().any(|f| f.ending == Ending::Problems), "a row that needs looking at is not pushed out by clean ones");
+        // and never without end: rows that did not go well are bounded too
+        for _ in 0..MAX_FINISHED + 20 {
+            a.start("import", "Importing", Cancel::Yes).finish(Outcome { ending: Ending::Failed, ..Default::default() });
+        }
+        assert_eq!(a.finished().len(), MAX_FINISHED);
+    }
+
+    #[test]
+    fn a_worker_that_dies_leaves_a_row_saying_so() {
+        let a = Activity::default();
+        let a2 = a.clone();
+        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
+            let _g = a2.start("previews", "Building previews", Cancel::Yes);
+            panic!("synthetic worker panic");
+        }));
+        assert!(r.is_err());
+        assert!(a.list().is_empty(), "no stuck bar");
+        let rows = a.finished();
+        assert_eq!(rows.len(), 1);
+        assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].ending), ("previews", "Building previews", Ending::Died));
+        assert_eq!(problems(&a), 1);
+        // a guard that is simply dropped (a job that does not report yet) leaves nothing, as before
+        drop(a.start("faces", "Finding faces", Cancel::No));
+        assert_eq!(a.finished().len(), 1);
+    }
+
     #[test]
     fn guard_drop_on_panic_unwind_removes_the_row() {
         let a = Activity::default();
diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs
--- a/crates/engine/src/cmd/activity.rs
+++ b/crates/engine/src/cmd/activity.rs
@@ -1,4 +1,5 @@
-//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
+//! `activity.list` / `activity.cancel`: the background tasks in flight; `activity.finished` / `activity.dismiss`:
+//! the ones that ended and left a row (see [`crate::activity`]).
 
 use serde_json::{Value, json};
 
@@ -32,6 +33,32 @@ pub fn specs() -> Vec<CommandSpec> {
                 Ok(json!({"cancelled": 1}))
             }
         ),
+        cmd!(
+            query "activity.finished",
+            "Finished Background Tasks",
+            [],
+            None,
+            "{} → {finished: [{id, kind, label, ending: done|problems|failed|stopped|died, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]} — the tasks that ended and left a row, newest first: those that ran 10 s or more, and every one that did not end well (problems = not for every file, stopped = cancelled, died = its worker crashed). `failed` counts the files that failed; `failures` holds the first 500 with their reasons (none for a job that only counts them); `path` is one of the files it made. `summary` and `detail` are as the app shows them, in its language, and `summary` is empty for a job that says nothing of itself. At most 50 clean endings and 200 rows in all are kept. They stay until dismissed, for this session",
+            always,
+            |s, _| Ok(json!({"finished": s.activity.finished()}))
+        ),
+        cmd!(
+            "activity.dismiss",
+            "Dismiss Finished Task",
+            [],
+            None,
+            "{id} | {all: true} → {dismissed: n} — let go of a finished task's row (or of all of them). An unknown id (it may have been dismissed already) is an error",
+            always,
+            |s, p| {
+                const C: &str = "activity.dismiss";
+                if p.get("all").and_then(Value::as_bool) == Some(true) {
+                    return Ok(json!({"dismissed": s.activity.dismiss_all()}));
+                }
+                let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
+                s.activity.dismiss(id).map_err(|e| bad(C, e))?;
+                Ok(json!({"dismissed": 1}))
+            }
+        ),
     ]
 }
 
@@ -54,6 +81,30 @@ mod tests {
         assert!(g.is_cancelled());
     }
 
+    #[test]
+    fn finished_and_dismiss_through_execute() {
+        use crate::activity::{Ending, Failure, Outcome};
+        let mut s = Session::new();
+        let g = s.activity.start("export", "Exporting", Cancel::Yes);
+        let id = g.id();
+        g.finish(Outcome {
+            ending: Ending::Problems,
+            summary: "Exported 1 of 2 photos".into(),
+            failures: vec![Failure { name: "IMG_2.jpg".into(), reason: "The disk is full".into() }],
+            ..Default::default()
+        });
+        let r = s.execute("activity.finished", &json!({})).unwrap();
+        assert_eq!(r["finished"][0]["id"], id);
+        assert_eq!(r["finished"][0]["ending"], "problems");
+        assert_eq!(r["finished"][0]["failures"][0], json!({"name": "IMG_2.jpg", "reason": "The disk is full"}));
+        assert_eq!(r["finished"][0]["failed"], 1);
+        assert!(s.execute("activity.dismiss", &json!({})).is_err());
+        assert!(s.execute("activity.dismiss", &json!({"id": 999})).is_err());
+        assert_eq!(s.execute("activity.dismiss", &json!({"id": id})).unwrap()["dismissed"], 1);
+        assert_eq!(s.execute("activity.dismiss", &json!({"all": true})).unwrap()["dismissed"], 0);
+        assert_eq!(s.execute("activity.finished", &json!({})).unwrap()["finished"], json!([]));
+    }
+
     #[test]
     fn cancel_all_and_errors() {
         let mut s = Session::new();
diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs
--- a/crates/engine/src/cmd/browse.rs
+++ b/crates/engine/src/cmd/browse.rs
@@ -471,9 +471,14 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
         return Ok(json!({"moved": 0, "folder": folder}));
     }
     let plural = |k: usize| if k == 1 { "" } else { "s" };
-    let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
+    let task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
     let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
-    s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
+    let moved = s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)));
+    task.finish(match &moved {
+        Ok(_) => crate::activity::Outcome::default(),
+        Err(e) => crate::activity::Outcome::failed(e.to_string()),
+    });
+    moved?;
     Ok(json!({"moved": n, "folder": folder}))
 }
 
diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs
--- a/crates/engine/src/cmd/geocode.rs
+++ b/crates/engine/src/cmd/geocode.rs
@@ -149,6 +149,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
     for (n, (id, at)) in todo.iter().enumerate() {
         if let Some(t) = &task {
             if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
+                if let Some(t) = task {
+                    t.finish(crate::activity::Outcome::ended(crate::activity::Ending::Stopped));
+                }
                 return Err(bad(C, "cancelled"));
             }
             t.progress(n as u64, todo.len() as u64);
@@ -172,11 +175,15 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
         }
         found.push(entry);
     }
-    drop(task);
     let changed = ops.len();
-    if !bool_or(p, "dryRun", false) && changed > 0 {
-        s.commit("Reverse Geocode", Op::Batch { ops })?;
+    let committed = if !bool_or(p, "dryRun", false) && changed > 0 { s.commit("Reverse Geocode", Op::Batch { ops }) } else { Ok(()) };
+    if let Some(t) = task {
+        t.finish(match &committed {
+            Ok(_) => crate::activity::Outcome::default(),
+            Err(e) => crate::activity::Outcome::failed(e.to_string()),
+        });
     }
+    committed?;
     Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
 }
 
@@ -261,9 +268,9 @@ mod download {
         let thread_dir = dir.clone();
         let thread_shared = shared.clone();
         let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
-            // the activity row goes when the thread ends, however it ends
-            let _task = task;
+            // the activity row says how it ended when the thread ends, however it ends
             let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
+            let panicked = outcome.is_err();
             let outcome = match outcome {
                 Ok(Ok(n)) => DownloadOutcome::Done(n),
                 _ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
@@ -273,10 +280,19 @@ mod download {
             if let DownloadOutcome::Failed(e) = &outcome {
                 log::warn!("{e}");
             }
+            let ended = match &outcome {
+                _ if panicked => crate::activity::Outcome::ended(crate::activity::Ending::Died),
+                DownloadOutcome::Done(_) => crate::activity::Outcome::default(),
+                DownloadOutcome::Cancelled => crate::activity::Outcome::ended(crate::activity::Ending::Stopped),
+                DownloadOutcome::Failed(why) => crate::activity::Outcome::failed(why.clone()),
+            };
             let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
             sh.running = false;
             sh.last = Some(outcome.clone());
             sh.unreported = Some(outcome);
+            drop(sh);
+            // last: whoever sees the row go finds the download over
+            task.finish(ended);
         });
         if let Err(e) = spawned {
             shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs
--- a/crates/engine/src/cmd/previews.rs
+++ b/crates/engine/src/cmd/previews.rs
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
 use serde_json::{Value, json};
 
 use super::{CommandSpec, always, bad, cmd, str_param};
-use crate::activity::{Cancel, TaskGuard};
+use crate::activity::{Cancel, Ending, Outcome, TaskGuard};
 use crate::media::{RenderJob, THUMB_SIZES};
 use crate::{Result, Session};
 
@@ -43,7 +43,7 @@ impl PreviewBuild {
             "total": self.total,
             "done": self.done.load(Ordering::Relaxed),
             "failed": self.failed.load(Ordering::Relaxed),
-            "running": !self.finished.load(Ordering::Relaxed),
+            "running": !self.finished.load(Ordering::Acquire),
             "cancelled": self.cancel.load(Ordering::Relaxed),
             "what": if self.what.is_empty() { "previews" } else { self.what },
             "repaired": self.repaired.load(Ordering::Relaxed),
@@ -54,6 +54,30 @@ impl PreviewBuild {
     pub fn error(&self) -> Option<String> {
         self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
     }
+
+    /// How the build ended, for its row: it failed as a whole, was stopped, or could not make some photos' previews.
+    fn outcome(&self) -> Outcome {
+        let failed = self.failed.load(Ordering::Relaxed);
+        if let Some(why) = self.error() {
+            return Outcome { failed, ..Outcome::failed(why) };
+        }
+        let ending = if self.cancel.load(Ordering::Relaxed) {
+            Ending::Stopped
+        } else if failed > 0 {
+            Ending::Problems
+        } else {
+            Ending::Done
+        };
+        Outcome { ending, failed, ..Default::default() }
+    }
+
+    /// The build is over: the row of how it went is kept, the build reads as finished, and only then does its running
+    /// row go (whoever waits for `finished` finds the row; whoever sees the running row go finds the build finished).
+    fn end(&self, mut task: TaskGuard) {
+        task.report(self.outcome());
+        self.finished.store(true, Ordering::Release);
+        drop(task);
+    }
 }
 
 /// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`
@@ -226,7 +250,7 @@ pub(crate) mod test_gate {
     }
 }
 
-fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {
+fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: TaskGuard, lanes: usize) {
     let total = jobs.len();
     let queue = std::sync::Mutex::new(jobs.into_iter());
     let work = || {
@@ -269,7 +293,7 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, la
         let _ = lanes;
         work();
     }
-    state.finished.store(true, Ordering::Relaxed);
+    state.end(task);
 }
 
 fn build(s: &mut Session, p: &Value) -> Result<Value> {
@@ -282,7 +306,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
         "full" | "1:1" => None,
         other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
     };
-    if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
+    if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) {
         return Err(bad(C, "a preview build is already running"));
     }
     // explicit ids, else the selection, else everything in view
@@ -297,7 +321,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
     let lanes = lanes(jobs.len(), edge.is_none());
     let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
     if wait {
-        run_all(jobs, &state, &task, lanes);
+        run_all(jobs, &state, task, lanes);
         return Ok(state.json());
     }
     #[cfg(not(target_arch = "wasm32"))]
@@ -306,7 +330,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
         let st = state.clone();
         std::thread::Builder::new()
             .name("lc-build-previews".into())
-            .spawn(move || run_all(jobs, &st, &task, lanes))
+            .spawn(move || run_all(jobs, &st, task, lanes))
             .map_err(|e| bad(C, format!("could not start: {e}")))?;
     }
     Ok(state.json())
@@ -471,7 +495,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
     let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
     let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
     let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
-    if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
+    if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) {
         return Err(bad(C, "a preview build is already running"));
     }
     let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
@@ -499,7 +523,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
                     log::warn!("{C}: {e}");
                     *st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
                 }
-                st.finished.store(true, Ordering::Relaxed);
+                st.end(task);
             })
             .map_err(|e| bad(C, format!("could not start: {e}")))?;
         return Ok(state.json());
diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs
--- a/crates/engine/src/face_download.rs
+++ b/crates/engine/src/face_download.rs
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex, PoisonError};
 use std::time::Duration;
 
-use crate::activity::{Activity, Cancel, TaskHandle, Unit};
+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit};
 
 use lightcraft_faces::known::Download;
 
@@ -94,12 +94,12 @@ impl Downloads {
         std::thread::Builder::new()
             .name("face-model-download".into())
             .spawn(move || {
-                // the row goes when the thread ends, however it ends
-                let _task = task;
+                // the row says how it ended when the thread ends, however it ends
                 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker)));
                 if outcome.is_err() {
                     worker.set(State::Failed("the download stopped unexpectedly".into()));
                 }
+                task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) });
             })
             .map_err(|e| format!("could not start the download: {e}"))?;
         self.jobs.insert(id, shared);
@@ -222,6 +222,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {
     Err("the web build downloads no models".into())
 }
 
+/// How a download that ran ended, for its row.
+fn ended(state: &State) -> Outcome {
+    match state {
+        State::Done { .. } | State::Installed => Outcome::default(),
+        State::Failed(why) => Outcome::failed(why.clone()),
+        State::Cancelled => Outcome::ended(Ending::Stopped),
+        // (the thread ended without saying how)
+        State::Running { .. } => Outcome::ended(Ending::Died),
+    }
+}
+
 /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`].
 fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {
     let target = staging.join(&spec.file_name);
@@ -355,6 +366,23 @@ mod tests {
         wait("the download never stopped", || !d.running());
         assert_eq!(d.jobs["test-model"].get(), State::Cancelled);
         wait("the row stayed", || activity.list().is_empty());
+        assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn a_download_that_failed_leaves_a_row_with_the_reason() {
+        use crate::activity::{Activity, Ending};
+        let dir = scratch("failed-row");
+        let activity = Activity::default();
+        let mut d = Downloads::default();
+        let mut spec = spec_for(b"abc");
+        spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);
+        d.start(spec, &dir, &activity, "Test Model").unwrap();
+        wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());
+        let row = activity.finished().remove(0);
+        assert_eq!((row.kind, row.ending), ("download", Ending::Failed));
+        assert!(!row.detail.is_empty(), "the reason is kept");
         let _ = std::fs::remove_dir_all(&dir);
     }
 
diff --git a/crates/engine/src/model_download.rs b/crates/engine/src/model_download.rs
--- a/crates/engine/src/model_download.rs
+++ b/crates/engine/src/model_download.rs
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex, PoisonError};
 use std::time::Duration;
 
-use crate::activity::{Activity, Cancel, TaskHandle, Unit};
+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit};
 
 use lightcraft_denoise::known::Download;
 
@@ -95,12 +95,12 @@ impl Downloads {
         std::thread::Builder::new()
             .name("denoise-model-download".into())
             .spawn(move || {
-                // the row goes when the thread ends, however it ends
-                let _task = task;
+                // the row says how it ended when the thread ends, however it ends
                 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker)));
                 if outcome.is_err() {
                     worker.set(State::Failed("the download stopped unexpectedly".into()));
                 }
+                task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) });
             })
             .map_err(|e| format!("could not start the download: {e}"))?;
         self.jobs.insert(id, shared);
@@ -203,6 +203,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {
     Err("the web build downloads no models".into())
 }
 
+/// How a download that ran ended, for its row.
+fn ended(state: &State) -> Outcome {
+    match state {
+        State::Done { .. } | State::Installing | State::Installed => Outcome::default(),
+        State::Failed(why) => Outcome::failed(why.clone()),
+        State::Cancelled => Outcome::ended(Ending::Stopped),
+        // (the thread ended without saying how)
+        State::Running { .. } => Outcome::ended(Ending::Died),
+    }
+}
+
 /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`].
 fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {
     let target = staging.join(&spec.file_name);
@@ -323,6 +334,32 @@ mod tests {
         wait("the download never stopped", || !d.running());
         assert_eq!(d.jobs["test-model"].get(), State::Cancelled);
         wait("the row stayed", || activity.list().is_empty());
+        assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn a_download_that_ended_leaves_a_row_saying_how() {
+        use crate::activity::{Activity, Ending};
+        let dir = scratch("ended");
+        let activity = Activity::default();
+        activity.keep_after(0);
+        let mut d = Downloads::default();
+        // a server that refuses: the row keeps the reason
+        let mut spec = spec_for(b"abc");
+        spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);
+        d.start(spec, &dir, &activity, "Test Model").unwrap();
+        wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());
+        let failed = activity.finished().remove(0);
+        assert_eq!((failed.kind, failed.ending), ("download", Ending::Failed));
+        assert!(failed.detail.contains("internet connection"), "{}", failed.detail);
+        // one that arrives
+        let bytes: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();
+        let mut spec = spec_for(&bytes);
+        spec.url = format!("{}/{}", serve("200 OK", bytes), spec.file_name);
+        d.start(spec, &dir, &activity, "Test Model").unwrap();
+        wait("the download's row never ended", || activity.finished().len() == 2);
+        assert_eq!(activity.finished()[0].ending, Ending::Done);
         let _ = std::fs::remove_dir_all(&dir);
     }
 
diff --git a/crates/engine/src/segment/download.rs b/crates/engine/src/segment/download.rs
--- a/crates/engine/src/segment/download.rs
+++ b/crates/engine/src/segment/download.rs
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};
 
 use lightcraft_segment::fetch::{self, Options, Progress};
 
-use crate::activity::{Activity, Cancel, Unit};
+use crate::activity::{Activity, Cancel, Ending, Outcome, Unit};
 
 /// What the session sees of a download.
 #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
@@ -141,14 +141,15 @@ impl Downloader {
             let r =
                 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
             let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));
-            let error = match r {
+            let (error, outcome) = match r {
                 Ok(Ok(())) => {
                     s.finished.store(true, Ordering::SeqCst);
                     log::info!("SAM 3 model downloaded to {}", dir.display());
-                    None
+                    (None, Outcome::default())
                 }
-                Ok(Err(e)) => Some(e.to_string()),
-                Err(_) => Some("the download failed unexpectedly".to_string()),
+                Ok(Err(e)) if s.cancel.load(Ordering::SeqCst) => (Some(e.to_string()), Outcome::ended(Ending::Stopped)),
+                Ok(Err(e)) => (Some(e.to_string()), Outcome::failed(e.to_string())),
+                Err(_) => (Some("the download failed unexpectedly".to_string()), Outcome::ended(Ending::Died)),
             };
             if let Some(e) = &error {
                 log::warn!("SAM 3 download: {e}");
@@ -158,6 +159,8 @@ impl Downloader {
             text.error = error;
             drop(text);
             drop(guard);
+            // last: whoever sees the row go finds the download over
+            task.finish(outcome);
         });
         match spawned {
             Ok(_) => Ok(true),
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1224,6 +1224,8 @@ fn activity_cancel_stops_a_preview_build() {
     assert_eq!(p["cancelled"], true, "{p}");
     assert_eq!(p["done"], json!(0), "no photo rendered after the accepted cancel: {p}");
     assert_eq!(p["running"], false, "{p}");
+    let row = s.activity.finished().into_iter().next().expect("a stopped build leaves a row");
+    assert_eq!((row.kind, row.ending), ("previews", crate::activity::Ending::Stopped));
 }
 
 /// The gated fixture is real work: without a cancel, both photos render and are counted.
@@ -1247,6 +1249,31 @@ fn preview_build_at_the_test_gate_completes_without_cancel() {
     assert_eq!(p["failed"], json!(0), "{p}");
 }
 
+/// A preview build that ran to its end leaves a row saying so (when it ran long enough to be worth one).
+#[test]
+fn a_finished_preview_build_leaves_a_row_saying_it_was_done() {
+    let mut s = demo();
+    s.activity.keep_after(0);
+    let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
+    s.execute("library.buildPreviews", &json!({"ids": ids, "edge": 256, "wait": true})).unwrap();
+    let row = s.activity.finished().into_iter().next().expect("a row for the build");
+    assert_eq!((row.kind, row.label.as_str(), row.ending, row.failed), ("previews", "Building previews", crate::activity::Ending::Done, 0));
+}
+
+/// A photo whose preview can't be made (its file is gone) leaves the build's row marked, with how many failed.
+#[test]
+fn a_preview_build_that_could_not_render_a_photo_says_how_many() {
+    use lightcraft_catalog::{Op, Photo, Source};
+    let mut s = Session::new();
+    let id = s.catalog.alloc_photo_id();
+    let gone = std::env::temp_dir().join(format!("lc-no-such-photo-{}.jpg", std::process::id()));
+    let p = Photo::new(id, Source::File { path: gone.to_string_lossy().to_string() }, "gone.jpg", "JPEG", 600, 400, "2026-01-01T10:00:00");
+    s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+    s.execute("library.buildPreviews", &json!({"ids": [id.0], "edge": 256, "wait": true})).unwrap();
+    let row = s.activity.finished().into_iter().next().expect("a build that did not go well always leaves a row");
+    assert_eq!((row.ending, row.failed), (crate::activity::Ending::Problems, 1));
+}
+
 /// Colour range: a click samples the colour under it, so the mask selects that colour (white
 /// in the mask view) and not a different one; ⇧ adds samples, up to five.
 #[test]
diff --git a/crates/engine/src/tests/folders.rs b/crates/engine/src/tests/folders.rs
--- a/crates/engine/src/tests/folders.rs
+++ b/crates/engine/src/tests/folders.rs
@@ -650,6 +650,21 @@ fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() {
     assert_eq!(file_of(&s, b), dir.path("out/b.jpg"));
 }
 
+/// A move is a task: when it ran long enough to be worth a row, the row says it was done.
+#[test]
+fn a_move_leaves_a_row_saying_it_was_done() {
+    let dir = Scratch::new("move-row");
+    std::fs::create_dir_all(dir.0.join("in")).unwrap();
+    std::fs::create_dir_all(dir.0.join("out")).unwrap();
+    let mut s = Session::new();
+    s.activity.keep_after(0);
+    let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");
+    let b = add_file(&mut s, &dir.path("in/b.jpg"), "B");
+    s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap();
+    let row = s.activity.finished().into_iter().next().expect("a row for the move");
+    assert_eq!((row.kind, row.label.as_str(), row.ending), ("move", "Moving 2 photos", crate::activity::Ending::Done));
+}
+
 #[test]
 fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() {
     let dir = Scratch::new("move-clash");
diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json
--- a/crates/ui-egui/locales/cs-formats.json
+++ b/crates/ui-egui/locales/cs-formats.json
@@ -65,6 +65,8 @@
   "Exported {n} keyword{}": "Exportovaná klíčová slova: {n}{:.0}",
   "Exported {n} photo{}": "Exportováno fotografií: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "Exportováno fotografií: {ok} z {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Exportován přehled snímků (fotografií: {photos}, stran: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Vytištěno do PDF (fotografií: {photos}, stran: {pages})",
   "Exported {} point curve preset{}": "Exportovaná přednastavení bodové křivky: {}{:.0}",
   "Exported {} preset{}": "Exportovaná přednastavení: {}{:.0}",
   "Exporting {} of {total}": "Exportování {} z {total}",
@@ -237,6 +239,7 @@
   "{} photos · exposures {} EV": "Fotografií: {} · expozice {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "Fotografií: {} · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "Nečitelné fotografie: {}{:.0}",
+  "{} import warning{}": "Varování importu: {}{:.0}",
   "{} sample{}": "Vzorků: {}{:.0}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "Retušované oblasti na této fotografii: {}",
diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json
--- a/crates/ui-egui/locales/cs.json
+++ b/crates/ui-egui/locales/cs.json
@@ -2147,6 +2147,7 @@
   "none (CPU rendering)": "žádné (vykreslování na CPU)",
   "none of": "žádné z",
   "not added to Apple Photos (cancelled)": "nepřidáno do Apple Photos (zrušeno)",
+  "cancelled": "zrušeno",
   "not available here": "zde nedostupné",
   "of the following rules:": "z následujících pravidel:",
   "or": "nebo",
diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json
--- a/crates/ui-egui/locales/de-formats.json
+++ b/crates/ui-egui/locales/de-formats.json
@@ -51,6 +51,8 @@
   "Example: {example}": "Beispiel: {example}",
   "Exported {n} photo{}": "{n} Foto{} exportiert",
   "Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktabzug exportiert (Fotos: {photos}, Seiten: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Als PDF gedruckt (Fotos: {photos}, Seiten: {pages})",
   "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",
   "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",
   "Exporting {} of {total}": "{} von {total} wird exportiert",
@@ -185,6 +187,7 @@
   "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "{} Foto{} nicht lesbar",
+  "{} import warning{}": "Importwarnungen: {}{:.0}",
   "{} sample{}": "Farbproben: {}{:.0}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",
diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json
--- a/crates/ui-egui/locales/de.json
+++ b/crates/ui-egui/locales/de.json
@@ -2159,6 +2159,7 @@
   "Add to Apple Photos": "Zu Apple Fotos hinzufügen",
   "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…",
   "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",
+  "cancelled": "abgebrochen",
   "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.",
   "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt",
   "Add Face Model…": "Gesichtsmodell hinzufügen…",
diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json
--- a/crates/ui-egui/locales/es-formats.json
+++ b/crates/ui-egui/locales/es-formats.json
@@ -51,6 +51,8 @@
   "Example: {example}": "Ejemplo: {example}",
   "Exported {n} photo{}": "{n} foto{0} exportada{0}",
   "Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Hoja de contactos exportada (fotos: {photos}, páginas: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Impreso en PDF (fotos: {photos}, páginas: {pages})",
   "Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",
   "Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",
   "Exporting {} of {total}": "Exportando {} de {total}",
@@ -185,6 +187,7 @@
   "{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "{0} foto{1} ilegible{1}",
+  "{} import warning{}": "{0} advertencia{1} de importación",
   "{} sample{}": "{} muestra{}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "{} mancha(s) en esta foto",
diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json
--- a/crates/ui-egui/locales/es.json
+++ b/crates/ui-egui/locales/es.json
@@ -2184,6 +2184,7 @@
   "Add to Apple Photos": "Añadir a Fotos de Apple",
   "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…",
   "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",
+  "cancelled": "cancelado",
   "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.",
   "Adding to Apple Photos": "Añadiendo a Fotos de Apple",
   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«guardado en memoria pero no escrito en el disco», la barra superior muestra un aviso y LightCraft lo sigue intentando hasta que el",
diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json
--- a/crates/ui-egui/locales/fi-formats.json
+++ b/crates/ui-egui/locales/fi-formats.json
@@ -305,5 +305,8 @@
   "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",
   "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",
-  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktivedos viety (kuvia: {photos}, sivuja: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Tulostettu PDF-tiedostoon (kuvia: {photos}, sivuja: {pages})",
+  "{} import warning{}": "Tuontivaroituksia: {}{:.0}"
 }
diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json
--- a/crates/ui-egui/locales/fi.json
+++ b/crates/ui-egui/locales/fi.json
@@ -2327,5 +2327,6 @@
   "Drop to open the face model": "Pudota avataksesi kasvomallin",
   "Dropped Files": "Pudotetut tiedostot",
   "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",
-  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"
+  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",
+  "cancelled": "peruttu"
 }
diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json
--- a/crates/ui-egui/locales/fr-formats.json
+++ b/crates/ui-egui/locales/fr-formats.json
@@ -52,6 +52,8 @@
   "Exported {n} photo{}": "{n} photo{} exportée(s)",
   "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}",
   "Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Planche-contact exportée (photos : {photos}, pages : {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Imprimé en PDF (photos : {photos}, pages : {pages})",
   "Exported {} preset{}": "{} préréglage{} exporté(s)",
   "Exporting {} of {total}": "Exportation : {} sur {total}",
   "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)",
@@ -185,6 +187,7 @@
   "{} photos · exposures {} EV": "{} photos · expositions {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "{} photo{} illisible(s)",
+  "{} import warning{}": "{} avertissement{} d’importation",
   "{} sample{}": "{} échantillon{}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "{} tache(s) sur cette photo",
diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json
--- a/crates/ui-egui/locales/fr.json
+++ b/crates/ui-egui/locales/fr.json
@@ -2000,6 +2000,7 @@
   "Add to Apple Photos": "Ajouter à Apple Photos",
   "Adding to Apple Photos…": "Ajout à Apple Photos…",
   "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",
+  "cancelled": "annulé",
   "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.",
   "Adding to Apple Photos": "Ajout à Apple Photos",
   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "« enregistré en mémoire mais pas écrit sur le disque », la barre supérieure affiche un avertissement et LightCraft réessaie jusqu'à ce que",
diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json
--- a/crates/ui-egui/locales/id-formats.json
+++ b/crates/ui-egui/locales/id-formats.json
@@ -305,5 +305,8 @@
   "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",
   "Inside: {names}": "Di dalam: {names}",
   "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",
-  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Lembar kontak diekspor (foto: {photos}, halaman: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Dicetak ke PDF (foto: {photos}, halaman: {pages})",
+  "{} import warning{}": "Peringatan impor: {}{:.0}"
 }
diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json
--- a/crates/ui-egui/locales/id.json
+++ b/crates/ui-egui/locales/id.json
@@ -2339,5 +2339,6 @@
   "Drop to open the face model": "Jatuhkan untuk membuka model wajah",
   "Dropped Files": "File yang dijatuhkan",
   "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",
-  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"
+  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",
+  "cancelled": "dibatalkan"
 }
diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json
--- a/crates/ui-egui/locales/it-formats.json
+++ b/crates/ui-egui/locales/it-formats.json
@@ -50,6 +50,8 @@
   "Example: {example}": "Esempio: {example}",
   "Exported {n} photo{}": "Foto esportate: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Foglio provini esportato (foto: {photos}, pagine: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Stampato in PDF (foto: {photos}, pagine: {pages})",
   "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}",
   "Exported {} preset{}": "Preset esportati: {}{:.0}",
   "Exporting {} of {total}": "Esportazione {} di {total}",
@@ -182,6 +184,7 @@
   "{} photos · exposures {} EV": "{} foto · esposizioni {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "Foto illeggibili: {}{:.0}",
+  "{} import warning{}": "Avvisi di importazione: {}{:.0}",
   "{} sample{}": "Campioni: {}{:.0}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "Macchie in questa foto: {}",
diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json
--- a/crates/ui-egui/locales/it.json
+++ b/crates/ui-egui/locales/it.json
@@ -2112,6 +2112,7 @@
   "Add to Apple Photos": "Aggiungi a Foto di Apple",
   "Adding to Apple Photos…": "Aggiunta a Foto di Apple…",
   "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)",
+  "cancelled": "annullato",
   "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.",
   "Adding to Apple Photos": "Aggiunta a Foto di Apple",
   "Add Face Model…": "Aggiungi modello dei volti…",
diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json
--- a/crates/ui-egui/locales/ja-formats.json
+++ b/crates/ui-egui/locales/ja-formats.json
@@ -28,6 +28,8 @@
   "Example: {example}": "例：{example}",
   "Exported {n} photo{}": "{n}枚を書き出しました{:.0}",
   "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "コンタクトシートを書き出しました：{photos}枚、{pages}ページ",
+  "Printed to PDF: {photos} photos, {pages} pages": "PDFに印刷しました：{photos}枚、{pages}ページ",
   "Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}",
   "Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}",
   "Exporting {} of {total}": "書き出し中：{total}枚中{}枚",
@@ -110,6 +112,7 @@
   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本：垂直または水平にする線に沿ってドラッグ",
   "{} photos": "{}枚",
   "{} photo{} not readable": "{}枚を読み込めません{:.0}",
+  "{} import warning{}": "読み込みの警告: {}件{:.0}",
   "{} spot(s) on this photo": "この写真に{}か所のスポット",
   "· {dups} already in the library": "・{dups}枚は読み込み済み",
   "· {n} photo{}": "・{n}枚{:.0}",
diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json
--- a/crates/ui-egui/locales/ja.json
+++ b/crates/ui-egui/locales/ja.json
@@ -2004,6 +2004,7 @@
   "Add to Apple Photos": "Appleの写真に追加",
   "Adding to Apple Photos…": "Appleの写真に追加中…",
   "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした（キャンセル）",
+  "cancelled": "キャンセル",
   "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。",
   "Adding to Apple Photos": "Appleの写真に追加中",
   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "「メモリーには保存されたがディスクに書き込まれていない」と報告し、上部バーに警告を表示して、LightCraft は",
diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json
--- a/crates/ui-egui/locales/ka-formats.json
+++ b/crates/ui-egui/locales/ka-formats.json
@@ -65,6 +65,8 @@
   "Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}",
   "Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "საკონტაქტო ფურცელი ექსპორტირებულია: {photos} ფოტო, {pages} გვერდი",
+  "Printed to PDF: {photos} photos, {pages} pages": "დაიბეჭდა PDF-ში: {photos} ფოტო, {pages} გვერდი",
   "Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}",
   "Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}",
   "Exporting {} of {total}": "ექსპორტი: {} / {total}",
@@ -236,6 +238,7 @@
   "{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}",
+  "{} import warning{}": "იმპორტის გაფრთხილებები: {}{:.0}",
   "{} sample{}": "ნიმუში: {}{:.0}",
   "{} sec": "{} წმ",
   "{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}",
diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json
--- a/crates/ui-egui/locales/ka.json
+++ b/crates/ui-egui/locales/ka.json
@@ -2566,6 +2566,7 @@
   "none (CPU rendering)": "არცერთი (CPU რენდერირება)",
   "none of": "არცერთი",
   "not added to Apple Photos (cancelled)": "Apple Photos-ში არ დაემატა (გაუქმდა)",
+  "cancelled": "გაუქმდა",
   "not available here": "აქ ხელმისაწვდომი არ არის",
   "noticeably duller.": "შესამჩნევად მკრთალი ჩანდა.",
   "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ახლა მუშავდება RAW მონაცემებიდან კამერის ჩაშენებული JPEG-ის ნაცვლად, ასე რომ შავ-თეთრი ან სხვა გამოსახულების სტილი, რომელიც დაყენებულია",
diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json
--- a/crates/ui-egui/locales/pl-formats.json
+++ b/crates/ui-egui/locales/pl-formats.json
@@ -50,6 +50,8 @@
   "Example: {example}": "Przykład: {example}",
   "Exported {n} photo{}": "Wyeksportowane zdjęcia: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "Wyeksportowane zdjęcia: {ok} z {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Wyeksportowano arkusz stykowy (zdjęcia: {photos}, strony: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Wydrukowano do PDF (zdjęcia: {photos}, strony: {pages})",
   "Exported {} point curve preset{}": "Wyeksportowane ustawienia krzywej punktowej: {}{:.0}",
   "Exported {} preset{}": "Wyeksportowane ustawienia wstępne: {}{:.0}",
   "Exporting {} of {total}": "Eksportowanie {} z {total}",
@@ -174,6 +176,7 @@
   "{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}",
+  "{} import warning{}": "Ostrzeżenia importu: {}{:.0}",
   "{} sample{}": "Próbki: {}{:.0}",
   "{} sec": "{} s",
   "{} spot(s) on this photo": "Plamki na tym zdjęciu: {}",
diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json
--- a/crates/ui-egui/locales/pl.json
+++ b/crates/ui-egui/locales/pl.json
@@ -1610,6 +1610,7 @@
   "none": "brak",
   "none (CPU rendering)": "Brak (renderowanie przez CPU)",
   "none of": "Żaden z",
+  "cancelled": "anulowano",
   "not available here": "Niedostępne tutaj",
   "of the following rules:": "Następujących reguł:",
   "or": "lub",
diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json
--- a/crates/ui-egui/locales/pt-br-formats.json
+++ b/crates/ui-egui/locales/pt-br-formats.json
@@ -37,6 +37,8 @@
   "Example: {example}": "Exemplo: {example}",
   "Exported {n} photo{}": "{n} foto{0} exportada{0}",
   "Exported {ok} of {total} photo{}": "Fotos exportadas: {ok} de {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Folha de contato exportada (fotos: {photos}, páginas: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Impresso em PDF (fotos: {photos}, páginas: {pages})",
   "Exported {} point curve preset{}": "{} predefinição(ões) de curva de pontos exportada(s){:.0}",
   "Exported {} preset{}": "{} predefinição(ões) exportada(s){:.0}",
   "Exporting {} of {total}": "Exportando {} de {total}",
@@ -138,6 +140,7 @@
   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guias — arraste ao longo das linhas que devem ficar verticais ou horizontais.",
   "{} photos": "{} fotos",
   "{} photo{} not readable": "{} foto(s) não legível(is){:.0}",
+  "{} import warning{}": "{} aviso{} de importação",
   "{} spot(s) on this photo": "{} ponto(s) nesta foto",
   "· {dups} already in the library": "· {dups} já na biblioteca",
   "· {n} photo{}": "· {n} foto{}",
diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json
--- a/crates/ui-egui/locales/pt-br.json
+++ b/crates/ui-egui/locales/pt-br.json
@@ -2002,6 +2002,7 @@
   "Add to Apple Photos": "Adicionar ao Fotos da Apple",
   "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…",
   "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",
+  "cancelled": "cancelado",
   "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.",
   "Adding to Apple Photos": "Adicionando ao Fotos da Apple",
   "Sort Albums A–Z": "Ordenar álbuns de A a Z",
diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json
--- a/crates/ui-egui/locales/ru-formats.json
+++ b/crates/ui-egui/locales/ru-formats.json
@@ -53,6 +53,8 @@
   "Example: {example}": "Пример: {example}",
   "Exported {n} photo{}": "Экспортировано фото: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "Экспортировано фото: {ok} из {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Контактный лист экспортирован (фото: {photos}, страниц: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Напечатано в PDF (фото: {photos}, страниц: {pages})",
   "Exported {} point curve preset{}": "Экспортировано пресетов точечной кривой: {}{:.0}",
   "Exported {} preset{}": "Экспортировано пресетов: {}{:.0}",
   "Exporting {} of {total}": "Экспорт: {} из {total}",
@@ -192,6 +194,7 @@
   "{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "Не читается фото: {}{:.0}",
+  "{} import warning{}": "Предупреждений при импорте: {}{:.0}",
   "{} sample{}": "Образцов: {}{:.0}",
   "{} sec": "{} с",
   "{} spot(s) on this photo": "Пятен на этом фото: {}",
diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json
--- a/crates/ui-egui/locales/ru.json
+++ b/crates/ui-egui/locales/ru.json
@@ -2194,6 +2194,7 @@
   "Add to Apple Photos": "Добавить в Apple Фото",
   "Adding to Apple Photos…": "Добавление в Apple Фото…",
   "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)",
+  "cancelled": "отменено",
   "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».",
   "Adding to Apple Photos": "Добавление в Apple Фото",
   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.",
diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json
--- a/crates/ui-egui/locales/sv-formats.json
+++ b/crates/ui-egui/locales/sv-formats.json
@@ -305,5 +305,8 @@
   "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",
   "Inside: {names}": "Inuti: {names}",
   "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",
-  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktark exporterat (foton: {photos}, sidor: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Utskrivet till PDF (foton: {photos}, sidor: {pages})",
+  "{} import warning{}": "Importvarningar: {}{:.0}"
 }
diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json
--- a/crates/ui-egui/locales/sv.json
+++ b/crates/ui-egui/locales/sv.json
@@ -2429,5 +2429,6 @@
   "Drop to open the face model": "Släpp för att öppna ansiktsmodellen",
   "Dropped Files": "Släppta filer",
   "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",
-  "Import dropped files without review": "Importera släppta filer utan granskning"
+  "Import dropped files without review": "Importera släppta filer utan granskning",
+  "cancelled": "avbruten"
 }
diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json
--- a/crates/ui-egui/locales/uk-formats.json
+++ b/crates/ui-egui/locales/uk-formats.json
@@ -51,6 +51,8 @@
   "Example: {example}": "Приклад: {example}",
   "Exported {n} photo{}": "Експортовано фото: {n}{:.0}",
   "Exported {ok} of {total} photo{}": "Експортовано фото: {ok} із {total}{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Аркуш мініатюр експортовано (фото: {photos}, сторінок: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Надруковано в PDF (фото: {photos}, сторінок: {pages})",
   "Exported {} point curve preset{}": "Експортовано наборів налаштувань точкової кривої: {}{:.0}",
   "Exported {} preset{}": "Експортовано наборів налаштувань: {}{:.0}",
   "Exporting {} of {total}": "Експорт: {} із {total}",
@@ -185,6 +187,7 @@
   "{} photos · exposures {} EV": "Фото: {} · експозиції {} EV",
   "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
   "{} photo{} not readable": "Не читається фото: {}{:.0}",
+  "{} import warning{}": "Попереджень під час імпорту: {}{:.0}",
   "{} sample{}": "Зразків: {}{:.0}",
   "{} sec": "{} с",
   "{} spot(s) on this photo": "Ділянок на цьому фото: {}",
diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json
--- a/crates/ui-egui/locales/uk.json
+++ b/crates/ui-egui/locales/uk.json
@@ -2194,6 +2194,7 @@
   "Add to Apple Photos": "Додати до Apple Фото",
   "Adding to Apple Photos…": "Додавання до Apple Фото…",
   "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",
+  "cancelled": "скасовано",
   "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».",
   "Adding to Apple Photos": "Додавання до Apple Фото",
   "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Файли Canon EOS R5 Mark II C-RAW, які відкривалися «лише як перегляд» (більше половини, залежно від",
diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json
--- a/crates/ui-egui/locales/vi-formats.json
+++ b/crates/ui-egui/locales/vi-formats.json
@@ -305,5 +305,8 @@
   "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}",
   "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",
-  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})",
+  "Exported contact sheet: {photos} photos, {pages} pages": "Đã xuất tờ tiếp xúc (ảnh: {photos}, trang: {pages})",
+  "Printed to PDF: {photos} photos, {pages} pages": "Đã in ra PDF (ảnh: {photos}, trang: {pages})",
+  "{} import warning{}": "Cảnh báo khi nhập: {}{:.0}"
 }
diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json
--- a/crates/ui-egui/locales/vi.json
+++ b/crates/ui-egui/locales/vi.json
@@ -2235,5 +2235,6 @@
   "Drop to open the face model": "Thả để mở mô hình khuôn mặt",
   "Dropped Files": "Tệp đã thả",
   "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",
-  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"
+  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",
+  "cancelled": "đã hủy"
 }
diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json
--- a/crates/ui-egui/locales/zh-hans-formats.json
+++ b/crates/ui-egui/locales/zh-hans-formats.json
@@ -28,6 +28,8 @@
   "Example: {example}": "示例：{example}",
   "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}",
   "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "已导出联系表：{photos} 张照片，{pages} 页",
+  "Printed to PDF: {photos} photos, {pages} pages": "已打印为 PDF：{photos} 张照片，{pages} 页",
   "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}",
   "Exported {} preset{}": "已导出 {0} 个预设{1:.0}",
   "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",
@@ -110,6 +112,7 @@
   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线（共 4 条）— 沿应为垂直或水平的线拖动。",
   "{} photos": "{0} 张照片",
   "{} photo{} not readable": "{0} 张照片无法读取{1:.0}",
+  "{} import warning{}": "{0} 条导入警告{1:.0}",
   "{} spot(s) on this photo": "此照片上有 {0} 处污点",
   "· {dups} already in the library": "· {dups} 张已在图库中",
   "· {n} photo{}": "· {n} 张照片{0:.0}",
diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json
--- a/crates/ui-egui/locales/zh-hans.json
+++ b/crates/ui-egui/locales/zh-hans.json
@@ -2034,6 +2034,7 @@
   "Add to Apple Photos": "添加到 Apple 照片",
   "Adding to Apple Photos…": "正在添加到 Apple 照片…",
   "not added to Apple Photos (cancelled)": "未添加到 Apple 照片（已取消）",
+  "cancelled": "已取消",
   "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时，macOS 会询问是否允许 LightCraft 控制“照片”。",
   "Adding to Apple Photos": "正在添加到 Apple 照片",
   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器：LightCraft 改用 CPU 渲染，速度会慢很多。详情见“帮助 → 系统信息”。",
diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json
--- a/crates/ui-egui/locales/zh-hant-formats.json
+++ b/crates/ui-egui/locales/zh-hant-formats.json
@@ -34,6 +34,8 @@
   "Example: {example}": "範例：{example}",
   "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",
   "Exported {ok} of {total} photo{}": "已匯出 {ok}／{total} 張照片{:.0}",
+  "Exported contact sheet: {photos} photos, {pages} pages": "已匯出縮圖目錄：{photos} 張照片，{pages} 頁",
+  "Printed to PDF: {photos} photos, {pages} pages": "已列印為 PDF：{photos} 張照片，{pages} 頁",
   "Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}",
   "Exported {} preset{}": "已匯出 {} 個預設集{:.0}",
   "Exporting {} of {total}": "正在匯出第 {}／{total} 張",
@@ -134,6 +136,7 @@
   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}／4 條參考線 — 沿應為垂直或水平的線條拖曳。",
   "{} photos": "{} 張照片",
   "{} photo{} not readable": "{} 張照片無法讀取{:.0}",
+  "{} import warning{}": "{0} 則匯入警告{1:.0}",
   "{} spot(s) on this photo": "此照片有 {} 個斑點",
   "· {dups} already in the library": "· {dups} 張已在照片庫中",
   "· {n} photo{}": "· {n} 張照片{:.0}",
diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json
--- a/crates/ui-egui/locales/zh-hant.json
+++ b/crates/ui-egui/locales/zh-hant.json
@@ -2002,6 +2002,7 @@
   "Add to Apple Photos": "加入 Apple 照片",
   "Adding to Apple Photos…": "正在加入 Apple 照片…",
   "not added to Apple Photos (cancelled)": "未加入 Apple 照片（已取消）",
+  "cancelled": "已取消",
   "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時，macOS 會詢問是否允許 LightCraft 控制「照片」。",
   "Adding to Apple Photos": "正在加入 Apple 照片",
   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器：LightCraft 改用 CPU 算繪，速度會慢很多。詳情請見「說明 → 系統資訊」。",
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -88,8 +88,9 @@ impl LightcraftApp {
                 && !self.tasks.is_running(LABEL)
             {
                 self.ui.auto_import_at = now;
+                let watched = folder.clone();
                 let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
-                let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
+                let done = move |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
                     let listing = match listing {
                         Ok(l) => l,
                         Err(e) => return log::debug!("auto import: {e}"),
@@ -103,7 +104,7 @@ impl LightcraftApp {
                         o.remove("paths");
                     }
                     let undo0 = app.session.undo.len();
-                    app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
+                    app.import = Some(import::ImportTask::new(paths, params, undo0, false).named_below(vec![watched]).auto());
                 };
                 // listed every few seconds: quiet, no row in the activity stack
                 if let Err(e) = tasks::spawn(self, LABEL, None, work, done) {
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -150,6 +150,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
         "tasks": app.tasks.labels(),
         // every long-running task in flight, as the activity stack shows them (`activity.list`)
         "activity": app.session.activity.list(),
+        // the tasks that ended and left a row (`activity.finished`)
+        "activityFinished": app.session.activity.finished(),
         // commands waiting on a native file dialog shown off the UI thread (`pick`)
         "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(),
         "memory": memory(app),
diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs
--- a/crates/ui-egui/src/dialogs/import/scan.rs
+++ b/crates/ui-egui/src/dialogs/import/scan.rs
@@ -1,4 +1,4 @@
-use super::task::ImportTask;
+use super::task::{ImportTask, not_readable_text};
 use super::*;
 
 /// A folder scan running on a worker thread (a network share can take minutes to read; the
@@ -145,6 +145,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
 
 /// Collect a finished scan and open the review (called every frame).
 pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
+    use lightcraft_engine::activity::{Ending, Failure, Outcome};
     show_pending_review(app, ctx);
     let Some(task) = app.scan.as_ref() else { return };
     ctx.request_repaint_after(std::time::Duration::from_millis(100));
@@ -153,7 +154,9 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
         Err(std::sync::mpsc::TryRecvError::Empty) => {
             if task.guard.is_cancelled() {
                 // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while)
-                app.scan = None;
+                if let Some(task) = app.scan.take() {
+                    task.guard.finish(Outcome::ended(Ending::Stopped));
+                }
                 return;
             }
             let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed));
@@ -163,15 +166,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
             return;
         }
         Err(_) => {
-            app.scan = None;
+            // the worker died (a panic, logged)
+            if let Some(task) = app.scan.take() {
+                task.guard.finish(Outcome::ended(Ending::Died));
+            }
             app.toast(ctx, crate::i18n::tr("Scan failed"));
             return;
         }
     };
     let Some(task) = app.scan.take() else { return };
+    // (a scan another one replaced was let go without a word; one here that was cancelled was stopped by its ✕)
     if task.progress.cancel.load(Ordering::Relaxed) {
+        task.guard.finish(Outcome::ended(Ending::Stopped));
         return;
     }
+    // the Local view's row says which folder; a folder opened again replaces its last row instead of adding one
+    let path = task.browse.then(|| app.session.browse.as_ref().map(|b| b.path.clone())).flatten();
+    // files that could not be read are a problem the row says, by name (in the Local view, by where they are in its
+    // folder: two subfolders can hold the same name): the Local view leaves them out, so nothing later would
+    let named = |c: &ImportCandidate| {
+        path.as_ref()
+            .and_then(|root| std::path::Path::new(&c.path).strip_prefix(root).ok())
+            .map_or_else(|| c.name.clone(), |rel| rel.to_string_lossy().into_owned())
+    };
+    let failed = out.candidates.iter().filter(|c| c.error.is_some()).count();
+    let failures: Vec<Failure> = out
+        .candidates
+        .iter()
+        .filter_map(|c| c.error.as_ref().map(|e| Failure { name: named(c), reason: e.clone() }))
+        .take(lightcraft_engine::activity::MAX_FAILURES)
+        .collect();
+    let found_none = out.candidates.is_empty() && !task.browse;
+    if let Some(path) = &path {
+        for old in app.session.activity.finished() {
+            if old.kind == "scan" && old.path.as_ref() == Some(path) {
+                let _ = app.session.activity.dismiss(old.id);
+            }
+        }
+    }
+    task.guard.finish(Outcome {
+        ending: if failed > 0 { Ending::Problems } else { Ending::Done },
+        detail: if found_none { crate::i18n::tr("No photos found").to_string() } else { not_readable_text(failed) },
+        failed,
+        failures,
+        path,
+        ..Default::default()
+    });
     app.session.import_probes = out.probes;
     if task.browse {
         // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so
@@ -193,7 +233,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
             let undo0 = app.session.undo.len();
             let total = queue.len();
             let _ = total;
-            app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));
+            let root = app.session.browse.as_ref().map(|b| b.path.clone());
+            app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true).named_below(root.into_iter().collect()));
         }
         return;
     }
diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs
--- a/crates/ui-egui/src/dialogs/import/task.rs
+++ b/crates/ui-egui/src/dialogs/import/task.rs
@@ -6,6 +6,42 @@ use super::*;
 #[cfg(target_arch = "wasm32")]
 const BATCH: usize = 8;
 
+/// The worker's loop: ready `files` batch by batch with `prepare` and send each to `tx`. `ended` is set when the loop
+/// ends by itself (every file, a stop, or an import that was dropped); a panic in `prepare` (logged) stops the loop
+/// and leaves it unset, so the receiving side can tell a worker that died from one that finished.
+#[cfg(not(target_arch = "wasm32"))]
+fn feed_batches(
+    files: Vec<String>,
+    batch: usize,
+    cancel: &std::sync::atomic::AtomicBool,
+    tx: &std::sync::mpsc::Sender<lightcraft_engine::import::Prepared>,
+    ctx: &egui::Context,
+    ended: &std::sync::atomic::AtomicBool,
+    mut prepare: impl FnMut(Vec<String>) -> lightcraft_engine::import::Prepared,
+) {
+    for chunk in files.chunks(batch.max(1)) {
+        if cancel.load(Ordering::Relaxed) {
+            break;
+        }
+        let prepared = match lightcraft_engine::guard::catch("import", || prepare(chunk.to_vec())) {
+            Ok(p) => p,
+            Err(_) => {
+                // logged; the files readied so far are added, and the import ends as one that died
+                ctx.request_repaint();
+                return;
+            }
+        };
+        if let Err(unsent) = tx.send(prepared) {
+            // the import was dropped: nothing will add these, so take back what a Move placed
+            unsent.0.rollback();
+            break;
+        }
+        ctx.request_repaint();
+    }
+    ended.store(true, Ordering::Release);
+    ctx.request_repaint();
+}
+
 /// A running import (see the module docs). The file-system work — expanding folders, probing,
 /// reading sidecars, copying or placing files — runs on a worker thread
 /// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the
@@ -14,6 +50,8 @@ const BATCH: usize = 8;
 pub struct ImportTask {
     /// Files and folders to import (handed to the worker when the task starts).
     queue: Vec<String>,
+    /// The files and folders the import was asked for: what the names of unreadable files are relative to.
+    roots: Vec<String>,
     pub total: usize,
     pub done: usize,
     params: Value,
@@ -24,6 +62,9 @@ pub struct ImportTask {
     /// The library photos the skipped duplicates match (shown when nothing new was added).
     existing: Vec<u64>,
     pub failed: usize,
+    /// The files that could not be read, as (path, reason) the engine reported them: the first
+    /// [`lightcraft_engine::activity::MAX_FAILURES`] (`failed` counts them all).
+    failures: Vec<(String, String)>,
     /// Move: originals moved, and sources left in place (reported by the engine with a reason).
     pub moved: usize,
     pub kept: usize,
@@ -36,6 +77,8 @@ pub struct ImportTask {
     pub(super) browse: bool,
     /// Cancel was pressed: no further files are started; batches already readied are added.
     pub cancelled: bool,
+    /// The worker that readies the files died (a panic, logged) before it got to the last one.
+    died: bool,
     /// Auto Import (the watched folder): the selection stays as it is, a short toast when done.
     auto: bool,
     /// Auto Import: the selection to keep.
@@ -56,6 +99,13 @@ impl ImportTask {
         ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }
     }
 
+    /// Name the files this import cannot read by where they are below `folders` (the sources the files were
+    /// found in, or the files and folders it was given), rather than by their file name alone.
+    pub fn named_below(mut self, folders: Vec<String>) -> Self {
+        self.roots = folders;
+        self
+    }
+
     /// An Auto Import (see [`ImportTask::auto`]).
     pub fn auto(mut self) -> Self {
         self.auto = true;
@@ -71,12 +121,27 @@ impl ImportTask {
 /// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no
 /// slow drives) inline, one batch per frame.
 enum Runner {
+    /// The batches the worker readies, and whether it came to its end by itself (a stop, a dropped import or the
+    /// last file) rather than by a panic: a channel that closes without it was a worker that died.
     #[cfg(not(target_arch = "wasm32"))]
-    Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),
+    Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>, std::sync::Arc<std::sync::atomic::AtomicBool>),
     #[cfg(target_arch = "wasm32")]
     Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> },
 }
 
+impl Runner {
+    /// The worker stopped without coming to its end: a panic (logged), so the files it did not get to were not
+    /// read. Only meaningful once [`ImportRun::next_batches`] has said it is done.
+    fn died(&self) -> bool {
+        match self {
+            #[cfg(not(target_arch = "wasm32"))]
+            Runner::Thread(_, ended) => !ended.load(Ordering::Acquire),
+            #[cfg(target_arch = "wasm32")]
+            Runner::Inline { .. } => false,
+        }
+    }
+}
+
 struct ImportRun {
     runner: Runner,
     cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -124,27 +189,20 @@ impl ImportRun {
         ctx: &egui::Context,
     ) -> Result<Runner, String> {
         let (tx, rx) = std::sync::mpsc::channel();
+        let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let worker_ended = ended.clone();
         let ctx = ctx.clone();
         let work = move || {
             let files = job.expand(&queue);
-            for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
-                if cancel.load(Ordering::Relaxed) {
-                    break;
-                }
-                let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {
-                    Ok(p) => p,
-                    Err(_) => break, // logged; the files readied so far are added
-                };
-                if let Err(unsent) = tx.send(prepared) {
-                    // the import was dropped: nothing will add these, so take back what a Move placed
-                    unsent.0.rollback();
-                    break;
-                }
-                ctx.request_repaint();
-            }
-            ctx.request_repaint();
+            feed_batches(files, lightcraft_engine::import::batch_size(), &cancel, &tx, &ctx, &worker_ended, |chunk| {
+                job.prepare_files(chunk, &cancel)
+            });
         };
-        std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))
+        std::thread::Builder::new()
+            .name("lc-import".into())
+            .spawn(work)
+            .map(|_| Runner::Thread(rx, ended))
+            .map_err(|e| format!("could not start: {e}"))
     }
 
     #[cfg(target_arch = "wasm32")]
@@ -161,7 +219,7 @@ impl ImportRun {
     fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {
         match &mut self.runner {
             #[cfg(not(target_arch = "wasm32"))]
-            Runner::Thread(rx) => {
+            Runner::Thread(rx, _) => {
                 let mut out = Vec::new();
                 while out.len() < 4 {
                     match rx.try_recv() {
@@ -248,7 +306,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
         }
     }
     let total = queue.len();
-    app.import = Some(ImportTask::new(queue, params, undo0, false));
+    app.import = Some(ImportTask::new(queue, params, undo0, false).named_below(d.sources.clone()));
     app.renderer.forget_imports();
     Ok(json!({"importing": total}))
 }
@@ -261,7 +319,7 @@ pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value,
     }
     let undo0 = app.session.undo.len();
     let total = paths.len();
-    app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));
+    app.import = Some(ImportTask::new(paths.clone(), json!({"mode": "add"}), undo0, false).named_below(paths));
     Ok(json!({"importing": total}))
 }
 
@@ -296,6 +354,9 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
     let Some(run) = task.run.as_mut() else { return };
     let batches = run.next_batches();
     let finished = batches.is_none();
+    if finished {
+        task.died = run.runner.died();
+    }
     if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) {
         task.moved_on = true;
     }
@@ -350,6 +411,15 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
             task.duplicates += len("duplicates");
             task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
             task.failed += len("failed");
+            let room = lightcraft_engine::activity::MAX_FAILURES.saturating_sub(task.failures.len());
+            task.failures.extend(
+                v["failed"]
+                    .as_array()
+                    .into_iter()
+                    .flatten()
+                    .take(room)
+                    .filter_map(|f| Some((f.get(0)?.as_str()?.to_string(), f.get(1)?.as_str()?.to_string()))),
+            );
             task.moved += len("moved");
             task.kept += len("kept");
             task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));
@@ -424,7 +494,7 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String {
 
 /// The import is done (or cancelled): one undo step, show and select the first photo (unless the
 /// user chose another meanwhile), say what happened.
-fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
+fn finish(app: &mut LightcraftApp, ctx: &egui::Context, mut task: ImportTask) {
     let steps = app.session.undo.len().saturating_sub(task.undo0);
     let plural = |n: usize| if n == 1 { "" } else { "s" };
     let label = if task.imported == 0 && task.restored > 0 {
@@ -436,6 +506,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
     };
     app.session.merge_undo(steps, &label);
     if task.auto {
+        let summary = crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported));
+        let detail = with_warnings(not_readable_text(task.failed), &task);
+        report(&mut task, summary, detail);
         if !task.warnings.is_empty() {
             app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
         } else if task.imported > 0 {
@@ -444,6 +517,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
         return;
     }
     if task.browse {
+        // (no summary: the row is worded from its name, "Reading folder", and says how many files could not be read)
+        let detail = with_warnings(not_readable_text(task.failed), &task);
+        report(&mut task, String::new(), detail);
         if !task.warnings.is_empty() {
             app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
         } else if task.failed > 0 {
@@ -462,15 +538,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
     let plural = |n: usize| if n == 1 { "" } else { "s" };
     // nothing new, only files the library already has: show where they are (Recently Deleted is
     // easy to miss, and the side panel that lists it starts collapsed)
-    if task.imported == 0
+    // (still reported to the activity stack below: the toast is the only part this takes the place of)
+    let existing_shown = task.imported == 0
         && task.restored == 0
         && task.failed == 0
         && task.warnings.is_empty()
         && !task.cancelled
-        && show_existing(app, ctx, &task.existing)
-    {
-        return;
-    }
+        && show_existing(app, ctx, &task.existing);
     let mut msg = if task.cancelled {
         crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
     } else if task.imported == 0 && task.restored > 0 {
@@ -478,6 +552,8 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
     } else {
         crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))
     };
+    // what was done is the first line of the row the import leaves; the rest is what needs reading under it
+    let summary = msg.clone();
     if task.restored > 0 && (task.imported > 0 || task.cancelled) {
         msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));
     }
@@ -497,6 +573,12 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
     if task.failed > 0 {
         msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
     }
+    let detail = msg.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();
+    let detail = with_warnings(detail, &task);
+    report(&mut task, summary, detail);
+    if existing_shown {
+        return;
+    }
     if task.warnings.is_empty() {
         app.toast(ctx, msg);
     } else {
@@ -506,6 +588,63 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
     }
 }
 
+/// How an import ended: stopped, failed to read every file, with problems, or well.
+fn ending_of(task: &ImportTask) -> lightcraft_engine::activity::Ending {
+    use lightcraft_engine::activity::Ending;
+    if task.cancelled {
+        Ending::Stopped
+    } else if task.died {
+        Ending::Died
+    } else if task.failed > 0 && task.imported == 0 && task.restored == 0 {
+        Ending::Failed
+    } else if task.failed > 0 || !task.warnings.is_empty() {
+        Ending::Problems
+    } else {
+        Ending::Done
+    }
+}
+
+/// The unreadable files, as the second line of an import's row (nothing when all could be read).
+pub(crate) fn not_readable_text(failed: usize) -> String {
+    if failed == 0 { String::new() } else { crate::i18n::tr_format!("{} photo{} not readable", failed, if failed == 1 { "" } else { "s" }) }
+}
+
+/// `detail` with how many XMP warnings the import met (the toast that spells them out passes; the row stays).
+fn with_warnings(mut detail: String, task: &ImportTask) -> String {
+    let n = task.warnings.len();
+    if n > 0 {
+        if !detail.is_empty() {
+            detail.push_str(" · ");
+        }
+        detail.push_str(&crate::i18n::tr_format!("{} import warning{}", n, if n == 1 { "" } else { "s" }));
+    }
+    detail
+}
+
+/// How a failed file is named in the import's row: by where it is below the folder it was found in (two subfolders
+/// can hold the same name), else by its own name.
+pub(crate) fn failure_name(path: &str, roots: &[String]) -> String {
+    let p = std::path::Path::new(path);
+    roots
+        .iter()
+        .filter_map(|root| p.strip_prefix(root).ok())
+        .find(|rel| !rel.as_os_str().is_empty())
+        .or_else(|| p.file_name().map(std::path::Path::new))
+        .map_or_else(|| path.to_string(), |rel| rel.to_string_lossy().into_owned())
+}
+
+/// The import says how it ended to the activity stack, on every way out of [`finish`] (a task dropped without
+/// saying is no row at all): one that ran long, was stopped or could not read every file stays in
+/// `activity.finished`, naming the files it could not read and why.
+fn report(task: &mut ImportTask, summary: String, detail: String) {
+    use lightcraft_engine::activity::{Failure, Outcome};
+    let ending = ending_of(task);
+    let failures = task.failures.iter().map(|(path, reason)| Failure { name: failure_name(path, &task.roots), reason: reason.clone() }).collect();
+    if let Some(guard) = task.guard.take() {
+        guard.finish(Outcome { ending, summary, detail, failed: task.failed, failures, ..Default::default() });
+    }
+}
+
 /// The photos among `ids` that are in Recently Deleted (each once).
 fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> {
     let mut v: Vec<u64> =
@@ -542,3 +681,105 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64])
     app.toast_for(ctx, msg, 6.0);
     true
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// The worker's end is told from its death: a channel that closes after every batch was sent is a finished
+    /// import, one that closes after a panic in readying a batch is an import that died (and ends as `Died`).
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn a_worker_that_panics_is_told_from_one_that_finished() {
+        let ctx = egui::Context::default();
+        let cancel = std::sync::atomic::AtomicBool::new(false);
+        for (panics, died) in [(false, false), (true, true)] {
+            let (tx, rx) = std::sync::mpsc::channel();
+            let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+            let files: Vec<String> = (0..3).map(|i| format!("f{i}")).collect();
+            let mut seen = 0;
+            feed_batches(files, 1, &cancel, &tx, &ctx, &ended, |_| {
+                seen += 1;
+                if panics && seen == 2 {
+                    panic!("a bug in readying a batch");
+                }
+                lightcraft_engine::import::Prepared::default()
+            });
+            drop(tx);
+            let sent = rx.try_iter().count();
+            assert_eq!(sent, if panics { 1 } else { 3 }, "batches readied before the end");
+            assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)), "the channel closed");
+            let runner = Runner::Thread(rx, ended);
+            assert_eq!(runner.died(), died, "panics: {panics}");
+        }
+    }
+
+    #[test]
+    fn an_import_whose_worker_died_ends_as_died() {
+        use lightcraft_engine::activity::Ending;
+        let mut task = ImportTask::new(vec!["a".into()], json!({}), 0, false);
+        task.imported = 2;
+        assert_eq!(ending_of(&task), Ending::Done);
+        task.died = true;
+        assert_eq!(ending_of(&task), Ending::Died);
+        task.cancelled = true;
+        assert_eq!(ending_of(&task), Ending::Stopped, "a stop the person asked for stays a stop");
+    }
+
+    /// Every way out of `finish` tells the activity stack how the import ended (a guard dropped without
+    /// saying leaves no row): an Auto Import and a folder read that met unreadable files, and an import that
+    /// only found duplicates it showed.
+    #[test]
+    fn every_way_an_import_ends_leaves_its_row_when_it_needs_one() {
+        use lightcraft_engine::activity::{Cancel, Ending};
+        let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
+        let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);
+        h.app.session.activity.keep_after(0);
+        let ctx = egui::Context::default();
+        let started = |h: &mut crate::headless::Headless| h.app.session.activity.start("import", "Importing", Cancel::Yes);
+        // an Auto Import that could not read a file, though it was short
+        let mut auto = ImportTask::new(vec![], json!({}), 0, false).auto();
+        auto.failed = 1;
+        auto.imported = 2;
+        auto.guard = Some(started(&mut h));
+        finish(&mut h.app, &ctx, auto);
+        // a folder read for the Local view that could read none
+        let mut browse = ImportTask::new(vec![], json!({}), 0, true);
+        browse.failed = 3;
+        browse.guard = Some(started(&mut h));
+        finish(&mut h.app, &ctx, browse);
+        // one that found only photos the library has, which it shows (and says so in a toast, as ever)
+        let existing: Vec<u64> = h.app.session.catalog.photos().take(2).map(|p| p.id.0).collect();
+        let mut quiet = ImportTask::new(vec![], json!({}), 0, false);
+        quiet.existing = existing;
+        quiet.guard = Some(started(&mut h));
+        finish(&mut h.app, &ctx, quiet);
+        let rows = h.app.session.activity.finished();
+        assert!(h.app.session.activity.list().is_empty());
+        let endings: Vec<(Ending, usize)> = rows.iter().rev().map(|r| (r.ending, r.failed)).collect();
+        assert_eq!(endings, vec![(Ending::Problems, 1), (Ending::Failed, 3), (Ending::Done, 0)], "{rows:?}");
+    }
+
+    /// An import that met XMP warnings says so in its row, not only in the toast that passes.
+    #[test]
+    fn an_import_with_xmp_warnings_says_so_in_its_row() {
+        use lightcraft_engine::activity::Cancel;
+        let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
+        let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);
+        h.app.session.activity.keep_after(0);
+        let ctx = egui::Context::default();
+        let warning =
+            || ImportWarning { path: "a.jpg".into(), code: ImportWarningCode::UnmappedXmp, profile: None, mode: None, fields: vec!["x".into()] };
+        for (auto, browse) in [(true, false), (false, true), (false, false)] {
+            let mut task = ImportTask::new(vec![], json!({}), 0, browse);
+            task.auto = auto;
+            task.imported = 1;
+            task.warnings = vec![warning()];
+            task.guard = Some(h.app.session.activity.start("import", "Importing", Cancel::Yes));
+            finish(&mut h.app, &ctx, task);
+        }
+        let rows = h.app.session.activity.finished();
+        assert_eq!(rows.len(), 3, "{rows:?}");
+        assert!(rows.iter().all(|r| r.detail.contains("1 import warning")), "{rows:?}");
+    }
+}
diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs
--- a/crates/ui-egui/src/export_task.rs
+++ b/crates/ui-egui/src/export_task.rs
@@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::mpsc::{Receiver, channel};
 use std::sync::{Arc, Mutex};
 
-use lightcraft_engine::activity::{Cancel, TaskGuard};
-use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};
+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard};
+use lightcraft_engine::export::{AfterExport, Destination, DestinationMode, ExportOptions, PreparedExport, run_batch};
 use serde_json::{Value, json};
 
 use crate::LightcraftApp;
@@ -27,6 +27,8 @@ pub struct ExportTask {
     rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>,
     /// The export's row in the activity stack (`activity.cancel` sets `cancel`).
     guard: TaskGuard,
+    /// The folder the files go to, for the row the export leaves when it ends.
+    folder: Option<String>,
 }
 
 impl ExportTask {
@@ -37,6 +39,22 @@ impl ExportTask {
     }
 }
 
+/// The folder a file is in, for the row of the contact sheet or print that writes it (none for a bare file name).
+fn folder_of(path: &str) -> Option<String> {
+    std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).filter(|d| !d.is_empty())
+}
+
+/// The folder the files of an export go to, for the row it leaves: the one an exact file path is in (the engine
+/// uses it for a single photo only), none when each photo goes to its own folder (`dir` is not used then), else `dir`.
+fn files_folder(to: &Destination, mode: DestinationMode, photos: usize) -> Option<String> {
+    match (to.exact.as_ref().filter(|_| photos == 1), mode) {
+        (Some(path), _) => folder_of(path),
+        (None, DestinationMode::SameAsOriginal) => None,
+        (None, _) => Some(to.dir.clone()),
+    }
+    .filter(|dir| !dir.is_empty())
+}
+
 /// Start exporting `items` in the background, then do `after` with the files written (Add to Apple
 /// Photos). Errors per photo are collected, not fatal.
 pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {
@@ -49,6 +67,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
     let cancel = Arc::new(AtomicBool::new(false));
     let (tx, rx) = channel();
     let (p, c) = (progress.clone(), cancel.clone());
+    let folder = files_folder(&to, opts.destination_mode, total);
     let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
     guard.progress(0, total as u64);
     let row = guard.handle();
@@ -81,7 +100,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
     std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
     #[cfg(target_arch = "wasm32")]
     work();
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard, folder });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -100,6 +119,8 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
     let cancel = Arc::new(AtomicBool::new(false));
     let (p, c) = (progress.clone(), cancel.clone());
     let (tx, rx) = channel();
+    // (where the PDF goes, for the row the sheet leaves when it ends)
+    let folder = folder_of(&path);
     let work = move || {
         let result = prepared
             .run(&mut |done, name| {
@@ -128,7 +149,7 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
     work();
     let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));
     row.progress(0, total as u64);
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -153,6 +174,8 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str
     let (p, c) = (progress.clone(), cancel.clone());
     let (tx, rx) = channel();
     let photos = ids.len();
+    // (where the PDF went, for the row the print leaves when it ends)
+    let folder = folder_of(&path);
     let work = move || {
         let result = prepared
             .run(&mut |done, name| {
@@ -181,7 +204,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str
     work();
     let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone()));
     row.progress(0, total as u64);
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -232,63 +255,113 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) {
 #[cfg(target_arch = "wasm32")]
 pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {}
 
-/// Per frame: keep the activity row up to date; when the batch finishes, report it.
+/// How an export that ran ended, for the activity stack. Photos taking only some of the files is a warning, and
+/// Photos not asked at all (`skipped`: the export wrote no files, an earlier request still running…) is not an error
+/// either: not all went well in both cases.
+fn export_ending(cancelled: bool, ok: usize, failed: usize, photos: Option<&Value>) -> Ending {
+    let photos_short = photos.is_some_and(|p| ["error", "warning", "skipped"].iter().any(|k| p.get(k).is_some()));
+    if cancelled {
+        Ending::Stopped
+    } else if failed > 0 && ok == 0 {
+        Ending::Failed
+    } else if failed > 0 || photos_short {
+        Ending::Problems
+    } else {
+        Ending::Done
+    }
+}
+
+/// An export that came back as an error. The error is the row's detail: its headline stays empty, so the row reads
+/// "Exporting: failed" (or "Printing to PDF: failed") under the task's own name.
+fn failed_outcome(error: String, stopped: bool) -> Outcome {
+    Outcome { ending: if stopped { Ending::Stopped } else { Ending::Failed }, detail: error, ..Default::default() }
+}
+
+/// Per frame: keep the activity row up to date; when the batch finishes, report it: in a toast, as ever, and to
+/// the activity stack, where an export that ran long or did not go well stays in `activity.finished` with its
+/// failed files and where its results are.
 pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
     let Some(task) = &app.export else { return };
-    match task.rx.try_recv() {
-        Ok(r) => {
-            let cancelled = matches!(&r, Ok((_, _, true)));
-            let total = task.total;
-            app.export = None;
-            // Photos refused (permission, not installed…): leave the way out on screen longer
-            let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());
-            let msg = match r {
-                Ok((files, photos, _)) => {
-                    let sheet = files.first().filter(|f| f["contactSheet"] == true);
-                    let ok = files.iter().filter(|f| f.get("path").is_some()).count();
-                    let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
-                    let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();
-                    let mut m =
-                        crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);
-                    if let Some(sheet) = sheet {
-                        m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]);
-                    }
-                    if let Some(pdf) = files.first().filter(|f| f["print"] == true) {
-                        m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]);
-                    }
-                    if skipped > 0 {
-                        m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);
-                    }
-                    if let Some(first) = failed.first() {
-                        m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
-                    }
-                    if cancelled {
-                        m += " · cancelled";
-                    }
-                    if let Some(photos) = &photos {
-                        m += " · ";
-                        m += &apple_photos_note(photos);
-                    }
-                    app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));
-                    m
-                }
-                Err(e) => e,
-            };
-            if photos_failed {
-                app.toast_for(ctx, msg, 15.0);
-            } else {
-                app.toast(ctx, msg);
-            }
-        }
+    let r = match task.rx.try_recv() {
+        Ok(r) => Some(r),
         Err(std::sync::mpsc::TryRecvError::Empty) => {
             let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
             task.guard.progress(done as u64, task.total as u64);
             task.guard.detail(&current);
+            return;
         }
-        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
-            app.export = None;
-            app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));
+        Err(std::sync::mpsc::TryRecvError::Disconnected) => None,
+    };
+    let Some(task) = app.export.take() else { return };
+    let total = task.total;
+    let folder = task.folder.clone();
+    // (a contact sheet that was stopped comes back as an error: it was stopped all the same)
+    let stopped = task.cancel.load(Ordering::Relaxed);
+    let Some(r) = r else {
+        let msg = crate::i18n::tr("Export stopped unexpectedly");
+        task.guard.finish(Outcome { ending: Ending::Died, summary: msg.to_string(), path: folder, ..Default::default() });
+        app.toast(ctx, msg);
+        return;
+    };
+    let cancelled = matches!(&r, Ok((_, _, true)));
+    // Photos refused (permission, not installed…): leave the way out on screen longer
+    let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());
+    let (msg, outcome) = match r {
+        Ok((files, photos, _)) => {
+            let sheet = files.first().filter(|f| f["contactSheet"] == true);
+            let ok = files.iter().filter(|f| f.get("path").is_some()).count();
+            let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
+            let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();
+            let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);
+            // (the finished row keeps this headline: it is translated like the toast's)
+            if let Some(sheet) = sheet {
+                m = crate::i18n::tr_format!(
+                    "Exported contact sheet: {photos} photos, {pages} pages",
+                    photos = sheet["photos"],
+                    pages = sheet["pages"]
+                );
+            }
+            if let Some(pdf) = files.first().filter(|f| f["print"] == true) {
+                m = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = pdf["photos"], pages = pdf["pages"]);
+            }
+            // what was done is the row's first line; the rest is what needs reading under it
+            let summary = m.clone();
+            if skipped > 0 {
+                m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);
+            }
+            if let Some(first) = failed.first() {
+                m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
+            }
+            if cancelled {
+                m += " · ";
+                m += crate::i18n::tr("cancelled");
+            }
+            if let Some(photos) = &photos {
+                m += " · ";
+                m += &apple_photos_note(photos);
+            }
+            let ending = export_ending(cancelled, ok, failed.len(), photos.as_ref());
+            // one of the files written shows where they all are (a subfolder of the folder asked for, maybe)
+            let path = files.iter().find_map(|f| f["path"].as_str()).map(str::to_string).or(folder);
+            let failures = failed
+                .iter()
+                .map(|f| Failure {
+                    name: f["file"].as_str().unwrap_or_default().to_string(),
+                    reason: f["error"].as_str().unwrap_or("error").to_string(),
+                })
+                .collect();
+            let detail = m.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();
+            app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));
+            (m, Outcome { ending, summary, detail, failures, path, ..Default::default() })
         }
+        // (the folder stays on the row, as for a worker that died: something may have been written there)
+        Err(e) => (e.clone(), Outcome { path: folder, ..failed_outcome(e, stopped) }),
+    };
+    task.guard.finish(outcome);
+    if photos_failed {
+        app.toast_for(ctx, msg, 15.0);
+    } else {
+        app.toast(ctx, msg);
     }
 }
 
@@ -396,6 +469,48 @@ mod contact_sheet_tests {
         let task = app.export.take().unwrap();
         assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());
     }
+
+    /// A contact sheet stopped by its ✕ comes back as an error, but it is a stopped task, not a failed one.
+    #[test]
+    fn a_stopped_contact_sheet_leaves_a_stopped_row_that_knows_its_folder() {
+        let mut app = LightcraftApp::new(
+            lightcraft_engine::Session::with_demo(),
+            crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },
+        );
+        let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
+        let path = std::env::temp_dir().join("lc-stopped-sheet.pdf");
+        start_contact_sheet(&mut app, &json!({"path": path.to_string_lossy(), "ids": ids})).unwrap();
+        let id = app.session.activity.list()[0].id;
+        app.session.activity.cancel(id).unwrap();
+        let ctx = egui::Context::default();
+        let deadline = Instant::now() + Duration::from_secs(60);
+        while app.export.is_some() {
+            poll(&mut app, &ctx);
+            assert!(Instant::now() < deadline, "the stopped sheet did not end");
+            std::thread::sleep(Duration::from_millis(10));
+        }
+        let row = app.session.activity.finished().into_iter().next().expect("a stopped task always leaves a row");
+        assert_eq!((row.kind, row.ending), ("export", Ending::Stopped), "{row:?}");
+        // (the row keeps the folder the sheet was to go to: something may have been written there)
+        assert_eq!(row.path.as_deref(), path.parent().and_then(|d| d.to_str()), "{row:?}");
+    }
+
+    /// A worker that is gone without a word leaves a row saying the export stopped unexpectedly.
+    #[test]
+    fn an_export_whose_worker_is_gone_leaves_a_died_row() {
+        let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default());
+        let cancel = Arc::new(AtomicBool::new(false));
+        let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
+        let (tx, rx) = channel();
+        drop(tx);
+        app.export =
+            Some(ExportTask { total: 3, progress: Arc::new(Mutex::new((0, String::new()))), cancel, rx, guard, folder: Some("/tmp/out".into()) });
+        poll(&mut app, &egui::Context::default());
+        assert!(app.export.is_none());
+        let row = app.session.activity.finished().into_iter().next().expect("a worker that died always leaves a row");
+        assert_eq!((row.kind, row.ending, row.path.as_deref()), ("export", Ending::Died, Some("/tmp/out")), "{row:?}");
+        assert_eq!(row.summary, "Export stopped unexpectedly");
+    }
 }
 
 #[cfg(test)]
@@ -424,4 +539,36 @@ mod tests {
             "not added to Apple Photos: an earlier, cancelled request is still running"
         );
     }
+
+    /// An Apple Photos step that did not add the files (error, warning, or skipped without a cancel) is a problem.
+    #[test]
+    fn export_ending_counts_an_uncancelled_photos_skip_as_a_problem() {
+        let skipped = json!({"skipped": "the export wrote no files"});
+        assert_eq!(export_ending(false, 3, 0, Some(&skipped)), Ending::Problems);
+        assert_eq!(export_ending(false, 3, 0, Some(&json!({"warning": "added 1 of 3"}))), Ending::Problems);
+        assert_eq!(export_ending(false, 3, 0, Some(&json!({"imported": 3}))), Ending::Done);
+        assert_eq!(export_ending(false, 3, 0, None), Ending::Done);
+        assert_eq!(export_ending(false, 0, 2, None), Ending::Failed);
+        assert_eq!(export_ending(true, 3, 0, Some(&json!({"skipped": "cancelled", "cancelled": true}))), Ending::Stopped);
+    }
+
+    /// The row of an export points at the folder its files go to, not at one it does not use.
+    #[test]
+    fn an_exports_row_names_the_folder_the_files_go_to() {
+        let to = |dir: &str, exact: Option<&str>| Destination { dir: dir.into(), exact: exact.map(str::to_string) };
+        assert_eq!(files_folder(&to("/out", None), DestinationMode::SpecificFolder, 3).as_deref(), Some("/out"));
+        assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 1).as_deref(), Some("/pics"));
+        // (an exact file is for a single photo: with more, the files go to the folder)
+        assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 2).as_deref(), Some("/out"));
+        assert_eq!(files_folder(&to("/out", None), DestinationMode::SameAsOriginal, 3), None);
+        assert_eq!(files_folder(&to("", None), DestinationMode::SpecificFolder, 1), None);
+    }
+
+    /// A failed export keeps its name in the row's headline; the error goes under it.
+    #[test]
+    fn a_failed_export_says_why_under_its_name() {
+        let o = failed_outcome("The disk is full".into(), false);
+        assert_eq!((o.ending, o.summary.as_str(), o.detail.as_str()), (Ending::Failed, "", "The disk is full"));
+        assert_eq!(failed_outcome("x".into(), true).ending, Ending::Stopped);
+    }
 }
diff --git a/crates/ui-egui/src/tests/activity.rs b/crates/ui-egui/src/tests/activity.rs
--- a/crates/ui-egui/src/tests/activity.rs
+++ b/crates/ui-egui/src/tests/activity.rs
@@ -2,7 +2,7 @@
 
 use std::time::Duration;
 
-use lightcraft_engine::activity::{Cancel, TaskInfo, Unit};
+use lightcraft_engine::activity::{Cancel, Ending, TaskInfo, Unit};
 use serde_json::json;
 
 use crate::headless::Headless;
@@ -336,6 +336,8 @@ fn scan_shows_a_row_and_cancel_closes_it() {
         h.step();
     }
     assert!(h.app.ui.dialog.is_none(), "no review opened");
+    let row = h.app.session.activity.finished().into_iter().next().expect("a stopped scan leaves a row");
+    assert_eq!((row.label.as_str(), row.ending), ("Scanning folder", Ending::Stopped));
     let _ = std::fs::remove_dir_all(&dir);
 }
 
@@ -534,3 +536,130 @@ fn immediate_library_commands_and_auto_sync_show_completion() {
     assert!(h.app.run("library.compact", json!({})).is_err());
     assert!(h.app.ui.toast.is_none());
 }
+
+/// The demo app, reading the files as they are on disk (the demo library reads none), keeping every ended task's row.
+fn demo_on_disk() -> Headless {
+    let mut h = demo();
+    h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;
+    h.app.session.activity.keep_after(0);
+    h
+}
+
+/// A folder with a valid PNG and, in a subfolder, a JPEG cut short before its end-of-image marker (the real reason).
+fn folder_with_a_truncated_jpeg(tag: &str) -> std::path::PathBuf {
+    use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta};
+    let dir = std::env::temp_dir().join(format!("lc-activity-unreadable-{tag}-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(dir.join("trip")).unwrap();
+    let img = lightcraft_raster::Rgba8 { width: 64, height: 48, data: vec![[90, 120, 160, 255]; 64 * 48] };
+    let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
+    std::fs::write(dir.join("good.png"), png).unwrap();
+    let mut jpeg = lightcraft_codecs::encode_jpeg(&EncodeImage::rgba8(&img), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
+    jpeg.truncate(jpeg.len() - 2);
+    std::fs::write(dir.join("trip").join("bad.jpg"), jpeg).unwrap();
+    dir
+}
+
+/// What an import's row says it could not read.
+fn the_row_names_the_truncated_jpeg(h: &Headless) {
+    let rows = h.app.session.activity.finished();
+    assert_eq!(rows.len(), 1, "{rows:?}");
+    let row = &rows[0];
+    assert_eq!((row.kind, row.ending, row.failed), ("import", Ending::Problems, 1), "{row:?}");
+    let names: Vec<&str> = row.failures.iter().map(|f| f.name.as_str()).collect();
+    assert_eq!(names, vec![std::path::Path::new("trip").join("bad.jpg").to_string_lossy()], "{row:?}");
+    assert!(row.failures[0].reason.contains("truncated"), "{}", row.failures[0].reason);
+}
+
+#[test]
+fn an_import_row_names_each_file_it_could_not_read_and_why() {
+    let dir = folder_with_a_truncated_jpeg("paths");
+    let mut h = demo_on_disk();
+    crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
+    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));
+    the_row_names_the_truncated_jpeg(&h);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// The review hands the engine the checked files, not the folder: the names of those it cannot read still start at
+/// the source folder. (A file the scan could not read is not offered, so this one is made to look readable.)
+#[test]
+fn a_reviewed_imports_row_names_unreadable_files_below_its_source() {
+    let dir = folder_with_a_truncated_jpeg("review");
+    let mut h = demo_on_disk();
+    crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
+    assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))));
+    h.app.session.activity.dismiss_all();
+    let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no review") };
+    for c in &mut opts.candidates {
+        c.error = None;
+    }
+    opts.checked.iter_mut().for_each(|on| *on = true);
+    assert_eq!(opts.selected_paths().len(), 2);
+    assert_eq!(h.request("ui.dialog.confirm", json!({}), T)["ok"], true);
+    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));
+    the_row_names_the_truncated_jpeg(&h);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// A scan that ran to its end says so, and says when it found nothing to import.
+#[test]
+fn a_scan_that_found_nothing_leaves_a_row_saying_so() {
+    let mut h = demo();
+    h.app.session.activity.keep_after(0);
+    let dir = std::env::temp_dir().join(format!("lc-activity-emptyscan-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
+    assert!(h.step_until(T, |h| h.app.scan.is_none()));
+    let row = h.app.session.activity.finished().into_iter().next().expect("a row for the scan");
+    assert_eq!((row.label.as_str(), row.ending, row.detail.as_str()), ("Scanning folder", Ending::Done, "No photos found"));
+    let _ = std::fs::remove_dir_all(dir);
+}
+
+/// A scan that met files it could not read says so in its row, by name, whether it was for the import review or the
+/// Local view (which leaves those files out, so nothing later would say it). The Local view's row says which folder,
+/// and opening that folder again replaces it instead of adding another.
+#[test]
+fn a_scan_that_met_unreadable_files_leaves_a_row_with_problems() {
+    for browse in [false, true] {
+        let mut h = demo();
+        h.app.session.activity.keep_after(0);
+        // the files are read as on disk (the demo library reads none)
+        h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;
+        let dir = std::env::temp_dir().join(format!("lc-activity-badscan-{browse}-{}", std::process::id()));
+        std::fs::create_dir_all(dir.join("sub")).unwrap();
+        std::fs::write(dir.join("broken.jpg"), b"not a jpeg at all").unwrap();
+        std::fs::write(dir.join("sub").join("also.jpg"), b"not a jpeg either").unwrap();
+        let path = dir.to_string_lossy().to_string();
+        let scan = |h: &mut Headless| {
+            if browse {
+                crate::import::browse(&mut h.app, &path, Some(true)).unwrap();
+            } else {
+                crate::import::open(&mut h.app, vec![path.clone()]).unwrap();
+            }
+            assert!(h.step_until(T, |h| h.app.scan.is_none()));
+        };
+        scan(&mut h);
+        let names = |h: &Headless| {
+            let mut v: Vec<String> = h.app.session.activity.finished()[0].failures.iter().map(|f| f.name.clone()).collect();
+            v.sort();
+            v
+        };
+        let row = h.app.session.activity.finished()[0].clone();
+        assert_eq!((row.ending, row.failed, row.detail.as_str()), (Ending::Problems, 2, "2 photos not readable"), "browse {browse}: {row:?}");
+        // the Local view names a file by where it is in the folder (two subfolders can hold the same name)
+        let deeper = if browse { std::path::Path::new("sub").join("also.jpg").to_string_lossy().into_owned() } else { "also.jpg".into() };
+        let mut want = vec!["broken.jpg".to_string(), deeper];
+        want.sort();
+        assert_eq!(names(&h), want, "{row:?}");
+        if browse {
+            let folder = std::path::absolute(&dir).unwrap().to_string_lossy().trim_end_matches(std::path::MAIN_SEPARATOR).to_string();
+            assert_eq!(row.path.as_deref(), Some(folder.as_str()), "{row:?}");
+            scan(&mut h);
+            let rows = h.app.session.activity.finished();
+            assert_eq!(rows.len(), 1, "one row for the folder: {rows:?}");
+            assert!(rows[0].id != row.id && rows[0].failed == 2, "the new row is the one kept: {rows:?}");
+        }
+        let _ = std::fs::remove_dir_all(dir);
+    }
+}
diff --git a/crates/ui-egui/src/tests/print.rs b/crates/ui-egui/src/tests/print.rs
--- a/crates/ui-egui/src/tests/print.rs
+++ b/crates/ui-egui/src/tests/print.rs
@@ -314,6 +314,57 @@ fn printing_shows_a_row_in_the_activity_stack_and_stops() {
     assert!(app.session.activity.list().is_empty(), "the row goes with the print");
 }
 
+#[test]
+fn a_print_that_fails_leaves_a_row_that_still_knows_its_folder() {
+    use lightcraft_engine::activity::Ending;
+    let mut app = LightcraftApp::new(
+        lightcraft_engine::Session::with_demo(),
+        Services { write_shared: Some(Arc::new(|_, _| Err("The disk is full".into()))), ..Default::default() },
+    );
+    app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();
+    let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();
+    let dir = std::env::temp_dir().join("lc-print-fails");
+    let path = dir.join("Full.pdf").to_string_lossy().into_owned();
+    crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();
+    let ctx = egui::Context::default();
+    let until = std::time::Instant::now() + Duration::from_secs(60);
+    while app.export.is_some() && std::time::Instant::now() < until {
+        crate::export_task::poll(&mut app, &ctx);
+        std::thread::sleep(Duration::from_millis(10));
+    }
+    assert!(app.export.is_none(), "the print did not end");
+    let rows = app.session.activity.finished();
+    assert_eq!(rows.len(), 1, "{rows:?}");
+    assert_eq!((rows[0].ending, rows[0].detail.as_str()), (Ending::Failed, "The disk is full"), "{:?}", rows[0]);
+    // the row can still show where the PDF was going
+    assert_eq!(rows[0].path.as_deref(), Some(dir.to_string_lossy().as_ref()), "{:?}", rows[0]);
+}
+
+/// A print that ended leaves a finished row (`activity.finished`): its headline is in the interface's language, like
+/// the rest of the row.
+#[test]
+fn a_finished_print_row_is_in_the_interface_language() {
+    crate::i18n::set_language(crate::i18n::Locale::De);
+    let mut app =
+        LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() });
+    app.session.activity.keep_after(0);
+    app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();
+    let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();
+    let path = std::env::temp_dir().join("lc-print-de").join("Druck.pdf").to_string_lossy().into_owned();
+    crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();
+    let ctx = egui::Context::default();
+    let until = std::time::Instant::now() + Duration::from_secs(60);
+    while app.export.is_some() && std::time::Instant::now() < until {
+        crate::export_task::poll(&mut app, &ctx);
+        std::thread::sleep(Duration::from_millis(10));
+    }
+    let rows = app.session.activity.finished();
+    // the catalog's own words, not ours: the row says what the German catalog says
+    let headline = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = 1, pages = 1);
+    crate::i18n::set_language(crate::i18n::Locale::En);
+    assert_eq!(rows.first().map(|r| r.summary.as_str()), Some(headline.as_str()), "{rows:?}");
+}
+
 #[test]
 fn the_print_menu_item_needs_a_photo_and_is_listed() {
     let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
diff --git a/docs/background-tasks.md b/docs/background-tasks.md
--- a/docs/background-tasks.md
+++ b/docs/background-tasks.md
@@ -4,7 +4,8 @@ Everything that takes a while runs off the UI thread and shows up in **one** pla
 top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while
 the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while
 a task that can stop is running. Agents see the same list through `activity.list` / `activity.cancel`, and
-`ui.inspect` reports it as `activity`.
+`ui.inspect` reports it as `activity`; the tasks that ended and left a row are `activity.finished` /
+`activity.dismiss` and `activityFinished`.
 
 The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is
 `crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress
@@ -48,6 +49,21 @@ guard.detail(&file_name);
   - `set_cancellable(false)` for a phase that has to finish once started (a Lightroom import adding its photos).
 - **Quiet periodic work** shows no row: the auto-import folder listing runs every few seconds through
   `tasks::spawn(.., None, ..)`.
+- **Say how it ended:** `guard.finish(Outcome { ending, summary, detail, failures, failed, path })` in place of
+  letting the guard drop. These jobs do: exports (contact sheet and Print to PDF from the app included), imports and
+  their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving
+  photos. The others do not yet and leave a row only when a panic drops them: Synchronize Folder, Photo Merge,
+  Lightroom catalogs, Find Missing Photos and Reload from Disk (the app's jobs that run through `tasks::spawn`, or
+  their own thread), slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying,
+  the face GPU check, Apple Photos imports, and the face scan and AI Denoise (queues whose row goes when they empty). `ending` is `Done`, `Problems` (not for every file),
+  `Failed`, `Stopped`, or `Died` when the job finds its worker gone; `summary` is the sentence the row shows (UI code
+  passes it translated); `failures` are the files with their reasons (`failed` when the job only counts them); `path`
+  is one of the files it made, or the folder they are in or were to go to. `Outcome::ended(ending)` and `Outcome::failed(why)` cover the plain cases. The row
+  stays in `activity.finished` until `activity.dismiss` lets go of it, for this
+  session: always for a task that did not end well, and for one that ended well only if it ran 10 s or more. Keep the
+  toast that says how it went: the row is the record, not the announcement. A guard that is only dropped leaves
+  nothing, as before, unless a panic dropped it: then the row says the task stopped unexpectedly. A scan or Local folder read
+  that another replaced leaves nothing either: only the row's ✕ makes it a stopped task.
 
 ## Examples in the code
 
@@ -68,4 +84,6 @@ guard.detail(&file_name);
 Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the
 job, and that the row is gone once the job ends. `crates/ui-egui/src/tests/activity.rs` has the helpers, and the
 job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears
-on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first.
+on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first. For a job
+that reports how it ended, assert `session.activity.finished()` (ending, summary, failures);
+`session.activity.keep_after(0)` keeps a clean end's row however short the test's job.
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -79,7 +79,7 @@ filmstrip is visible.
 | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
 | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
 | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
-| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
+| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `activityFinished` (the tasks that ended and left a row, same shape as `activity.finished`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
 | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
 | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) |
 | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply) |
@@ -98,6 +98,8 @@ filmstrip is visible.
 | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
 | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
 | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
+| `engine.execute {command: "activity.finished"}` | — | The tasks that ended and left a row, newest first: `{finished: [{id, kind, label, ending, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]}`. `ending` is `done`, `problems` (not for every file), `failed`, `stopped` (cancelled) or `died` (its worker crashed). A clean end is kept when the task ran 10 s or more, any other always; `failed` counts the files that failed and `failures` holds the first 500 with their reasons; `path` is one of the files the task made, or the folder they are in or were to go to; at most 50 clean endings and 200 rows in all are kept; `summary` is as the app shows it (in its language) and empty for jobs that say nothing of themselves. These jobs report how they ended: exports (the contact sheet and Print to PDF from the app included), imports and their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos. The others leave no row yet, or only one for a worker that died: Synchronize Folder, Photo Merge, Lightroom catalogs, Find Missing Photos, Reload from Disk, slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying, the face GPU check, Apple Photos imports, the face scan and AI Denoise |
+| `engine.execute {command: "activity.dismiss"}` | `{id}` or `{all: true}` | Lets go of a finished task's row (or of all of them) → `{dismissed: n}`; an unknown id (it may have been dismissed already) is an error |
 | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
 | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes |
 | `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -1,6 +1,6 @@
 # Lightroom parity checklist
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (LR-BEHAV-BG-TASKS: a background task says how it ended (`activity.finished`); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
 
 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
 have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
 | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
 | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe |
 | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
-| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |
+| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `cmd:activity.finished`, `cmd:activity.dismiss`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`, `activityFinished`) see the same list; exports (and the contact sheet and the app's Print to PDF), imports and their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos say how they ended and leave a finished row for agents when they ran long or did not go well (`activity.finished`, `activity.dismiss`); the other jobs do not yet. No pause (Lightroom can pause some tasks) |
 | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
 | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
 | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
 | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
 | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
 | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
+| 2026-10-26 | minor | LR-BEHAV-BG-TASKS: a task says how it ended (`cmd:activity.finished`, `cmd:activity.dismiss`); exports and imports (each file that failed, with the reason), folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos that ran long, were stopped or did not go well leave a finished row for agents; status unchanged (✅) |
