diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs
--- a/crates/ui-egui/src/views.rs
+++ b/crates/ui-egui/src/views.rs
@@ -51,6 +51,21 @@ pub(crate) const HEADER_H: f32 = 48.0;
 /// Height of the footer bar.
 pub(crate) const FOOTER_H: f32 = 34.0;
 
+/// The table's App column never gets narrower than this.
+const TABLE_APP_MIN_W: f32 = 220.0;
+const TABLE_STATUS_W: f32 = 176.0;
+const TABLE_ACTIONS_W: f32 = 206.0;
+/// The columns a narrow table can do without, with their widths, kept in this order: Released goes
+/// first, then Version, so the action buttons stay in the window.
+const TABLE_OPTIONAL: [(TableColumn, f32); 2] = [(TableColumn::Version, 150.0), (TableColumn::Released, 104.0)];
+
+/// A table column a narrow window can drop.
+#[derive(Clone, Copy, PartialEq)]
+enum TableColumn {
+    Version,
+    Released,
+}
+
 const CARD_MIN_W: f32 = 320.0;
 const CARD_H: f32 = 314.0;
 /// The card's top strip: number tab and links.
@@ -538,23 +553,39 @@ impl LauncherUi {
             ui.ctx().layer_painter(ui.layer_id()).with_clip_rect(clip).hline(span, y, t.hairline());
         };
         egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {
-            TableBuilder::new(ui)
+            // A narrow window drops the optional columns that don't fit, rather than push the
+            // buttons out of view.
+            let spacing = ui.spacing().item_spacing.x;
+            let mut room = ui.available_width() - TABLE_APP_MIN_W - TABLE_STATUS_W - TABLE_ACTIONS_W - 2.0 * spacing;
+            let shown: Vec<TableColumn> = TABLE_OPTIONAL
+                .iter()
+                .take_while(|(_, w)| {
+                    room -= w + spacing;
+                    room >= 0.0
+                })
+                .map(|(k, _)| *k)
+                .collect();
+            let mut table = TableBuilder::new(ui)
                 .striped(false)
                 .vscroll(false)
                 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
-                .column(Column::remainder().at_least(220.0).clip(true))
-                .column(Column::exact(176.0))
-                .column(Column::exact(150.0))
-                .column(Column::exact(104.0))
-                .column(Column::exact(206.0))
+                .column(Column::remainder().at_least(TABLE_APP_MIN_W).clip(true))
+                .column(Column::exact(TABLE_STATUS_W));
+            for (c, w) in TABLE_OPTIONAL {
+                if shown.contains(&c) {
+                    table = table.column(Column::exact(w));
+                }
+            }
+            table
+                .column(Column::exact(TABLE_ACTIONS_W))
                 .header(42.0, |mut header| {
-                    for (key, label) in [
-                        (Some(SortKey::Name), tl!("App")),
-                        (Some(SortKey::Status), tl!("Status")),
-                        (None, tr_ctx(current(), "column", "Version")),
-                        (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),
-                        (None, ""),
-                    ] {
+                    let optional = shown.iter().map(|&c| match c {
+                        TableColumn::Version => (None, tr_ctx(current(), "column", "Version")),
+                        TableColumn::Released => (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),
+                    });
+                    let columns =
+                        [(Some(SortKey::Name), tl!("App")), (Some(SortKey::Status), tl!("Status"))].into_iter().chain(optional).chain([(None, "")]);
+                    for (key, label) in columns {
                         header.col(|ui| {
                             if key == Some(SortKey::Name) {
                                 rule(ui);
@@ -563,6 +594,10 @@ impl LauncherUi {
                                 // A column that is not sorted still names itself.
                                 if !label.is_empty() {
                                     widgets::hud(ui, label, t.faint);
+                                } else if !shown.contains(&TableColumn::Released) {
+                                    // Over the buttons, where the header is empty: the sort menu, once
+                                    // Released is hidden, so the table can still be sorted by it.
+                                    self.sort_picker(ui);
                                 }
                                 return;
                             };
@@ -655,35 +690,43 @@ impl LauncherUi {
                                     r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
                                 }
                             });
-                            row.col(|ui| {
-                                // The version: what is installed, with an arrow to the new one in the app's colour
-                                // when an update waits; the latest for an app not installed yet.
-                                let mut mono = |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));
-                                match (status == AppStatus::UpdateAvailable).then(|| entry.installed_label().zip(entry.latest_label())).flatten() {
-                                    Some((from, to)) => {
-                                        mono(format!("{from} → {to}"), t.app_ink(entry.app));
-                                    }
-                                    None => match (entry.installed_label(), entry.latest_label()) {
-                                        (Some(installed), _) => {
-                                            mono(installed, t.ink);
-                                        }
-                                        (None, Some(latest)) => {
-                                            mono(latest, t.faint);
+                            for &c in &shown {
+                                row.col(|ui| match c {
+                                    TableColumn::Version => {
+                                        // The version: what is installed, with an arrow to the new one in the app's colour
+                                        // when an update waits; the latest for an app not installed yet.
+                                        let mut mono =
+                                            |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));
+                                        match (status == AppStatus::UpdateAvailable)
+                                            .then(|| entry.installed_label().zip(entry.latest_label()))
+                                            .flatten()
+                                        {
+                                            Some((from, to)) => {
+                                                mono(format!("{from} → {to}"), t.app_ink(entry.app));
+                                            }
+                                            None => match (entry.installed_label(), entry.latest_label()) {
+                                                (Some(installed), _) => {
+                                                    mono(installed, t.ink);
+                                                }
+                                                (None, Some(latest)) => {
+                                                    mono(latest, t.faint);
+                                                }
+                                                (None, None) => {
+                                                    mono("—".into(), t.faint);
+                                                }
+                                            },
                                         }
-                                        (None, None) => {
-                                            mono("—".into(), t.faint);
+                                    }
+                                    TableColumn::Released => {
+                                        let when = entry.release().and_then(|r| r.published_at);
+                                        let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), date_text)).color(t.muted));
+                                        if let Some(w) = when {
+                                            r.on_hover_text(ago_text(w, now));
                                         }
-                                    },
-                                }
-                            });
-                            row.col(|ui| {
-                                let when = entry.release().and_then(|r| r.published_at);
-                                let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), date_text)).color(t.muted));
-                                if let Some(w) = when {
-                                    r.on_hover_text(ago_text(w, now));
-                                }
-                            });
-                            row.col(|ui| self.actions(ui, &entry, &status, 204.0));
+                                    }
+                                });
+                            }
+                            row.col(|ui| self.actions(ui, &entry, &status, TABLE_ACTIONS_W - 2.0));
                         });
                     }
                 });
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -1998,3 +1998,38 @@ fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes
     h.run_steps(30);
     assert!(!showing(&h, "photocraft"), "a click outside did not close it");
 }
