diff --git a/crates/openloops-desktop/src/claim_view.rs b/crates/openloops-desktop/src/claim_view.rs index ca94603..1bfdf13 100644 --- a/crates/openloops-desktop/src/claim_view.rs +++ b/crates/openloops-desktop/src/claim_view.rs @@ -142,6 +142,56 @@ pub struct LoopItem { pub from_call_summary: bool, pub meeting_time: Option, pub meeting_time_approx: bool, + pub mentions: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MentionOrigin { + Restated, + OwnerLink, + Duplicate, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Mention { + pub message: String, + pub block: usize, + pub quote: String, + pub timestamp: i64, + pub origin: MentionOrigin, +} + +pub(crate) fn fold_into( + survivor: &mut LoopItem, + dropped: LoopItem, + origin: MentionOrigin, + timestamp: i64, +) { + let mention = Mention { + message: dropped.evidence.message.clone(), + block: dropped.evidence.block, + quote: dropped.evidence.quote.clone(), + timestamp, + origin, + }; + if (mention.message != survivor.evidence.message || mention.block != survivor.evidence.block) + && !survivor + .mentions + .iter() + .any(|existing| existing.message == mention.message && existing.block == mention.block) + { + survivor.mentions.push(mention); + } + for mention in dropped.mentions { + if (mention.message != survivor.evidence.message + || mention.block != survivor.evidence.block) + && !survivor.mentions.iter().any(|existing| { + existing.message == mention.message && existing.block == mention.block + }) + { + survivor.mentions.push(mention); + } + } } pub struct LoopItems { diff --git a/crates/openloops-desktop/src/lib.rs b/crates/openloops-desktop/src/lib.rs index 246ac36..15291f1 100644 --- a/crates/openloops-desktop/src/lib.rs +++ b/crates/openloops-desktop/src/lib.rs @@ -6,6 +6,7 @@ pub(crate) mod app_model; pub mod claim_view; #[cfg(feature = "native-ui")] pub(crate) mod deadline_view; +pub(crate) mod link_state; #[cfg(feature = "native-ui")] pub(crate) mod loop_state; #[cfg(feature = "native-ui")] diff --git a/crates/openloops-desktop/src/link_state.rs b/crates/openloops-desktop/src/link_state.rs new file mode 100644 index 0000000..cae3904 --- /dev/null +++ b/crates/openloops-desktop/src/link_state.rs @@ -0,0 +1,387 @@ +//! Bounded owner-taught relationships. Stores fingerprints, never readable mail. +use crate::loop_state::{Decisions, LoopKeys, now, writer_lock}; +use zeroize::Zeroizing; + +const MAGIC: &[u8] = b"OLRelations\x01"; +const CAP: usize = 34; +const TARGET: &str = "OpenLoops/Relations/v1"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkKind { + SameLoop = 0, + NotSameLoop = 1, + SameThread = 2, + NotSameThread = 3, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Link { + pub kind: LinkKind, + pub a: [u8; 32], + pub b: [u8; 32], + pub updated: i64, +} + +pub struct Relations { + keys: LoopKeys, + pub links: Vec, + entry: Option, + previous: Zeroizing>, + pub error: Option, +} + +fn failure() -> String { + "Saved links are unavailable or changed in another window. Close the other window and reopen OpenLoops; your previous links have been preserved.".into() +} + +impl Relations { + pub fn open_with(decisions: &Decisions) -> Self { + Self::open( + decisions.keys(), + if cfg!(any(test, feature = "ui-screenshot")) { + None + } else { + Some(TARGET) + }, + ) + } + + fn open(keys: LoopKeys, target: Option<&str>) -> Self { + let mut result = Self { + keys, + links: Vec::new(), + entry: None, + previous: Zeroizing::new(Vec::new()), + error: None, + }; + let Some(target) = target else { + return result; + }; + #[cfg(windows)] + { + use keyring_core::api::CredentialStoreApi; + let entry = windows_native_keyring_store::Store::new() + .ok() + .and_then(|store| { + store + .build( + "OpenLoops", + "relations", + Some(&std::collections::HashMap::from([ + ("target", target), + ("persistence", "Local"), + ])), + ) + .ok() + }); + if let Some(entry) = entry { + let loaded = entry.get_secret(); + result.entry = Some(entry); + match loaded { + Ok(bytes) => { + result.previous = Zeroizing::new(bytes); + match decode(&result.previous) { + Ok(links) => result.links = links, + Err(()) => result.error = Some(failure()), + } + } + Err(keyring_core::Error::NoEntry) => { + if result.save().is_err() { + result.error = Some(failure()); + } + } + Err(_) => result.error = Some(failure()), + } + } else { + result.error = Some(failure()); + } + } + #[cfg(not(windows))] + { + let _ = target; + result.error = Some(failure()); + } + result + } + + pub fn thread_key(&self, account: &str, conversation: &str) -> [u8; 32] { + self.keys + .domain_key(&["openloops-conversation-v1", account, conversation]) + } + + pub fn set(&mut self, kind: LinkKind, a: [u8; 32], b: [u8; 32]) -> Result<(), String> { + if let Some(error) = &self.error { + return Err(error.clone()); + } + if a == b { + return self.remove(a, b); + } + let (a, b) = canonical(a, b); + let old = self.links.clone(); + if self.get(a, b).is_some() { + let link = self + .links + .iter_mut() + .find(|link| link.a == a && link.b == b) + .expect("get found the canonical pair"); + *link = Link { + kind, + a, + b, + updated: now(), + }; + } else { + if self.links.len() >= CAP { + return Err( + "The saved-link limit (34) is reached. Existing links are preserved.".into(), + ); + } + self.links.push(Link { + kind, + a, + b, + updated: now(), + }); + } + if self.save().is_err() { + self.links = old; + self.error = Some(failure()); + return Err(failure()); + } + Ok(()) + } + + pub fn remove(&mut self, a: [u8; 32], b: [u8; 32]) -> Result<(), String> { + if let Some(error) = &self.error { + return Err(error.clone()); + } + let (a, b) = canonical(a, b); + let old = self.links.clone(); + self.links.retain(|link| link.a != a || link.b != b); + if self.save().is_err() { + self.links = old; + self.error = Some(failure()); + return Err(failure()); + } + Ok(()) + } + + pub fn get(&self, a: [u8; 32], b: [u8; 32]) -> Option { + let (a, b) = canonical(a, b); + self.links + .iter() + .find(|link| link.a == a && link.b == b) + .map(|link| link.kind) + } + + pub fn pairs(&self, kind: LinkKind) -> Vec<([u8; 32], [u8; 32])> { + self.links + .iter() + .filter(|link| link.kind == kind) + .map(|link| (link.a, link.b)) + .collect() + } + + pub fn linked_to(&self, key: &[u8; 32], kind: LinkKind) -> Vec<[u8; 32]> { + self.links + .iter() + .filter(|link| link.kind == kind && (&link.a == key || &link.b == key)) + .map(|link| if &link.a == key { link.b } else { link.a }) + .collect() + } + + fn save(&mut self) -> Result<(), ()> { + let bytes = encode(&self.links)?; + if let Some(entry) = &self.entry { + let _writer = writer_lock("OpenLoops-relations-writer.lock")?; + let current = match entry.get_secret() { + Ok(bytes) => Zeroizing::new(bytes), + Err(keyring_core::Error::NoEntry) => Zeroizing::new(Vec::new()), + Err(_) => return Err(()), + }; + if *current != *self.previous { + return Err(()); + } + entry.set_secret(&bytes).map_err(|_| ())?; + } else if !cfg!(any(test, feature = "ui-screenshot")) { + return Err(()); + } + self.previous = bytes; + Ok(()) + } +} + +fn canonical(a: [u8; 32], b: [u8; 32]) -> ([u8; 32], [u8; 32]) { + if a < b { (a, b) } else { (b, a) } +} + +fn encode(links: &[Link]) -> Result>, ()> { + if links.len() > CAP { + return Err(()); + } + let mut bytes = Zeroizing::new(MAGIC.to_vec()); + bytes.push(u8::try_from(links.len()).map_err(|_| ())?); + for link in links { + bytes.push(link.kind as u8); + bytes.extend_from_slice(&link.a); + bytes.extend_from_slice(&link.b); + bytes.extend_from_slice(&link.updated.to_le_bytes()); + } + if bytes.len() > 2560 { + return Err(()); + } + Ok(bytes) +} + +fn decode(bytes: &[u8]) -> Result, ()> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + if bytes.len() > 2560 { + return Err(()); + } + let payload = bytes.strip_prefix(MAGIC).ok_or(())?; + let count = usize::from(*payload.first().ok_or(())?); + if count > CAP { + return Err(()); + } + let mut rest = &payload[1..]; + let mut links = Vec::with_capacity(count); + for _ in 0..count { + let (row, remaining) = rest.split_at_checked(73).ok_or(())?; + let kind = match row[0] { + 0 => LinkKind::SameLoop, + 1 => LinkKind::NotSameLoop, + 2 => LinkKind::SameThread, + 3 => LinkKind::NotSameThread, + _ => return Err(()), + }; + let a = row[1..33].try_into().map_err(|_| ())?; + let b = row[33..65].try_into().map_err(|_| ())?; + if a >= b || links.iter().any(|link: &Link| link.a == a && link.b == b) { + return Err(()); + } + links.push(Link { + kind, + a, + b, + updated: i64::from_le_bytes(row[65..73].try_into().map_err(|_| ())?), + }); + rest = remaining; + } + if !rest.is_empty() { + return Err(()); + } + Ok(links) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn links_replay_without_readable_mail_and_thread_keys_are_account_bound() { + let decisions = Decisions::default(); + let mut relations = Relations::open_with(&decisions); + let a = decisions.fingerprint("account-a", "message-a", 0, "Synthetic ask", "ask"); + let b = decisions.fingerprint("account-a", "message-b", 0, "Synthetic reply", "reply"); + relations.set(LinkKind::SameLoop, a, b).unwrap(); + assert_ne!( + relations.thread_key("account-a", "thread"), + relations.thread_key("account-b", "thread") + ); + let bytes = encode(&relations.links).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Synthetic")); + assert_eq!(decode(&bytes).unwrap(), relations.links); + for length in 1..bytes.len() { + assert!(decode(&bytes[..length]).is_err()); + } + } + + #[test] + fn every_link_kind_round_trips_with_stable_tags() { + for (tag, kind) in [ + LinkKind::SameLoop, + LinkKind::NotSameLoop, + LinkKind::SameThread, + LinkKind::NotSameThread, + ] + .into_iter() + .enumerate() + { + let link = Link { + kind, + a: [1; 32], + b: [2; 32], + updated: 1_788_350_400, + }; + let bytes = encode(&[link]).unwrap(); + assert_eq!(usize::from(bytes[MAGIC.len() + 1]), tag); + assert_eq!(decode(&bytes).unwrap(), vec![link]); + } + } + + #[test] + fn set_replaces_the_pair_and_caps_at_34() { + let decisions = Decisions::default(); + let mut relations = Relations::open_with(&decisions); + relations.set(LinkKind::SameLoop, [1; 32], [2; 32]).unwrap(); + relations + .set(LinkKind::NotSameLoop, [2; 32], [1; 32]) + .unwrap(); + assert_eq!(relations.links.len(), 1); + assert_eq!(relations.get([1; 32], [2; 32]), Some(LinkKind::NotSameLoop)); + for value in 3..=35 { + relations + .set(LinkKind::SameLoop, [0; 32], [value; 32]) + .unwrap(); + } + assert_eq!(relations.links.len(), CAP); + assert!( + relations + .set(LinkKind::SameLoop, [36; 32], [37; 32]) + .is_err() + ); + assert_eq!(relations.links.len(), CAP); + } + + #[cfg(windows)] + #[test] + fn native_links_survive_reopen() { + let target = format!("OpenLoops/TestRelations/{}-{}", std::process::id(), now()); + let decisions = Decisions::default(); + let mut relations = Relations::open(decisions.keys(), Some(&target)); + relations.set(LinkKind::SameLoop, [1; 32], [2; 32]).unwrap(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "link_state::tests::read_links_in_new_process", + "--ignored", + ]) + .env("OPENLOOPS_TEST_RELATIONS", &target) + .output() + .unwrap(); + let result = + child.status.success() && String::from_utf8_lossy(&child.stdout).contains("1 passed"); + let reopened = Relations::open(decisions.keys(), Some(&target)); + assert_eq!(reopened.get([1; 32], [2; 32]), Some(LinkKind::SameLoop)); + reopened + .entry + .as_ref() + .unwrap() + .delete_credential() + .unwrap(); + assert!(result, "isolated fresh-process relation check failed"); + } + + #[cfg(windows)] + #[test] + #[ignore = "parent test supplies isolated native credential"] + fn read_links_in_new_process() { + let target = std::env::var("OPENLOOPS_TEST_RELATIONS").unwrap(); + assert!(target.starts_with("OpenLoops/TestRelations/")); + let decisions = Decisions::default(); + let relations = Relations::open(decisions.keys(), Some(&target)); + assert_eq!(relations.get([1; 32], [2; 32]), Some(LinkKind::SameLoop)); + } +} diff --git a/crates/openloops-desktop/src/loop_state.rs b/crates/openloops-desktop/src/loop_state.rs index 90a4cee..c392ab5 100644 --- a/crates/openloops-desktop/src/loop_state.rs +++ b/crates/openloops-desktop/src/loop_state.rs @@ -90,6 +90,48 @@ pub struct Decisions { pub error: Option, } +#[derive(Clone)] +pub struct LoopKeys(Zeroizing<[u8; 32]>); + +impl LoopKeys { + pub fn key( + &self, + account: &str, + source: &str, + block: usize, + quote: &str, + action_phrase: &str, + ) -> [u8; 32] { + let mut mac = + Hmac::::new_from_slice(self.0.as_ref()).expect("fixed length HMAC key"); + let block = block.to_string(); + let quote = normalize_for_fingerprint(quote); + let action_phrase = normalize_for_fingerprint(action_phrase); + for field in [ + "openloops-expectation-v2", + account, + source, + &block, + "e, + &action_phrase, + ] { + mac.update(&(field.len() as u64).to_le_bytes()); + mac.update(field.as_bytes()); + } + mac.finalize().into_bytes().into() + } + + pub(crate) fn domain_key(&self, fields: &[&str]) -> [u8; 32] { + let mut mac = + Hmac::::new_from_slice(self.0.as_ref()).expect("fixed length HMAC key"); + for field in fields { + mac.update(&(field.len() as u64).to_le_bytes()); + mac.update(field.as_bytes()); + } + mac.finalize().into_bytes().into() + } +} + fn failure() -> String { "Saved loop decisions are unavailable or changed in another window. Close the other window and reopen OpenLoops; your previous decisions have been preserved.".into() } @@ -222,23 +264,11 @@ impl Decisions { quote: &str, action_phrase: &str, ) -> [u8; 32] { - let mut mac = - Hmac::::new_from_slice(self.secret.as_ref()).expect("fixed length HMAC key"); - let block = block.to_string(); - let quote = normalize_for_fingerprint(quote); - let action_phrase = normalize_for_fingerprint(action_phrase); - for field in [ - "openloops-expectation-v2", - account, - source, - &block, - "e, - &action_phrase, - ] { - mac.update(&(field.len() as u64).to_le_bytes()); - mac.update(field.as_bytes()); - } - mac.finalize().into_bytes().into() + self.keys() + .key(account, source, block, quote, action_phrase) + } + pub(crate) fn keys(&self) -> LoopKeys { + LoopKeys(self.secret.clone()) } pub fn get(&self, key: &[u8; 32]) -> Record { self.records @@ -288,7 +318,7 @@ impl Decisions { if let Some(entry) = &self.entry { // One OS-protected, empty lock file serializes the credential CAS. // It contains no record, account identifier, key or mailbox content. - let _writer = writer_lock()?; + let _writer = writer_lock("OpenLoops-decision-writer.lock")?; let current = match entry.get_secret() { Ok(b) => Zeroizing::new(b), Err(keyring_core::Error::NoEntry) => Zeroizing::new(vec![]), @@ -307,8 +337,8 @@ impl Decisions { } #[cfg(windows)] -fn writer_lock() -> Result { - exclusive_writer_file(&std::env::temp_dir().join("OpenLoops-decision-writer.lock")) +pub(crate) fn writer_lock(name: &str) -> Result { + exclusive_writer_file(&std::env::temp_dir().join(name)) } #[cfg(windows)] @@ -325,7 +355,7 @@ fn exclusive_writer_file(path: &std::path::Path) -> Result { } #[cfg(not(windows))] -fn writer_lock() -> Result { +pub(crate) fn writer_lock(_name: &str) -> Result { Err(()) } @@ -618,6 +648,7 @@ mod tests { from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }; let state = Decisions { secret: Zeroizing::new([7; 32]), diff --git a/crates/openloops-desktop/src/review_model.rs b/crates/openloops-desktop/src/review_model.rs index fffc86b..c7e2a71 100644 --- a/crates/openloops-desktop/src/review_model.rs +++ b/crates/openloops-desktop/src/review_model.rs @@ -1,9 +1,10 @@ //! Toolkit-free review state and pure decision/urgency/status logic. use crate::claim_view::{ - Anchor, EventPassed, LoopItem, LoopItems, Owner, ResolutionKind, ResolvedUpdate, + Anchor, EventPassed, LoopItem, LoopItems, MentionOrigin, Owner, ResolutionKind, ResolvedUpdate, SuggestedUpdate, SuggestedUpdateKind, }; use crate::deadline_view::{DeadlineView, classify, classify_with_hint}; +use crate::link_state::{LinkKind, Relations}; use crate::loop_state::{Decision, Decisions, Record, Reminder, now}; use openloops_graph::live::{ reminders::ReminderRequest, @@ -70,6 +71,7 @@ pub(crate) struct CardContext { pub(crate) closed: bool, pub(crate) deadline: Option, pub(crate) from_call_summary: bool, + pub(crate) mentions: usize, } pub(crate) fn is_past_due(view: &DeadlineView) -> bool { @@ -82,12 +84,13 @@ pub(crate) fn is_past_due(view: &DeadlineView) -> bool { ) } -fn card_rank(card: Option<&CardContext>) -> u8 { - match card { +fn card_rank(card: Option<&CardContext>) -> (u8, u8) { + let group = match card { Some(card) if card.closed => 2, Some(card) if card.deadline.as_ref().is_some_and(is_past_due) => 0, _ => 1, - } + }; + (group, u8::from(card.is_none_or(|card| card.mentions == 0))) } pub(crate) fn card_order(cards: &[Option]) -> Vec { @@ -96,6 +99,21 @@ pub(crate) fn card_order(cards: &[Option]) -> Vec { order } +fn find(parent: &mut [usize], index: usize) -> usize { + if parent[index] != index { + parent[index] = find(parent, parent[index]); + } + parent[index] +} + +fn union(parent: &mut [usize], a: usize, b: usize) { + let a = find(parent, a); + let b = find(parent, b); + if a != b { + parent[b] = a; + } +} + /// What resolving a suggested update asks the caller to do next. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SuggestionOutcome { @@ -110,7 +128,6 @@ pub(crate) enum SuggestionOutcome { MarkHandled, } -#[derive(Default)] #[allow(clippy::struct_excessive_bools)] pub struct ReviewState { pub messages: Vec, @@ -124,6 +141,7 @@ pub struct ReviewState { pub failed_sources: BTreeSet, pub failed_conversations_detail: Vec, pub decisions: Decisions, + pub relations: Relations, /// Owner accept/reject outcomes on suggested updates, kept after /// [`LoopItem::suggested_update`] is cleared so a later training-data /// export ([`crate::training_export`]) can still recover the gold @@ -175,6 +193,44 @@ pub struct ReviewState { conversation_notes_by_id: BTreeMap>, conversation_rejection_reasons: BTreeMap>, closure_pass_failure: Option, + pub(crate) linked_card_titles: BTreeMap<[u8; 32], String>, +} + +impl Default for ReviewState { + fn default() -> Self { + let decisions = Decisions::default(); + let relations = Relations::open_with(&decisions); + Self { + messages: Vec::new(), + notices: Vec::new(), + analysis: None, + analysis_model: String::new(), + scan_summary: String::new(), + scan_errors: Vec::new(), + scan_incomplete: false, + source_failures: 0, + failed_sources: BTreeSet::new(), + failed_conversations_detail: Vec::new(), + decisions, + relations, + resolved_updates: Vec::new(), + action_status: String::new(), + action_status_succeeded: false, + pending_reminder: None, + draft: None, + show_handled: false, + show_call_summaries: false, + search_query: String::new(), + scan_failed: false, + source_notes: BTreeMap::new(), + source_issue_counts: BTreeMap::new(), + conversation_quality: BTreeMap::new(), + conversation_notes_by_id: BTreeMap::new(), + conversation_rejection_reasons: BTreeMap::new(), + closure_pass_failure: None, + linked_card_titles: BTreeMap::new(), + } + } } struct MergedConversationMetadata { @@ -456,6 +512,7 @@ impl ReviewState { self.scan_errors.push(failure); } self.analysis = Some(result.analysis); + self.apply_loop_links(); self.analysis_model = model; self.resolved_updates.clear(); // A rescan rebuilds `analysis`/`messages` from scratch; an open @@ -721,6 +778,7 @@ impl ReviewState { closed, deadline, from_call_summary: item.from_call_summary, + mentions: item.mentions.len(), }) } @@ -817,6 +875,175 @@ impl ReviewState { .collect() } + pub fn apply_loop_links(&mut self) -> usize { + let Some(analysis) = self.analysis.as_ref() else { + return 0; + }; + let cards = self.card_contexts(&analysis.items); + for (card, item) in cards.iter().zip(&analysis.items) { + if let Some(card) = card { + self.linked_card_titles + .insert(card.record.key, item.action.clone()); + } + } + let mut parent: Vec = (0..analysis.items.len()).collect(); + let key_to_index: BTreeMap<[u8; 32], usize> = cards + .iter() + .enumerate() + .filter_map(|(index, card)| card.as_ref().map(|card| (card.record.key, index))) + .collect(); + for (a, b) in self.relations.pairs(LinkKind::SameLoop) { + let (Some(&a), Some(&b)) = (key_to_index.get(&a), key_to_index.get(&b)) else { + continue; + }; + union(&mut parent, a, b); + } + let mut groups: BTreeMap> = BTreeMap::new(); + for index in 0..parent.len() { + let root = find(&mut parent, index); + groups.entry(root).or_default().push(index); + } + let timestamps: Vec = analysis + .items + .iter() + .map(|item| self.item_timestamp(item)) + .collect(); + let mut folds = Vec::new(); + for mut group in groups.into_values().filter(|group| group.len() > 1) { + group.sort_by_key(|&index| (timestamps[index], index)); + let survivor = group[0]; + for &dropped in &group[1..] { + folds.push((survivor, dropped, timestamps[dropped])); + } + } + let count = folds.len(); + let analysis = self.analysis.as_mut().expect("analysis was checked above"); + for (survivor, dropped, timestamp) in &folds { + let dropped_item = analysis.items[*dropped].clone(); + crate::claim_view::fold_into( + &mut analysis.items[*survivor], + dropped_item, + MentionOrigin::OwnerLink, + *timestamp, + ); + } + let mut removals: Vec = folds.iter().map(|(_, dropped, _)| *dropped).collect(); + removals.sort_unstable(); + removals.dedup(); + for index in removals.into_iter().rev() { + analysis.items.remove(index); + } + count + } + + pub fn link_cards(&mut self, kind: LinkKind, a: usize, b: usize) -> Result<(), String> { + if a == b { + return Err("Select a different card.".into()); + } + let analysis = self + .analysis + .as_ref() + .ok_or("No review results are loaded.")?; + let (item_a, item_b) = ( + analysis + .items + .get(a) + .ok_or("The first card is unavailable.")?, + analysis + .items + .get(b) + .ok_or("The second card is unavailable.")?, + ); + let keys = match kind { + LinkKind::SameLoop | LinkKind::NotSameLoop => { + let cards = self.card_contexts(&analysis.items); + let a = cards[a].as_ref().ok_or("The first card is unavailable.")?; + let b = cards[b].as_ref().ok_or("The second card is unavailable.")?; + (a.record.key, b.record.key) + } + LinkKind::SameThread | LinkKind::NotSameThread => { + let a = self + .thread_key_for_item(item_a) + .ok_or("The first conversation is unavailable.")?; + let b = self + .thread_key_for_item(item_b) + .ok_or("The second conversation is unavailable.")?; + if a == b { + return Err("Already the same conversation".into()); + } + (a, b) + } + }; + let saved = self.relations.set(kind, keys.0, keys.1); + self.action_status_succeeded = saved.is_ok(); + self.action_status = match &saved { + Ok(()) => { + "Link saved on this Windows account. No mail text or names were stored.".into() + } + Err(error) => error.clone(), + }; + saved + } + + pub fn unlink_card(&mut self, index: usize) -> Result { + let analysis = self + .analysis + .as_ref() + .ok_or("No review results are loaded.")?; + let item = analysis + .items + .get(index) + .ok_or("The card is unavailable.")?; + let card_key = self + .card_context(item, now(), chrono::Local::now().offset().local_minus_utc()) + .ok_or("The card is unavailable.")? + .record + .key; + let thread_key = self.thread_key_for_item(item); + let mut changes = Vec::new(); + for link in self.relations.links.clone() { + let replacement = match link.kind { + LinkKind::SameLoop if link.a == card_key || link.b == card_key => { + Some(LinkKind::NotSameLoop) + } + LinkKind::SameThread + if thread_key.is_some_and(|key| link.a == key || link.b == key) => + { + Some(LinkKind::NotSameThread) + } + _ => None, + }; + if let Some(kind) = replacement { + changes.push((kind, link.a, link.b)); + } + } + for &(kind, a, b) in &changes { + self.relations.set(kind, a, b)?; + } + self.action_status_succeeded = true; + self.action_status = + "Link removed on this Windows account. No mail text or names were stored.".into(); + Ok(changes.len()) + } + + fn item_timestamp(&self, item: &LoopItem) -> i64 { + self.messages + .iter() + .find(|message| message.input.handle == item.evidence.message) + .map_or(0, |message| message.input.timestamp) + } + + fn thread_key_for_item(&self, item: &LoopItem) -> Option<[u8; 32]> { + let message = self + .messages + .iter() + .find(|message| message.input.handle == item.evidence.message)?; + Some( + self.relations + .thread_key(&message.account, &message.conversation), + ) + } + /// Still-open, reminder-bearing decisions eligible for a To Do sync /// right now: a `Mine`/`Watching`/`Review` decision with a /// `Reminder::Created` record carrying real (non-empty) ids, matched @@ -1456,6 +1683,7 @@ pub fn layout_fixture() -> ReviewState { from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }, // Card 2: still needs a decision, no reminder -- this is the card // the preview's open draft attaches to. Its evidence message (m1) @@ -1494,6 +1722,7 @@ pub fn layout_fixture() -> ReviewState { from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }, // Card 3: a reminder attempt with no confirmed outcome, so the // preview also exercises the "attempted" marker callout and its two @@ -1530,6 +1759,7 @@ pub fn layout_fixture() -> ReviewState { from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }, ]; // T7 (brief §5): `source_failures` must be set before `set_scan` runs -- @@ -2275,6 +2505,7 @@ mod tests { from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }; (state, item) } @@ -2410,7 +2641,7 @@ mod tests { state.decisions.records = vec![record]; let card = state.card_context(&item, CLEARLY_PAST_DUE, 0).unwrap(); assert!(card.terminal); - assert_eq!(card_rank(Some(&card)), 2); + assert_eq!(card_rank(Some(&card)), (2, 1)); } state.decisions.records[0].decision = Decision::Review; let overdue = state.card_context(&item, CLEARLY_PAST_DUE, 0); @@ -2419,7 +2650,7 @@ mod tests { let mut range_item = item.clone(); range_item.deadline.as_mut().unwrap().quote = "this week".into(); let range = state.card_context(&range_item, CLEARLY_PAST_WEEK, 0); - assert_eq!(card_rank(range.as_ref()), 0); + assert_eq!(card_rank(range.as_ref()), (0, 1)); let (mut terminal_state, terminal_item) = aging_fixture(); let mut record = terminal_state .card_context(&terminal_item, CLEARLY_PAST_DUE, 0) @@ -2450,7 +2681,7 @@ mod tests { let resolved = resolved_state.card_context(&resolved_item, CLEARLY_DUE, 0); assert!(resolved.as_ref().unwrap().closed); assert!(!resolved.as_ref().unwrap().terminal); - assert_eq!(card_rank(resolved.as_ref()), 2); + assert_eq!(card_rank(resolved.as_ref()), (2, 1)); // The same resolved item, once the saved decision explicitly // overrides closure with `Decision::Mine`, ranks and hides like any @@ -2466,7 +2697,7 @@ mod tests { overridden_state.decisions.records = vec![override_record]; let overridden = overridden_state.card_context(&resolved_item, CLEARLY_DUE, 0); assert!(!overridden.as_ref().unwrap().closed); - assert_eq!(card_rank(overridden.as_ref()), 1); + assert_eq!(card_rank(overridden.as_ref()), (1, 1)); } #[test] fn past_reminder_times_are_rejected() { @@ -2665,7 +2896,7 @@ mod tests { }); let card = state.card_context(&item, 0, 0).unwrap(); assert!(card.closed); - assert_eq!(card_rank(Some(&card)), 2); + assert_eq!(card_rank(Some(&card)), (2, 1)); assert_eq!( status_base_label(Decision::Review, &item), event_passed_status_label(item.event_passed.as_ref().unwrap()) @@ -3112,6 +3343,7 @@ the scan stopped after a provider error." closed, deadline, from_call_summary: false, + mentions: 0, }; let expect = |deadline: Option, expected: ListGroup| { let group = list_group(&card(deadline)); @@ -3219,6 +3451,7 @@ the scan stopped after a provider error." closed, deadline: None, from_call_summary: false, + mentions: 0, } } @@ -3418,4 +3651,34 @@ the scan stopped after a provider error." fn show_call_summary_label_matches_the_review_spec() { assert_eq!(SHOW_CALL_SUMMARY_LABEL, "Show loops from call summaries"); } + + #[test] + fn apply_loop_links_folds_the_newer_linked_card_into_the_older_and_respects_not_same() { + let mut review = layout_fixture(); + review.messages[0].input.timestamp = 100; + review.messages[1].input.timestamp = 200; + let cards = review.card_contexts(&review.analysis.as_ref().unwrap().items); + let a = cards[0].as_ref().unwrap().record.key; + let b = cards[1].as_ref().unwrap().record.key; + review.relations.set(LinkKind::NotSameLoop, a, b).unwrap(); + assert_eq!(review.apply_loop_links(), 0); + review.relations.set(LinkKind::SameLoop, a, b).unwrap(); + assert_eq!(review.apply_loop_links(), 1); + let items = &review.analysis.as_ref().unwrap().items; + assert_eq!(items.len(), 2); + assert_eq!(items[0].mentions.len(), 1); + assert_eq!(items[0].mentions[0].origin, MentionOrigin::OwnerLink); + assert_eq!(items[0].mentions[0].timestamp, 200); + } + + #[test] + fn unlink_records_not_same_loop() { + let mut review = layout_fixture(); + let cards = review.card_contexts(&review.analysis.as_ref().unwrap().items); + let a = cards[0].as_ref().unwrap().record.key; + let b = cards[1].as_ref().unwrap().record.key; + review.relations.set(LinkKind::SameLoop, a, b).unwrap(); + assert_eq!(review.unlink_card(0).unwrap(), 1); + assert_eq!(review.relations.get(a, b), Some(LinkKind::NotSameLoop)); + } } diff --git a/crates/openloops-desktop/src/review_scan.rs b/crates/openloops-desktop/src/review_scan.rs index 310d46f..fbfa469 100644 --- a/crates/openloops-desktop/src/review_scan.rs +++ b/crates/openloops-desktop/src/review_scan.rs @@ -2578,6 +2578,7 @@ fn map_accepted_claim( from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }) } @@ -9445,6 +9446,7 @@ at the downtown courthouse. Let me know if that works.", from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), } } @@ -10349,6 +10351,7 @@ at the downtown courthouse. Let me know if that works.", from_call_summary: false, meeting_time: None, meeting_time_approx: false, + mentions: Vec::new(), }; (all, item) } diff --git a/crates/openloops-desktop/src/slint_review.rs b/crates/openloops-desktop/src/slint_review.rs index dfd9ed1..6823f88 100644 --- a/crates/openloops-desktop/src/slint_review.rs +++ b/crates/openloops-desktop/src/slint_review.rs @@ -1,6 +1,7 @@ //! Review-screen projection and callback wiring for the Slint adapter. use std::{cell::RefCell, rc::Rc, sync::atomic::Ordering}; +use crate::link_state::LinkKind; use crate::{ app_model::{self, AppModel, Outcome, Service, Status}, claim_view::{ @@ -313,6 +314,20 @@ fn evidence_cards( messages, )); } + for mention in &item.mentions { + let message = messages + .iter() + .find(|message| message.input.handle == mention.message); + cards.push(EvidenceView { + label: "Asked again".into(), + sender: message.map_or_else(String::new, sender_label), + time: meeting_time_label(mention.timestamp, false), + quote: typographic_quote(&mention.quote), + context: String::new(), + subject_note: String::new(), + url: message.map_or_else(String::new, |message| gated_outlook_url(&message.web_link)), + }); + } cards } @@ -519,6 +534,7 @@ struct ProjectionCache { struct ReviewUiState { selected: Option<[u8; 32]>, + linking: Option<(LinkKind, [u8; 32])>, filter: Filter, coverage_open: bool, cache: ProjectionCache, @@ -528,6 +544,7 @@ impl Default for ReviewUiState { fn default() -> Self { Self { selected: None, + linking: None, filter: Filter::All, coverage_open: false, cache: ProjectionCache::default(), @@ -694,6 +711,13 @@ fn pills_for(item: &LoopItem, card: &CardContext) -> Vec { hint: String::new(), }); } + if !item.mentions.is_empty() { + pills.push(PillView { + text: format!("Asked {}x", item.mentions.len() + 1), + kind: "neutral", + hint: "The same ask appeared again in a later message.".into(), + }); + } if let Some(deadline) = &card.deadline { pills.push(PillView { text: deadline_label(deadline), @@ -959,6 +983,10 @@ fn selected_view( ("Deadline stated in email".into(), deadline), ("Source".into(), source.source.clone()), ]; + let linked_titles = linked_titles(review, selected, cards, &analysis.items); + if !linked_titles.is_empty() { + meta.push(("Linked to".into(), linked_titles.join("; "))); + } if let Some(meeting_time) = item.meeting_time { meta.push(( "Meeting time".into(), @@ -1019,6 +1047,30 @@ fn selected_view( }) } +fn linked_titles( + review: &ReviewState, + selected: [u8; 32], + cards: &[Option], + items: &[LoopItem], +) -> Vec { + review + .relations + .linked_to(&selected, LinkKind::SameLoop) + .into_iter() + .filter_map(|linked| { + cards + .iter() + .enumerate() + .find_map(|(index, card)| { + card.as_ref() + .is_some_and(|card| card.record.key == linked) + .then(|| items[index].action.clone()) + }) + .or_else(|| review.linked_card_titles.get(&linked).cloned()) + }) + .collect() +} + #[allow(clippy::too_many_lines)] pub(crate) fn scan_strip_view( strip: &ScanStrip, @@ -1245,6 +1297,17 @@ fn sync_review_inner( window.set_scan_strip(strip); window.set_coverage_open(review_ui.coverage_open); window.set_review_has_analysis(model.review.analysis.is_some()); + if model.review.analysis.is_none() { + review_ui.linking = None; + } + window.set_linking_status( + if review_ui.linking.is_some() { + "Select the other card. Esc cancels." + } else { + "" + } + .into(), + ); let visible = visible_handles( &model.review, cards, @@ -1357,6 +1420,36 @@ fn sync_review_inner( window.set_can_track(selected.can_track && !busy); window.set_can_watch(selected.can_watch && !busy); window.set_can_remind(selected.can_remind && !busy); + let has_links = review_ui.selected.is_some_and(|selected_key| { + let loop_link = !model + .review + .relations + .linked_to(&selected_key, LinkKind::SameLoop) + .is_empty(); + let thread_link = model.review.analysis.as_ref().is_some_and(|analysis| { + cards + .iter() + .position(|card| { + card.as_ref() + .is_some_and(|card| card.record.key == selected_key) + }) + .and_then(|index| analysis.items.get(index)) + .and_then(|item| source_message(&model.review, item)) + .is_some_and(|source| { + let key = model + .review + .relations + .thread_key(&source.account, &source.conversation); + !model + .review + .relations + .linked_to(&key, LinkKind::SameThread) + .is_empty() + }) + }); + loop_link || thread_link + }); + window.set_has_links(has_links); if let Some(draft) = selected.draft { window.set_draft_open(true); window.set_draft_title(draft.title.into()); @@ -1437,6 +1530,7 @@ fn sync_review_inner( window.set_conversation_rows(m); }); } else { + window.set_has_links(false); window.set_review_has_selection(false); window.set_review_title("".into()); sync_list_cached(&mut review_ui.cache.meta, Vec::new(), |m| { @@ -1520,8 +1614,13 @@ fn start_mail_load(model: &Rc>, mode: ScanMode) -> Result<(), let mut model_ref = model.borrow_mut(); if mode == ScanMode::Full { let decisions = std::mem::take(&mut model_ref.review.decisions); + let relations = std::mem::replace( + &mut model_ref.review.relations, + crate::link_state::Relations::open_with(&decisions), + ); model_ref.review = ReviewState::default(); model_ref.review.decisions = decisions; + model_ref.review.relations = relations; model_ref.review_status = Status::default(); } let progress = std::sync::Arc::new(LoadProgress::default()); @@ -1708,6 +1807,53 @@ fn decide_selected(model: &mut AppModel, selected: Option<[u8; 32]>, value: i32) } } +fn begin_link(model: &AppModel, kind: LinkKind) { + let selected = REVIEW_UI.with(|state| state.borrow().selected); + if let Some(key) = selected { + REVIEW_UI.with(|state| state.borrow_mut().linking = Some((kind, key))); + } else { + let _ = model; + } +} + +fn complete_link(model: &mut AppModel, row: usize) { + let linking = REVIEW_UI.with(|state| state.borrow().linking); + let Some((kind, first_key)) = linking else { + return; + }; + let Some(analysis) = &model.review.analysis else { + return; + }; + let cards = model.review.card_contexts(&analysis.items); + let Some(second_key) = cards + .get(row) + .and_then(Option::as_ref) + .map(|card| card.record.key) + else { + return; + }; + if first_key == second_key { + cancel_link(model); + return; + } + let Some(first) = cards.iter().position(|card| { + card.as_ref() + .is_some_and(|card| card.record.key == first_key) + }) else { + return; + }; + if model.review.link_cards(kind, first, row).is_ok() + && matches!(kind, LinkKind::SameLoop | LinkKind::NotSameLoop) + { + model.review.apply_loop_links(); + } + REVIEW_UI.with(|state| state.borrow_mut().linking = None); +} + +fn cancel_link(_model: &AppModel) { + REVIEW_UI.with(|state| state.borrow_mut().linking = None); +} + /// Accepts or rejects the pending suggested update on the selected card. /// /// Accepting a closure takes the ordinary Handled path @@ -1894,8 +2040,13 @@ pub(crate) fn register_callbacks( model_ref.scan_progress = None; model_ref.clear_mail_cache(); let decisions = std::mem::take(&mut model_ref.review.decisions); + let relations = std::mem::replace( + &mut model_ref.review.relations, + crate::link_state::Relations::open_with(&decisions), + ); model_ref.review = ReviewState::default(); model_ref.review.decisions = decisions; + model_ref.review.relations = relations; model_ref.review.action_status = "Results and mail cleared from memory. Saved decisions and To Do tasks are preserved." .into(); @@ -1991,6 +2142,13 @@ pub(crate) fn register_callbacks( let model = Rc::clone(&model); let weak = window.as_weak(); window.on_select_review_row(move |handle| { + if REVIEW_UI.with(|state| state.borrow().linking.is_some()) { + if let Ok(row) = usize::try_from(handle) { + complete_link(&mut model.borrow_mut(), row); + refresh(&model, &weak); + } + return; + } let selected = usize::try_from(handle).ok().and_then(|index| { let model_ref = model.borrow(); let analysis = model_ref.review.analysis.as_ref()?; @@ -2006,6 +2164,49 @@ pub(crate) fn register_callbacks( refresh(&model, &weak); }); } + { + let model = Rc::clone(&model); + let weak = window.as_weak(); + window.on_link_loop(move || { + begin_link(&model.borrow(), LinkKind::SameLoop); + refresh(&model, &weak); + }); + } + { + let model = Rc::clone(&model); + let weak = window.as_weak(); + window.on_link_thread(move || { + begin_link(&model.borrow(), LinkKind::SameThread); + refresh(&model, &weak); + }); + } + { + let model = Rc::clone(&model); + let weak = window.as_weak(); + window.on_unlink(move || { + let selected = REVIEW_UI.with(|state| state.borrow().selected); + let mut model_ref = model.borrow_mut(); + if let (Some(selected), Some(analysis)) = (selected, &model_ref.review.analysis) { + let cards = model_ref.review.card_contexts(&analysis.items); + if let Some(index) = cards.iter().position(|card| { + card.as_ref() + .is_some_and(|card| card.record.key == selected) + }) { + let _ = model_ref.review.unlink_card(index); + } + } + drop(model_ref); + refresh(&model, &weak); + }); + } + { + let model = Rc::clone(&model); + let weak = window.as_weak(); + window.on_cancel_link(move || { + cancel_link(&model.borrow()); + refresh(&model, &weak); + }); + } { let model = Rc::clone(&model); let weak = window.as_weak(); @@ -3075,6 +3276,7 @@ mod tests { closed: true, deadline: base.deadline, from_call_summary: false, + mentions: 0, }; assert_eq!(status_pill(&item, &card).text, text); } @@ -3236,6 +3438,7 @@ mod tests { closed: true, deadline: None, from_call_summary: false, + mentions: 0, }), ]; let handles = visible_handles(&review, &cards, Filter::All, true, false, ""); @@ -3884,4 +4087,78 @@ mod tests { assert_eq!(suggested_view(&app.review, None), SuggestedView::default()); assert_eq!(truncated_evidence(&"x".repeat(300)).chars().count(), 241); } + + #[test] + fn follow_up_mention_shows_asked_again_card_pill_and_sorts_first() { + let mut review = crate::review_model::layout_fixture(); + let analysis = review.analysis.as_mut().unwrap(); + analysis.items[0].resolution = None; + analysis.items[0].resolution_kind = None; + analysis.items[0].cross_thread = false; + analysis.items[1].mentions.push(crate::claim_view::Mention { + message: "m2".into(), + block: 0, + quote: "Synthetic repeated request.".into(), + timestamp: 1_788_350_400, + origin: crate::claim_view::MentionOrigin::Restated, + }); + let cards = review.card_contexts(&review.analysis.as_ref().unwrap().items); + assert_eq!(card_order(&cards)[0], 1); + let key = cards[1].as_ref().unwrap().record.key; + let selected = selected_view(&review, Some(key), &cards).unwrap(); + assert!(selected.pills.iter().any(|pill| pill.text == "Asked 2x")); + let asked_again = selected + .evidence + .iter() + .find(|evidence| evidence.label == "Asked again") + .unwrap(); + assert!(!asked_again.url.is_empty()); + } + + #[test] + fn two_step_link_folds_the_second_card_and_lists_it_under_linked_to() { + let mut app = model(); + app.review = crate::review_model::layout_fixture(); + let cards = app + .review + .card_contexts(&app.review.analysis.as_ref().unwrap().items); + let first = cards[0].as_ref().unwrap().record.key; + REVIEW_UI.with(|state| { + let mut state = state.borrow_mut(); + state.selected = Some(first); + state.linking = None; + }); + begin_link(&app, LinkKind::SameLoop); + complete_link(&mut app, 1); + let items = &app.review.analysis.as_ref().unwrap().items; + assert_eq!(items.len(), 2); + assert_eq!( + items[0].mentions[0].origin, + crate::claim_view::MentionOrigin::OwnerLink + ); + assert_eq!(app.review.relations.pairs(LinkKind::SameLoop).len(), 1); + let refreshed = app.review.card_contexts(items); + let selected = selected_view(&app.review, Some(first), &refreshed).unwrap(); + assert!(selected.meta.iter().any(|(label, value)| { + label == "Linked to" && value == "Confirm who will send the team budget" + })); + } + + #[test] + fn linking_cancels_on_escape_and_on_the_same_card() { + let mut app = model(); + app.review = crate::review_model::layout_fixture(); + let cards = app + .review + .card_contexts(&app.review.analysis.as_ref().unwrap().items); + let first = cards[0].as_ref().unwrap().record.key; + REVIEW_UI.with(|state| state.borrow_mut().selected = Some(first)); + begin_link(&app, LinkKind::SameLoop); + cancel_link(&app); + assert!(REVIEW_UI.with(|state| state.borrow().linking.is_none())); + begin_link(&app, LinkKind::SameLoop); + complete_link(&mut app, 0); + assert!(REVIEW_UI.with(|state| state.borrow().linking.is_none())); + assert!(app.review.relations.links.is_empty()); + } } diff --git a/crates/openloops-desktop/src/slint_ui.rs b/crates/openloops-desktop/src/slint_ui.rs index 68f2c1a..66c67b7 100644 --- a/crates/openloops-desktop/src/slint_ui.rs +++ b/crates/openloops-desktop/src/slint_ui.rs @@ -215,6 +215,7 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { .map(|analysis| model.review.card_contexts(&analysis.items)) .unwrap_or_default(); let saved = model.review.decisions.records.len(); + let links = model.review.relations.links.len(); let provider = model.provider.label(); let selected = model.selected_model(); let busy = model.pending.is_some(); @@ -243,7 +244,9 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { Provider::OpenRouter => "OpenRouter".into(), }); window.set_provider_connected(provider_connected(model)); - window.set_status_left(format!("{saved} saved decisions · Windows Credential Manager").into()); + window.set_status_left( + format!("{saved} saved decisions · {links} links · Windows Credential Manager").into(), + ); window.set_status_center(if selected.is_empty() { "".into() } else { @@ -482,29 +485,33 @@ pub(crate) fn register_training_export_callbacks( let mut model_ref = model.borrow_mut(); if model_ref.training_export_armed == ExportArmed::Yes { let folder = model_ref.training_export_folder.trim().to_owned(); - model_ref.training_export_status = - match training_export::export(&model_ref.review, std::path::Path::new(&folder)) - { - Ok(summary) => Status { - lines: vec![format!( - "Wrote {} triage rows and {} closure rows.", - summary.triage_rows, summary.closure_rows - )], - succeeded: true, - }, - Err(training_export::ExportError::RelativePath) => Status { - lines: vec!["Enter an absolute folder path.".into()], - succeeded: false, - }, - Err(training_export::ExportError::PathInRepo) => Status { - lines: vec!["Choose a folder outside the repository.".into()], - succeeded: false, - }, - Err(training_export::ExportError::Io(error)) => Status { - lines: vec![format!("Could not write to {folder} ({}).", error.kind())], - succeeded: false, - }, - }; + model_ref.training_export_status = match training_export::export( + &model_ref.review, + std::path::Path::new(&folder), + ) { + Ok(summary) => Status { + lines: vec![format!( + "Wrote {} files: {} triage rows, {} closure rows, and {} rules rows.", + summary.files_written, + summary.triage_rows, + summary.closure_rows, + summary.rules_rows + )], + succeeded: true, + }, + Err(training_export::ExportError::RelativePath) => Status { + lines: vec!["Enter an absolute folder path.".into()], + succeeded: false, + }, + Err(training_export::ExportError::PathInRepo) => Status { + lines: vec!["Choose a folder outside the repository.".into()], + succeeded: false, + }, + Err(training_export::ExportError::Io(error)) => Status { + lines: vec![format!("Could not write to {folder} ({}).", error.kind())], + succeeded: false, + }, + }; model_ref.training_export_armed = ExportArmed::No; } else { model_ref.training_export_armed = ExportArmed::Yes; diff --git a/crates/openloops-desktop/src/training_export.rs b/crates/openloops-desktop/src/training_export.rs index 7ddfe19..f037164 100644 --- a/crates/openloops-desktop/src/training_export.rs +++ b/crates/openloops-desktop/src/training_export.rs @@ -9,6 +9,7 @@ //! logs or returns row content; callers may only surface row counts. use crate::claim_view::{LoopItem, Owner, RecoveredClaimKind, SuggestedUpdateKind, claim_kind_of}; +use crate::link_state::LinkKind; use crate::review_model::{ ReviewMessage, ReviewState, closure_candidates, same_thread_closure_candidates, }; @@ -28,6 +29,8 @@ const MAX_ROWS_PER_FILE: usize = 20_000; pub struct ExportSummary { pub triage_rows: usize, pub closure_rows: usize, + pub rules_rows: usize, + pub files_written: usize, } #[derive(Debug)] @@ -59,11 +62,123 @@ pub fn export(review: &ReviewState, folder: &Path) -> Result Vec { + let Some(analysis) = &review.analysis else { + return Vec::new(); + }; + let cards = review.card_contexts(&analysis.items); + let actions: std::collections::BTreeMap<[u8; 32], &str> = cards + .iter() + .zip(&analysis.items) + .filter_map(|(card, item)| { + card.as_ref() + .map(|card| (card.record.key, item.action.as_str())) + }) + .collect(); + let mut rows = Vec::new(); + for link in &review.relations.links { + let duplicate = match link.kind { + LinkKind::SameLoop => Some(true), + LinkKind::NotSameLoop => Some(false), + LinkKind::SameThread | LinkKind::NotSameThread => None, + }; + if let Some(label) = duplicate + && let (Some(action_a), Some(action_b)) = (actions.get(&link.a), actions.get(&link.b)) + { + rows.push(rule_duplicate_row(action_a, action_b, label)); + } + } + let threads = thread_export_groups(review); + for link in &review.relations.links { + let merged = match link.kind { + LinkKind::SameThread => Some(true), + LinkKind::NotSameThread => Some(false), + LinkKind::SameLoop | LinkKind::NotSameLoop => None, + }; + if let Some(label) = merged + && let (Some(a), Some(b)) = (threads.get(&link.a), threads.get(&link.b)) + { + rows.push(rule_thread_row(a, b, label)); + } + } + rows.truncate(MAX_ROWS_PER_FILE); + rows +} + +fn rule_duplicate_row(action_a: &str, action_b: &str, duplicate: bool) -> Value { + serde_json::json!({ + "action_a": action_a, + "action_b": action_b, + "id": stable_id(&format!("rules|duplicate|{action_a}|{action_b}")), + "label": {"rules.duplicate_action": duplicate}, + "label_source": "gold", + "set": "rules", + "source": "owner", + }) +} + +struct ThreadExport<'a> { + subject: String, + first_paragraph: String, + marker: &'a str, +} + +fn thread_export_groups( + review: &ReviewState, +) -> std::collections::BTreeMap<[u8; 32], ThreadExport<'_>> { + let mut groups: std::collections::BTreeMap<(String, String), Vec<&ReviewMessage>> = + std::collections::BTreeMap::new(); + for message in &review.messages { + groups + .entry((message.account.clone(), message.conversation.clone())) + .or_default() + .push(message); + } + groups + .into_iter() + .filter_map(|((account, conversation), messages)| { + let earliest = messages + .into_iter() + .min_by_key(|message| message.input.timestamp)?; + let key = review.relations.thread_key(&account, &conversation); + Some(( + key, + ThreadExport { + subject: earliest.input.message.subject.as_string(), + first_paragraph: earliest.input.message.body_blocks.first().map_or_else( + String::new, + openloops_inference::blocks::CanonicalBlock::as_string, + ), + marker: earliest.input.handle.as_str(), + }, + )) + }) + .collect() +} + +fn rule_thread_row(a: &ThreadExport<'_>, b: &ThreadExport<'_>, merged: bool) -> Value { + serde_json::json!({ + "subject_a": a.subject, + "subject_b": b.subject, + "first_paragraph_a": a.first_paragraph, + "first_paragraph_b": b.first_paragraph, + "id": stable_id(&format!("rules|thread|{}|{}", a.marker, b.marker)), + "label": {"rules.thread_merge": merged}, + "label_source": "gold", + "set": "rules", + "source": "owner", }) } @@ -452,6 +567,60 @@ mod tests { .collect() } + #[test] + fn rules_rows_carry_exactly_the_allowed_keys() { + let mut review = layout_fixture(); + let cards = review.card_contexts(&review.analysis.as_ref().unwrap().items); + let a = cards[0].as_ref().unwrap().record.key; + let b = cards[1].as_ref().unwrap().record.key; + review.relations.set(LinkKind::SameLoop, a, b).unwrap(); + let rows = rules_rows(&review); + assert_eq!(rows.len(), 1); + let object = rows[0].as_object().unwrap(); + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "action_a", + "action_b", + "id", + "label", + "label_source", + "set", + "source" + ] + ); + assert_eq!( + object["label"] + .as_object() + .unwrap() + .keys() + .collect::>(), + vec!["rules.duplicate_action"] + ); + } + + #[test] + fn a_same_loop_link_yields_gold_duplicate_rows() { + let mut review = layout_fixture(); + let cards = review.card_contexts(&review.analysis.as_ref().unwrap().items); + review + .relations + .set( + LinkKind::SameLoop, + cards[0].as_ref().unwrap().record.key, + cards[1].as_ref().unwrap().record.key, + ) + .unwrap(); + let rows = rules_rows(&review); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["label_source"], "gold"); + assert_eq!(rows[0]["source"], "owner"); + assert_eq!(rows[0]["set"], "rules"); + assert_eq!(rows[0]["label"]["rules.duplicate_action"], true); + } + #[test] fn triage_rows_carry_exactly_the_allowed_keys() { let review = layout_fixture(); diff --git a/crates/openloops-desktop/ui/app.slint b/crates/openloops-desktop/ui/app.slint index 4803fc7..b435609 100644 --- a/crates/openloops-desktop/ui/app.slint +++ b/crates/openloops-desktop/ui/app.slint @@ -96,6 +96,8 @@ export component AppWindow inherits Window { in property review-uncertainty; in property review-action-status; in property review-action-status-succeeded; + in property linking-status; + in property has-links; in property selected-open; in property selected-terminal; in property can-track; @@ -155,6 +157,10 @@ export component AppWindow inherits Window { callback move-review-selection(int); callback list-primary-action; callback review-decision(int); + callback link-loop; + callback link-thread; + callback unlink; + callback cancel-link; callback suggested-update-resolved(bool); callback open-reminder; callback draft-title-edited(string); @@ -201,7 +207,7 @@ export component AppWindow inherits Window { scan-strip: root.scan-strip; coverage-open <=> root.coverage-open; has-analysis: root.review-has-analysis; no-usable-items: root.review-no-usable-items; no-usable-text: root.review-no-usable-text; has-selection: root.review-has-selection; pills: root.review-pills; title: root.review-title; meta: root.review-meta; uncertainty: root.review-uncertainty; - action-status: root.review-action-status; action-status-succeeded: root.review-action-status-succeeded; selected-open: root.selected-open; selected-terminal: root.selected-terminal; + action-status: root.review-action-status; action-status-succeeded: root.review-action-status-succeeded; linking-status: root.linking-status; has-links: root.has-links; selected-open: root.selected-open; selected-terminal: root.selected-terminal; can-track: root.can-track; can-watch: root.can-watch; can-remind: root.can-remind; draft-open: root.draft-open; draft-title <=> root.draft-title; draft-when <=> root.draft-when; draft-scheduled-line: root.draft-scheduled-line; draft-valid: root.draft-valid; reminder-state: root.reminder-state; source-url: root.source-url; conversation-url: root.conversation-url; source-unavailable-note: root.source-unavailable-note; evidence-cards: root.evidence-cards; completion-card: root.completion-card; suggested-update: root.suggested-update; conversation-title: root.conversation-title; conversation-rows: root.conversation-rows; @@ -212,7 +218,7 @@ export component AppWindow inherits Window { coverage-toggled(value) => { root.coverage-toggled(value); } sources-link => { root.navigate(1); root.active-screen = 1; } select-row(handle) => { root.select-review-row(handle); } move-selection(delta) => { root.move-review-selection(delta); } list-primary-action => { root.list-primary-action(); } - decision(value) => { root.review-decision(value); } suggested-update-resolved(accept) => { root.suggested-update-resolved(accept); } open-reminder => { root.open-reminder(); } + decision(value) => { root.review-decision(value); } link-loop => { root.link-loop(); } link-thread => { root.link-thread(); } unlink => { root.unlink(); } cancel-link => { root.cancel-link(); } suggested-update-resolved(accept) => { root.suggested-update-resolved(accept); } open-reminder => { root.open-reminder(); } draft-title-edited(value) => { root.draft-title-edited(value); } draft-when-edited(value) => { root.draft-when-edited(value); } draft-in-hour => { root.draft-in-hour(); } draft-tomorrow => { root.draft-tomorrow(); } create-reminder => { root.create-reminder(); } cancel-reminder => { root.cancel-reminder(); } reconcile-reminder(exists) => { root.reconcile-reminder(exists); } open-external(url) => { root.open-external(url); } diff --git a/crates/openloops-desktop/ui/review.slint b/crates/openloops-desktop/ui/review.slint index a6e8fae..4f27632 100644 --- a/crates/openloops-desktop/ui/review.slint +++ b/crates/openloops-desktop/ui/review.slint @@ -97,6 +97,8 @@ export component ReviewScreen { in property uncertainty; in property action-status; in property action-status-succeeded; + in property linking-status; + in property has-links; in property selected-open; in property selected-terminal; in property can-track; @@ -140,6 +142,10 @@ export component ReviewScreen { callback move-selection(int); callback list-primary-action; callback decision(int); + callback link-loop; + callback link-thread; + callback unlink; + callback cancel-link; callback suggested-update-resolved(bool); callback open-reminder; callback draft-title-edited(string); @@ -276,6 +282,7 @@ export component ReviewScreen { vertical-stretch: 1; accessible-role: list; accessible-label: "Review expectations"; accessible-item-count: root.rows.length; key-pressed(event) => { if root.busy { return reject; } + if event.text == Key.Escape { root.cancel-link(); return accept; } if event.text == Key.UpArrow { root.move-selection(-1); return accept; } if event.text == Key.DownArrow { root.move-selection(1); return accept; } if event.text == Key.Return { root.list-primary-action(); return accept; } @@ -406,6 +413,7 @@ export component ReviewScreen { text-color: Tokens.text-3; size: Tokens.font-11; } + if !root.linking-status.is-empty: WrappedText { text: root.linking-status; text-color: Tokens.text-2; size: Tokens.font-11; } // Decision callback codes are defined once in slint_review.rs. // Two rows, not one: an undecided Team-owned card shows Track // *and* Watch alongside all three terminal actions, five buttons @@ -430,10 +438,14 @@ export component ReviewScreen { Rectangle { horizontal-stretch: 1; } OutlineButton { text: "Set To Do reminder…"; enabled: root.can-remind; clicked => { root.open-reminder(); } } } - if root.selected-open: HorizontalLayout { width: 100%; spacing: Tokens.space-2; alignment: start; + if root.selected-open: HorizontalLayout { width: 100%; spacing: Tokens.space-2; SecondaryButton { text: "Handled"; enabled: !root.busy; clicked => { root.decision(2); } } SecondaryButton { text: "Not mine / dismiss"; enabled: !root.busy; clicked => { root.decision(3); } } SecondaryButton { text: "No longer relevant"; enabled: !root.busy; clicked => { root.decision(5); } } + Rectangle { horizontal-stretch: 1; } + OutlineButton { text: "Same loop as…"; enabled: !root.busy; clicked => { root.link-loop(); } } + OutlineButton { text: "Same conversation as…"; enabled: !root.busy; clicked => { root.link-thread(); } } + if root.has-links: OutlineButton { text: "Not the same"; enabled: !root.busy; clicked => { root.unlink(); } } } // X3 (replaces the earlier egui-parity plan, S5): §4.5 and the // Companion show both actions on every closed card -- diff --git a/docs/inbox-review.md b/docs/inbox-review.md index 80c3222..04c8a53 100644 --- a/docs/inbox-review.md +++ b/docs/inbox-review.md @@ -223,7 +223,7 @@ is stored between scans. ## Saved decisions and reminders The native preview stores a bounded versioned Windows Credential Manager record -containing a random HMAC key, up to 50 keyed per-account source/action +containing a random HMAC key, up to 55 keyed per-account source/action fingerprints, decision enums, reminder-attempt enums, and timestamps. No names, mail addresses, subjects, descriptions, source quotations, or Graph identifiers are in that record. Descriptions are reconstructed after the next scan. @@ -239,11 +239,18 @@ decision does not complete or delete a Microsoft task. All remote content is limited to the reviewed task title/time and an opaque correlation reference. No email is sent and no shared task list is modified. -Completed/dismissed records without reminders expire after 30 days when state +Completed/dismissed records without reminders expire after 180 days when state is updated. Reminder records remain available for user reconciliation. Hitting the storage bound fails visibly and preserves existing records. A failed/corrupt read or detected concurrent edit stops saving rather than overwriting data. +Owner-taught links use a second generic credential, +`OpenLoops/Relations/v1`, containing at most 34 canonical pairs of HMAC +fingerprints, link-kind tags, and timestamps. It contains no readable mail, +names, addresses, subjects, or quotations. Resetting the decisions credential +also replaces the HMAC key, so any saved relation fingerprints become orphaned +and no longer match cards from later scans. + This is a foreground native preview. Scans require a current in-process Microsoft session; there is no unattended monitoring, Outlook add-in, or automatic external-task reconciliation. @@ -255,11 +262,22 @@ API references: [messages](https://learn.microsoft.com/en-us/graph/api/user-list [To Do creation](https://learn.microsoft.com/en-us/graph/api/todotasklist-post-tasks?view=graph-rest-1.0), [Ollama structured-output limits](https://docs.ollama.com/capabilities/structured-outputs). +# Linking loops and conversations + +**Same loop as…** and **Same conversation as…** start a two-step selection: +choose the action, then select the other card. Escape cancels. **Not the same** +records the opposite relationship for every current link involving the selected +card. Same-loop links apply immediately by folding the newer card into the older +card and showing the later evidence as **Asked again**. Conversation links are +saved now and take effect during the next scan once rule consumption is enabled +in PR 2. Only HMAC fingerprints, link-kind tags, and timestamps are stored. + ### Training data export (deliberate exception) The Sources screen's OpenRouter card has an owner-invoked **Export training data** control: with a scan result loaded and a folder path typed into -**Training export folder**, it writes `triage.jsonl` and `closure.jsonl` -- +**Training export folder**, it writes `triage.jsonl`, `closure.jsonl`, and +`rules.jsonl` -- real mail text (subjects, paragraphs, participants) from the currently loaded scan, plus the owner's own accept/reject decisions on suggested updates -- for the `Jev` question-optimization harness in @@ -271,6 +289,12 @@ first only arms a warning). The folder path itself lives in memory only for the running session and is never saved to Windows Credential Manager or any other settings record; nothing about the export is logged. +`rules.jsonl` contains two gold owner-label shapes. Loop rows carry +`action_a`, `action_b`, and `label.rules.duplicate_action`. Conversation rows +carry `subject_a`, `subject_b`, `first_paragraph_a`, `first_paragraph_b`, and +`label.rules.thread_merge`. Both also carry `id`, `label_source`, `set`, and +`source`. + # Retry failed conversations and sources After a Review scan, **Retry failed (N)** reloads only sources whose listing failed and re-analyzes only conversations that timed out, hit a transport or retryable provider failure, panicked, or were not started; newly loaded conversations from those sources are included automatically. It never resends rate-limited or quota-blocked conversations, and decisions on untouched conversations—as well as decisions whose retried item keeps the same fingerprint—are preserved. diff --git a/docs/native-setup.md b/docs/native-setup.md index 913ad76..eb1f5b9 100644 --- a/docs/native-setup.md +++ b/docs/native-setup.md @@ -110,7 +110,8 @@ need to be unpinned and re-pinned (or `ie4uinit.exe -show` run) once. The native window uses Slint 1.17.1 with the Fluent style and native renderer. A narrow `settings.rs` module uses pinned `keyring-core` and `windows-native-keyring-store` -dependencies to access only the `OpenLoops/Setup/v1` generic credential, with +dependencies to access the `OpenLoops/Setup/v1`, `OpenLoops/Decisions/v1`, and +`OpenLoops/Relations/v1` generic credentials, with credential search disabled. Network requests run on a background thread, inputs are disabled while a request is pending, and the window remains responsive with elapsed-time feedback. Settings operations are