diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt index 5d1d26a2..d1305be1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -29,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,9 +62,18 @@ fun GroupChatSettingsDialog( ) { var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } - var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + val systemPromptTextFieldState = rememberTextFieldState(currentSystemPrompt.orEmpty()) var showHighLimitsConfirmation by remember { mutableStateOf(false) } + LaunchedEffect(currentSystemPrompt) { + val loaded = currentSystemPrompt.orEmpty() + if (systemPromptTextFieldState.text.toString() != loaded) { + systemPromptTextFieldState.edit { + replace(0, length, loaded) + } + } + } + val isHighLimit = maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES @@ -217,8 +230,7 @@ fun GroupChatSettingsDialog( ) Spacer(modifier = Modifier.height(6.dp)) OutlinedTextField( - value = systemPrompt, - onValueChange = { systemPrompt = it }, + state = systemPromptTextFieldState, placeholder = { Text( text = stringResource(R.string.group_chat_settings_system_prompt_hint), @@ -231,7 +243,7 @@ fun GroupChatSettingsDialog( .heightIn(min = 80.dp, max = 160.dp) .testTag("room_system_prompt_input"), textStyle = MaterialTheme.typography.bodyMedium, - maxLines = 4, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), ) } } @@ -245,7 +257,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) } }, @@ -260,7 +275,7 @@ fun GroupChatSettingsDialog( onClick = { maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() - systemPrompt = "" + systemPromptTextFieldState.clearText() }, ) { Text(stringResource(R.string.group_chat_settings_reset_default)) @@ -307,7 +322,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) }, colors = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt new file mode 100644 index 00000000..47d7f069 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow + +/** + * Two-way bridge between an external [String] holder (ViewModel state, a parent + * callback, …) and a [TextFieldState]-backed text field. + * + * Why not `OutlinedTextField(value:, onValueChange:)`? The value-based overload + * keeps its selection inside the legacy `BasicTextField` implementation, so when + * a scrolled field gains focus it scrolls the cursor into view while the cursor + * is still at index 0 — the viewport jumps back to the top instead of staying + * where the user tapped. `TextFieldState`-backed fields only follow the cursor + * while the user is actively editing, so the scroll position survives the tap. + * + * Inbound (external → field) updates are applied only when the text actually + * differs, which keeps ViewModel round-trips from clobbering in-progress typing; + * outbound (field → external) updates skip the identity case so the two + * directions can never ping-pong. + */ +@Composable +fun rememberSyncedTextFieldState( + value: String, + onValueChange: (String) -> Unit, +): TextFieldState { + val state = rememberTextFieldState(value) + val currentValue by rememberUpdatedState(value) + val currentOnValueChange by rememberUpdatedState(onValueChange) + + LaunchedEffect(state, value) { + if (state.text.toString() != value) { + state.edit { replace(0, length, value) } + } + } + + LaunchedEffect(state) { + snapshotFlow { state.text.toString() } + .collect { newText -> + if (newText != currentValue) currentOnValueChange(newText) + } + } + + return state +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 014b844e..3291ef4a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Code @@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -71,6 +74,7 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -290,6 +294,8 @@ private fun YAMLEditor( return } + val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange) + Column( modifier = Modifier @@ -297,14 +303,12 @@ private fun YAMLEditor( .padding(horizontal = 16.dp), ) { OutlinedTextField( - value = yamlText, - onValueChange = onYamlTextChange, + state = textFieldState, modifier = Modifier .fillMaxWidth() .weight(1f), textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - maxLines = Int.MAX_VALUE, ) Spacer(modifier = Modifier.height(12.dp)) @@ -936,34 +940,41 @@ private fun JsonField( label: String, onChange: (JsonElement) -> Unit, ) { - var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") } + val textFieldState = remember(key) { TextFieldState(value?.let(::jsonText) ?: "") } var hasFocus by remember { mutableStateOf(false) } var invalid by remember { mutableStateOf(false) } - LaunchedEffect(value) { - if (!hasFocus) text = value?.let(::jsonText) ?: "" + LaunchedEffect(value, hasFocus) { + if (!hasFocus) { + val canonical = value?.let(::jsonText) ?: "" + if (textFieldState.text.toString() != canonical) { + textFieldState.edit { + replace(0, length, canonical) + } + } + } + } + + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text.toString() } + .collect { newText -> + val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull() + invalid = newText.isNotBlank() && parsed == null + if (parsed != null && newText.isNotBlank() && parsed != value) { + onChange(parsed) + } + } } OutlinedTextField( - value = text, - onValueChange = { newText -> - text = newText - val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull() - invalid = newText.isNotBlank() && parsed == null - if (parsed != null && newText.isNotBlank()) { - onChange(parsed) - } - }, + state = textFieldState, modifier = Modifier .fillMaxWidth() .heightIn(min = 96.dp) .onFocusChanged { hasFocus = it.isFocused - if (!it.isFocused) { - invalid = false - text = value?.let(::jsonText) ?: "" - } + if (!it.isFocused) invalid = false }, isError = invalid, supportingText = @@ -974,6 +985,6 @@ private fun JsonField( }, textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), label = { Text(label) }, - maxLines = Int.MAX_VALUE, + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 4), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index b463b804..7e408b72 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -72,6 +72,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.util.CronExpressionFormatter @Composable @@ -377,6 +378,11 @@ fun CronJobEditorDialog( state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() || state.monitorMode != "off" + val promptTextFieldState = + rememberSyncedTextFieldState(state.prompt) { onFieldChange("prompt", it) } + val skillsTextFieldState = + rememberSyncedTextFieldState(state.skills) { onFieldChange("skills", it) } + ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast) Dialog( @@ -506,8 +512,7 @@ fun CronJobEditorDialog( // Prompt OutlinedTextField( - value = state.prompt, - onValueChange = { onFieldChange("prompt", it) }, + state = promptTextFieldState, label = { Text(stringResource(R.string.cron_edit_field_prompt)) }, modifier = Modifier @@ -531,8 +536,7 @@ fun CronJobEditorDialog( // Skills OutlinedTextField( - value = state.skills, - onValueChange = { onFieldChange("skills", it) }, + state = skillsTextFieldState, label = { Text(stringResource(R.string.cron_edit_field_skills)) }, placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) }, modifier = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index cfe5d865..f2a3ed35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -13,6 +13,7 @@ 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.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess @@ -49,6 +50,7 @@ import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import kotlinx.coroutines.launch private const val PARKED_VALUE = "__parked__" @@ -68,6 +70,7 @@ fun KanbanCreateTaskDialog( ) { var title by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") } + val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it } var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } var selectedAssignee by remember { mutableStateOf(null) } var priority by remember { mutableIntStateOf(0) } @@ -114,12 +117,10 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( - value = desc, - onValueChange = { desc = it }, + state = descTextFieldState, label = { Text(stringResource(R.string.kanban_task_desc)) }, modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 5, + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 3, maxHeightInLines = 5), enabled = !isCreating, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt index 039c5ac0..b968520f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.UploadFile @@ -37,6 +38,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.theme.Spacing import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.mcp.McpServersUiState import com.m57.hermescontrol.ui.mcp.McpServersViewModel @@ -112,6 +114,9 @@ fun McpDialogs( } if (state.showImportDialog) { + val importTextFieldState = + rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput) + AlertDialog( onDismissRequest = { viewModel.toggleImportDialog() }, title = { @@ -157,8 +162,7 @@ fun McpDialogs( } Spacer(modifier = Modifier.height(spacing.xs)) OutlinedTextField( - value = state.importJsonInput, - onValueChange = viewModel::updateImportJsonInput, + state = importTextFieldState, placeholder = { Text( "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", @@ -170,7 +174,7 @@ fun McpDialogs( Modifier .fillMaxWidth() .height(180.dp), - maxLines = 10, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), ) } }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index cada9a40..2b362b23 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -586,7 +588,15 @@ fun ProfilesScreen( if (soulEditProfileName != null) { val initialText = state.selectedSoulContent ?: "" - var soulText by remember(initialText) { mutableStateOf(initialText) } + val soulTextFieldState = rememberTextFieldState(initialText) + + LaunchedEffect(initialText) { + if (soulTextFieldState.text.toString() != initialText) { + soulTextFieldState.edit { + replace(0, length, initialText) + } + } + } AlertDialog( onDismissRequest = { @@ -615,10 +625,9 @@ fun ProfilesScreen( } } else { OutlinedTextField( - value = soulText, - onValueChange = { soulText = it }, + state = soulTextFieldState, modifier = Modifier.fillMaxWidth(), - maxLines = 10, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), ) } }, @@ -627,7 +636,7 @@ fun ProfilesScreen( onClick = { val profileName = soulEditProfileName if (profileName != null) { - viewModel.saveSoul(profileName, soulText) + viewModel.saveSoul(profileName, soulTextFieldState.text.toString()) } soulEditProfileName = null }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt index f1109a0c..c27f80b7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.skills.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save import androidx.compose.material3.AlertDialog @@ -44,13 +45,21 @@ internal fun SkillEditorDialog( onDismiss: () -> Unit, onClearSaveSuccess: () -> Unit, ) { - var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) } + val textFieldState = rememberTextFieldState(initialContent.orEmpty()) var showDiscardConfirm by remember { mutableStateOf(false) } + LaunchedEffect(initialContent) { + val newContent = initialContent.orEmpty() + if (textFieldState.text.toString() != newContent) { + textFieldState.edit { + replace(0, length, newContent) + } + } + } + val hasChanges = - remember(initialContent, contentText) { - val original = initialContent.orEmpty() - original != contentText + remember(initialContent, textFieldState.text) { + initialContent.orEmpty() != textFieldState.text.toString() } LaunchedEffect(saveSuccess) { @@ -95,7 +104,7 @@ internal fun SkillEditorDialog( actions = { if (!isLoading) { IconButton( - onClick = { onSave(contentText) }, + onClick = { onSave(textFieldState.text.toString()) }, enabled = !isSaving, ) { if (isSaving) { @@ -126,8 +135,7 @@ internal fun SkillEditorDialog( else -> { OutlinedTextField( - value = contentText, - onValueChange = { contentText = it }, + state = textFieldState, modifier = Modifier .fillMaxSize()