Merge nucleic/quiet-jade-marten into dev
This commit is contained in:
@@ -633,6 +633,63 @@ final class RemoteStore: ObservableObject {
|
|||||||
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
|
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.
|
/// Whether `summary` completed its work after the user last looked at it on this device.
|
||||||
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
|
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
|
||||||
let done = summary.status == .finished
|
let done = summary.status == .finished
|
||||||
|
|||||||
@@ -322,7 +322,10 @@ struct NewChatAffordance: ViewModifier {
|
|||||||
.animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented)
|
.animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented)
|
||||||
.onChange(of: isPresented) { _, open in
|
.onChange(of: isPresented) { _, open in
|
||||||
if open {
|
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,
|
// 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)
|
// 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
|
// instead of waiting for the spring to settle first — no perceptible delay
|
||||||
@@ -330,9 +333,13 @@ struct NewChatAffordance: ViewModifier {
|
|||||||
draftFocused = true
|
draftFocused = true
|
||||||
} else {
|
} else {
|
||||||
draftFocused = false
|
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("Discard", role: .destructive) { store.discard(sessionID) }
|
||||||
Button("Cancel", role: .cancel) {}
|
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
|
// 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 —
|
// 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.
|
// 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.
|
// and unlocks them. Leaving the detail ends it too.
|
||||||
.onChange(of: draft) { _, text in
|
.onChange(of: draft) { _, text in
|
||||||
store.composerDraftChanged(sessionID, text: text)
|
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) }
|
.onDisappear { store.composerDraftEnded(sessionID) }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user