From 40d4c53a7635a542d3778d3f6d3fe261efbf36e6 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Mon, 3 Aug 2026 04:24:06 -0700 Subject: [PATCH] Merge nucleic/lucid-coral-otter-x7ib into dev --- .../NucleicRemote/Models/RemoteStore.swift | 118 +++++++++++------- .../Views/SessionDetailView.swift | 84 +++++++++++-- 2 files changed, 151 insertions(+), 51 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e3f1cd2..cb33774 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -432,35 +432,38 @@ final class RemoteStore: ObservableObject { // MARK: - Live composer streaming (the "composer.typing" channel) - /// The live remote typer per session (mesh composer streaming) — folded from - /// `composer.typing` casts, *other* devices only (this phone's own typing, republished by - /// the session's host, is skipped so the typer never locks itself). The session detail - /// locks its composer and renders the streamed draft while an entry is present; - /// expiry-guarded locally so a typer that vanished without its tombstone can't leave the - /// composer locked. + /// The session's shared composer draft as other devices left it (mesh composer streaming) — + /// folded from `composer.typing` casts, *other* devices only (this phone's own typing, + /// republished by the session's host, is skipped so it never locks itself or re-adopts its + /// own text). An `editing` entry locks the session detail's composer and renders the + /// incoming draft above the field; a settled one moves into it. Settle-guarded locally too, + /// so an editor that vanished can't leave the composer locked — and its text is kept. @Published private(set) var composerTypingBySession: [SessionID: ComposerTypingState] = [:] - /// Watcher-side expiry timers for `composerTypingBySession` (the crash guard). + /// Watcher-side settle timers for `composerTypingBySession` (the crash guard). private var composerExpiryTasks: [SessionID: Task] = [:] /// Sender-side throttle + idle bookkeeping for streaming THIS device's drafts: the - /// trailing-edge send, the last send time, the newest not-yet-sent draft, the idle-stop - /// timer, and which sessions have an active report out (so `ended` sends exactly one stop). + /// trailing-edge send, the last send time, the newest not-yet-sent draft, the newest draft + /// known (retained past the send, so the idle settle can report the final text), the idle + /// timer, and which sessions have an active report out (so a settle reports exactly once). private var composerSendTasks: [SessionID: Task] = [:] private var composerLastSentAt: [SessionID: Date] = [:] private var composerPendingText: [SessionID: String] = [:] + private var composerLatestText: [SessionID: String] = [:] private var composerIdleTasks: [SessionID: Task] = [:] private var composerStreamingSessions: Set = [] /// The composer's draft for `sessionID` changed on this phone — report it (throttled) to /// the session's host, which republishes it on the `composer.typing` channel for the rest - /// of the mesh, and (re)arm the idle stop. An emptied field ends the typing immediately. - /// A no-op toward a host that never advertised `canStreamComposer` (an older host would - /// throw on the unknown tag). + /// of the mesh, and (re)arm the idle settle. An emptied field clears the shared draft + /// mesh-wide immediately. A no-op toward a host that never advertised `canStreamComposer` + /// (an older host would throw on the unknown tag). func composerDraftChanged(_ sessionID: SessionID, text: String) { guard !demoMode else { return } - guard !text.isEmpty else { composerDraftEnded(sessionID); return } + guard !text.isEmpty else { composerDraftCleared(sessionID); return } guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive, conn.capabilities.canStreamComposer else { return } composerPendingText[sessionID] = text + composerLatestText[sessionID] = text scheduleComposerIdleStop(sessionID) guard composerSendTasks[sessionID] == nil else { return } // trailing edge armed let elapsed = Date().timeIntervalSince(composerLastSentAt[sessionID] ?? .distantPast) @@ -472,19 +475,43 @@ final class RemoteStore: ObservableObject { } } - /// Typing for `sessionID` finished on this phone — sent, cleared, or the detail closed. - /// Sends the stop that tombstones the session's entry (the host also idle-stops on its own - /// after `idleTimeout`, so a phone that vanishes still unlocks everyone). + /// Editing stopped on this phone but the text stands — it went idle, or the detail closed. + /// Reports the final draft with `active: false`, which settles the session's entry: every + /// composer keeps that text and unlocks. (The host also idle-settles on its own after + /// `idleTimeout`, so a phone that vanishes still unlocks everyone.) + /// + /// A no-op when this phone never claimed the editing lock — a chat whose shared draft it + /// merely adopted is left exactly as it found it, not settled *or* cleared. func composerDraftEnded(_ sessionID: SessionID) { - composerSendTasks[sessionID]?.cancel() - composerSendTasks[sessionID] = nil - composerPendingText[sessionID] = nil - composerIdleTasks[sessionID]?.cancel() - composerIdleTasks[sessionID] = nil - composerLastSentAt[sessionID] = nil + cancelComposerSendWork(sessionID) + let text = composerLatestText.removeValue(forKey: sessionID) ?? "" guard composerStreamingSessions.remove(sessionID) != nil else { return } connection(owningSession: sessionID)?.send( - .composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false))) + .composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: false))) + } + + /// The shared draft was consumed on this phone — sent, or the field emptied by hand. Clears + /// the session's entry mesh-wide so every other composer empties too. Reported even when + /// this phone never streamed anything: it may be consuming a draft it merely adopted. + func composerDraftCleared(_ sessionID: SessionID) { + guard !demoMode else { return } + cancelComposerSendWork(sessionID) + composerLatestText[sessionID] = nil + let held = composerTypingBySession.removeValue(forKey: sessionID) != nil + composerExpiryTasks.removeValue(forKey: sessionID)?.cancel() + let streamed = composerStreamingSessions.remove(sessionID) != nil + guard streamed || held else { return } + guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive, + conn.capabilities.canStreamComposer else { return } + conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false))) + } + + /// Drop the throttle's in-flight work for a session (shared by settle and clear). + private func cancelComposerSendWork(_ sessionID: SessionID) { + composerSendTasks.removeValue(forKey: sessionID)?.cancel() + composerPendingText[sessionID] = nil + composerIdleTasks.removeValue(forKey: sessionID)?.cancel() + composerLastSentAt[sessionID] = nil } /// One throttle-window flush: report the newest pending draft to the session's host. @@ -498,7 +525,8 @@ final class RemoteStore: ObservableObject { conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: true))) } - /// Re-arm the typer-side idle stop: `idleTimeout` with no keystrokes ends the typing. + /// Re-arm the editor-side idle settle: `idleTimeout` with no keystrokes hands the text to + /// every composer and unlocks them. private func scheduleComposerIdleStop(_ sessionID: SessionID) { composerIdleTasks[sessionID]?.cancel() composerIdleTasks[sessionID] = Task { [weak self] in @@ -508,31 +536,37 @@ final class RemoteStore: ObservableObject { } } - /// Fold composer-typing casts into the watcher projection: fresh entries from other - /// devices lock (and render into) that session's composer; tombstones — and local expiry, - /// for a typer that vanished without one — unlock it. + /// Fold composer-typing casts into the watcher projection: a fresh `editing` entry from + /// another device locks (and renders above) that session's composer; a settled one hands + /// its text to the composer and unlocks; a tombstone empties it. private func updateComposerTyping(_ casts: [WireCast]) { for cast in casts where cast.channel == MeshCastChannel.composerTyping { guard let state = ComposerTypingState.decode(cast) else { continue } - if cast.deleted { - guard composerTypingBySession[state.sessionID]?.deviceID == state.deviceID - else { continue } + // This phone's own entries (republished by the session's host under its deviceID) + // never touch its own composer — it already holds what it typed. + guard state.deviceID != IdentityStore.deviceID() else { continue } + guard !cast.deleted else { + // The draft was consumed or emptied somewhere in the mesh. Authoritative from + // whichever device did it — any holder may consume a shared draft. composerTypingBySession[state.sessionID] = nil - composerExpiryTasks[state.sessionID]?.cancel() - composerExpiryTasks[state.sessionID] = nil + composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel() continue } - // Own typing (republished by the session's host under this phone's deviceID) - // never locks this composer; an entry already past its window (a catch-up - // replay) never locks at all. - guard state.deviceID != IdentityStore.deviceID(), - state.isFresh(at: Date()) else { continue } - composerTypingBySession[state.sessionID] = state - scheduleComposerExpiry(state.sessionID, publishedAt: state.at) + if state.locksComposers(at: Date()) { + composerTypingBySession[state.sessionID] = state + scheduleComposerExpiry(state.sessionID, publishedAt: state.at) + } else { + // Settled — or an `editing` entry so old it's plainly abandoned (a catch-up + // replay), which is the same thing: keep the text, lock nobody. + composerTypingBySession[state.sessionID] = state.settled() + composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel() + } } } - /// Arm (or re-arm) the watcher-side expiry for a session's typing entry. + /// Arm (or re-arm) the watcher-side settle for a session's editing entry — the crash guard + /// for an editor that went silent without publishing its own settle. Its text is kept (it + /// moves into the composer, as a published settle would); only the lock is released. private func scheduleComposerExpiry(_ sessionID: SessionID, publishedAt: Date) { composerExpiryTasks[sessionID]?.cancel() let deadline = publishedAt.addingTimeInterval(ComposerTypingState.staleTimeout) @@ -541,8 +575,8 @@ final class RemoteStore: ObservableObject { try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled, let self else { return } guard let entry = self.composerTypingBySession[sessionID], - !entry.isFresh(at: Date()) else { return } - self.composerTypingBySession[sessionID] = nil + entry.editing, !entry.isFresh(at: Date()) else { return } + self.composerTypingBySession[sessionID] = entry.settled() self.composerExpiryTasks[sessionID] = nil } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 9003fed..53dedf8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -11,6 +11,12 @@ struct SessionDetailView: View { // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1" @State private var draft = "" + // Mesh composer streaming bookkeeping for `draft`: the text this composer last took from the + // mesh (so a draft consumed on another device clears only what came from there, never what + // has been typed here since), and the same text held for one update as the echo to swallow — + // adopting the shared draft must not stream it straight back out and lock every other device. + @State private var meshDraft: String? + @State private var meshDraftEcho: String? // Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and // materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick // refused for exceeding the per-message size cap. @@ -46,13 +52,49 @@ struct SessionDetailView: View { store.sessions.first { $0.sessionID == sessionID } } - /// Another device's live typing in this chat's composer (mesh composer streaming). While - /// present, this composer is locked and renders the streamed draft; it unlocks when the - /// typer sends or goes idle (their tombstone — or the local expiry guard — clears it). - private var remoteTyping: ComposerTypingState? { + /// This chat's shared composer draft as another device left it (mesh composer streaming) — + /// being edited there right now, or settled and waiting to be picked up here. + private var sharedComposerDraft: ComposerTypingState? { store.composerTypingBySession[sessionID] } + /// Another device is editing this chat's composer right now. While present, this composer is + /// locked and renders the incoming draft above the field; it unlocks when that device stops + /// — and the text it left settles into the field (`sharedComposerDraftChanged`). + private var remoteTyping: ComposerTypingState? { + sharedComposerDraft.flatMap { $0.editing ? $0 : nil } + } + + /// This chat's shared draft changed on another device — apply it to the field. + /// + /// A *settled* entry (the other device stopped typing) moves into the composer: the text + /// carries on where they left off, on every device, instead of vanishing with the lock. A + /// tombstone means the draft was consumed there (sent) or emptied, so this field empties too + /// — but only the part that came from the mesh; anything typed here since stands, and is + /// itself the newer shared draft. An `editing` entry applies nothing: it renders live in the + /// row above the (locked) field until it settles. + private func sharedComposerDraftChanged( + from old: ComposerTypingState?, to new: ComposerTypingState? + ) { + guard let new else { + guard old != nil, draft == meshDraft else { return } + meshDraft = nil + applyMeshDraft("") + return + } + guard !new.editing else { return } + meshDraft = new.text + applyMeshDraft(new.text) + } + + /// Put mesh-sourced text in the composer without streaming it back out (see the `draft` + /// `onChange` — `meshDraftEcho` is the one update it swallows). + private func applyMeshDraft(_ text: String) { + guard draft != text else { return } + meshDraftEcho = text + draft = text + } + /// The friendly label for a remote typer, never blank. private func typerName(_ typing: ComposerTypingState) -> String { typing.deviceName.isEmpty ? "Another device" : typing.deviceName @@ -109,8 +151,17 @@ struct SessionDetailView: View { .onAppear { openToken = store.open(sessionID) // Restore any unsent follow-up — the composer keeps what was typed across - // backgrounding, locking, or the view being torn down and remounted. - draft = store.sessionDraft(sessionID) + // backgrounding, locking, or the view being torn down and remounted. With + // nothing kept here, pick up the chat's shared draft instead: another device + // may have left one settled while this phone was away. + let kept = store.sessionDraft(sessionID) + if kept.isEmpty, let shared = sharedComposerDraft, !shared.editing, + !shared.text.isEmpty { + meshDraft = shared.text + applyMeshDraft(shared.text) + } else { + draft = kept + } } // Pass our own id + token so an iPad split-view A→B switch (which may mount B // before A disappears) unsubscribes A without tearing down B's just-opened state — @@ -126,14 +177,29 @@ struct SessionDetailView: View { } // Stream this composer's draft to the session's host (throttled in the store) // so every other device viewing this chat sees it live and locks its own - // composer; an emptied field — including the clear on send — ends the typing - // and unlocks them. Leaving the detail ends it too. + // composer; an emptied field — including the clear on send — consumes the + // shared draft and empties theirs too. Leaving the detail releases the lock + // while leaving the text as the chat's shared draft. .onChange(of: draft) { _, text in - store.composerDraftChanged(sessionID, text: text) + // Text this composer just took from the mesh is already the shared draft — + // echoing it back out would claim the editing lock on every other device + // for a change the user never made. + if meshDraftEcho == text { + meshDraftEcho = nil + } else { + meshDraftEcho = nil + store.composerDraftChanged(sessionID, text: text) + } // Durably save the draft (cleared once the field empties, e.g. on send) so it // survives leaving the app — see `RemoteStore.setSessionDraft`. store.setSessionDraft(sessionID, text) } + // The other device stopped typing (or its draft was consumed): move its text + // down into this field, or clear it — the composer's text is one draft shared + // across the mesh. + .onChange(of: sharedComposerDraft) { old, new in + sharedComposerDraftChanged(from: old, to: new) + } .onDisappear { store.composerDraftEnded(sessionID) } } }