@@ -507,6 +507,13 @@ class ChatViewModel(
507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
510+ // Permission ids already responded to, guarding against a duplicate response after a
511+ // config change: the dialog's `responded` flag uses plain `remember` so a re-queued
512+ // permission is always answerable, but a rotation between tap and VM resolution resets
513+ // it to false, allowing a second tap. Cleared on failure (so a re-queued permission is
514+ // answerable) and on connection change.
515+ private val respondedPermissionIds = mutableSetOf<String>()
510 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
517 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
511 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
518 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
512 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
519 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
@@ -553,6 +560,7 @@ class ChatViewModel(
554 private fun clearPermissions() {
561 private fun clearPermissions() {
555 pendingPermissions.clear()
562 pendingPermissions.clear()
563+ respondedPermissionIds.clear()
556 _pendingPermission.value = null
564 _pendingPermission.value = null
557 updatePermissionProgress()
565 updatePermissionProgress()
@@ -873,6 +881,7 @@ class ChatViewModel(
873 // user intent that is now persisted; wiping it on every (re)connect — which
881 // user intent that is now persisted; wiping it on every (re)connect — which
874 // this collector does, including transient SSE drops and the cold-start
882 // this collector does, including transient SSE drops and the cold-start
875 // restore path — would drop a legitimately queued/recovered follow-up.
883 // restore path — would drop a legitimately queued/recovered follow-up.
884+ hasShownMessages = false
876 _selectedAgent.value = null
885 _selectedAgent.value = null
877 // Apply the user's persisted preferred-agent for this new session/load, so a
886 // Apply the user's persisted preferred-agent for this new session/load, so a
878 // user who always runs a specific agent doesn't have to pick it every session.
887 // user who always runs a specific agent doesn't have to pick it every session.
@@ -1447,6 +1456,7 @@ class ChatViewModel(
1447 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1456 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1448 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1457 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1449 runEndedByIdle = false
1458 runEndedByIdle = false
1459+ lastSentPrompt = null
1450 viewModelScope.launch {
1460 viewModelScope.launch {
1451 runCatchingCancellable {
1461 runCatchingCancellable {
1452 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1462 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
@@ -1500,7 +1510,7 @@ class ChatViewModel(
1500 fun resendLastPrompt() {
1510 fun resendLastPrompt() {
1501 val prompt = lastSentPrompt ?: return
1511 val prompt = lastSentPrompt ?: return
1502 lastSentPrompt = null
1512 lastSentPrompt = null
1503− send(prompt, includeAttachments = false)
1513+ if (!send(prompt, includeAttachments = false)) lastSentPrompt = prompt
1506 fun addAttachment(attachment: PendingAttachment) {
1516 fun addAttachment(attachment: PendingAttachment) {
@@ -1842,6 +1852,7 @@ class ChatViewModel(
1842 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1852 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1843 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1853 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1844 runEndedByIdle = false
1854 runEndedByIdle = false
1855+ lastSentPrompt = null
1845 viewModelScope.launch {
1856 viewModelScope.launch {
1846 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1857 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1847 .onFailure {
1858 .onFailure {
@@ -1861,6 +1872,7 @@ class ChatViewModel(
1861 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1872 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1862 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1873 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1863 runEndedByIdle = false
1874 runEndedByIdle = false
1875+ lastSentPrompt = null
1864 viewModelScope.launch {
1876 viewModelScope.launch {
1865 runCatchingCancellable { conn.api.initSession(sessionId) }
1877 runCatchingCancellable { conn.api.initSession(sessionId) }
1866 .onFailure {
1878 .onFailure {
@@ -1887,6 +1899,7 @@ class ChatViewModel(
1887 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1899 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1888 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1900 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1889 runEndedByIdle = false
1901 runEndedByIdle = false
1902+ lastSentPrompt = null
1890 viewModelScope.launch {
1903 viewModelScope.launch {
1891 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1904 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1892 .onFailure {
1905 .onFailure {
@@ -1977,6 +1990,7 @@ class ChatViewModel(
1978 fun respondPermission(permission: Permission, response: PermissionResponse) {
1991 fun respondPermission(permission: Permission, response: PermissionResponse) {
1979 val conn = connection ?: return
1992 val conn = connection ?: return
1993+ if (!respondedPermissionIds.add(permission.id)) return
1980 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1994 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1981 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1995 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1982 // client-side set is what gives it the "for this session" semantics.
1996 // client-side set is what gives it the "for this session" semantics.
@@ -2007,6 +2021,7 @@ class ChatViewModel(
2007 val type = permission.type.orEmpty()
2021 val type = permission.type.orEmpty()
2008 val pattern = permission.patternText.orEmpty()
2022 val pattern = permission.patternText.orEmpty()
2009 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2023 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2024+ respondedPermissionIds.remove(permission.id)
2010 enqueuePermission(permission)
2025 enqueuePermission(permission)