Skip to content

feat: Save unsent chat message as draft#5686

Merged
jamesarich merged 4 commits into
mainfrom
copilot/save-unsent-chat-message-draft
May 31, 2026
Merged

feat: Save unsent chat message as draft#5686
jamesarich merged 4 commits into
mainfrom
copilot/save-unsent-chat-message-draft

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 31, 2026

Summary

Saves unsent chat message text as a draft so it persists across navigation (e.g., switching contacts and coming back). The draft is stored in the ViewModel's SavedStateHandle, surviving both navigation and process death.

Changes

MessageViewModel.kt

  • Store savedStateHandle as a private val property for post-construction access
  • Add draftMessage StateFlow backed by SavedStateHandle persistence
  • Add setDraftMessage() / clearDraftMessage() methods

Message.kt

  • Initialize rememberTextFieldState with the saved draft (one-time read of StateFlow.value — avoids recomposition cycle)
  • Sync text field changes back to ViewModel via snapshotFlow + LaunchedEffect
  • Clear draft on message send

Design Decisions

  • One-time read for initialization: rememberTextFieldState only uses its initialText param on first composition (no saved state). Continuously collecting the draft flow would trigger unnecessary recompositions on every keystroke via the sync loop.
  • Dual persistence: SavedStateHandle survives navigation/process death (ViewModel scope); rememberTextFieldState internally uses rememberSaveable for config changes (composable scope). Both stay in sync via the snapshotFlow.
  • Explicit clearDraftMessage() on send: Redundant with the snapshotFlow sync but documents intent clearly.

Copilot AI linked an issue May 31, 2026 that may be closed by this pull request
6 tasks
Store the message input text in the ViewModel (backed by SavedStateHandle)
so it persists when navigating away and returning to the chat screen.

- Add draftMessage StateFlow to MessageViewModel
- Initialize TextFieldState from ViewModel draft when route message is empty
- Sync text field changes back to ViewModel via snapshotFlow
- Clear draft when message is successfully sent
Copilot AI changed the title [WIP] Add feature to save unsent chat message as draft feat: Save unsent chat message as draft May 31, 2026
Copilot AI requested a review from jamesarich May 31, 2026 14:36
- Store savedStateHandle as private val so setDraftMessage/clearDraftMessage
  can access it after construction (was causing compilation error)
- Replace continuous collectAsStateWithLifecycle of draft with one-time
  StateFlow.value read for rememberTextFieldState initialization, eliminating
  unnecessary recompositions on every keystroke

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the enhancement New feature or request label May 31, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 31, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2528 1 2527 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.core.model.NodeTest::isOnline_usesStrictThresholdBoundary
Stack Traces | 0.129s run time
java.lang.AssertionError: Expected value to be true.
	at org.junit.Assert.fail(Assert.java:89)
	at kotlin.test.junit.JUnitAsserter.fail(JUnitSupport.kt:56)
	at kotlin.test.Asserter.assertTrue(Assertions.kt:766)
	at kotlin.test.junit.JUnitAsserter.assertTrue(JUnitSupport.kt:30)
	at kotlin.test.Asserter.assertTrue(Assertions.kt:776)
	at kotlin.test.junit.JUnitAsserter.assertTrue(JUnitSupport.kt:30)
	at kotlin.test.AssertionsKt__AssertionsKt.assertTrue(Assertions.kt:44)
	at kotlin.test.AssertionsKt.assertTrue(Unknown Source)
	at kotlin.test.AssertionsKt__AssertionsKt.assertTrue$default(Assertions.kt:42)
	at kotlin.test.AssertionsKt.assertTrue$default(Unknown Source)
	at org.meshtastic.core.model.NodeTest.isOnline_usesStrictThresholdBoundary(NodeTest.kt:37)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
	at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
	at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
	at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
	at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
	at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
	at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
	at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
	at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jamesarich jamesarich marked this pull request as ready for review May 31, 2026 17:31
@jamesarich jamesarich added this pull request to the merge queue May 31, 2026
Merged via the queue into main with commit a36b60e May 31, 2026
18 checks passed
@jamesarich jamesarich deleted the copilot/save-unsent-chat-message-draft branch May 31, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Save unsent chat message as draft

2 participants