Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions specs/task-room-pane-action-ownership.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
spec: task
name: "Scope room pane navigation actions to their owning controls"
inherits: project
tags: [bugfix, threads, search, actions]
---

## Intent

Selecting a thread in one room screen must not start an event search in another
cached room or thread tab. Carry pane ownership through dynamic thread/search
rows and consume pane, toolbar, and header actions only in their owning screen.

## Constraints

- Preserve actions for other room screens and split views; do not discard all actions in inactive tabs.
- Keep Matrix requests, message actions, timeline lifecycle, and global app navigation unchanged.
- Do not change layout, visible strings, locale keys, dependencies, or formatter policy.
- Do not contact Matrix services in automated tests.

## Decisions

- The pure function `room_control_action(action: &Action, owners: &[WidgetUid]) -> Option<&WidgetAction>` in `src/home/room_screen/action_scope.rs` is the ownership decision used by room controls.
- Dynamic thread and search entries store their pane's WidgetUid at binding time and emit selection under that owner rather than their recycled row UID.
- Thread pane actions accept only the owning pane UID. Toolbar and RoomTopBar actions accept only their own control UID. Search actions accept the local pane, search button, and room-screen relay UIDs.
- Example and property tests verify WidgetAction ownership before building. Studio/CUA checks the rendered entry-to-pane wiring. The original blanket cast is retained only long enough to record a failing regression, then replaced by the ownership decision.
- The i18n review finds no new text or changed alignment; translation resources are unchanged.

## Boundaries

### Allowed Changes
- src/home/room_screen/action_scope.rs
- src/home/room_screen/mod.rs
- src/home/room_screen/threads_pane.rs
- src/home/room_screen/search.rs
- src/home/search_messages.rs
- specs/task-room-pane-action-ownership.spec.md

