Merge nucleic/quiet-jade-marten into dev

This commit is contained in:
2026-07-12 22:18:01 -07:00
parent 954eafebcd
commit e4fd648b17
3 changed files with 75 additions and 3 deletions
@@ -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
@@ -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)
}
}
}
@@ -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) }
}