fix(ui): keep scroll position when focusing scrolled editors (#1116)
This commit is contained in:
8 files changed
+155
-50
No files matched your search
+25
-7
@@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -29,6 +32,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -58,9 +62,18 @@ fun GroupChatSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
||||||
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.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) }
|
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 =
|
val isHighLimit =
|
||||||
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
||||||
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
||||||
@@ -217,8 +230,7 @@ fun GroupChatSettingsDialog(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = systemPrompt,
|
state = systemPromptTextFieldState,
|
||||||
onValueChange = { systemPrompt = it },
|
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
||||||
@@ -231,7 +243,7 @@ fun GroupChatSettingsDialog(
|
|||||||
.heightIn(min = 80.dp, max = 160.dp)
|
.heightIn(min = 80.dp, max = 160.dp)
|
||||||
.testTag("room_system_prompt_input"),
|
.testTag("room_system_prompt_input"),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 4,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +257,10 @@ fun GroupChatSettingsDialog(
|
|||||||
onSave(
|
onSave(
|
||||||
maxMessages.roundToInt(),
|
maxMessages.roundToInt(),
|
||||||
maxPasses.roundToInt(),
|
maxPasses.roundToInt(),
|
||||||
systemPrompt.trim().ifBlank { null },
|
systemPromptTextFieldState.text
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.ifBlank { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -260,7 +275,7 @@ fun GroupChatSettingsDialog(
|
|||||||
onClick = {
|
onClick = {
|
||||||
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
||||||
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
||||||
systemPrompt = ""
|
systemPromptTextFieldState.clearText()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.group_chat_settings_reset_default))
|
Text(stringResource(R.string.group_chat_settings_reset_default))
|
||||||
@@ -307,7 +322,10 @@ fun GroupChatSettingsDialog(
|
|||||||
onSave(
|
onSave(
|
||||||
maxMessages.roundToInt(),
|
maxMessages.roundToInt(),
|
||||||
maxPasses.roundToInt(),
|
maxPasses.roundToInt(),
|
||||||
systemPrompt.trim().ifBlank { null },
|
systemPromptTextFieldState.text
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.ifBlank { null },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors =
|
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.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Code
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
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.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -290,6 +294,8 @@ private fun YAMLEditor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -297,14 +303,12 @@ private fun YAMLEditor(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = yamlText,
|
state = textFieldState,
|
||||||
onValueChange = onYamlTextChange,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
maxLines = Int.MAX_VALUE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -936,34 +940,41 @@ private fun JsonField(
|
|||||||
label: String,
|
label: String,
|
||||||
onChange: (JsonElement) -> Unit,
|
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 hasFocus by remember { mutableStateOf(false) }
|
||||||
var invalid by remember { mutableStateOf(false) }
|
var invalid by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(value) {
|
LaunchedEffect(value, hasFocus) {
|
||||||
if (!hasFocus) text = value?.let(::jsonText) ?: ""
|
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(
|
OutlinedTextField(
|
||||||
value = text,
|
state = textFieldState,
|
||||||
onValueChange = { newText ->
|
|
||||||
text = newText
|
|
||||||
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
|
||||||
invalid = newText.isNotBlank() && parsed == null
|
|
||||||
if (parsed != null && newText.isNotBlank()) {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 96.dp)
|
.heightIn(min = 96.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
hasFocus = it.isFocused
|
hasFocus = it.isFocused
|
||||||
if (!it.isFocused) {
|
if (!it.isFocused) invalid = false
|
||||||
invalid = false
|
|
||||||
text = value?.let(::jsonText) ?: ""
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isError = invalid,
|
isError = invalid,
|
||||||
supportingText =
|
supportingText =
|
||||||
@@ -974,6 +985,6 @@ private fun JsonField(
|
|||||||
},
|
},
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
label = { Text(label) },
|
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.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import com.m57.hermescontrol.util.CronExpressionFormatter
|
import com.m57.hermescontrol.util.CronExpressionFormatter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -377,6 +378,11 @@ fun CronJobEditorDialog(
|
|||||||
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
||||||
state.monitorMode != "off"
|
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)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -506,8 +512,7 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
// Prompt
|
// Prompt
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.prompt,
|
state = promptTextFieldState,
|
||||||
onValueChange = { onFieldChange("prompt", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -531,8 +536,7 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
// Skills
|
// Skills
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.skills,
|
state = skillsTextFieldState,
|
||||||
onValueChange = { onFieldChange("skills", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
||||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
+5
-4
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
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.KanbanProfile
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val PARKED_VALUE = "__parked__"
|
private const val PARKED_VALUE = "__parked__"
|
||||||
@@ -68,6 +70,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var desc by remember { mutableStateOf("") }
|
var desc by remember { mutableStateOf("") }
|
||||||
|
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
|
||||||
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
|
||||||
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||||
var priority by remember { mutableIntStateOf(0) }
|
var priority by remember { mutableIntStateOf(0) }
|
||||||
@@ -114,12 +117,10 @@ fun KanbanCreateTaskDialog(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = desc,
|
state = descTextFieldState,
|
||||||
onValueChange = { desc = it },
|
|
||||||
label = { Text(stringResource(R.string.kanban_task_desc)) },
|
label = { Text(stringResource(R.string.kanban_task_desc)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3,
|
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 3, maxHeightInLines = 5),
|
||||||
maxLines = 5,
|
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
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.data.model.McpServer
|
||||||
import com.m57.hermescontrol.theme.Spacing
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
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.common.toDetailRows
|
||||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
@@ -112,6 +114,9 @@ fun McpDialogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.showImportDialog) {
|
if (state.showImportDialog) {
|
||||||
|
val importTextFieldState =
|
||||||
|
rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput)
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||||
title = {
|
title = {
|
||||||
@@ -157,8 +162,7 @@ fun McpDialogs(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.importJsonInput,
|
state = importTextFieldState,
|
||||||
onValueChange = viewModel::updateImportJsonInput,
|
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||||
@@ -170,7 +174,7 @@ fun McpDialogs(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp),
|
.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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -586,7 +588,15 @@ fun ProfilesScreen(
|
|||||||
|
|
||||||
if (soulEditProfileName != null) {
|
if (soulEditProfileName != null) {
|
||||||
val initialText = state.selectedSoulContent ?: ""
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
@@ -615,10 +625,9 @@ fun ProfilesScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = soulText,
|
state = soulTextFieldState,
|
||||||
onValueChange = { soulText = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 10,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -627,7 +636,7 @@ fun ProfilesScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val profileName = soulEditProfileName
|
val profileName = soulEditProfileName
|
||||||
if (profileName != null) {
|
if (profileName != null) {
|
||||||
viewModel.saveSoul(profileName, soulText)
|
viewModel.saveSoul(profileName, soulTextFieldState.text.toString())
|
||||||
}
|
}
|
||||||
soulEditProfileName = null
|
soulEditProfileName = null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.skills.components
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -44,13 +45,21 @@ internal fun SkillEditorDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onClearSaveSuccess: () -> Unit,
|
onClearSaveSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
|
val textFieldState = rememberTextFieldState(initialContent.orEmpty())
|
||||||
var showDiscardConfirm by remember { mutableStateOf(false) }
|
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 =
|
val hasChanges =
|
||||||
remember(initialContent, contentText) {
|
remember(initialContent, textFieldState.text) {
|
||||||
val original = initialContent.orEmpty()
|
initialContent.orEmpty() != textFieldState.text.toString()
|
||||||
original != contentText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(saveSuccess) {
|
LaunchedEffect(saveSuccess) {
|
||||||
@@ -95,7 +104,7 @@ internal fun SkillEditorDialog(
|
|||||||
actions = {
|
actions = {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onSave(contentText) },
|
onClick = { onSave(textFieldState.text.toString()) },
|
||||||
enabled = !isSaving,
|
enabled = !isSaving,
|
||||||
) {
|
) {
|
||||||
if (isSaving) {
|
if (isSaving) {
|
||||||
@@ -126,8 +135,7 @@ internal fun SkillEditorDialog(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = contentText,
|
state = textFieldState,
|
||||||
onValueChange = { contentText = it },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
Reference in new issue
Block a user