From 209dcd2a1c908ff69d98c6e86aa2a9d0cba2f998 Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 19:57:04 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E8=A1=A8=E5=8D=95=E5=92=8C=E8=AF=A2=E9=97=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=A2=AB=E8=BD=AF=E9=94=AE=E7=9B=98=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 键盘弹起时三个编辑器页(提示词/技能/子代理)的滚动容器没有 IME inset 处理,底部内容被输入法盖住且滚不到: 补 consumeWindowInsets(padding) + imePadding();询问面板改用 weight(1f, fill = false) 参与剩余高度分配, 不再顶到键盘之下;「其他」输入框换成项目统一的 AppTextField。 --- .../presentation/component/AIChatPanel.kt | 4 +++- .../component/AskUserQuestionPanel.kt | 19 ++++--------------- .../component/PromptEditorScreen.kt | 4 ++++ .../component/SkillEditorScreen.kt | 4 ++++ .../component/SubAgentEditorScreen.kt | 4 ++++ docs-site/docs/guide/chat.md | 6 ++++++ docs-site/docs/guide/custom-prompts.md | 2 ++ docs-site/docs/guide/skills.md | 2 ++ docs-site/docs/guide/subagent.md | 2 ++ 9 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt index 552f7441..02dc41dc 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt @@ -1576,7 +1576,9 @@ fun AIChatPanel( val questionForPanel = rememberLastNonNull(pendingQuestion) AnimatedVisibility( visible = pendingQuestion != null, - modifier = Modifier.graphicsLayer { alpha = floatingPanelAlpha }, + modifier = Modifier + .weight(1f, fill = false) + .graphicsLayer { alpha = floatingPanelAlpha }, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AskUserQuestionPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AskUserQuestionPanel.kt index 3db95e32..6f2666be 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AskUserQuestionPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AskUserQuestionPanel.kt @@ -31,8 +31,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -43,7 +41,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -56,6 +53,7 @@ import com.aicode.feature.agent.domain.tool.question.SingleAnswer import com.aicode.feature.agent.domain.tool.question.UserQuestionAnswer import androidx.compose.ui.res.stringResource import com.aicode.R +import com.aicode.core.ui.AppTextField import com.aicode.core.ui.ExpandableChevronIcon import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronDown @@ -309,25 +307,16 @@ private fun QuestionCard( // 「其他」被选中时展开文本输入框 if (isOther && isSelected) { Spacer(Modifier.height(Spacing.xs)) - TextField( + AppTextField( value = customText, onValueChange = onCustomTextChanged, - placeholder = { Text(stringResource(R.string.ask_input_hint), style = MaterialTheme.typography.bodySmall) }, + placeholder = stringResource(R.string.ask_input_hint), modifier = Modifier .fillMaxWidth() .padding(start = 28.dp), textStyle = MaterialTheme.typography.bodySmall, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - shape = RoundedCornerShape(Radius.sm) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt index b0743de1..92619ef1 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt @@ -4,6 +4,8 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -109,6 +111,8 @@ internal fun PromptEditorScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .consumeWindowInsets(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.lg) .padding(bottom = Spacing.xl), diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt index 00c2301b..c568b0e2 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt @@ -4,6 +4,8 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -126,6 +128,8 @@ internal fun SkillEditorScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .consumeWindowInsets(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.lg) .padding(bottom = Spacing.xl), diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SubAgentEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SubAgentEditorScreen.kt index bda5abf9..ab3e0947 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SubAgentEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SubAgentEditorScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -170,6 +172,8 @@ internal fun SubAgentEditorScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .consumeWindowInsets(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.lg) .padding(bottom = Spacing.xl), diff --git a/docs-site/docs/guide/chat.md b/docs-site/docs/guide/chat.md index 33766226..d2d999cb 100644 --- a/docs-site/docs/guide/chat.md +++ b/docs-site/docs/guide/chat.md @@ -59,6 +59,12 @@ 滚动消息列表时,输入框区域会淡出到 40% 透明度以减少干扰,停止滚动后恢复。 +## AI 询问面板 + +AI 需要你选择方案时,会在聊天输入框上方显示询问面板。选择预设选项,或选择「其他」填写自己的回答,再点「确认」提交;也可以点「补充」,回到聊天输入框补充说明。 + +填写「其他」时,面板会随键盘弹出缩小可见区域。问题较多时,可以在面板内部上下滚动,找到输入框和底部操作按钮。面板标题可点击收起或展开。 + ## 工作区管理 点击工作区按钮弹出的面板里可以: diff --git a/docs-site/docs/guide/custom-prompts.md b/docs-site/docs/guide/custom-prompts.md index e361bfe0..fae7f62f 100644 --- a/docs-site/docs/guide/custom-prompts.md +++ b/docs-site/docs/guide/custom-prompts.md @@ -21,6 +21,8 @@ AiCode 的系统提示词可以自行修改。默认提示词随 App 内置、 - 右上角「+」可用未占用的编号新增片段。编辑时摘要与正文分开填写;摘要显示在列表和预览页,不会注入 AI。手工编写文件时,开头的 `` 同样会被识别为摘要,正文预览会隐藏这段注释。 - 首次进入会自动弹出使用说明公告,右上角 Info 可随时重看。 +编辑页会避让软键盘;摘要或正文较长时可以上下滚动,继续编辑末尾内容。 + App 里的改动与手工放文件完全等价,两种方式可以混用。 ## 目录结构 diff --git a/docs-site/docs/guide/skills.md b/docs-site/docs/guide/skills.md index 8003ac1d..408f7f61 100644 --- a/docs-site/docs/guide/skills.md +++ b/docs-site/docs/guide/skills.md @@ -53,6 +53,8 @@ - **基本信息**:名称、描述、作用域。名称就是 AI 加载技能时使用的标识,建议使用英文小写加连字符(如 `pdf-report`);描述写明「何时该使用它」,AI 依据这句话判断是否需要加载;作用域选「当前项目」或「全局」,决定技能目录落在哪一级。 - **指令正文**:技能的完整指令,支持 Markdown。写清这类任务的标准流程、约束与注意事项。 +软键盘弹出时,编辑区域会避让键盘;正文较长时可以上下滚动,继续编辑末尾内容。 + 名称和正文都填写后才能点击右上角「保存」。保存后会写成 `<技能目录>/SKILL.md`,与手写的技能完全等价,之后同样可以用文本编辑器或容器命令修改。 几个需要留意的地方: diff --git a/docs-site/docs/guide/subagent.md b/docs-site/docs/guide/subagent.md index 0e45cf8b..35930be3 100644 --- a/docs-site/docs/guide/subagent.md +++ b/docs-site/docs/guide/subagent.md @@ -133,6 +133,8 @@ inject: [base, projectRules] - **启用 / 禁用**:详情页顶部的开关。禁用后它不再出现在主会话 AI 的可派发清单里,AI 也无法再指名派发它,但定义文件保留,已在运行的子会话也不受影响; - **删除**:列表里左滑条目,会删掉对应的 `.md` 定义文件,不可恢复。 +新建和编辑时,表单会避让软键盘;提示词正文较长时可以上下滚动,继续编辑末尾内容。 + 启停状态存在 `agents.json`,与技能的停用名单同一套约定:全局名单位于当前执行环境的 `~/.aicode/agents.json`,本地模式保存在手机 App 私有目录,远程模式保存在服务器上;两者独立,不会自动迁移或同步。项目级名单在本地模式位于 `<工作区>/.aicode/agents.json`,远程模式仍保存在手机 App 私有目录,按服务器和工作区隔离。两级取并集,任一作用域禁用即生效。 在 App 中切换启停状态后列表会刷新。本地模式下手工修改定义或名单,列表几秒内自动刷新;远程模式下不会监听服务器上的文件变化,手工修改后需退出并重新进入设置页刷新列表。 From 458661c184093f7975b1dfb1ce6dd0e9b87e8f9a Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 10:42:30 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(settings):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97=E5=BC=B9=E7=AA=97=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=97=A0=E6=B3=95=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit heightIn 原写在 verticalScroll 内侧:滚动容器量到的子项高度就等于上限、可滚动距离为 0,看起来完全不能滚。 把 heightIn(max = 320.dp) 挪到 verticalScroll 外侧限制视口高度后,超长更新日志才真能滚。 --- .../settings/presentation/component/UpdateCheckDialog.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/UpdateCheckDialog.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/UpdateCheckDialog.kt index 07c3b622..a2f10d40 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/UpdateCheckDialog.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/UpdateCheckDialog.kt @@ -49,8 +49,8 @@ internal fun UpdateCheckDialog( text = state.changelog, modifier = Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .heightIn(max = 320.dp), + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) From b722a1ca8d6fbdb1e6da3304f075f4a01822b545 Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 19:34:15 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(settings):=20=E5=87=8F=E5=B0=91?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E9=85=8D=E7=BD=AE=E9=A1=B5=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 返回键每次都无条件走 saveCurrent() → onSave(currentConfig()) 写库一次(Room 事务 + provider JSON 序列化), 什么都没改也照写,返回因此发卡。记下上次提交的配置,内容相同直接跳过;顺带把 getAllProviders / getProviderById / saveProvider 的实体转换与查询放进 Dispatchers.IO,不再占主线程。 --- .../data/repository/AIProviderRepositoryImpl.kt | 11 +++++++---- .../presentation/component/ProviderEditorScreen.kt | 9 ++++++++- docs-site/docs/guide/providers.md | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/AIProviderRepositoryImpl.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/AIProviderRepositoryImpl.kt index 11750deb..46bf8d7a 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/AIProviderRepositoryImpl.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/AIProviderRepositoryImpl.kt @@ -15,8 +15,11 @@ import com.aicode.feature.settings.domain.model.ProxyType import com.aicode.feature.settings.domain.model.sanitized import com.aicode.feature.settings.domain.repository.AIProviderRepository import androidx.room.withTransaction +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject @@ -56,18 +59,18 @@ class AIProviderRepositoryImpl @Inject constructor( override fun getAllProviders(): Flow> { return aiProviderDao.getAllProviders().map { entities -> entities.map { it.toDomainModel() } - } + }.flowOn(Dispatchers.IO) } - override suspend fun getProviderById(id: String): AIProviderConfig? { - return aiProviderDao.getProviderById(id)?.toDomainModel() + override suspend fun getProviderById(id: String): AIProviderConfig? = withContext(Dispatchers.IO) { + aiProviderDao.getProviderById(id)?.toDomainModel() } /** * 保存提供商。排序值以数据库当前值为准(重排可能异步持久化,UI 传入的 * sortOrder 可能陈旧,直接使用会撤销刚完成的排序);新提供商取最大排序 +1。 */ - override suspend fun saveProvider(provider: AIProviderConfig) { + override suspend fun saveProvider(provider: AIProviderConfig): Unit = withContext(Dispatchers.IO) { agentDatabase.withTransaction { val current = aiProviderDao.getProviderById(provider.id) val sortOrder = current?.sortOrder ?: (aiProviderDao.getMaxSortOrder() + 1) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt index 131008dc..b0ef5f12 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt @@ -348,9 +348,16 @@ fun ProviderEditorScreen( customHeaders.any { it.first.isNotBlank() } || models.isNotEmpty() + var lastSubmittedConfig by remember { + mutableStateOf(if (initialProvider != null) currentConfig() else null) + } + fun saveCurrent() { if (!hasSubstantiveInput()) return - onSave(currentConfig()) + val config = currentConfig() + if (config == lastSubmittedConfig) return + onSave(config) + lastSubmittedConfig = config } // 拉取成功后自动对齐:远端已不存在的本地模型直接移除(可在「偏好设置」关掉)。拉取失败或返回空列表时不动列表。 diff --git a/docs-site/docs/guide/providers.md b/docs-site/docs/guide/providers.md index 8bd39d5a..cf7c2d4d 100644 --- a/docs-site/docs/guide/providers.md +++ b/docs-site/docs/guide/providers.md @@ -24,7 +24,7 @@ AiCode 自身不提供模型,需先接入一个模型服务。入口在「设 列表每行显示名称、上游格式、模型数量和启用状态。 -- 点一行进入编辑页。 +- 点一行进入编辑页。修改后点左上角返回箭头或按系统返回键,配置会自动保存;未修改时直接返回,不重复保存。 - **左滑一行**露出红色删除按钮。 - **长按名称或上游格式区域拖动**调整顺序,重启后保持。 From c73ab4391e7bac3b88cc0f6d503cf96d0bc82385 Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 12:55:05 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E6=96=9C?= =?UTF-8?q?=E6=9D=A0=E5=91=BD=E4=BB=A4=E5=88=97=E8=A1=A8=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 斜杠命令列表的 Column 没有高度上限也不可滚动,候选一多就被上方输入框与下方输入栏截断,后面的命令选不到; 加 heightIn(max = 200.dp) + verticalScroll。 --- .../aicode/feature/agent/presentation/component/ChatInputBar.kt | 2 ++ docs-site/docs/guide/chat.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt index d404e946..eb49a878 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt @@ -279,6 +279,8 @@ internal fun ChatInputBar( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.sm, vertical = Spacing.xs) ) { filteredCommands.forEach { command -> diff --git a/docs-site/docs/guide/chat.md b/docs-site/docs/guide/chat.md index d2d999cb..d3d69100 100644 --- a/docs-site/docs/guide/chat.md +++ b/docs-site/docs/guide/chat.md @@ -78,7 +78,7 @@ AI 需要你选择方案时,会在聊天输入框上方显示询问面板。 ## 斜杠命令 -在输入框输入 `/` 会弹出命令菜单,**内置命令排在前面(按字母序),技能排在后面(也按字母序)**。 +在输入框输入 `/` 会弹出命令菜单,**内置命令排在前面(按字母序),技能排在后面(也按字母序)**。命令较多时,可以在菜单内上下滑动查看;继续输入命令名可以筛选列表,点击条目即可补全到输入框。 内置命令: From b8c025d68f5fd6536c05a91c16a5684df5c28512 Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 16:20:18 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=A1=B5=E9=9D=A2=E5=90=8E=E8=BE=93=E5=85=A5=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 草稿同时存在 Composable 本地 state(remember { mutableStateOf("") })与 ViewModel.inputDraft: 切页面重建时 remember 归零,而 LaunchedEffect(inputDraft) 只在草稿值变化时触发、不会把归零的本地 state 补回来, 输入框于是空了。改为输入框直接读 viewModel.inputDraft(单一数据源),onValueChange 走 updateInputDraft, 删掉本地 state 与发送后的手工清空。 --- .../agent/presentation/component/AIChatPanel.kt | 11 +++-------- docs-site/docs/guide/chat.md | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt index 02dc41dc..a179d75f 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt @@ -614,11 +614,7 @@ fun AIChatPanel( val currentMode by viewModel.currentSessionMode.collectAsStateWithLifecycle() val slashCommands by viewModel.slashCommands.collectAsStateWithLifecycle() - var inputText by remember { mutableStateOf("") } - val inputDraft by viewModel.inputDraft.collectAsStateWithLifecycle() - LaunchedEffect(inputDraft) { - if (inputText != inputDraft) inputText = inputDraft - } + val inputText by viewModel.inputDraft.collectAsStateWithLifecycle() // 输入 "/" 打开命令菜单时重扫技能,反映磁盘上技能的增删改。 LaunchedEffect(inputText) { if (inputText == "/") viewModel.refreshSlashCommands() @@ -1205,7 +1201,6 @@ fun AIChatPanel( inputImages = images, inputAttachments = attachments.toAgentAttachments() ) - inputText = "" viewModel.clearInputDraft() pendingAttachments = emptyList() followBottom = true @@ -1612,7 +1607,7 @@ fun AIChatPanel( ChatInputBar( value = inputText, - onValueChange = { inputText = it; viewModel.updateInputDraft(it) }, + onValueChange = viewModel::updateInputDraft, onSend = sendMessage, enterToSend = settingsViewModel?.enterToSend?.collectAsStateWithLifecycle()?.value ?: false, onStop = { viewModel.stopAgent() }, @@ -1743,7 +1738,7 @@ fun AIChatPanel( promptSnippet = targetMsg?.content ?: "", onOptionSelected = { option -> viewModel.executeRewindOption(targetId, option) { text, attachments -> - inputText = text + viewModel.updateInputDraft(text) pendingAttachments = attachments.map { it.toPendingAttachment() } } }, diff --git a/docs-site/docs/guide/chat.md b/docs-site/docs/guide/chat.md index d3d69100..2ffc0e77 100644 --- a/docs-site/docs/guide/chat.md +++ b/docs-site/docs/guide/chat.md @@ -55,7 +55,7 @@ 上传的附件会以预览卡显示在输入框上方,发送前可以移除。上传较大的文件需要一定时间,期间输入框上方会显示上传中的提示,传完自动变成预览卡。点击图片缩略图可以[全屏看大图](#全屏看图)。 -未发送的草稿按会话分别保存:在当前会话输入内容后切换至其它会话,返回时草稿依然保留。草稿持久化到本地,强制关闭 App 后重新打开(进程重启)依然存在。 +未发送的文字草稿按会话持久化保存到本地。 滚动消息列表时,输入框区域会淡出到 40% 透明度以减少干扰,停止滚动后恢复。 From 8d0061b889eed299e3bd05067d80f8621ffa40f2 Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 18:45:15 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E9=95=BF?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E9=9D=A2=E6=9D=BF=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=8F=8A=E8=BE=93=E5=85=A5=E6=A1=86=E8=A2=AB?= =?UTF-8?q?=E6=8C=A4=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展开的自定义面板按内容撑高、既无上限也不可滚动,内容一长就把输入栏挤出屏幕。改为由 ChatInputBar 用 BoxWithConstraints 算出可视高度(maxHeight - imeInset 的 40%)传给面板,面板按此限高(heightIn)并内部 verticalScroll。 --- .../feature/agent/presentation/component/ChatInputBar.kt | 5 ++++- .../agent/presentation/component/ProviderDashboardBar.kt | 7 +++++++ docs-site/docs/advanced/dashboard-cards.md | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt index eb49a878..229c3e09 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputBar.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -234,9 +235,10 @@ internal fun ChatInputBar( // 渐变终点固定在蒙版可视高度内:若跟随整个 Box(含 imeInset 被键盘拉长的部分), // 键盘弹起时可见区域只占渐变前段,alpha 被摊薄到几乎透明——看起来像没有蒙版。 val maskGradientEndY = with(LocalDensity.current) { INPUT_BAR_MASK_HEIGHT.toPx() } - Box( + BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { + val dashboardBodyMaxHeight = (maxHeight - imeInset - Spacing.md).coerceAtLeast(0.dp) * 0.4f // 半透明渐变蒙版:盖住输入框区域 + 导航栏(手势小白条)区域,滚动内容滑入时被遮罩 // (能看见但看不清);高度含 IME inset 随键盘上移,渐变在 INPUT_BAR_MASK_HEIGHT 内完成, // 之下为纯色。注意 IME padding 不能加在外层 Box 上——align(BottomCenter) 的子项 @@ -353,6 +355,7 @@ internal fun ChatInputBar( onRefresh = onRefreshDashboard, onRefreshByButton = onRefreshDashboardByButton, onExpandedChange = onDashboardExpandedChange, + maxExpandedBodyHeight = dashboardBodyMaxHeight, forceCollapse = forceCollapseDashboard ) } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ProviderDashboardBar.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ProviderDashboardBar.kt index 0d193eec..b11310c7 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ProviderDashboardBar.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ProviderDashboardBar.kt @@ -20,9 +20,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator @@ -42,6 +45,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.aicode.core.theme.Radius @@ -65,6 +69,7 @@ fun ProviderDashboardBar( provider: AIProviderConfig, state: ProviderDashboardState, onRefresh: () -> Unit, + maxExpandedBodyHeight: Dp, modifier: Modifier = Modifier, forceCollapse: Boolean = false, onRefreshByButton: () -> Unit = {}, @@ -242,6 +247,8 @@ fun ProviderDashboardBar( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = maxExpandedBodyHeight) + .verticalScroll(rememberScrollState()) .padding(top = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { diff --git a/docs-site/docs/advanced/dashboard-cards.md b/docs-site/docs/advanced/dashboard-cards.md index a119b0b0..7eeae6f7 100644 --- a/docs-site/docs/advanced/dashboard-cards.md +++ b/docs-site/docs/advanced/dashboard-cards.md @@ -4,6 +4,8 @@ AI 助手与开发者可以依据此规范,编写面板脚本(Python / Node / Shell)输出结构化卡片 JSON,从而完全自定义面板在收起态(Compact)与展开态(Expanded)下的排版、配色、进度条、指标卡片及明细信息。 +点击聊天输入框上方的面板标题可展开或收起。展开正文最多占聊天区域可用高度的约 40%;内容较长时,在正文内上下滑动即可查看全部内容,顶部标题与收起按钮保持固定,输入框仍保留在面板下方。短面板按实际内容高度显示,无需在脚本中截断明细列表。 + --- ## 1. 脚本存放路径与管理方式 From 17cc627d7c422c3284772b82877b9cae5fbfb7ed Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:44:02 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(chat):=20=E6=80=9D=E8=80=83=E4=B8=8D?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E6=97=B6=E4=BF=9D=E7=95=99=E7=BC=93=E5=86=B2?= =?UTF-8?q?=E4=B8=8D=E9=80=80=E4=BC=91=EF=BC=8C=E5=AF=BC=E8=87=B4=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=9E=E5=A4=8D=E6=98=BE=E7=A4=BA=E4=B8=A4?= =?UTF-8?q?=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:同一条 AI 回复在聊天里显示两遍。 调查结论(只读):落库侧没有双写(AgentMessageDao 是 @Insert(REPLACE)、按 id 覆盖), 重复只可能来自 UI 层——「保留缓冲气泡」与「已落库气泡」同屏。 机制:isAssistantSettled = textSettled && reasoningSettled;而 reasoningSettled 在 currentReasoning 非空时要拿前 20 字前缀去比 lastMsg.reasoning。思考过程不落库的场景下 lastMsg.reasoning 恒为空 → 前缀永不吻合 → isAssistantSettled 恒 false → 保留文本永不 退场,尾巴的 StreamingBubble(key="__active__")与已落库消息气泡同时渲染 → 看起来两遍。 修法沿用 origin/fix/chat-duplicate-reply(929d8684;该分支从未并入 main, GitHub compare 显示 ahead_by 1 / behind_by 120):reasoningSettled 判定补 || lastMsg.reasoning.isNullOrBlank()。textSettled 不动——正文一定落库,前缀比对仍是 有意义的守卫。 说明:该判定是 Composable 内的局部 val,全仓 isAssistantSettled 只出现在本文件, 没有单测覆盖;要可测需抽成纯函数,属于额外重构,本次不做。容器无 JDK,编译与真机复现未验证。 --- .../feature/agent/presentation/component/AIChatPanel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt index a179d75f..46a910b1 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt @@ -954,7 +954,11 @@ fun AIChatPanel( val prefix = currentText.trimStart().take(20) prefix.isEmpty() || lastMsg.content.trimStart().startsWith(prefix) } - val reasoningSettled = if (currentReasoning.isNullOrBlank()) { + // 落库消息没有 reasoning 时也算已就位:思考过程不落库的场景下,拿前缀去比会永远不吻合, + // 保留缓冲就永不退休 → 尾巴气泡与已落库气泡同屏,看起来就是同一条回复显示两遍。 + // (textSettled 那条仍要比前缀——正文一定落库;reasoning 不一定。) + // 同 origin/fix/chat-duplicate-reply(929d8684)的判定。 + val reasoningSettled = if (currentReasoning.isNullOrBlank() || lastMsg.reasoning.isNullOrBlank()) { true } else { val prefix = currentReasoning.trimStart().take(20) From d97760be888baab1cb3fe76d83ea10776b2f2d04 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:49:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(ui):=20=E5=B7=A5=E5=85=B7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=B8=B2=E6=9F=93=E8=B7=AF=E5=BE=84=E7=9A=84=20JSON?= =?UTF-8?q?=20=E8=AE=BF=E9=97=AE=E5=8A=A0=E5=9B=BA=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E4=B8=8D=E7=AC=A6=E5=B4=A9=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报的崩溃(Element class json.c ... is not a JsonPrimitive)确认来自这里: ToolMessageComponents 有两条从 Compose 组合期直接调用的链,内层都没有兜底 —— 而 Compose 没有错误边界,AIEditorApp 的未捕获处理器把它转成 CrashActivity, 所以一条形状不符的历史工具结果能直接崩界面。 - 链 A:remember → parseEditDiff(:830) → :833 早退分支无 runCatching → parseEditDiffObject 里裸用 ?.jsonPrimitive?/?.jsonArray/el.jsonObject (同函数 :839 那条 fallback 分支反而有 runCatching,属"半保护",更容易漏) - 链 B:remember → formatToolResult → :867 obj["status"]?.jsonPrimitive (解析被 runCatching 包了、访问器没包) 改法:内层全部改显式 as? 转换(坏 hunk 由 mapNotNull 单独跳过,其余照渲染), 外层给两处 remember 加 runCatching 兜底,parseEditDiff/formatToolResult 补函数级 兜底。正常形状下输出逐位不变(仿真 42 组 payload 对照 0 处差异),只有原本必崩 的 4 类输入降级为"该行不显示 diff/显示原始文本"。 其余 UI 侧 JSON 消费者(TodoCardComponents / WebSearchResultComponents)本身已有 try/catch 或 runCatching;presentation 之外的工具路径由 runToolSync 的 try 收口, 到不了崩溃处理器,本次不动。 --- .../component/ToolMessageComponents.kt | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ToolMessageComponents.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ToolMessageComponents.kt index 7c06918e..69d19b74 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ToolMessageComponents.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ToolMessageComponents.kt @@ -93,9 +93,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -149,11 +146,11 @@ internal fun ToolMessageBody( val edit = if (!running && !message.isError && (message.toolName == "editFile" || message.toolName == "writeFile") ) { - remember(message.id, message.content) { parseEditDiff(message.content) } + remember(message.id, message.content) { runCatching { parseEditDiff(message.content) }.getOrNull() } } else null val resultText = if (!running) { - remember(message.id, message.content) { formatToolResult(message.content) } + remember(message.id, message.content) { runCatching { formatToolResult(message.content) }.getOrElse { message.content } } } else null val argHint = remember(message.toolArgs) { toolArgHint(message.toolArgs) } val argsFull = remember(message.toolArgs) { formatToolArgs(message.toolArgs) } @@ -827,32 +824,32 @@ internal data class EditDiff( /** * 从持久化的 TOOL 内容中解析 editFile / writeFile 的结构化差异 */ -internal fun parseEditDiff(content: String): EditDiff? { +internal fun parseEditDiff(content: String): EditDiff? = runCatching { val dataObj = extractToolDataObject(content) if (dataObj != null) { - return parseEditDiffObject(dataObj) + return@runCatching parseEditDiffObject(dataObj) } val start = content.indexOf('{') val end = content.lastIndexOf('}') - if (start < 0 || end <= start) return null - return runCatching { - parseEditDiffObject(Json.parseToJsonElement(content.substring(start, end + 1)).jsonObject) - }.getOrNull() -} + if (start < 0 || end <= start) return@runCatching null + val parsed = Json.parseToJsonElement(content.substring(start, end + 1)) as? JsonObject + ?: return@runCatching null + parseEditDiffObject(parsed) +}.getOrNull() private fun parseEditDiffObject(obj: JsonObject): EditDiff? { - val path = obj["path"]?.jsonPrimitive?.contentOrNull ?: "" - val added = obj["added_lines"]?.jsonPrimitive?.intOrNull ?: 0 - val removed = obj["removed_lines"]?.jsonPrimitive?.intOrNull ?: 0 - - val hunks = obj["hunks"]?.jsonArray?.mapNotNull { el -> - val ho = el.jsonObject - val d = ho["diff"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null - EditHunk(startLine = ho["start_line"]?.jsonPrimitive?.intOrNull ?: 1, diff = d) + val path = (obj["path"] as? JsonPrimitive)?.contentOrNull ?: "" + val added = (obj["added_lines"] as? JsonPrimitive)?.intOrNull ?: 0 + val removed = (obj["removed_lines"] as? JsonPrimitive)?.intOrNull ?: 0 + + val hunks = (obj["hunks"] as? JsonArray)?.mapNotNull { el -> + val ho = el as? JsonObject ?: return@mapNotNull null + val d = (ho["diff"] as? JsonPrimitive)?.contentOrNull ?: return@mapNotNull null + EditHunk(startLine = (ho["start_line"] as? JsonPrimitive)?.intOrNull ?: 1, diff = d) } ?: run { - val d = obj["diff"]?.jsonPrimitive?.contentOrNull ?: return null - listOf(EditHunk(startLine = obj["start_line"]?.jsonPrimitive?.intOrNull ?: 1, diff = d)) + val d = (obj["diff"] as? JsonPrimitive)?.contentOrNull ?: return null + listOf(EditHunk(startLine = (obj["start_line"] as? JsonPrimitive)?.intOrNull ?: 1, diff = d)) } if (hunks.isEmpty()) return null return EditDiff(path = path, added = added, removed = removed, hunks = hunks) @@ -861,43 +858,46 @@ private fun parseEditDiffObject(obj: JsonObject): EditDiff? { /** * 把落库的原始工具结果清洗成可读文本 */ -internal fun formatToolResult(raw: String): String { +internal fun formatToolResult(raw: String): String = runCatching { val s = raw.withoutToolStatusPrefix() - parseToolTransport(s)?.let { obj -> - return when (obj["status"]?.jsonPrimitive?.contentOrNull) { - "error" -> obj["message"]?.jsonPrimitive?.contentOrNull ?: s + val transport = parseToolTransport(s)?.let { obj -> + when ((obj["status"] as? JsonPrimitive)?.contentOrNull) { + "error" -> (obj["message"] as? JsonPrimitive)?.contentOrNull ?: s "success", "partial" -> formatToolData(obj["data"]) ?: s else -> s } } + if (transport != null) return@runCatching transport when { s.startsWith("Error(") -> { val msgIdx = s.indexOf("message=") - if (msgIdx >= 0) { + if (msgIdx < 0) { + s + } else { var body = s.substring(msgIdx + "message=".length) val codeIdx = body.lastIndexOf(", code=") body = if (codeIdx >= 0) body.substring(0, codeIdx) else body.removeSuffix(")") - return body.trim() + body.trim() } } s.startsWith("Success(data=") -> { val inner = s.removePrefix("Success(data=").removeSuffix(")") - return formatJsonData(inner) ?: inner.trim() + formatJsonData(inner) ?: inner.trim() } s.startsWith("Partial(data=") -> { var inner = s.removePrefix("Partial(data=") val msgIdx = inner.lastIndexOf(", message=") inner = if (msgIdx >= 0) inner.substring(0, msgIdx) else inner.removeSuffix(")") - return formatJsonData(inner) ?: inner.trim() + formatJsonData(inner) ?: inner.trim() } + else -> s } - return s -} +}.getOrElse { raw } private fun parseToolTransport(raw: String): JsonObject? { return runCatching { - val obj = Json.parseToJsonElement(raw.trim()).jsonObject + val obj = Json.parseToJsonElement(raw.trim()) as? JsonObject ?: return@runCatching null if (obj["status"] != null) obj else null }.getOrNull() } @@ -943,7 +943,7 @@ internal fun formatJsonData(jsonStr: String): String? = runCatching { internal fun formatToolArgs(argsJson: String?): String? { if (argsJson.isNullOrBlank()) return null return runCatching { - val obj = Json.parseToJsonElement(argsJson).jsonObject + val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null if (obj.isEmpty()) return null obj.entries.joinToString("\n") { (k, v) -> val vv = (v as? JsonPrimitive)?.contentOrNull ?: v.toString() @@ -956,7 +956,7 @@ internal fun formatToolArgs(argsJson: String?): String? { internal fun toolArgHint(argsJson: String?): String? { if (argsJson.isNullOrBlank()) return null return runCatching { - val obj = Json.parseToJsonElement(argsJson).jsonObject + val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null val preferred = listOf("command", "cmd", "path", "file_path", "file", "query", "pattern", "url", "name") val v = preferred.firstNotNullOfOrNull { obj[it] } ?: obj.values.firstOrNull() val str = (v as? JsonPrimitive)?.contentOrNull ?: v?.toString() @@ -968,7 +968,7 @@ internal fun toolArgHint(argsJson: String?): String? { private fun extractFilePathArg(argsJson: String?): String? { if (argsJson.isNullOrBlank()) return null return runCatching { - val obj = Json.parseToJsonElement(argsJson).jsonObject + val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null listOf("path", "file_path", "file").firstNotNullOfOrNull { key -> (obj[key] as? JsonPrimitive)?.contentOrNull }