Skip to content

Commit 7387ebb

Browse files
author
Developer
committed
fix(ui): 修复多个 UI 与状态管理问题
- 输入框草稿跨页面保持(从 remember 提升到 ViewModel StateFlow) - 会话选模型时同步全局 active provider - 侧边栏自动滚到当前选中会话 - 本地通道加目录选择器(SAF OpenDocumentTree) - 远程连接卡片长路径不再挤掉编辑/删除按钮 - 选中的自定义容器也显示编辑/删除按钮 - 切换容器弹确认提示,确认后停 AI 关终端(不重启 App) - 切换工作区弹确认提示,确认后停 AI 关终端 - stopAgent 取消后同步清理所有 UI 状态,修复等待动画不消失
1 parent e33f9dd commit 7387ebb

11 files changed

Lines changed: 136 additions & 20 deletions

File tree

app/src/main/java/com/aicode/MainActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ fun AppNavigation() {
252252
onNavigateBack = {
253253
navController.popBackStack()
254254
scope.launch { drawerState.open() }
255-
}
255+
},
256+
onStopAllAndCloseTerminal = { agentViewModel.stopAllAndCloseTerminal() }
256257
)
257258
}
258259
composable("terminal") {

app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ class AIAgentViewModel @Inject constructor(
6161
private val containerEngine: LinuxContainerEngine,
6262
private val sessionUseCase: SessionUseCase,
6363
private val messagePersistenceUseCase: MessagePersistenceUseCase,
64-
private val planApprovalManager: com.aicode.feature.agent.domain.tool.mode.PlanApprovalManager
64+
private val planApprovalManager: com.aicode.feature.agent.domain.tool.mode.PlanApprovalManager,
65+
private val terminalSessionManager: com.aicode.feature.terminal.domain.TerminalSessionManager
6566
) : ViewModel() {
6667

6768
private val sessionJobs = mutableMapOf<String, Job>()
@@ -586,6 +587,24 @@ class AIAgentViewModel @Inject constructor(
586587
* 主动打断正在运行的 agent:取消协程(会一并取消挂起的网络请求与容器命令进程),
587588
* 并把「执行中」的工具占位行收尾为「已停止」,避免悬挂的 spinner 与孤儿记录。
588589
*/
590+
/** 停止当前工作区所有正在运行的 AI 会话并关闭所有终端标签(切换工作区前调用)。 */
591+
fun stopAllAndCloseTerminal() {
592+
stopAllAgents()
593+
terminalSessionManager.tabs.value.map { it.id }.forEach { terminalSessionManager.closeTab(it) }
594+
}
595+
596+
/** 停止当前工作区所有正在运行的 AI 会话(切换工作区前调用)。 */
597+
fun stopAllAgents() {
598+
val jobs = sessionJobs.values.filter { it.isActive }
599+
jobs.forEach { it.cancel() }
600+
sessionJobs.clear()
601+
_agentStates.value = _agentStates.value.mapValues { AgentUIState.Idle }
602+
_streamingTexts.value = emptyMap()
603+
_streamingReasonings.value = emptyMap()
604+
_runningTools.value = emptyMap()
605+
_retryStates.value = emptyMap()
606+
}
607+
589608
fun stopAgent() {
590609
val sessionId = _currentSessionId.value ?: return
591610
val job = sessionJobs[sessionId] ?: return
@@ -594,6 +613,12 @@ class AIAgentViewModel @Inject constructor(
594613
val streamingText = _streamingTexts.value[sessionId]
595614
val streamingReasoning = _streamingReasonings.value[sessionId]
596615
job.cancel()
616+
setAgentState(sessionId, AgentUIState.Idle)
617+
setRunningTool(sessionId, null)
618+
setStreamingText(sessionId, null)
619+
setStreamingReasoning(sessionId, null)
620+
setCompacting(sessionId, false)
621+
setRetryState(sessionId, null)
597622
viewModelScope.launch {
598623
if (running != null) {
599624
val partial = running.text.trimEnd()

app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,7 @@ fun AIChatPanel(
651651
isBusy = isBusy,
652652
workspaceViewModel = workspaceViewModel,
653653
hasRunningSessions = { viewModel.hasRunningSessionsInCurrentWorkspace() },
654+
onSwitchWorkspaceConfirmed = { viewModel.stopAllAndCloseTerminal() },
654655
activeProvider = activeProvider,
655656
providers = providers,
656657
onSelectModel = { p, m ->

app/src/main/java/com/aicode/feature/agent/presentation/component/ChatDrawer.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
1818
import androidx.compose.foundation.layout.systemBars
1919
import androidx.compose.foundation.lazy.LazyColumn
2020
import androidx.compose.foundation.lazy.items
21+
import androidx.compose.foundation.lazy.rememberLazyListState
2122
import androidx.compose.foundation.shape.RoundedCornerShape
2223
import androidx.compose.material.icons.Icons
2324
import androidx.compose.material.icons.outlined.Add
@@ -29,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
2930
import androidx.compose.material3.Text
3031
import androidx.compose.material3.TextButton
3132
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.LaunchedEffect
3234
import androidx.compose.runtime.getValue
3335
import androidx.compose.runtime.mutableStateOf
3436
import androidx.compose.runtime.remember
@@ -61,6 +63,17 @@ fun ChatDrawerContent(
6163
modifier: Modifier = Modifier
6264
) {
6365
var pendingDelete by remember { mutableStateOf<ChatSession?>(null) }
66+
val listState = rememberLazyListState()
67+
68+
LaunchedEffect(currentSessionId, sessions) {
69+
if (sessions.isEmpty()) return@LaunchedEffect
70+
val index = sessions.indexOfFirst { it.id == currentSessionId }
71+
if (index >= 0) {
72+
listState.scrollToItem(index)
73+
} else {
74+
listState.scrollToItem(0)
75+
}
76+
}
6477

6578
Column(
6679
modifier = modifier
@@ -112,6 +125,7 @@ fun ChatDrawerContent(
112125
)
113126
} else {
114127
LazyColumn(
128+
state = listState,
115129
modifier = Modifier.fillMaxSize(),
116130
verticalArrangement = Arrangement.spacedBy(Spacing.xs)
117131
) {

app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ internal fun ChatInputBar(
8282
isBusy: Boolean,
8383
workspaceViewModel: WorkspaceViewModel?,
8484
hasRunningSessions: () -> Boolean,
85+
onSwitchWorkspaceConfirmed: () -> Unit = {},
8586
activeProvider: AIProviderConfig?,
8687
providers: List<AIProviderConfig>,
8788
onSelectModel: (String, String) -> Unit,
@@ -189,7 +190,8 @@ internal fun ChatInputBar(
189190
if (workspaceViewModel != null) {
190191
WorkspaceIconButton(
191192
viewModel = workspaceViewModel,
192-
hasRunningSessions = hasRunningSessions
193+
hasRunningSessions = hasRunningSessions,
194+
onSwitchConfirmed = onSwitchWorkspaceConfirmed
193195
)
194196
}
195197

app/src/main/java/com/aicode/feature/settings/presentation/component/ContainerSettingsSection.kt

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ internal fun ContainerSection(
5757
onSelect: (String) -> Unit,
5858
onSaveCustom: (ContainerProfile) -> Unit,
5959
onEditCustom: (ContainerProfile) -> Unit,
60-
onDeleteCustom: (ContainerProfile) -> Unit
60+
onDeleteCustom: (ContainerProfile) -> Unit,
61+
onSwitchConfirmed: () -> Unit = {}
6162
) {
6263
var showAddDialog by remember { mutableStateOf(false) }
6364
var editingProfile by remember { mutableStateOf<ContainerProfile?>(null) }
6465
var deletingProfile by remember { mutableStateOf<ContainerProfile?>(null) }
66+
var pendingSwitch by remember { mutableStateOf<ContainerProfile?>(null) }
6567

6668
LazyColumn(
6769
modifier = Modifier.fillMaxSize(),
@@ -92,7 +94,9 @@ internal fun ContainerSection(
9294
Row(
9395
modifier = Modifier
9496
.fillMaxWidth()
95-
.clickable { onSelect(profile.id) }
97+
.clickable {
98+
if (!active) pendingSwitch = profile
99+
}
96100
.padding(Spacing.lg),
97101
verticalAlignment = Alignment.CenterVertically
98102
) {
@@ -123,14 +127,7 @@ internal fun ContainerSection(
123127
)
124128
}
125129
}
126-
if (active) {
127-
Icon(
128-
imageVector = FeatherIcons.Check,
129-
contentDescription = null,
130-
tint = MaterialTheme.colorScheme.primary,
131-
modifier = Modifier.size(22.dp)
132-
)
133-
} else if (!profile.isBuiltin) {
130+
if (!profile.isBuiltin) {
134131
IconButton(onClick = { editingProfile = profile }) {
135132
Icon(
136133
imageVector = FeatherIcons.Edit3,
@@ -146,6 +143,14 @@ internal fun ContainerSection(
146143
)
147144
}
148145
}
146+
if (active) {
147+
Icon(
148+
imageVector = FeatherIcons.Check,
149+
contentDescription = null,
150+
tint = MaterialTheme.colorScheme.primary,
151+
modifier = Modifier.size(22.dp)
152+
)
153+
}
149154
}
150155
}
151156
}
@@ -237,6 +242,22 @@ internal fun ContainerSection(
237242
dismissButton = { TextButton(onClick = { deletingProfile = null }) { Text("取消") } }
238243
)
239244
}
245+
246+
pendingSwitch?.let { target ->
247+
AlertDialog(
248+
onDismissRequest = { pendingSwitch = null },
249+
title = { Text("切换容器镜像") },
250+
text = { Text("切换到「${target.name}」后,当前正在运行的 AI 会话将被停止、终端标签将被关闭。是否继续切换?") },
251+
confirmButton = {
252+
TextButton(onClick = {
253+
onSwitchConfirmed()
254+
onSelect(target.id)
255+
pendingSwitch = null
256+
}) { Text("切换") }
257+
},
258+
dismissButton = { TextButton(onClick = { pendingSwitch = null }) { Text("取消") } }
259+
)
260+
}
240261
}
241262

242263
@Composable

app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ internal enum class SettingsSection(val title: String) {
7777
@Composable
7878
fun SettingsScreen(
7979
viewModel: SettingsViewModel,
80-
onNavigateBack: () -> Unit
80+
onNavigateBack: () -> Unit,
81+
onStopAllAndCloseTerminal: () -> Unit = {}
8182
) {
8283
val providers by viewModel.providers.collectAsStateWithLifecycle()
8384
val activeProvider by viewModel.activeProvider.collectAsStateWithLifecycle()
@@ -254,7 +255,8 @@ fun SettingsScreen(
254255
onSelect = { viewModel.setActiveContainerProfile(it) },
255256
onSaveCustom = { viewModel.saveCustomContainerProfile(it) },
256257
onEditCustom = { viewModel.editCustomContainerProfile(it) },
257-
onDeleteCustom = { viewModel.deleteCustomContainerProfile(it) }
258+
onDeleteCustom = { viewModel.deleteCustomContainerProfile(it) },
259+
onSwitchConfirmed = onStopAllAndCloseTerminal
258260
)
259261
SettingsSection.Log -> LogSection(
260262
current = logLevel,

app/src/main/java/com/aicode/feature/terminal/presentation/TerminalViewModel.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ class TerminalViewModel @Inject constructor(
8181

8282
fun closeTab(id: String) = sessionManager.closeTab(id)
8383

84+
/** 关闭所有终端标签(切换工作区前调用)。 */
85+
fun closeAllTabs() {
86+
sessionManager.tabs.value.map { it.id }.forEach { sessionManager.closeTab(it) }
87+
}
88+
8489
fun reconnectActive() {
8590
val id = activeTabId.value ?: return
8691
viewModelScope.launch { runCatching { sessionManager.reconnect(id) } }

app/src/main/java/com/aicode/feature/workspace/presentation/component/WorkspacePicker.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import compose.icons.feathericons.*
5050
fun WorkspaceChip(
5151
viewModel: WorkspaceViewModel,
5252
hasRunningSessions: () -> Boolean = { false },
53+
onSwitchConfirmed: () -> Unit = {},
5354
modifier: Modifier = Modifier
5455
) {
5556
val workspaces by viewModel.workspaces.collectAsStateWithLifecycle()
@@ -96,6 +97,7 @@ fun WorkspaceChip(
9697
if (hasRunningSessions()) {
9798
pendingWorkspaceSelect = it
9899
} else {
100+
onSwitchConfirmed()
99101
viewModel.selectWorkspace(it.name)
100102
showSheet = false
101103
}
@@ -110,9 +112,10 @@ fun WorkspaceChip(
110112
AlertDialog(
111113
onDismissRequest = { pendingWorkspaceSelect = null },
112114
title = { Text("切换工作区") },
113-
text = { Text("当前工作区有正在运行的会话,切换工作区后它们将在后台继续运行。确定要切换吗?") },
115+
text = { Text("切换工作区会停止当前工作区所有正在运行的 AI 会话并关闭终端标签。确定要切换吗?") },
114116
confirmButton = {
115117
TextButton(onClick = {
118+
onSwitchConfirmed()
116119
viewModel.selectWorkspace(ws.name)
117120
pendingWorkspaceSelect = null
118121
showSheet = false
@@ -133,6 +136,7 @@ fun WorkspaceChip(
133136
fun WorkspaceIconButton(
134137
viewModel: WorkspaceViewModel,
135138
hasRunningSessions: () -> Boolean = { false },
139+
onSwitchConfirmed: () -> Unit = {},
136140
modifier: Modifier = Modifier
137141
) {
138142
val workspaces by viewModel.workspaces.collectAsStateWithLifecycle()
@@ -160,6 +164,7 @@ fun WorkspaceIconButton(
160164
if (hasRunningSessions()) {
161165
pendingWorkspaceSelect = it
162166
} else {
167+
onSwitchConfirmed()
163168
viewModel.selectWorkspace(it.name)
164169
showSheet = false
165170
}
@@ -174,9 +179,10 @@ fun WorkspaceIconButton(
174179
AlertDialog(
175180
onDismissRequest = { pendingWorkspaceSelect = null },
176181
title = { Text("切换工作区") },
177-
text = { Text("当前工作区有正在运行的会话,切换工作区后它们将在后台继续运行。确定要切换吗?") },
182+
text = { Text("切换工作区会停止当前工作区所有正在运行的 AI 会话并关闭终端标签。确定要切换吗?") },
178183
confirmButton = {
179184
TextButton(onClick = {
185+
onSwitchConfirmed()
180186
viewModel.selectWorkspace(ws.name)
181187
pendingWorkspaceSelect = null
182188
showSheet = false

app/src/main/java/com/aicode/feature/workspace/presentation/remote/RemoteDialogs.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import androidx.compose.ui.Alignment
1313
import androidx.compose.ui.Modifier
1414
import androidx.compose.ui.text.font.FontWeight
1515
import androidx.compose.ui.unit.dp
16+
import androidx.activity.compose.rememberLauncherForActivityResult
17+
import androidx.activity.result.contract.ActivityResultContracts
18+
import android.net.Uri
19+
import android.provider.DocumentsContract
1620
import com.aicode.feature.workspace.domain.model.RemoteConnection
1721
import com.aicode.feature.workspace.domain.model.RemoteMount
1822
import com.aicode.feature.workspace.domain.model.RemoteProtocol
@@ -38,6 +42,15 @@ fun AddRemoteConnectionDialog(
3842
var isTesting by remember { mutableStateOf(false) }
3943
val isLocal = protocol == RemoteProtocol.LOCAL
4044

45+
val folderPicker = rememberLauncherForActivityResult(
46+
ActivityResultContracts.OpenDocumentTree()
47+
) { uri: Uri? ->
48+
if (uri != null) {
49+
val path = uriToFilePath(context, uri)
50+
if (path != null) host = path
51+
}
52+
}
53+
4154
AlertDialog(
4255
containerColor = MaterialTheme.colorScheme.surface,
4356
tonalElevation = 0.dp,
@@ -86,7 +99,14 @@ fun AddRemoteConnectionDialog(
8699
null
87100
},
88101
singleLine = true,
89-
modifier = Modifier.fillMaxWidth()
102+
modifier = Modifier.fillMaxWidth(),
103+
trailingIcon = if (isLocal) {
104+
{
105+
IconButton(onClick = { folderPicker.launch(null) }) {
106+
Icon(FeatherIcons.Folder, contentDescription = "选择目录", tint = MaterialTheme.colorScheme.onSurfaceVariant)
107+
}
108+
}
109+
} else null
90110
)
91111
if (!isLocal) {
92112
OutlinedTextField(value = port, onValueChange = { port = it }, label = { Text("端口") }, singleLine = true, modifier = Modifier.fillMaxWidth())
@@ -398,3 +418,20 @@ fun RemoteDirectoryBrowserDialog(
398418
}
399419
)
400420
}
421+
422+
private fun uriToFilePath(context: android.content.Context, uri: Uri): String? {
423+
if (DocumentsContract.isTreeUri(uri)) {
424+
val docId = DocumentsContract.getTreeDocumentId(uri)
425+
if (docId.startsWith("primary:")) {
426+
val sub = docId.substringAfter("primary:", "")
427+
return "/storage/emulated/0/" + sub.trimStart('/')
428+
}
429+
val parts = docId.split(":")
430+
if (parts.size >= 2) {
431+
val storage = parts[0]
432+
val sub = parts[1]
433+
return "/storage/$storage/$sub"
434+
}
435+
}
436+
return null
437+
}

0 commit comments

Comments
 (0)