fix(ui): keep scroll position when focusing scrolled editors (#1116)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 05:13:56 +03:00
1 parent 2d71135a85
commit 65c8e07162
8 files changed
+155 -50

No files matched your search

@@ -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 =
@@ -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
}
@@ -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),
)
}
@@ -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 =
@@ -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<String?>(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,
)
@@ -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),
)
}
},
@@ -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
},
@@ -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()