@@ -64,6 +64,24 @@ open class DraftStore private constructor(
64 /** True once the background prefs load has completed. */
64 /** True once the background prefs load has completed. */
65 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
65 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
67+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
68+ * finished. For these the in-memory state is authoritative — the load must not
69+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
70+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
71+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
72+ * but live under different prefs keys. */
73+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
74+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
75+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
76+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
78+ private fun markDraftTouched(sessionId: String) {
79+ if (!_ready.value) touchedDraftKeys.add(sessionId)
81+ private fun markFollowUpTouched(sessionId: String) {
82+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
68 // Eagerly load the prefs file on a background thread so the first main-thread
86 // Eagerly load the prefs file on a background thread so the first main-thread
69 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
87 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
@@ -84,9 +102,21 @@ open class DraftStore private constructor(
84 // Merge instead of overwriting: any in-memory writes that arrived before
102 // Merge instead of overwriting: any in-memory writes that arrived before
85 // the background load completed (e.g. a share-intent draft injected via
103 // the background load completed (e.g. a share-intent draft injected via
86 // setImmediate) take priority over the persisted snapshot so they aren't
104 // setImmediate) take priority over the persisted snapshot so they aren't
87− // clobbered.
105+ // clobbered. For keys the user explicitly touched before the load finished,
106+ // drop the persisted entry first so a value they *cleared* isn't resurrected
107+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
88− _drafts.update { current -> drafts.apply { putAll(current) } }
108+ _drafts.update { current ->
110+ touchedDraftKeys.forEach { remove(it) }
89− _followUps.update { current -> followUps.apply { putAll(current) } }
114+ _followUps.update { current ->
116+ touchedFollowUpKeys.forEach { remove(it) }
90 _ready.value = true
120 _ready.value = true
@@ -105,6 +135,7 @@ open class DraftStore private constructor(
105 * so a follow-up queued just before process death is flushed before the process exits.
135 * so a follow-up queued just before process death is flushed before the process exits.
106 * A blank [text] clears the stored follow-up. */
136 * A blank [text] clears the stored follow-up. */
107 open fun flushFollowUp(sessionId: String, text: String) {
137 open fun flushFollowUp(sessionId: String, text: String) {
138+ markFollowUpTouched(sessionId)
108 _followUps.update { current ->
139 _followUps.update { current ->
109 val updated = current.toMutableMap()
140 val updated = current.toMutableMap()
110 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
141 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -130,6 +161,7 @@ open class DraftStore private constructor(
130 * Used when navigation must see the new value synchronously (e.g. share-intent
161 * Used when navigation must see the new value synchronously (e.g. share-intent
131 * injection before ChatScreen composes). Call [set] afterwards to persist. */
162 * injection before ChatScreen composes). Call [set] afterwards to persist. */
132 open fun setImmediate(sessionId: String, text: String) {
163 open fun setImmediate(sessionId: String, text: String) {
164+ markDraftTouched(sessionId)
133 _drafts.update { current ->
165 _drafts.update { current ->
134 val updated = current.toMutableMap()
166 val updated = current.toMutableMap()
135 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
167 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -138,6 +170,7 @@ open class DraftStore private constructor(
140 open suspend fun set(sessionId: String, text: String) {
172 open suspend fun set(sessionId: String, text: String) {
173+ markDraftTouched(sessionId)
141 _drafts.update { current ->
174 _drafts.update { current ->
142 val updated = current.toMutableMap()
175 val updated = current.toMutableMap()
143 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
176 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -155,6 +188,8 @@ open class DraftStore private constructor(
155 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
188 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
156 * Also clears any queued follow-up so a deleted session leaves nothing behind. */
189 * Also clears any queued follow-up so a deleted session leaves nothing behind. */
157 open suspend fun remove(sessionId: String) {
190 open suspend fun remove(sessionId: String) {
191+ markDraftTouched(sessionId)
192+ markFollowUpTouched(sessionId)
158 _drafts.update { current ->
193 _drafts.update { current ->
159 val updated = current.toMutableMap()
194 val updated = current.toMutableMap()
160 updated.remove(sessionId)
195 updated.remove(sessionId)
@@ -187,6 +222,7 @@ open class DraftStore private constructor(
187 * preferable to an ANR.
222 * preferable to an ANR.
189 open fun flushDraft(sessionId: String, text: String) {
224 open fun flushDraft(sessionId: String, text: String) {
225+ markDraftTouched(sessionId)
190 _drafts.update { current ->
226 _drafts.update { current ->
191 val updated = current.toMutableMap()
227 val updated = current.toMutableMap()
192 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
228 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
@@ -215,7 +251,8 @@ open class DraftStore private constructor(
216 private companion object {
252 private companion object {
217 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
253 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
218− * prefs file. Uses a NUL so it can't collide with a real session id. */
254+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
255+ * id (which are non-blank, space-free tokens like "ses_..."). */
219 const val FOLLOWUP_PREFIX = "