diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ee74360..0736777 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -633,6 +633,63 @@ final class RemoteStore: ObservableObject { UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey) } + // MARK: - Composer drafts (durable) + + // Unsent composer text, kept until the user sends or clears the field. Unlike the ephemeral + // mesh typing buffer (`composerPendingText`, which is deliberately never persisted), these + // survive backgrounding, locking, view teardown, and a relaunch — so switching apps or + // locking the phone mid-message never loses what was typed. Persisted eagerly on every edit + // (small strings) so no scene-phase hook is needed to capture the latest value. Keyed by + // `SessionID.rawValue` for the in-session follow-up composer, and by project (or "" for the + // no-project picker) for the new-chat composer. + private static let sessionDraftsKey = "nucleic.composerDrafts" + private static let newChatDraftsKey = "nucleic.newChatDrafts" + private var sessionDrafts: [SessionID: String] = RemoteStore.loadSessionDrafts() + private var newChatDrafts: [String: String] = RemoteStore.loadNewChatDrafts() + + private static func loadSessionDrafts() -> [SessionID: String] { + guard let raw = UserDefaults.standard.dictionary(forKey: sessionDraftsKey) else { return [:] } + return raw.reduce(into: [:]) { result, entry in + if let text = entry.value as? String { result[SessionID(rawValue: entry.key)] = text } + } + } + private static func loadNewChatDrafts() -> [String: String] { + UserDefaults.standard.dictionary(forKey: newChatDraftsKey) as? [String: String] ?? [:] + } + + /// The saved follow-up draft for a session's composer, or "" if none. + func sessionDraft(_ id: SessionID) -> String { sessionDrafts[id] ?? "" } + + /// Persist a session composer's draft — or drop it once the field is emptied, so a cleared + /// composer restores empty rather than reviving stale text. + func setSessionDraft(_ id: SessionID, _ text: String) { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + guard sessionDrafts[id] != nil else { return } + sessionDrafts[id] = nil + } else { + sessionDrafts[id] = text + } + let raw = sessionDrafts.reduce(into: [String: String]()) { $0[$1.key.rawValue] = $1.value } + UserDefaults.standard.set(raw, forKey: Self.sessionDraftsKey) + } + + private func newChatKey(_ project: ProjectID?) -> String { project?.rawValue ?? "" } + + /// The saved new-chat prompt for a project scope (or the no-project picker), or "" if none. + func newChatDraft(_ project: ProjectID?) -> String { newChatDrafts[newChatKey(project)] ?? "" } + + /// Persist the new-chat prompt for a project scope — or drop it once the field is emptied. + func setNewChatDraft(_ project: ProjectID?, _ text: String) { + let key = newChatKey(project) + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + guard newChatDrafts[key] != nil else { return } + newChatDrafts[key] = nil + } else { + newChatDrafts[key] = text + } + UserDefaults.standard.set(newChatDrafts, forKey: Self.newChatDraftsKey) + } + /// Whether `summary` completed its work after the user last looked at it on this device. func unseenCompletion(_ summary: WireSessionSummary) -> Bool { let done = summary.status == .finished diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index bb7e1b3..ed7d8b0 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -322,7 +322,10 @@ struct NewChatAffordance: ViewModifier { .animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented) .onChange(of: isPresented) { _, open in if open { - draft = "" + // Restore any unsent prompt — the composer keeps what was typed until it's sent + // or cleared, so dismissing the bar (or backgrounding/locking the phone) never + // discards it. + draft = store.newChatDraft(project?.id) // Focus in the same turn the bar is presented so the keyboard rises immediately, // riding up together with the bar (both animate from the bottom edge at t=0) // instead of waiting for the spring to settle first — no perceptible delay @@ -330,9 +333,13 @@ struct NewChatAffordance: ViewModifier { draftFocused = true } else { draftFocused = false - draft = "" } } + // Durably save the prompt (cleared once the field empties, e.g. on send) so it survives + // dismissal and leaving the app — see `RemoteStore.setNewChatDraft`. + .onChange(of: draft) { _, text in + store.setNewChatDraft(project?.id, text) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 031f69e..9232d28 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -100,7 +100,12 @@ struct SessionDetailView: View { Button("Discard", role: .destructive) { store.discard(sessionID) } Button("Cancel", role: .cancel) {} } - .onAppear { openToken = store.open(sessionID) } + .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) + } // 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 — // and so a covered duplicate of the *same* session tears down nothing at all. @@ -119,6 +124,9 @@ struct SessionDetailView: View { // and unlocks them. Leaving the detail ends it too. .onChange(of: draft) { _, text in 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) } .onDisappear { store.composerDraftEnded(sessionID) } }