diff --git a/Native/OptChat/optchat-chief/src/brain/inbox.rs b/Native/OptChat/optchat-chief/src/brain/inbox.rs index 798fa4af414d..93da47adefbb 100644 --- a/Native/OptChat/optchat-chief/src/brain/inbox.rs +++ b/Native/OptChat/optchat-chief/src/brain/inbox.rs @@ -123,10 +123,13 @@ impl Brain { } } - /// Spec 5a.2: an edit or a retraction of a message the log holds is - /// logged as a note that names its line (`edit of : `, - /// `retracted `), so the next turn's delta carries it and the next - /// re-render folds it in. A reaction on the Chief's own messages goes to + /// Spec 5a.2: an edit or a deletion (the owner's retraction) of a message + /// the log holds is logged as a note that names its line (`edit of : + /// `, `deleted `), so the next turn's delta carries it and the + /// next re-render folds it in. A deleted message's own text becomes the + /// memory's tombstone at once (`delete_from_memory`), so the Memory + /// Inspector and the next view no longer show its line; the summaries + /// above it are built again by the change book's worker. A reaction on the Chief's own messages goes to /// `on_chief_reactions` (reaction_wake.rs); another update logs nothing. A message still queued /// takes the edited text, or leaves the queue when retracted. The note's key /// names the change, so a replayed update is logged once. @@ -156,7 +159,9 @@ impl Brain { let (text, change) = match (&message.retracted_at, &message.edited_at) { (Some(_), _) => { self.reply_not_needed(std::slice::from_ref(&message.id)); - (optchat_core::retracted(target), "retracted".to_owned()) + // The key stays `:retracted`, so a deletion an older build + // logged is not logged again. + (optchat_core::deleted(target), "retracted".to_owned()) } (None, Some(at)) => ( optchat_core::edit_of(target, &message_text(message)), @@ -173,6 +178,28 @@ impl Brain { "logging the change of message {target} failed: {e}" )); } + if message.retracted_at.is_some() { + self.delete_from_memory(target); + } + } + + /// Writes logged message `target` over with the tombstone through the + /// memory's change book (backup, journal, stale summaries built again on + /// the editor's worker), before the next turn renders. Once per message: + /// a replay or a catch-up does not delete again a message the owner put + /// back in the inspector, and a write that failed is tried again then. + fn delete_from_memory(&self, target: u64) { + let Some(editor) = &self.memory_editor else { + return; + }; + if editor.has_deleted(target) { + return; + } + if let Err((code, why)) = editor.delete_message(target) { + (self.log)(&format!( + "deleting message {target} from the memory failed ({code}): {why}" + )); + } } /// A tapback or emoji someone else put on one of the Chief's own messages @@ -293,6 +320,18 @@ impl Brain { } self.summary = Some(summary); self.handled = from; + // A deletion made while the brain was not listening (a restart, + // a lost connection): the newest page's retracted messages go + // through the live path, which writes each one once. + let me = &self.settings.membership.participant; + let retracted: Vec = messages + .iter() + .filter(|m| m.seq <= from && m.retracted_at.is_some() && &m.author != me) + .cloned() + .collect(); + for message in &retracted { + self.on_message_updated(message); + } for message in pending { self.handle(message); } diff --git a/Native/OptChat/optchat-chief/src/brain/mod.rs b/Native/OptChat/optchat-chief/src/brain/mod.rs index fc03a517f82d..6ff8e872b57c 100644 --- a/Native/OptChat/optchat-chief/src/brain/mod.rs +++ b/Native/OptChat/optchat-chief/src/brain/mod.rs @@ -969,6 +969,10 @@ pub struct Brain { repeat_denials: u32, /// The rights table and the audit (rights_gate.rs); None: off. rights: Option, + /// The owner's change book for this Chief's memory (inspect/edit.rs): a + /// message the person deletes in the chat is written over with its + /// tombstone there, journaled and undoable like an inspector delete. + memory_editor: Option>, /// Writes the log's description of each turn image (None: references only). describer: Option>, /// A user's reaction that may wake the Chief (reaction_wake.rs). @@ -1187,6 +1191,7 @@ impl Brain { steer_due: None, reply_waits: reply_ledger::REPLY_RETRY_WAITS.to_vec(), rights: None, + memory_editor: None, }; // A host can exit after sub_done persists Done but before deliver // queues the report. Recover that durable outbox before accepting new @@ -1221,6 +1226,22 @@ impl Brain { self } + /// The memory's change book (`inspect/edit.rs`): a deleted chat message + /// becomes its tombstone in the memory, so the Memory Inspector and the + /// next re-rendered view no longer hold its text. + pub fn with_memory_editor( + mut self, + editor: Option>, + ) -> Brain { + self.set_memory_editor(editor); + self + } + + /// `with_memory_editor` on a built brain. + pub fn set_memory_editor(&mut self, editor: Option>) { + self.memory_editor = editor; + } + /// Writes the monitoring trace (`trace.rs`). /// A turn that waits for the compactor writes how far it is here. pub fn with_settle_status(mut self, status: crate::settle_status::SettleStatus) -> Brain { diff --git a/Native/OptChat/optchat-chief/src/host.rs b/Native/OptChat/optchat-chief/src/host.rs index 7255103f5f77..c2aaba4a17a3 100644 --- a/Native/OptChat/optchat-chief/src/host.rs +++ b/Native/OptChat/optchat-chief/src/host.rs @@ -1635,13 +1635,16 @@ fn start( Arc::new(|line: &str| log(line)), ); } + // The memory's one change book: the inspector's edits and the chat's + // deleted messages (brain/inbox.rs) share its journal and rebuild worker. + let memory_editor = crate::inspect::edit::Editor::open(chat.clone(), &paths.root); crate::tools::serve_all( &paths.tools_socket, crate::tools::Served { memory: chat.clone(), orchestrator, control: Some(control), - inspector: start_inspector(paths, &chat, &claude_text), + inspector: start_inspector(paths, &chat, &claude_text, &memory_editor), chief: Some(chief_tools.clone()), // The turn names of `Settings::turn_prefix`, the subagent names // of the sub preset (`optchat-sub--`). @@ -1803,6 +1806,7 @@ fn start( ) .on_turn_end(Arc::new(move |key: &str| persister.turn_ended(key))) .with_trace(trace.clone()) + .with_memory_editor(Some(memory_editor.clone())) .with_settle_status(crate::settle_status::SettleStatus::new( &paths.settle_status, )) @@ -2327,12 +2331,12 @@ fn start_inspector( paths: &Paths, chat: &Arc, system_text: &str, + editor: &Arc, ) -> Option> { let _ = std::fs::remove_file(&paths.inspector); if env("OPTCHAT_INSPECTOR").as_deref() == Some("0") { return None; } - let editor = crate::inspect::edit::Editor::open(chat.clone(), &paths.root); let inspector = Arc::new( crate::inspect::Inspector::new( chat.clone(), @@ -2340,7 +2344,7 @@ fn start_inspector( paths.settle_status.clone(), system_text.to_owned(), ) - .with_editor(editor), + .with_editor(editor.clone()), ); if env("OPTCHAT_INSPECTOR_WEB").as_deref() != Some("1") { log("memory inspector: native (through the daemon); web page off"); diff --git a/Native/OptChat/optchat-chief/src/inspect/edit.rs b/Native/OptChat/optchat-chief/src/inspect/edit.rs index c91ae4d9e638..1913cf62bf61 100644 --- a/Native/OptChat/optchat-chief/src/inspect/edit.rs +++ b/Native/OptChat/optchat-chief/src/inspect/edit.rs @@ -244,6 +244,21 @@ impl Editor { } } + /// Deletes message `id` (the person deleted it in the chat): its text + /// becomes `TOMBSTONE`, as `/api/memory/delete` writes it. A message that + /// already holds the tombstone answers 400 "nothing changed". + pub fn delete_message(&self, id: u64) -> Answer { + self.change("delete", Target::Message(id), TOMBSTONE) + } + + /// Whether a delete of message `id` is in the change book (undone too). + pub fn has_deleted(&self, id: u64) -> bool { + self.lock() + .changes + .iter() + .any(|c| c.op == "delete" && c.target == Target::Message(id)) + } + /// The current text of `target`, or 404. fn current(&self, target: Target) -> Result { match target { diff --git a/Native/OptChat/optchat-chief/tests/chat_delete.rs b/Native/OptChat/optchat-chief/tests/chat_delete.rs new file mode 100644 index 000000000000..e837a204caa6 --- /dev/null +++ b/Native/OptChat/optchat-chief/tests/chat_delete.rs @@ -0,0 +1,191 @@ +//! A message the person deletes in the Chief chat (https://github.com/manaflow-ai/cmux/issues/36019): +//! the app retracts it at its owner, and the Chief's memory forgets it. The +//! next turn's input names the line as `deleted ` after ``, the +//! memory's copy of the message holds the tombstone (what the Memory +//! Inspector shows), the change book journals it like an inspector delete, +//! and all of it holds after the host restarts. + +mod common; + +use std::sync::Arc; + +use cmux_conversation::Change; +use common::*; +use optchat_chief::brain::Input; +use optchat_chief::daemon::DaemonEvent; +use optchat_chief::inspect::edit::{Editor, JOURNAL, TOMBSTONE}; +use serde_json::Value; + +const SECRET: &str = "my bank PIN is 4417, keep it handy"; + +fn joined(blocks: &[Value]) -> String { + blocks + .iter() + .map(|b| b["text"].as_str().unwrap_or_default()) + .collect() +} + +fn with_editor(h: &mut Harness) -> Arc { + let editor = Editor::open(h.chat.clone(), h.dir.path()); + h.brain.set_memory_editor(Some(editor.clone())); + editor +} + +#[test] +fn a_deleted_chat_message_leaves_the_chiefs_memory_and_stays_deleted_after_a_restart() { + let mut h = Harness::new(default_script()); + let editor = with_editor(&mut h); + h.connect(); + let first = h.say("user_local", SECRET); + h.settle_posts(); + let target = h + .log() + .iter() + .position(|(kind, text)| kind == "user" && text == SECRET) + .expect("the message is logged") as u64; + + // The person deletes it: the owner retracts it and the brain hears the update. + let mut deleted = first.clone(); + deleted.parts = Vec::new(); + deleted.retracted_at = Some("2026-11-04T12:00:00.000Z".into()); + h.owner.lock().unwrap().messages[0] = deleted.clone(); + h.brain.step(Input::from(DaemonEvent::Changed { + conversation: CONV.into(), + change: Change::MessageUpdated { + message: deleted.clone(), + }, + })); + + // The memory's copy is the tombstone, journaled as a delete. + assert_eq!(h.chat.message(target).unwrap().1, TOMBSTONE); + let changes = editor.changes(); + let list = changes["changes"].as_array().unwrap(); + assert_eq!(list.len(), 1, "one change: {changes}"); + assert_eq!(list[0]["op"], "delete"); + assert_eq!(list[0]["target"]["message"], target); + assert!( + h.dir.path().join(JOURNAL).exists(), + "the change book journals it" + ); + + // The next turn names the line as deleted and holds the text nowhere. + h.say("user_local", "what did I tell you before?"); + h.settle_posts(); + let prompts = h.agents.inner.lock().unwrap().prompts.clone(); + let next = joined(prompts.last().expect("a second turn")); + let after = &next[next.find("").expect("the view") + "".len()..]; + assert!( + after + .lines() + .any(|l| l.ends_with(&format!("|deleted {target}"))), + "the turn's input names line {target} as deleted after : {after}" + ); + assert!( + !next.contains("4417"), + "the deleted text is in no part of the turn's input: {next}" + ); + + // A replayed update (a reconnect's catch-up) changes nothing again. + h.brain.step(Input::from(DaemonEvent::Changed { + conversation: CONV.into(), + change: Change::MessageUpdated { message: deleted }, + })); + assert_eq!( + editor.changes()["changes"].as_array().unwrap().len(), + 1, + "a replay is not a second delete" + ); + + // After a restart the memory still holds only the tombstone. + drop(editor); + let Harness { + dir, + chat, + owner, + brain, + .. + } = h; + drop(brain); + chat.shutdown(); + drop(chat); + let mut h = Harness::in_dir(dir, default_script(), owner); + let editor = with_editor(&mut h); + assert_eq!(h.chat.message(target).unwrap().1, TOMBSTONE); + assert!( + !h.log().iter().any(|(_, text)| text.contains("4417")), + "no line of the memory holds the deleted text" + ); + let changes = editor.changes(); + assert_eq!( + changes["changes"][0]["op"], "delete", + "the reopened change book lists the delete: {changes}" + ); +} + +/// Restarts the host over the same memory and owner, with a change book. +fn restart(h: Harness) -> (Harness, Arc) { + let Harness { + dir, + chat, + owner, + brain, + .. + } = h; + drop(brain); + chat.shutdown(); + drop(chat); + let mut h = Harness::in_dir(dir, default_script(), owner); + let editor = with_editor(&mut h); + (h, editor) +} + +#[test] +fn a_message_deleted_while_the_chief_was_down_leaves_its_memory_on_reconnect_and_an_undo_holds() { + let mut h = Harness::new(default_script()); + h.connect(); + h.say("user_local", SECRET); + h.settle_posts(); + let target = h + .log() + .iter() + .position(|(kind, text)| kind == "user" && text == SECRET) + .expect("the message is logged") as u64; + + // The person deletes it while the Chief host is not running: the brain + // hears no update, only the owner holds the retraction. + { + let mut owner = h.owner.lock().unwrap(); + let m = &mut owner.messages[0]; + m.parts = Vec::new(); + m.retracted_at = Some("2026-11-04T12:00:00.000Z".into()); + } + let (mut h, editor) = restart(h); + h.connect(); + assert_eq!( + h.chat.message(target).unwrap().1, + TOMBSTONE, + "the reconnect's catch-up deletes it from the memory" + ); + assert!( + h.log() + .iter() + .any(|(kind, text)| kind == "note" && text == &format!("deleted {target}")), + "the deletion is logged as a note naming the line" + ); + + // The owner puts the text back in the Memory Inspector; the next + // reconnect does not delete it again. + let change = editor.changes()["changes"][0]["id"].as_u64().unwrap(); + editor + .answer("/api/memory/undo", &[("change".into(), change.to_string())]) + .expect("the undo"); + assert_eq!(h.chat.message(target).unwrap().1, SECRET); + let (mut h, editor) = restart(h); + h.connect(); + assert_eq!( + h.chat.message(target).unwrap().1, + SECRET, + "an undone delete stays undone" + ); + assert_eq!(editor.changes()["changes"].as_array().unwrap().len(), 1); +} diff --git a/Packages/Shared/CmuxHomeCore/Sources/CmuxHomeCore/Store/InboxRow.swift b/Packages/Shared/CmuxHomeCore/Sources/CmuxHomeCore/Store/InboxRow.swift index a129b35dc31b..afb8d8ef2d04 100644 --- a/Packages/Shared/CmuxHomeCore/Sources/CmuxHomeCore/Store/InboxRow.swift +++ b/Packages/Shared/CmuxHomeCore/Sources/CmuxHomeCore/Store/InboxRow.swift @@ -77,6 +77,13 @@ extension HomeMirror { case .sendMessage(let id, _, _, _): if case .failed = entry.state { failed.insert(id) } pendingSend[id] = entry + case .retractMessage(let message, let id): + // Delete or Undo Send leaves the preview at the press, not at the owner's echo (#36019); + // a refused retract is discarded from the log, which shows the preview again. + if summaries[id]?.lastMessage?.id == message { + summaries[id]?.lastMessage?.parts = [] + summaries[id]?.lastMessage?.retractedAt = entry.intent.issuedAt + } default: break } diff --git a/Packages/Shared/CmuxHomeCore/Tests/CmuxHomeCoreTests/MirrorAndIntentLogTests.swift b/Packages/Shared/CmuxHomeCore/Tests/CmuxHomeCoreTests/MirrorAndIntentLogTests.swift index b2821795a1f2..95608441d263 100644 --- a/Packages/Shared/CmuxHomeCore/Tests/CmuxHomeCoreTests/MirrorAndIntentLogTests.swift +++ b/Packages/Shared/CmuxHomeCore/Tests/CmuxHomeCoreTests/MirrorAndIntentLogTests.swift @@ -57,6 +57,22 @@ import Testing #expect(mirror.inboxRows(log: log).first?.isPinned == true) } + /// Delete in a Chief chat (https://github.com/manaflow-ai/cmux/issues/36019): the list's preview + /// drops the deleted message at the press, before the owner's echo; a refused delete (discarded from the log) shows it again. + @Test func aPendingRetractClearsTheNewestMessagesPreviewUntilRefused() { + var mirror = HomeMirror() + var newest = summary(lastSeq: 1, rev: 1) + newest.lastMessage = Message(id: MessageID("m1"), conversation: conv, seq: 1, clientMessageID: IdempotencyKey("k1"), + author: me.id, parts: [.text("my locker code is 4417")], createdAt: Date(timeIntervalSince1970: 1)) + mirror.apply(inbox: InboxSnapshot(me: me, conversations: [newest], rev: 1)) + var log = IntentLog() + #expect(mirror.inboxRows(log: log).first?.preview == "my locker code is 4417") + log.append(HomeIntent(key: IdempotencyKey("r"), op: .retractMessage(message: MessageID("m1"), conversation: conv))) + #expect(mirror.inboxRows(log: log).first?.preview == "") + log.discard(IdempotencyKey("r")) // HomeStore discards a refused retract + #expect(mirror.inboxRows(log: log).first?.preview == "my locker code is 4417") + } + @Test func disconnectMarksInFlightUnconfirmedAndReconnectResendsInOrder() { var log = IntentLog() let a = HomeIntent(key: IdempotencyKey("a"), op: .sendMessage(conversation: conv, parts: [.text("1")])) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/ChatHostSeams.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/ChatHostSeams.swift index b4894c6ab8dd..c7e97b88a43e 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/ChatHostSeams.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/ChatHostSeams.swift @@ -66,6 +66,11 @@ protocol ChatIntents: AnyObject { func retryChiefTurn(_ message: ID) /// Delete: the failed send goes (`HomeStore.discardFailed`). func deleteFailedSend(_ message: ID) + /// My committed message in a Chief chat can be deleted (#36019). + func canDeleteMessage(_ message: ID) -> Bool + /// Delete: the message leaves the transcript at once and its owner + /// retracts it; the Chief's memory keeps only its tombstone. + func deleteMessage(_ message: ID) /// Forward… is offered (the host routes a bubble's text to another chat). var canForward: Bool { get } /// Forward…: the bubble's text goes to another chat or agent the host asks for. @@ -110,6 +115,9 @@ extension ChatIntents { /// No Forward… unless the host routes it (test hosts, the New Message composer). var canForward: Bool { false } func forward(_ text: String) {} + /// Hosts without a Chief chat delete no committed message. + func canDeleteMessage(_ message: ID) -> Bool { false } + func deleteMessage(_ message: ID) {} /// Hosts without a Chief control path do not expose turn retry. func canRetryChiefTurn(_ message: ID) -> Bool { false } func retryChiefTurn(_ message: ID) {} diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/CmuxStrings.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/CmuxStrings.swift index b247f77eefc6..7d97aa19c31f 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/CmuxStrings.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/CmuxStrings.swift @@ -140,6 +140,11 @@ enum CmuxStrings { String(localized: "home.menu.deleteFailed", defaultValue: "Delete", table: "CmuxHome", bundle: .module) } + /// The context menu item on my message in a Chief chat that deletes it (#36019). + static var deleteMessage: String { + String(localized: "home.menu.deleteMessage", defaultValue: "Delete", table: "CmuxHome", bundle: .module) + } + static var playVideo: String { String(localized: "home.video.play", defaultValue: "Play Video", table: "CmuxHome", bundle: .module) } static var viewVideoLarger: String { String(localized: "home.video.viewLarger", defaultValue: "View Larger", table: "CmuxHome", bundle: .module) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeDiff.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeDiff.swift index 45337bb67baa..91d440cd5735 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeDiff.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeDiff.swift @@ -159,7 +159,11 @@ struct HomeDiff: Equatable { /// Actions for one shown message; false when MessagesLab cannot express it. private static func changes(from o: TranscriptItem, to n: TranscriptItem, oldSummary: ConversationSummary?, newSummary: ConversationSummary?, me: ParticipantID, id: ID, into d: inout HomeDiff) -> Bool { - if !o.isRetracted, n.isRetracted { d.actions.append(.unsend(id)); return true } + if !o.isRetracted, n.isRetracted { + // A Chief chat drops the row (Delete, #36019); elsewhere Messages' unsend note. + d.actions.append(HomeMapping.hidesRetracted(newSummary, me: me) ? .delete(id) : .unsend(id)) + return true + } // MessagesLab's `.edit` replaces one text bubble: a text that shows as // several (Messages' link rule) before or after the edit is a rebuild. // Mapped only for an edit: mapping both sides of every shown row on each store change diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMapping.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMapping.swift index 14fc4383c39b..59bada5f06a5 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMapping.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMapping.swift @@ -78,6 +78,12 @@ enum HomeMapping { return PartRef(messageId: r.id, partIndex: homePart == 0 ? last : r.owners.firstIndex(of: homePart) ?? last) } + /// A Chief chat draws no row for a retracted message (Delete, #36019); other chats keep + /// Messages' "You unsent a message" note. + static func hidesRetracted(_ summary: ConversationSummary?, me: ParticipantID) -> Bool { + summary?.kind(me: me) == .chief + } + /// The client id prefix of Chief's first-launch welcome messages (CmuxNext `ChiefWelcome`). static let welcomeKeyPrefix = "chief-welcome-" @@ -104,7 +110,12 @@ enum HomeMapping { if item.key.rawValue.hasPrefix(welcomeKeyPrefix) { message.cmuxStandsAlone = true } // The Chief's messages to its agents and their reports: asides under its reply (no avatar, no name). if item.agentMessage != nil { message.cmuxAside = true } - if !item.isRetracted, !item.parts.isEmpty, parts.isEmpty { + if item.isRetracted, hidesRetracted(summary, me: me) { + // A Chief chat shows no "unsent" row: a deleted message is gone (#36019). + message.deletedAt = message.retractedAt + message.replyTo = nil + message.parent = nil + } else if !item.isRetracted, !item.parts.isEmpty, parts.isEmpty { // Nothing to read (a Chief turn's done marker, a work part with no preview): kept so // its seq still moves the read cursor, drawn as no row and counted as no reply. message.deletedAt = Instant.format(item.createdAt) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection+Delete.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection+Delete.swift new file mode 100644 index 000000000000..e63e598fd578 --- /dev/null +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection+Delete.swift @@ -0,0 +1,60 @@ +import CmuxHomeCore + +/// Delete on my committed message in a Chief chat (https://github.com/manaflow-ai/cmux/issues/36019): +/// the row leaves at once (`deleting` shows the message retracted before the owner answers), the +/// owner retracts it (`message.retract`), and the Chief's memory writes the message over with its +/// tombstone when it hears the retraction (optchat-chief brain/inbox.rs). A refusal brings the row back. +extension HomeProjection { + /// My committed, not retracted message in a one-to-one Chief chat (the owner retracts only its author's): + /// a message I typed, not a run's read-only row, an agents row, a Feed row or an agent's send through my CLI. + private func deletableMessage(_ message: ID) -> TranscriptItem? { + guard isSendEnabled, shownSummary?.kind(me: me) == .chief, + let item = shown.first(where: { HomeMapping.id($0, aliases: aliases) == message }), + item.author == me, item.seq != nil, let canonical = item.messageID, + !item.isRetracted, !item.isReadOnly, item.agentMessage == nil, item.feedRow == nil, + HomeAgentSender(item, me: me) == nil, !deleting.contains(canonical) else { return nil } + return item + } + + func canDeleteMessage(_ message: ID) -> Bool { deletableMessage(message) != nil } + + func deleteMessage(_ message: ID) { + guard let item = deletableMessage(message), let canonical = item.messageID else { return } + deleting.insert(canonical) + refresh() + let intent = HomeIntent(op: .retractMessage(message: canonical, conversation: conversation)) + let homeStore = self.homeStore + // task-owner: one op; ends with the owner's answer (a refusal shows the row again) + Task { [weak self] in + do { + _ = try await homeStore.perform(intent.op, key: intent.key) + } catch let rejection as HomeRejection { + guard let self, !self.stopped else { return } + self.deleting.remove(canonical) + self.refresh() + self.onRefusal(intent, rejection) + } catch { + guard let self, !self.stopped else { return } + self.deleting.remove(canonical) + self.refresh() + } + } + } + + /// The store's items with the messages deleted here shown retracted until the owner's echo says so. + /// A message whose retraction the store holds leaves `deleting`. + func withDeletes(_ items: [TranscriptItem]) -> [TranscriptItem] { + guard !deleting.isEmpty else { return items } + for item in items where item.isRetracted { + if let id = item.messageID { deleting.remove(id) } + } + return items.map { item in + guard let id = item.messageID, deleting.contains(id), !item.isRetracted else { return item } + var gone = item + gone.isRetracted = true + gone.parts = [] + gone.reactions = [] + return gone + } + } +} diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection.swift index e2fc6a9f5389..db4777218225 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeProjection.swift @@ -82,6 +82,9 @@ final class HomeProjection: @preconcurrency ChatIntents { /// Retry a committed person message in a Chief conversation. The app /// forwards this canonical owner message id to `chief.retry`. var onChiefRetry: ((MessageID) -> Void)? + /// My Chief-chat messages deleted here whose retraction the owner has not refused (#36019): shown + /// as retracted from the click on, so the row leaves at once and no rebuild brings it back. + var deleting: Set = [] /// Cancel Upload (`HomeStoreBinding.cancelSend`). var onCancelSend: (IdempotencyKey) -> Bool = { _ in false } /// Bubble pictures and originals. @@ -200,7 +203,7 @@ final class HomeProjection: @preconcurrency ChatIntents { let live = homeStore.liveRun(in: conversation) let timeline = homeStore.timeline(for: conversation) passStep("timeline", &mark) - apply(items: timeline.items, summary: homeStore.summary(conversation), + apply(items: withDeletes(timeline.items), summary: homeStore.summary(conversation), typing: homeStore.typists(in: conversation), hasOlder: homeStore.hasOlderMessages(in: conversation), streams: timeline.streams) mark = CACurrentMediaTime() diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneMenu.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneMenu.swift index cf94eb68d3bc..5bfb1d0de652 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneMenu.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneMenu.swift @@ -116,8 +116,12 @@ extension ChatController { if !Self.shareItems(hit).isEmpty { menu.addItem(MenuAction(title: Strings.menuShare, symbol: "square.and.arrow.up") { [weak self] in self?.share(hit) }) } - // cmux: no Delete…: MessagesLab's `.delete` hides the message on this device, and - // HomeStore has no local hide (HomeOp has no delete), so nothing could honour it. + // cmux: Delete (#36019) on my committed message in a Chief chat: it leaves the transcript at once + // (MessagesLab's `.delete`), its owner retracts it, and the Chief's memory keeps its tombstone. + if intents?.canDeleteMessage(ref.messageId) == true { + if menu.items.last.map({ !$0.isSeparatorItem }) == true { menu.addItem(.separator()) } + menu.addItem(MenuAction(title: CmuxStrings.deleteMessage, symbol: "trash") { [weak self] in self?.intents?.deleteMessage(ref.messageId) }) + } while menu.items.last?.isSeparatorItem == true { menu.removeItem(at: menu.items.count - 1) } return menu } diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Resources/CmuxHome.xcstrings b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Resources/CmuxHome.xcstrings index aa6f6bb8532b..2da201187009 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Resources/CmuxHome.xcstrings +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Resources/CmuxHome.xcstrings @@ -3806,6 +3806,137 @@ } } }, + "home.menu.deleteMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حذف" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Izbriši" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Elimina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "លុប" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slett" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Apagar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sil" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Xóa" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除" + } + } + } + }, "home.compose.removeAttachment": { "extractionState": "manual", "localizations": { diff --git a/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/HomeChiefDeleteTests.swift b/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/HomeChiefDeleteTests.swift new file mode 100644 index 000000000000..0705c3b89f6f --- /dev/null +++ b/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/HomeChiefDeleteTests.swift @@ -0,0 +1,66 @@ +import AppKit +import CmuxHomeCore +import Foundation +import Testing +@testable import MessagesLabHome + +/// Delete on my message in a Chief chat (https://github.com/manaflow-ai/cmux/issues/36019): the row leaves +/// the transcript at once, the owner retracts the message, and a reopened chat shows no row and no +/// "You unsent a message" note for it. Other chats offer no Delete for a committed message. +@MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct HomeChiefDeleteTests { + static let me = HomeChiefRetryTests.me + static let chief = HomeChiefRetryTests.chief + + private func projected(_ view: MessagesLabHomeView, _ id: ID) -> MessagesLabHome.Message? { + view.projection.controller.store?.state.conversation.messages.first { $0.id == id } + } + + @Test func deleteRemovesTheRowAtOnceRetractsItAndStaysGoneOnReopen() async throws { + let (store, view) = await HomeChiefRetryTests().open(Self.chief) + // A message I send now (the mock owner keeps it, as the real owner keeps every message). + let key = IdempotencyKey("chief-delete-mine") + _ = try await store.perform(.sendMessage(conversation: Self.chief, parts: [.text("delete me")]), key: key) + await HomeRetrySendTests().until { + store.transcript(for: Self.chief).contains { $0.key == key && $0.seq != nil && $0.messageID != nil } + } + view.projection.refresh() + let item = try #require(view.projection.shown.first { $0.key == key }) + let canonical = try #require(item.messageID) + let id = HomeMapping.id(item, aliases: view.projection.aliases) + #expect(view.projection.canDeleteMessage(id)) + + view.projection.deleteMessage(id) + // At once, before the owner answers: the row is gone, and Delete is not offered again. + #expect(projected(view, id)?.deletedAt != nil) + #expect(!view.projection.canDeleteMessage(id)) + + // The owner retracts it. + await HomeRetrySendTests().until { + store.transcript(for: Self.chief).contains { $0.messageID == canonical && $0.isRetracted } + } + view.projection.refresh() + #expect(projected(view, id)?.deletedAt != nil) + view.stop() + + // Reopened: no row, and no unsent note in its place. + let reopened = MessagesLabHomeView(store: store, conversation: Self.chief, me: Self.me, + wake: HomeRetrySendTests.NoWake()) + defer { reopened.stop() } + reopened.projection.refresh() + let again = try #require(reopened.projection.shown.first { $0.messageID == canonical }) + let shownAgain = try #require(projected(reopened, HomeMapping.id(again, aliases: reopened.projection.aliases))) + #expect(shownAgain.deletedAt != nil, "a deleted Chief message draws no row (no \"unsent\" note)") + } + + @Test func theChiefsMessagesAndOtherChatsOfferNoDelete() async throws { + let (_, chiefView) = await HomeChiefRetryTests().open(Self.chief) + defer { chiefView.stop() } + let reply = try #require(chiefView.projection.shown.first { $0.author != Self.me && $0.seq != nil }) + #expect(!chiefView.projection.canDeleteMessage(HomeMapping.id(reply, aliases: chiefView.projection.aliases))) + + let (_, direct) = await HomeChiefRetryTests().open(ConversationID("conv_austin")) + defer { direct.stop() } + let mine = try #require(direct.projection.shown.first { $0.author == Self.me && $0.seq != nil }) + #expect(!direct.projection.canDeleteMessage(HomeMapping.id(mine, aliases: direct.projection.aliases))) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugHomeNativeFixture.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugHomeNativeFixture.swift index 1e37f825568e..a241864ecd38 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugHomeNativeFixture.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugHomeNativeFixture.swift @@ -93,7 +93,7 @@ final class DebugHomeNativeFixture: InternalPageProvider { } if params["panel"]?.boolValue == true { services.home.chatColorsPanel.show(conversation: id, name: "", over: view.window) } return .object(["ok": .bool(true), "conversation": .string(id), "colors": .string(store.colors(for: id).hexSummary)]) - case "menu": + case "menu": if let row = params["choose"]?.stringValue { return .object(["ok": .bool(view.debugChooseMenuItem(row, onBubble: params["text"]?.stringValue ?? ""))]) } // {choose, text}: that row of the bubble's menu let titles = view.debugMenuTitles(mine: params["mine"]?.boolValue ?? false) ?? [] return .object(["ok": .bool(!titles.isEmpty), "items": .array(titles.map { .string($0) })]) case "link": diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+AttachPaths.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+AttachPaths.swift index c3c1d79190a6..9579d9b26e0f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+AttachPaths.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+AttachPaths.swift @@ -79,6 +79,10 @@ extension HomeNativeTranscriptView { public func debugToggleNewestVideo() -> Bool { transcript.debugToggleNewestVideo() } /// The context menu's titles at the newest text bubble (the right-click's path). public func debugMenuTitles(mine: Bool) -> [String]? { transcript.debugMenuTitles(mine: mine) } + /// Chooses `title` in the context menu of the bubble whose text is `text` (the right-click's path). + public func debugChooseMenuItem(_ title: String, onBubble text: String) -> Bool { + transcript.debugChooseMenuItem(title, onBubble: text) + } /// The field's height and the gap under the newest receipt, in points. /// With the drop target's state: the draft's attachment count, drops on their way, the highlight (1 shown). public func debugGeometry() -> [String: Double] { diff --git a/cmux-next-ios/verify/parity-mac.md b/cmux-next-ios/verify/parity-mac.md index 68a84fbd07bd..de4d05ab94bb 100644 --- a/cmux-next-ios/verify/parity-mac.md +++ b/cmux-next-ios/verify/parity-mac.md @@ -703,6 +703,7 @@ or the relay. States here are from the live run in this batch's proof report. Ri | Automations page (list, Run now, Pause / Resume, Delete) | `cloud.automation`: `automation.list`, `automation.runs.list` (`/v1/read`), `automation.run` / `update {enabled}` / `delete` (`/v1/ops`) | Settings → Cloud → **Automations**: Needs you / Today / Later / Paused; a row pushes its page (Run Now, Pause or Resume, Delete, History); swipe to pause or delete | works (live) | Cloud automations only: this Mac's local runner (`cmux automation list`) is the Mac's own store, not on the phone's lane. Delete asks first; its run history stays. | | Show Artifacts (synced tree, preview, history) | platform `/v1/artifacts/tree`, `/file`, `/history` with the app's bearer | — | not from a phone (blocked) | The platform's artifact reads take only "a cmux access token of a team install" (`artifacts.unauthorized`, checked live on the development platform 2026-11-03); that token is minted from a Mac's install key (`cmux auth token`). The phone's own account token is refused, so a phone page needs the platform to accept a signed-in person's token for reads, or a phone install grant: a trust decision (#13742). | | Delete Conversation from List / Show Hidden Conversations | the Mac's Home source (local) | Home row menu **Delete Conversation from List** (asks first); **Show N Hidden Conversations** under the list | works (live) | Kept per account on this iPhone, like the Mac's; a newer message brings the row back. | +| Delete a message in a Chief chat (https://github.com/manaflow-ai/cmux/issues/36019) | `message.retract` on my committed message; the row leaves at once, and the Chief writes the message over with its tombstone in its memory (`deleted ` in the next turn) | Chief chat: hold my message > **Undo Send** (within 2 minutes; the same `message.retract`, so the Chief's memory forgets it too) | partial | The phone keeps Messages' 2-minute Undo Send and its "You unsent a message" note; a Delete with no time limit that leaves no note is a follow-up on `ConversationStore`. | | Next-step card (a tagged ask at a turn's end) | the agent pane's `nextStepAsk.ts`; an answer is a `session/prompt` | the same card under the answer: question, muted line, answer buttons; a tap sends the answer; the card folds to the answer once a prompt follows | works (live) | `NextStepAsk.swift` is a port of the Mac's reading, as strict (one tagged question, 2-4 answers or a yes-or-no question). | | Continue or Retry leads a failed turn's error card | `_acpmux/retry` | **Continue** when the turn had done work (or had no prompt of its own), else **Retry** | works (live) | Both are acpmux's turn retry, as on the Mac. | | New Agent Chat on… | palette picks the machine, then `session/new` there | Workspaces → New Workspace (hold) → **New Agent Chat on…** → a Mac → one of its folders | works (live) | This Mac's acpmux only. A peer machine or a Cloud machine as the destination is not from a phone: `_acpmux/route/*` is unix-socket only (acpmux `remote_guard.rs`), a trust decision for #13742. |