### Forbidden
- Do not edit src/app.rs, src/event_preview.rs, tools/ux-harness/**, or src/sliding_sync.rs.
- Do not run cargo fmt or rustfmt.
- Do not commit or create a PR before user testing.

## Acceptance Criteria

<!--
pane-owner: accept(a, owners) <=> widget(a) exists and uid(a) != 0 and uid(a) in owners
The owner is a widget instance, not a room ID: same-room thread tabs remain distinct.
Filtering never consumes or rewrites another screen's action.
-->

### Rule: pane-owner — Room controls accept only an explicitly owned action

Scenario: Other thread panes cannot consume a root selection
Test: room_control_action_keeps_thread_selection_in_its_own_pane
Given main, Claude-thread, Codex-thread and other-room panes have distinct widget identities
When the main pane emits a Codex root selection
Then only the main pane accepts that action and the other panes retain no jump target

Scenario: Search pane and header relays stay local
Test: room_control_action_accepts_local_search_controls_only
Given search pane, search button and header relay identities belong to one room screen
When each control emits a search action
Then that screen accepts the original action and a different room screen rejects it

Scenario: Missing ownership fails closed
Test: room_control_action_rejects_missing_or_zero_ownership
Given a non-widget action, an empty owner list, or an unbound zero widget identity
When ownership is checked
Then no action is accepted

Scenario: Generated identities preserve action isolation
Test: prop_room_control_action_matches_explicit_owner_set
Given arbitrary source identities and owner sets
When ownership is checked
Then acceptance equals nonzero source membership in the owner set

## Out of Scope

- Repairing unrelated global-search routing in src/app.rs.
- Changing background pagination completion or Matrix history availability.
76 changes: 76 additions & 0 deletions src/home/room_screen/action_scope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Widget-instance ownership for room controls shared by multiple cached tabs.

use makepad_widgets::*;

pub(super) fn room_control_action<'a>(action: &'a Action, owners: &[WidgetUid]) -> Option<&'a WidgetAction> {
let action = action.as_widget_action()?;
(action.widget_uid.0 != 0 && owners.contains(&action.widget_uid)).then_some(action)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::home::room_screen::ThreadsPaneAction;
use crate::home::search_messages::SearchMessagesAction;
use matrix_sdk::ruma::OwnedEventId;
use proptest::prelude::*;

fn emitted_by(uid: u64, payload: impl WidgetActionTrait) -> Action {
Box::new(WidgetAction {
data: None,
action: Box::new(payload),
widget_uid: WidgetUid(uid),
group: None,
})
}

#[test]
fn room_control_action_keeps_thread_selection_in_its_own_pane() {
let root = OwnedEventId::try_from("$codex-root").unwrap();
let action = emitted_by(11, ThreadsPaneAction::OpenThread(root.clone()));
// Main and two thread tabs share a Matrix room, but never a widget UID.
for (pane, uid) in [("Claude thread", 12), ("Codex thread", 13), ("other room", 21)] {
assert!(room_control_action(&action, &[WidgetUid(uid)]).is_none(), "{pane} accepted another pane's jump");
}
let selected = room_control_action(&action, &[WidgetUid(11)]).unwrap();
assert!(matches!(selected.cast_ref::<ThreadsPaneAction>(), ThreadsPaneAction::OpenThread(event) if event == &root));
assert_eq!(action.as_widget_action().unwrap().widget_uid, WidgetUid(11));
}

#[test]
fn room_control_action_accepts_local_search_controls_only() {
let local = [WidgetUid(30), WidgetUid(31), WidgetUid(32)];
let other = [WidgetUid(40), WidgetUid(41), WidgetUid(42)];
for source in local {
let action = emitted_by(source.0, SearchMessagesAction::OpenRequested);
assert!(matches!(room_control_action(&action, &local).cast_ref(), SearchMessagesAction::OpenRequested));
assert!(room_control_action(&action, &other).is_none());
}
}

#[test]
fn room_control_action_rejects_missing_or_zero_ownership() {
let plain: Action = Box::new(());
assert!(room_control_action(&plain, &[WidgetUid(1)]).is_none());
assert!(room_control_action(&emitted_by(1, ThreadsPaneAction::CloseRequested), &[]).is_none());
assert!(room_control_action(&emitted_by(0, ThreadsPaneAction::CloseRequested), &[WidgetUid(0)]).is_none());
}

proptest! {
#[test]
fn prop_room_control_action_matches_explicit_owner_set(
source in prop_oneof![Just(0), any::<u64>()],
mut owners in prop::collection::vec(any::<u64>(), 0..8),
include_source in any::<bool>(),
) {
if include_source {
owners.push(source);
}
let expected = source != 0 && owners.iter().any(|owner| *owner == source);
let action = emitted_by(source, ThreadsPaneAction::LoadMoreRequested);
let owners = owners.into_iter().map(WidgetUid).collect::<Vec<_>>();
prop_assert_eq!(room_control_action(&action, &owners).is_some(), expected);
}
}

}
17 changes: 8 additions & 9 deletions src/home/room_screen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ use rangemap::RangeSet;

use super::{ContextMenuOpenGesture, event_reaction_list::ReactionData, invite_modal::is_invite_modal_open, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}};

mod action_scope;
mod bot_admin;
mod bot_message;
mod dsl;
Expand All @@ -74,6 +75,7 @@ mod thread_lifecycle;
mod threads_pane;
mod updates;

use action_scope::room_control_action;
pub use bot_admin::*;
use bot_message::*;
pub use message::*;
Expand Down Expand Up @@ -645,7 +647,7 @@ impl Widget for RoomScreen {
}

// Mobile RoomTopBar (header + Chat/Info tabs) actions.
match action.as_widget_action().cast::<RoomTopBarAction>() {
match room_control_action(action, &[self.room_top_bar(cx, ids!(room_top_bar)).widget_uid()]).cast::<RoomTopBarAction>() {
RoomTopBarAction::Back => {
cx.widget_action(room_screen_widget_uid, StackNavigationAction::Pop);
}
Expand Down Expand Up @@ -828,12 +830,9 @@ impl Widget for RoomScreen {
}
}

// No `widget_uid_eq` filter here — `OpenThread` is emitted from
// a `ThreadsPaneEntry` (a list item), not from the pane itself,
// so its widget_uid is the entry's. `LoadMoreRequested` and
// `CloseRequested` come from the pane, but `cast_ref` handles
// all three regardless of emitter.
match action.as_widget_action().cast_ref::<ThreadsPaneAction>() {
// Entries emit under their pane's UID so cached room/thread
// tabs only handle selections from their own pane.
match room_control_action(action, &[threads_sliding_pane.widget_uid()]).cast_ref::<ThreadsPaneAction>() {
ThreadsPaneAction::OpenThread(thread_root_event_id) => {
log!("RoomScreen: OpenThread received, jumping to {}", thread_root_event_id);
threads_sliding_pane.hide(cx);
Expand Down Expand Up @@ -1001,7 +1000,7 @@ impl Widget for RoomScreen {

// Floating threads button click → open the threads sliding pane.
for action in actions {
if let ThreadsButtonAction::OpenRequested = action.as_widget_action().cast_ref() {
if let ThreadsButtonAction::OpenRequested = room_control_action(action, &[self.view.threads_button(cx, ids!(timeline.threads_button)).widget_uid()]).cast_ref() {
self.show_threads_pane(cx);
break;
}
Expand All @@ -1010,7 +1009,7 @@ impl Widget for RoomScreen {
// Floating info button click → open the room info sliding pane
// (desktop only — the button is hidden on mobile).
for action in actions {
if let InfoButtonAction::OpenRequested = action.as_widget_action().cast_ref() {
if let InfoButtonAction::OpenRequested = room_control_action(action, &[self.view.widget(cx, ids!(timeline.info_button)).widget_uid()]).cast_ref() {
self.show_room_info_pane(cx, scope.data.get::<AppState>());
break;
}
Expand Down
9 changes: 4 additions & 5 deletions src/home/room_screen/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ impl RoomScreen {
) {
let pane = self.search_messages_sliding_pane(cx, ids!(search_messages_pane));
let button = self.search_messages_button(cx, ids!(timeline.search_messages_button));
let owners = [pane.widget_uid(), button.widget_uid(), self.widget_uid()];

let mut requested_close = false;
let mut requested_open = false;
Expand All @@ -176,11 +177,9 @@ impl RoomScreen {
let mut jump_target: Option<OwnedEventId> = None;

for action in actions {
// Widget-emitted actions are wrapped in a `WidgetAction`, so we
// must unwrap via `as_widget_action()` before downcasting to the
// inner `SearchMessagesAction`. `cast_ref` falls back to the
// `None` sentinel for non-matching actions.
match action.as_widget_action().cast_ref::<SearchMessagesAction>() {
// Result rows emit under the pane UID; the RoomTopBar search
// control relays under this room screen's UID.
match room_control_action(action, &owners).cast_ref::<SearchMessagesAction>() {
SearchMessagesAction::OpenRequested => requested_open = true,
SearchMessagesAction::CloseRequested => requested_close = true,
SearchMessagesAction::QueryChanged(q) => new_query = Some(q.clone()),
Expand Down
15 changes: 9 additions & 6 deletions src/home/room_screen/threads_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,15 @@ pub struct ThreadsPaneEntry {
#[deref] view: View,

#[rust] thread_root_event_id: Option<OwnedEventId>,
#[rust] owner_widget_uid: Option<WidgetUid>,
}

impl Widget for ThreadsPaneEntry {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// Hit-test the parent area BEFORE propagating to children, so the inner
// HtmlOrPlaintext (and its TextFlow / HtmlLink children) don't steal
// FingerDown/Up — mirrors the pattern in rooms_list_entry.rs.
if let Some(thread_root_event_id) = self.thread_root_event_id.clone() {
if let (Some(thread_root_event_id), Some(owner_widget_uid)) = (self.thread_root_event_id.clone(), self.owner_widget_uid) {
let area = self.view.area();
match event.hits(cx, area) {
Hit::FingerDown(_) => {
Expand All @@ -336,7 +337,7 @@ impl Widget for ThreadsPaneEntry {
Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => {
log!("ThreadsPaneEntry: tap detected, emitting OpenThread({})", thread_root_event_id);
cx.widget_action(
self.widget_uid(),
owner_widget_uid,
ThreadsPaneAction::OpenThread(thread_root_event_id),
);
}
Expand All @@ -353,8 +354,9 @@ impl Widget for ThreadsPaneEntry {
}

impl ThreadsPaneEntry {
fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) {
fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo, owner_widget_uid: WidgetUid) {
self.thread_root_event_id = Some(entry.thread_root_event_id.clone());
self.owner_widget_uid = Some(owner_widget_uid);
self.html_or_plaintext(cx, ids!(title)).show_html(cx, &entry.title);
self.label(cx, ids!(time)).set_text(cx, &entry.time);
self.label(cx, ids!(subtitle)).set_text(cx, &entry.subtitle);
Expand All @@ -363,9 +365,9 @@ impl ThreadsPaneEntry {
}

impl ThreadsPaneEntryRef {
fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) {
fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo, owner_widget_uid: WidgetUid) {
let Some(mut inner) = self.borrow_mut() else { return };
inner.set_entry(cx, entry);
inner.set_entry(cx, entry, owner_widget_uid);
}
}

Expand Down Expand Up @@ -481,6 +483,7 @@ impl Widget for ThreadsSlidingPane {
}

fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let pane_widget_uid = self.widget_uid();
let Some(info) = self.info.as_ref() else {
self.visible = false;
return self.view.draw_walk(cx, scope, walk);
Expand Down Expand Up @@ -520,7 +523,7 @@ impl Widget for ThreadsSlidingPane {
while let Some(item_id) = list.next_visible_item(cx) {
let Some(entry) = info.entries.get(item_id) else { continue };
let item = list.item(cx, item_id, id!(ThreadEntry));
item.as_threads_pane_entry().set_entry(cx, entry);
item.as_threads_pane_entry().set_entry(cx, entry, pane_widget_uid);
item.draw_all(cx, &mut Scope::empty());
}
}
Expand Down
11 changes: 7 additions & 4 deletions src/home/search_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,18 +452,19 @@ pub struct SearchResultItem {
/// The matrix event this row represents. `None` until the row is bound
/// to a [`MessageSearchHit`] via [`SearchResultItem::set_hit`].
#[rust] event_id: Option<OwnedEventId>,
#[rust] owner_widget_uid: Option<WidgetUid>,
}

impl Widget for SearchResultItem {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);

let Some(event_id) = self.event_id.as_ref() else { return };
let (Some(event_id), Some(owner_widget_uid)) = (self.event_id.as_ref(), self.owner_widget_uid) else { return };
let area = self.view.area();
if let Hit::FingerUp(fe) = event.hits(cx, area) {
if fe.is_over && fe.is_primary_hit() && fe.was_tap() {
cx.widget_action(
self.widget_uid(),
owner_widget_uid,
SearchMessagesAction::JumpToEvent(event_id.clone()),
);
}
Expand All @@ -476,8 +477,9 @@ impl Widget for SearchResultItem {
}

impl SearchResultItem {
pub fn set_hit(&mut self, cx: &mut Cx, hit: &MessageSearchHit) {
pub fn set_hit(&mut self, cx: &mut Cx, hit: &MessageSearchHit, owner_widget_uid: WidgetUid) {
self.event_id = Some(hit.event_id.clone());
self.owner_widget_uid = Some(owner_widget_uid);
self.label(cx, ids!(sender_label)).set_text(cx, &hit.sender_display);
self.label(cx, ids!(ts_label)).set_text(cx, &hit.timestamp_display);
self.label(cx, ids!(body_label)).set_text(cx, &hit.body_preview);
Expand Down Expand Up @@ -689,6 +691,7 @@ impl Widget for SearchMessagesSlidingPane {

self.refresh_status_display(cx);

let pane_widget_uid = self.widget_uid();
while let Some(widget) = self.view.draw_walk(cx, scope, walk).step() {
let portal_list_ref = widget.as_portal_list();
let Some(mut list) = portal_list_ref.borrow_mut() else { continue };
Expand All @@ -698,7 +701,7 @@ impl Widget for SearchMessagesSlidingPane {
let Some(hit) = self.hits.get(idx) else { continue };
let item = list.item(cx, idx, id!(result_item));
if let Some(mut row) = item.as_search_result_item().borrow_mut() {
row.set_hit(cx, hit);
row.set_hit(cx, hit, pane_widget_uid);
}
item.draw_all(cx, &mut Scope::empty());
}
Expand Down
Loading