From b5c0b132103abd4f2ac9769d8d2ef56142e7c9c6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:20:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E3=80=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=B7=B2=E5=88=A0=E3=80=8D=E9=82=A3=E5=8F=A5=E8=A7=A3=E9=87=8A?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E7=94=B1=E4=B8=8D=E4=BC=9A=E8=A2=AB=E9=94=80?= =?UTF-8?q?=E6=AF=81=E7=9A=84=E9=82=A3=E4=B8=80=E5=B1=82=E6=8C=81=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机验收 404 兜底时发现的真问题:**切换本身是对的,但用户看不到任何解释**。 - logcat 链路完整:`GET /sessions/web:{已删}/messages?limit=50` → `404` →(约 0.9 秒后) `GET /sessions` → 界面切回主会话。也就是说「退回主会话 + 如实说一句」里的**前半句成了**。 - 但**后半句一个字都没显示**:那句横幅原来挂在 `ChatUiState.sessionGone` 上,而 `onSessionGone` 一被调用,`activeSessionId` 就被收敛回主会话、`ChatScreen` 的 `key` 随之变化, 承载它的那个 `ChatViewModel` 连同状态**立刻被销毁** —— 横幅来不及组合出来就没了。 (这正是「把一次性告知放在会被销毁的状态里」的经典坑,值得单记一条。) 改法: - `ChatUiState.sessionGone` 保留,但只当**触发信号**(KDoc 已改写说明这一点); - 文案由 `ChatHomeScreen` 的 `remember` 持有(它活得比会话 VM 久),通过新的 `sessionNotice: String?` 参数注入 `ChatScreen`,仍然走页面上那同一排 `Banner`, 8 秒后自己消失(它描述一次刚发生的动作,不是持续状态); - `onSessionGone` 里**先**立起告知、**再**刷新列表——顺序反了同样什么都看不到。 验证:装机复测通过,红字横幅如期出现在主会话页顶部 (证据 `app/build/e2e-shots/w7-gone-2.png`),logcat 链路 404 → `GET /sessions` 完整。 assembleDebug 与 testDebugUnitTest 全绿(884 测试 / 0 失败,与改动前同数:本次只动了接线与文案, 没有可新增的纯函数)。 --- .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 41 +++++++++++++++++-- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 15 ++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 04d874d..a91bdd2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.ui.chat import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -10,6 +11,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.delay import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.ui.admin.AdminHomeScreen import top.yeij.cyrene.ui.common.MiuixDrawer @@ -18,6 +20,19 @@ import top.yeij.cyrene.ui.sessions.SessionsViewModel import top.yeij.cyrene.ui.settings.AboutScreen import top.yeij.cyrene.ui.settings.SettingsScreen +/** 「这个会话在服务端已经没了」那句告知挂多久(毫秒):它描述一次刚发生的动作,不是持续状态。 */ +private const val SESSION_GONE_NOTICE_MILLIS = 8_000L + +/** + * 会话在别处被删掉时那句如实告知。 + * + * 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用, + * `activeSessionId` 就会被收敛回主会话,那个会话的 VM 连同它的状态**立刻被销毁**, + * 挂在上面的横幅一个字都来不及显示(真机实测过)。这一层的 `remember` 活得比它久。 + */ +private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话" + + /** * 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。 * 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。 @@ -36,6 +51,17 @@ fun ChatHomeScreen( var showAdmin by remember { mutableStateOf(false) } var showAbout by remember { mutableStateOf(false) } + // 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的 + // 会话 VM 会在切回主会话的瞬间被销毁。 + var sessionNotice by remember { mutableStateOf(null) } + + LaunchedEffect(sessionNotice) { + if (sessionNotice != null) { + delay(SESSION_GONE_NOTICE_MILLIS) + sessionNotice = null + } + } + BackHandler(enabled = showAdmin) { showAdmin = false } // 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉, // 返回才只退一层(关于 → 设置),不会一步跳回聊天。 @@ -75,10 +101,17 @@ fun ChatHomeScreen( sessionsViewModel.refresh(silent = true) }, onLogout = onLogout, - // 当前会话在别处被删了(`404 session_not_found`)⇒ 重拉列表:`refresh` 里面会把 - // 「指向已删会话」的 activeSessionId 收敛回主会话(`resolveActiveSession`), - // 抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。 - onSessionGone = { sessionsViewModel.refresh(silent = true) }, + // 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表: + // `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话 + // (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。 + // + // 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、 + // 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。 + onSessionGone = { + sessionNotice = SESSION_GONE_MESSAGE + sessionsViewModel.refresh(silent = true) + }, + sessionNotice = sessionNotice, ) }, drawerContent = { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index f034475..50c232e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -140,6 +140,7 @@ fun ChatScreen( onOpenDrawer: () -> Unit = {}, onLogout: () -> Unit = {}, onSessionGone: () -> Unit = {}, + sessionNotice: String? = null, ) { val viewModel: ChatViewModel = viewModel( key = sessionId, @@ -151,6 +152,11 @@ fun ChatScreen( // 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。 // 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅), // 所以这里不会出现「已经主会话还往上跳一次」的回环。 + // + // ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回 + // 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅 + // 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。 + // 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。 LaunchedEffect(state.sessionGone) { if (state.sessionGone) onSessionGone() } @@ -493,11 +499,10 @@ fun ChatScreen( state.error?.let { message -> Banner(text = message, color = MiuixTheme.colorScheme.error) } - if (state.sessionGone) { - Banner( - text = "这个会话在服务端已经不存在了(可能已在别处删除),正在切回主会话", - color = MiuixTheme.colorScheme.error, - ) + // 「这个会话在服务端已经没了」那句解释:由上层注入(见函数头上那段说明), + // 因为承载它的会话 VM 在切走的那一刻就被销毁了。 + sessionNotice?.let { message -> + Banner(text = message, color = MiuixTheme.colorScheme.error) } state.shareNotice?.let { message -> Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)