From feeb49ebeb6c08831a4bc5ee55d42768885b3e54 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 17:10:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(sessions):=20=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=BD=93=E5=89=8D=E4=BC=9A=E8=AF=9D=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E8=BF=98=E5=9C=A8=EF=BC=8C=E5=88=AB=E4=B8=80=E7=9B=B4?= =?UTF-8?q?=E6=8C=87=E7=9D=80=E5=B7=B2=E5=88=A0=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)时客户端无法感知:`refresh()` 成功后会整体替换 `sessions`,却**不校验 `activeSessionId` 是否还在新列表里**, 于是当前会话会一直指着一个已删会话——聊天页对着空会话,而且这个陈旧 id 会被 `prefs.setActive` 持久化下去,重启后依然是它。 本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),漏的正是「别处删除」这一路。 - ui/sessions/SessionsViewModel.kt:新增纯函数 `resolveActiveSession(activeId, fetchedIds)` —— 不在列表里就回落主会话;**列表为空时不回落**(那是异常响应,不该拿它改用户的选择)。 `refresh()` 成功分支调用它,值变化时同步持久化 测试:272 全绿(269 → +3):已删会话回落主会话 / 仍在列表则保留 / 空列表不覆盖用户选择。 实测:会话在别处删除后 force-stop 重开(重走 init 与刷新), `cyrene_sessions.preferences_pb` 里 **`web:` 残留彻底消失**、`active_session_id` 回落 `web`; 界面回到主会话对话,不再出现已删会话的幽灵行。 --- .../cyrene/ui/sessions/SessionsViewModel.kt | 22 +++++++++++++++++++ .../cyrene/ui/sessions/SessionItemTest.kt | 18 +++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 08c91a0..038f877 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -89,9 +89,14 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { .onSuccess { list -> prefs.saveSnapshot(list, fingerprint()) val cached = prefs.data.first() + // 会话可能在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API),而别处删除无法感知—— + // 不校验 `activeSessionId` 就会一直指着一个已删会话,且这个陈旧 id 会被持久化下去。 + val active = resolveActiveSession(_state.value.activeSessionId, list.map { it.id }) + if (active != _state.value.activeSessionId) prefs.setActive(active) _state.update { it.copy( sessions = list.map { dto -> dto.toItem() }, + activeSessionId = active, loading = false, fromCache = false, syncedAt = cached.snapshotAt, @@ -191,6 +196,23 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem( lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() }, ) +/** + * 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。 + * + * 什么时候会不在:会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)。 + * 本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),但**别处删除无法感知**—— + * 不校验的话 `activeSessionId` 会一直指着一个已删会话:聊天页对着空会话, + * 而且这个陈旧 id 会被 `prefs.setActive` 持久化下去,重启后依然是它。 + * + * 列表为空时**不回落**:那是异常响应,不该拿它去改用户的选择。 + */ +internal fun resolveActiveSession(activeSessionId: String, fetchedIds: List): String = + if (fetchedIds.isEmpty() || activeSessionId in fetchedIds) { + activeSessionId + } else { + SessionSummaryDto.MAIN_SESSION_ID + } + /** * 列表行标题:优先用服务端的自动标题(首条用户消息生成), * 会话还没有用户消息时 `title` 键整个缺席(或为空白),此时才兜底。 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt index 4369f23..9eb8820 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt @@ -106,4 +106,22 @@ class SessionItemTest { assertEquals("", " ".toPreviewText()) assertNull(dto(lastMessage = SessionLastMessageDto("assistant", "", 0L)).toItem().lastMessage) } + + @Test + fun `an active session that vanished from the list falls back to main`() { + // 会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)时客户端无法感知—— + // 不回落的话 `activeSessionId` 会一直指着一个已删会话,聊天页对着空会话。 + assertEquals("web", resolveActiveSession("web:gone", listOf("web", "web:alive"))) + } + + @Test + fun `an active session still present is kept`() { + assertEquals("web:alive", resolveActiveSession("web:alive", listOf("web", "web:alive"))) + } + + @Test + fun `an empty list does not override the user's choice`() { + // 空列表是异常响应,不该拿它去改用户的选择。 + assertEquals("web:alive", resolveActiveSession("web:alive", emptyList())) + } }