Merge nucleic/lucid-coral-otter-x7ib into dev

This commit is contained in:
2026-08-03 04:24:06 -07:00
parent c251e75f02
commit 40d4c53a76
2 changed files with 151 additions and 51 deletions
@@ -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<Void, Never>] = [:]
/// 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<Void, Never>] = [:]
private var composerLastSentAt: [SessionID: Date] = [:]
private var composerPendingText: [SessionID: String] = [:]
private var composerLatestText: [SessionID: String] = [:]
private var composerIdleTasks: [SessionID: Task<Void, Never>] = [:]
private var composerStreamingSessions: Set<SessionID> = []
/// 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 }
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
}
}
@@ -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
// 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) }
}
}