Phase 4: GUI — bell, side panel, mark-read¶
Prompt¶
Before responding to questions or discussion points in this document, explore the ryll codebase thoroughly. Read relevant source files, understand existing patterns (SPICE protocol handling, channel architecture, async task model, image decompression, egui rendering), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Where a question touches on external concepts (SPICE protocol, QEMU, QXL, TLS/RSA, LZ/GLZ compression), research as needed to give a confident answer. Flag any uncertainty explicitly rather than guessing.
The master plan is
PLAN-notifications.md. Phases 1–3
are complete (commits a16f6781, b3e520b1, 3780be03).
Read the master plan's "Discoveries during this work" section
first — the QEMU-only-emits-on-main-channel finding and the
existing-parser finding both shape Phase 4's expected
on-screen behaviour.
Goal¶
Surface the notification store to the operator. At the end of this phase:
- A bell glyph sits in the status-bar right-edge cluster, immediately before the separator that precedes the hamburger menu (where the Gaps badge used to live before Phase 3 deleted it).
- When
unread_count > 0, the bell shows a small filled dot coloured by the highest-severity unread entry — amber for Warn, red for Error, default colour for Info. Low-visibility entries (Q4) do not flash the bell. - Clicking the bell toggles a right-side panel
("Notifications") that mirrors the layout of the
existing Traffic viewer at
app.rs:1761. - Each entry renders a severity glyph, a source label ("SPICE/main", "Gap", "BugReport", "Internal"), a relative timestamp ("3s ago"), and the message text. Read entries dim; unread entries render at full opacity.
- Per-entry actions: a small Dismiss button removes
that entry from the store entirely. Header actions:
Mark all read (sets
read=trueon every entry) and Clear all (drops every entry). - Closing the panel calls
mark_all_readper master-plan Q2 — the operator gets one chance to triage what was unread, and the bell dot clears on close. - The Phase 3 manual-smoke gap is closed: a TLS-off QEMU session shows the "channel is insecure" notification in the side panel.
No new producers and no changes to the Phase 1 store
shape; Phase 4 adds two small store helpers
(remove(id) and highest_bell_severity()) plus all the
egui rendering.
Background¶
Post-Phase-3 status-bar layout¶
ryll/src/app.rs:1605-1758 renders the status bar inside a
TopBottomPanel::bottom("stats"). The right-edge cluster
ends with:
The bell goes immediately before that ui.separator() —
the same slot the deleted Gaps: N button occupied. The
hamburger keeps its position as the rightmost element.
The hamburger menu's items today are: Traffic, USB, Folders, Screenshot (F8), Report, and (optionally) Paste. The bell is not an item inside the menu — it's a standalone button outside, because click semantics differ ("toggle a side panel" vs "menu item action").
Traffic viewer as the rendering precedent¶
app.rs:1760-1848 is the model. Same egui::SidePanel::right
shape, same egui::ScrollArea::vertical(), same ui.heading
+ small action buttons header pattern. Phase 4's panel
copies the structure with notification-shaped fields swapped
in.
Hamburger plan status¶
PLAN-hamburger-menu.md is
Complete. The hamburger is in place at app.rs:1722,
which means the master plan's conditional "if hamburger
hasn't landed yet" branch in §"Phase 4" doesn't apply — we
go straight to "bell sits next to the hamburger".
What's in the store today (Phases 2 + 3 producers)¶
Spice { channel: Main, what: N }entries from QEMU's insecure-channel warnings (per the smoke-test discovery, these arrive on main only with the affected channel named in the message).Gapentries from theregister_gap_notification_observercallback (one per distinctwarn_once!key).BugReportentries fromfinish_bug_reportOk/Err.Internalentries from screenshot Ok/Err/no-surface and paste-completed.
The bell needs to handle every variant; the panel renders each with the appropriate source label.
Design¶
Two new store helpers¶
In ryll/src/notifications.rs add:
impl NotificationStore {
/// Remove the entry with the given id. No-op if id is unknown.
/// Used by the side panel's per-entry Dismiss button.
pub fn remove(&mut self, id: u64) {
self.entries.retain(|e| e.id != id);
}
/// Highest severity among unread entries that should flash the
/// bell. Excludes entries with `visibility == Some(SpiceVisibility::Low)`
/// per master-plan Q4 — low-visibility is informational and
/// must not pull the operator's eye.
pub fn highest_bell_severity(&self) -> Option<NotifySeverity> {
self.entries
.iter()
.filter(|e| !e.read)
.filter(|e| e.visibility != Some(SpiceVisibility::Low))
.map(|e| e.severity)
.max()
}
}
Phase 1's highest_unread_severity() is left untouched —
it still has its place for any future "show me the worst
of everything regardless of visibility" surface, and
keeping it stable means Phase 1's tests don't churn.
Three new unit tests in the existing mod tests block:
| Test | Asserts |
|---|---|
remove_drops_entry |
After push of three, remove(id_of_middle) leaves two; the middle's id is gone. |
remove_unknown_id_is_noop |
remove(999) on a 3-entry store does not panic and leaves length 3. |
highest_bell_severity_skips_low_visibility |
Push Warn-with-Low, Info-with-High, Error-with-Low; highest_bell_severity() returns Some(Info) (Error is filtered for Low visibility, Warn is filtered for Low visibility, Info-High remains). |
Helper functions on NotificationSource and severity¶
In ryll/src/notifications.rs add:
impl NotificationSource {
/// Compact human label used by the side panel.
/// SPICE entries render as "SPICE/<channel-name>"; other
/// variants render as their static name.
pub fn label(&self) -> String {
match self {
NotificationSource::Gap => "Gap".to_string(),
NotificationSource::BugReport => "BugReport".to_string(),
NotificationSource::Internal => "Internal".to_string(),
NotificationSource::Spice { channel, .. } => {
format!("SPICE/{}", channel.name())
}
}
}
}
Two unit tests (deterministic, no UI):
| Test | Asserts |
|---|---|
source_label_static_variants |
Gap.label() == "Gap", BugReport.label() == "BugReport", Internal.label() == "Internal". |
source_label_spice_variant |
Spice { channel: Display, what: 0 }.label() == "SPICE/display". |
Severity glyph and colour¶
A small renderer module — keep it private inside
ryll/src/notifications.rs since it's the only file that
touches notification rendering primitives until somebody
needs them elsewhere:
/// (glyph, optional colour). None colour means "default text colour".
fn severity_visuals(s: NotifySeverity) -> (&'static str, Option<egui::Color32>) {
match s {
NotifySeverity::Info => ("\u{2139}", None), // ℹ
NotifySeverity::Warn => ("\u{26A0}",
Some(egui::Color32::from_rgb(255, 180, 80))), // ⚠ amber
NotifySeverity::Error => ("\u{2716}",
Some(egui::Color32::from_rgb(220, 90, 90))), // ✖ muted red
}
}
The amber matches the existing "inputs" channel colour in
the Traffic viewer palette (app.rs:1822); the muted red
is close to the deleted Gaps button's colour
(Color32::from_rgb(200, 80, 80)) for visual continuity.
egui::Color32::RED is too saturated against a dark theme.
This function lives next to the rendering code, not in the
public API. egui import is fine inside the notifications
module — there's already no protocol-vs-GUI separation
because Phase 1 deliberately put the store in ryll/.
Bell rendering¶
Code lives in app.rs inline, in the status-bar
horizontal layout. Replace the current sequence
with
let unread_count = self.notifications
.lock().map(|s| s.unread_count()).unwrap_or(0);
let bell_severity = self.notifications
.lock().map(|s| s.highest_bell_severity()).unwrap_or(None);
let mut text = egui::RichText::new("\u{1F514}"); // 🔔
if let Some(sev) = bell_severity {
if let (_, Some(colour)) = notifications::severity_visuals(sev) {
text = text.color(colour);
}
}
let response = ui.add(egui::Button::new(text));
if unread_count > 0 {
response.clone().on_hover_text(format!(
"{} unread notification{}",
unread_count,
if unread_count == 1 { "" } else { "s" }));
}
if response.clicked() {
if self.show_notifications_panel {
// Closing — mark all read per Q2.
if let Ok(mut s) = self.notifications.lock() {
s.mark_all_read();
}
}
self.show_notifications_panel = !self.show_notifications_panel;
}
ui.separator();
egui::menu::menu_button(ui, "☰", |ui| { ... });
Two notes worth pinning:
- Two locks per frame. Acquiring the notifications
mutex twice (once for
unread_count, once forhighest_bell_severity) is fine but mildly wasteful. The cleaner shape combines them into a singlelock().map(|s| (s.unread_count(), s.highest_bell_severity()))call. Implementer's choice; the wasteful form is more readable. - The bell glyph itself doesn't change colour when unread. The master plan §Q7 says "the bell's dot picks up the highest-severity unread item's colour", but that's a "dot next to the bell" intent, not a tint of the bell. We approximate by tinting the bell glyph itself, which is a single character and so renders simpler. If a separate dot is needed later, that's an iteration — Future work.
Side-panel rendering¶
if self.show_notifications_panel {
egui::SidePanel::right("notifications")
.default_width(360.0)
.show(ctx, |ui| {
// Header: title + counts + actions
ui.horizontal(|ui| {
ui.heading("Notifications");
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.small_button("Clear all").clicked() {
if let Ok(mut s) = self.notifications.lock() {
s.clear();
}
}
if ui.small_button("Mark all read").clicked() {
if let Ok(mut s) = self.notifications.lock() {
s.mark_all_read();
}
}
},
);
});
// Total/unread summary
let (total, unread, snapshot) = match self.notifications.lock() {
Ok(s) => (s.len(), s.unread_count(),
s.iter_newest_first().cloned().collect::<Vec<_>>()),
Err(_) => (0, 0, Vec::new()),
};
ui.label(format!("{} total / {} unread", total, unread));
ui.separator();
// Entries
let mut to_remove: Vec<u64> = Vec::new();
egui::ScrollArea::vertical().show(ui, |ui| {
if snapshot.is_empty() {
ui.label("No notifications.");
}
for entry in &snapshot {
ui.horizontal(|ui| {
let (glyph, colour) =
notifications::severity_visuals(entry.severity);
let mut g = egui::RichText::new(glyph);
if let Some(c) = colour { g = g.color(c); }
if entry.read { g = g.weak(); }
ui.label(g);
ui.monospace(notifications::format_relative(entry.when));
ui.colored_label(
egui::Color32::GRAY,
entry.source.label());
let mut msg_text =
egui::RichText::new(&entry.message);
if entry.read { msg_text = msg_text.weak(); }
if entry.count > 1 {
msg_text = msg_text; // base message
// Append a [N×] suffix for folded entries.
ui.label(msg_text);
ui.label(egui::RichText::new(
format!("[{}\u{00D7}]", entry.count))
.weak());
} else {
ui.label(msg_text);
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.small_button("Dismiss").clicked() {
to_remove.push(entry.id);
}
},
);
});
}
});
if !to_remove.is_empty() {
if let Ok(mut s) = self.notifications.lock() {
for id in to_remove {
s.remove(id);
}
}
}
});
} else if self.notifications_panel_was_open_last_frame {
// The panel was closed this frame (operator clicked the bell
// again or used the panel-close affordance). Mark all read so
// the bell dot clears on the next frame's render — Q2.
if let Ok(mut s) = self.notifications.lock() {
s.mark_all_read();
}
}
self.notifications_panel_was_open_last_frame =
self.show_notifications_panel;
Two design points:
- Snapshot-then-render.
iter_newest_first().cloned()builds an ownedVec<NotificationEntry>so the lock is released before any UI code runs. The Traffic viewer follows the same pattern withtraffic_viewer_entries: Vec<TrafficViewEntry>(app.rs:320). For notifications, we don't bother with a refresh-cadence field — the store mutates rarely (a few per session) and rebuilding the snapshot every frame at typical notification volumes is trivial. - Close detection.
egui::SidePaneldoesn't expose a close button; closing happens by clicking the bell again (or via theshowboolean toggling for any other reason). To mark-all-read on close, we tracknotifications_panel_was_open_last_frameand act on thetrue → falseedge. This is the same trick used forlast_sent_resizeand friends elsewhere inapp.rs.
format_relative¶
pub fn format_relative(when: SystemTime) -> String {
let now = SystemTime::now();
let delta = now.duration_since(when).unwrap_or(Duration::ZERO);
let secs = delta.as_secs();
if secs < 1 { "now".to_string() }
else if secs < 60 { format!("{}s ago", secs) }
else if secs < 3600 { format!("{}m ago", secs / 60) }
else if secs < 86_400 { format!("{}h ago", secs / 3600) }
else { format!("{}d ago", secs / 86_400) }
}
Public on the module so the side panel can call it. Two unit tests:
| Test | Asserts |
|---|---|
format_relative_seconds |
A when 5 seconds before now formats as "5s ago". (Uses SystemTime::now() - Duration::from_secs(5).) |
format_relative_in_future_returns_now |
A when 1 second AFTER now formats as "now" (since duration_since errors and falls back to ZERO). Pins the clock-skew defence. |
format_relative_seconds has a small race risk if the test
host pauses between constructing when and calling
format_relative. Mitigate by allowing either "5s ago"
or "6s ago" in the assertion; alternatively skip the
specific-value test and just assert the result ends with
"s ago". The second test is fully deterministic.
New RyllApp fields¶
Both initialised false in RyllApp::new. No persistence
across sessions — the panel always starts closed.
What does NOT change¶
- The Phase 1 store API beyond the two new helpers.
- Producer behaviour from Phases 2 and 3.
- Any tracing / log output.
- The hamburger menu's items.
Manual smoke test¶
make buildmake test-qemu./target/debug/ryll --verbose --direct localhost:5900- After channels connect, observe the bell in the
right-edge cluster of the status bar. With QEMU's
disable-ticketing=on,plaintext-channel=allconfig from the Phase 2 smoke test, the bell should show a coloured dot once the main channel's NOTIFY arrives. - Click the bell → side panel opens. Confirm an entry
labelled
SPICE/mainwith a Warn glyph and the "channel is insecure" message text is visible. - Click Dismiss on that entry. It vanishes.
- Trigger F8 (screenshot); cancel the dialog. No notification fires (the cancel path is silent).
- Trigger F8 and save to a path that fails (e.g. a read-only path). An Error/Internal notification appears.
- Trigger F12 (bug report) → submit. An Info/BugReport notification appears.
- Click Mark all read — entries dim. Bell dot clears on next frame.
- Click Clear all — panel empties.
- With unread entries present, click the bell to close the panel. Re-open — entries are present but read (dimmed); bell has no dot.
Record outcome in the commit message.
Steps¶
Step 1: store helpers + module helpers + tests¶
In ryll/src/notifications.rs:
- Add
NotificationStore::remove(id: u64)andNotificationStore::highest_bell_severity(&self) -> Option<NotifySeverity>. - Add
NotificationSource::label(&self) -> String. - Add
pub fn format_relative(when: SystemTime) -> String. - Add
pub(crate) fn severity_visuals(s: NotifySeverity) -> (&'static str, Option<egui::Color32>). This is the first egui-typed item in the module — add the egui import. Sub-agent should verify theeguicrate is already available to ryll (it is — eframe re-exports it andapp.rsusesegui::*everywhere). - Add the seven unit tests listed in the §"Tests"-style
sub-tables:
remove_drops_entry,remove_unknown_id_is_noop,highest_bell_severity_skips_low_visibility,source_label_static_variants,source_label_spice_variant,format_relative_seconds(with the ±1s tolerance),format_relative_in_future_returns_now. - Run
make test; ryll test count goes from 213 to 220. - Run
pre-commit run --all-files.
Step 2: bell + side panel in app.rs¶
In ryll/src/app.rs:
- Add
show_notifications_panel: boolandnotifications_panel_was_open_last_frame: boolfields, bothfalseinRyllApp::new. - Insert the bell-button block immediately before the
ui.separator()that precedes the hamburgermenu_button(currently atapp.rs:1721-1722— verify line by reading context). - Add the side-panel rendering block right after the
hamburger's enclosing
TopBottomPanel::bottom("stats") ...show(...)returns — co-located with the existingif self.show_traffic_viewer { ... }block atapp.rs:1761. - Add the "panel was just closed"
mark_all_readshim after the side-panel block (handles thetrue → falsetransition ofshow_notifications_panel). - Run
make build(must succeed; expect a few Phase-1-era#[allow(dead_code)]exemptions to start firing as their items get used — clean those allows off the items that are now used). - Run
make test. - Run
pre-commit run --all-files.
Step 3: cleanup¶
After step 2, the module-level #![allow(dead_code)] at
the top of ryll/src/notifications.rs may be removable
(every public item except snapshot and possibly some
mark_* variants is now used). Remove the allow if
clippy permits; if a few items remain unused, attribute
them individually with #[allow(dead_code)] or remove
them. Phase 5 will need snapshot for bug-report
serialisation, so leave that one — perhaps with an
item-level allow.
Sub-agent execution table¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1 (helpers + tests) | medium | sonnet | none | In ryll/src/notifications.rs, add the two NotificationStore methods (remove(id), highest_bell_severity()), NotificationSource::label(), format_relative(SystemTime), and severity_visuals(NotifySeverity) per PLAN-notifications-phase-04-gui.md §"Two new store helpers" through §"format_relative". Add the seven unit tests listed across those sections. Use egui::Color32::from_rgb for the colour pair (Warn=255,180,80; Error=220,90,90); Info returns None. Read the existing imports in the file before adding use egui; — the module is in the ryll crate which already depends on eframe/egui. The format_relative_seconds test should accept "5s ago" or "6s ago" to absorb the ±1s host pause race. Run make test (expecting 220 ryll tests) and pre-commit run --all-files. Rust toolchain runs in Docker via the Makefile. |
| 2 (bell + side panel) | medium | sonnet | none | In ryll/src/app.rs, add the bell button + side panel + close-detection shim per PLAN-notifications-phase-04-gui.md §"Bell rendering", §"Side-panel rendering", and §"New RyllApp fields". The bell goes immediately BEFORE the ui.separator() that precedes the hamburger menu_button (currently around line 1722). The side panel goes alongside the existing if self.show_traffic_viewer { ... } block (currently around line 1761). The close-detection shim sits in the same area; track notifications_panel_was_open_last_frame: bool and call mark_all_read on the true→false edge. Use the helpers from step 1 (notifications::format_relative, notifications::severity_visuals, NotificationSource::label). Take an iter_newest_first().cloned().collect::<Vec<_>>() snapshot inside the lock so rendering happens off-lock — same pattern the Traffic viewer uses with traffic_viewer_entries. Run make build (watch for new dead-code warnings), make test, pre-commit run --all-files. The line numbers above are approximate — read context before editing. Do not commit. |
Step 1 lands first because step 2 calls the helpers. Both land in one phase commit.
Administration and logistics¶
Success criteria¶
- Bell glyph appears in the status-bar right-edge cluster, immediately before the hamburger separator.
- Bell shows a coloured dot/tint when
unread_count > 0, picking up the highest-severity unread (excluding low-visibility) entry's colour. Plain when zero unread. - Clicking the bell toggles the right-side Notifications panel.
- Closing the panel via the bell calls
mark_all_read. - Per-entry Dismiss button removes the entry.
- Header Mark all read and Clear all behave as named.
- Read entries render dimmed; unread render full.
[N×]suffix appears on entries withcount > 1.make build,make test,pre-commit run --all-filespass; ryll tests grow from 213 to 220.- Manual smoke test confirms QEMU "channel is insecure" message visible in panel; F12 bug report and F8 screenshot land in panel; Dismiss / Mark all read / Clear all / panel-close-marks-read all work.
Risks¶
- Lock acquisition every frame. The bell-rendering
code grabs
self.notifications.lock()once or twice per frame at 60 FPS. Mutex contention with the channel-handler tokio tasks (which alsolock()on push) is theoretically possible. In practice push frequency is low (a handful per session) and lock hold time is microseconds; the existingArc<TrafficBuffers>has the same shape and works fine at hundreds of pushes per second. - Side panel layout regression. The side panel shares space with the central display surface; on small windows the panel may eat the surface area. The Traffic viewer has the same constraint and is not gated; we'll inherit whatever its UX has been.
SystemTime::duration_sinceand clock skew. If the system clock moves backward between push and render,duration_sinceerrors.format_relativefalls back toDuration::ZERO("now") on that error. Pinned by testformat_relative_in_future_returns_now.- Severity glyph font support. The chosen glyphs (ℹ ⚠ ✖) are in egui's default font family. If a glyph fails to render on a particular Linux setup it'll show as a tofu box — annoying but not a correctness bug. Future iteration could swap to ASCII labels or eframe's icon font.
Future work (deferred from this phase)¶
- A real coloured dot next to the bell (rather than
tinting the bell glyph). Requires custom painting via
Painter::circle_filledoverlaid on the button rect; enough rendering ceremony that it's not worth it unless the tinted-glyph approach proves unintelligible. - Per-severity / per-source filters in the panel. Master plan §"Future work".
- Persistence across sessions. Master plan §"Future work".
- Click-to-detail for SPICE entries — link the
whatenum to documentation. Master plan §"Future work". - Configurable severity threshold for bell flash (e.g. Info never flashes). Master plan §"Future work" item 5; Q4's recommendation is implemented but the configuration knob isn't.
Documentation index maintenance¶
When this phase lands, update the master plan's Execution table row 4 from "Not started" to "Complete" with a link to the merge commit.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.