fix(chat): 「会话已删」那句解释必须由不会被销毁的那一层持有

真机验收 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 失败,与改动前同数:本次只动了接线与文案,
没有可新增的纯函数)。
This commit is contained in:
AskaEth committed 2026-10-04 22:20:59 +08:00
1 parent 1794e53d1c
commit b5c0b13210
2 files changed
+47 -9

No files matched your search

@@ -2,6 +2,7 @@ package top.yeij.cyrene.ui.chat
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -10,6 +11,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.delay
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.ui.admin.AdminHomeScreen import top.yeij.cyrene.ui.admin.AdminHomeScreen
import top.yeij.cyrene.ui.common.MiuixDrawer 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.AboutScreen
import top.yeij.cyrene.ui.settings.SettingsScreen 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 showAdmin by remember { mutableStateOf(false) }
var showAbout by remember { mutableStateOf(false) } var showAbout by remember { mutableStateOf(false) }
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
// 会话 VM 会在切回主会话的瞬间被销毁。
var sessionNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionNotice) {
if (sessionNotice != null) {
delay(SESSION_GONE_NOTICE_MILLIS)
sessionNotice = null
}
}
BackHandler(enabled = showAdmin) { showAdmin = false } BackHandler(enabled = showAdmin) { showAdmin = false }
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉, // 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。 // 返回才只退一层(关于 → 设置),不会一步跳回聊天。
@@ -75,10 +101,17 @@ fun ChatHomeScreen(
sessionsViewModel.refresh(silent = true) sessionsViewModel.refresh(silent = true)
}, },
onLogout = onLogout, onLogout = onLogout,
// 当前会话在别处被删了(`404 session_not_found`)⇒ 重拉列表:`refresh` 里面会把 // 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
// 「指向已删会话」的 activeSessionId 收敛回主会话(`resolveActiveSession`), // `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// 抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。 // (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
onSessionGone = { sessionsViewModel.refresh(silent = true) }, //
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、
// 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。
onSessionGone = {
sessionNotice = SESSION_GONE_MESSAGE
sessionsViewModel.refresh(silent = true)
},
sessionNotice = sessionNotice,
) )
}, },
drawerContent = { drawerContent = {
@@ -140,6 +140,7 @@ fun ChatScreen(
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
onLogout: () -> Unit = {}, onLogout: () -> Unit = {},
onSessionGone: () -> Unit = {}, onSessionGone: () -> Unit = {},
sessionNotice: String? = null,
) { ) {
val viewModel: ChatViewModel = viewModel( val viewModel: ChatViewModel = viewModel(
key = sessionId, key = sessionId,
@@ -151,6 +152,11 @@ fun ChatScreen(
// 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。 // 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。
// 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅), // 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅),
// 所以这里不会出现「已经主会话还往上跳一次」的回环。 // 所以这里不会出现「已经主会话还往上跳一次」的回环。
//
// ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回
// 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅
// 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。
// 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
LaunchedEffect(state.sessionGone) { LaunchedEffect(state.sessionGone) {
if (state.sessionGone) onSessionGone() if (state.sessionGone) onSessionGone()
} }
@@ -493,11 +499,10 @@ fun ChatScreen(
state.error?.let { message -> state.error?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error) Banner(text = message, color = MiuixTheme.colorScheme.error)
} }
if (state.sessionGone) { // 「这个会话在服务端已经没了」那句解释:由上层注入(见函数头上那段说明),
Banner( // 因为承载它的会话 VM 在切走的那一刻就被销毁了。
text = "这个会话在服务端已经不存在了(可能已在别处删除),正在切回主会话", sessionNotice?.let { message ->
color = MiuixTheme.colorScheme.error, Banner(text = message, color = MiuixTheme.colorScheme.error)
)
} }
state.shareNotice?.let { message -> state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)