+
+#[test]
+fn a_narrow_table_keeps_each_apps_buttons_in_the_window() {
+    let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
+    for width in [1180.0, 900.0, 720.0] {
+        h.set_size(egui::vec2(width, 500.0));
+        h.run_steps(6);
+        snapshot(&mut h, &format!("table-{width}"));
+        let button = h.get_by_label("Update to 0.5.0").rect();
+        assert!(button.right() <= width, "at {width} px the button ends at {}", button.right());
+        for column in ["App", "Status"] {
+            assert!(h.query_by_label(column).is_some(), "at {width} px the {column} column is gone");
+        }
+    }
+}
+
+#[test]
+fn a_narrow_table_still_sorts_by_the_columns_it_hides() {
+    let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
+    h.set_size(egui::vec2(1180.0, 500.0));
+    h.run_steps(6);
+    let menu = egui::accesskit::Role::ComboBox;
+    assert!(h.query_by_role(menu).is_none(), "a table with every column sorts from its headers");
+    h.set_size(egui::vec2(720.0, 500.0));
+    h.run_steps(6);
+    assert!(h.query_by_label("Released").is_none());
+    h.get_by_role(menu).click();
+    h.run_steps(2);
+    // A click in the menu's popup goes through AccessKit: the simulated pointer misses it.
+    h.get_by_label("Recently updated").click_accesskit();
+    h.run_steps(3);
+    let settings = &h.state().as_ref().unwrap().settings;
+    assert_eq!(settings.sort, artcraft_launcher_ui_egui::settings::SortKey::Released);
+    snapshot(&mut h, "table-720-sort-menu");
+}
