Fix: Prevent AuthManager state pollution on connection verification (#348)

* Fix: Prevent AuthManager state pollution on connection verification

Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.

Fixes #260

* Fix: Prevent AuthManager state pollution on connection verification

Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.

Fixes #260

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-23 20:49:58 +03:00
1 parent c21e3e9ed0
commit 2c6e437a5b
42 files changed
+296 -667

No files matched your search

@@ -65,16 +65,17 @@ fun AchievementsScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() },
) {
) { paddingValues ->
when {
state.isLoading && state.achievements.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && state.achievements.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAchievements() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -83,12 +84,13 @@ fun AchievementsScreen(
title = stringResource(R.string.achievements_empty_title),
subtitle = stringResource(R.string.achievements_empty_desc),
icon = Icons.Filled.Refresh,
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -99,7 +101,7 @@ fun AchievementsScreen(
placeholder = "Search achievements...",
)
}
items(filteredAchievements, key = { it.id }) { achievement ->
items(filteredAchievements) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card(
@@ -69,16 +69,17 @@ fun ChannelsScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPlatforms() },
) {
) { paddingValues ->
when {
state.isLoading && state.platforms.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlatforms() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -86,12 +87,13 @@ fun ChannelsScreen(
EmptyState(
title = stringResource(R.string.channels_empty_title),
subtitle = stringResource(R.string.channels_empty_desc),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
@@ -10,9 +10,10 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -52,7 +53,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
@@ -129,6 +129,7 @@ fun ChatBubble(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun UserBubble(
message: ChatMessage,
@@ -173,18 +174,17 @@ private fun UserBubble(
)
val primary = MaterialTheme.colorScheme.primary
val secondary = MaterialTheme.colorScheme.secondary
val onPrimary = MaterialTheme.colorScheme.onPrimary
val avgLuminance = (primary.luminance() + secondary.luminance()) / 2f
val userBubbleTextColor =
if (avgLuminance > 0.5f) {
if (onPrimary.luminance() < 0.5f) {
onPrimary
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color(0xFF1A1A24)
}
} else {
if (onPrimary.luminance() > 0.5f) {
onPrimary
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color.White
}
@@ -203,13 +203,13 @@ private fun UserBubble(
),
).background(brush = gradientBrush)
.testTag("chat_bubble_user")
.pointerInput(message.id) {
detectTapGestures(
onLongPress = {
showCopyButton = true
},
)
},
.combinedClickable(
role = Role.Button,
onClick = {},
onLongClick = {
showCopyButton = true
},
),
color = Color.Transparent,
tonalElevation = 0.dp,
) {
@@ -270,6 +270,7 @@ private fun UserBubble(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AssistantBubble(
message: ChatMessage,
@@ -280,17 +281,16 @@ private fun AssistantBubble(
modifier: Modifier = Modifier,
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val onSurface = MaterialTheme.colorScheme.onSurface
val textColor =
if (bubbleColor.luminance() > 0.5f) {
if (onSurface.luminance() < 0.5f) {
onSurface
if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFF1A1A24)
}
} else {
if (onSurface.luminance() > 0.5f) {
onSurface
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFFE8E6EE)
}
@@ -318,7 +318,7 @@ private fun AssistantBubble(
modifier =
Modifier
.widthIn(max = maxWidth)
.let { if (!message.isStreaming) it.animateContentSize() else it }
.animateContentSize()
.clip(
RoundedCornerShape(
topStart = 4.dp,
@@ -327,13 +327,13 @@ private fun AssistantBubble(
bottomEnd = 16.dp,
),
).testTag("chat_bubble_assistant")
.pointerInput(message.id) {
detectTapGestures(
onLongPress = {
showCopyButton = true
},
)
},
.combinedClickable(
role = Role.Button,
onClick = {},
onLongClick = {
showCopyButton = true
},
),
color = bubbleColor,
border =
BorderStroke(
@@ -75,6 +75,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -103,6 +104,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusRed
@@ -139,9 +141,8 @@ fun ChatScreen(
// during composition — BEFORE any WebSocket event (GatewayReady) can be
// processed. This ensures GatewayReady sees the pending session and
// resumes it instead of creating a new empty chat (issue #240).
remember(sessionId) {
SideEffect {
viewModel.initialSessionId = sessionId
true
}
// Switch to the session from a notification tap or history screen when provided.
@@ -173,7 +174,6 @@ fun ChatScreen(
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
@@ -212,7 +212,7 @@ fun ChatScreen(
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.streamingMessage != null, state.isThinking) {
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
@@ -245,21 +245,15 @@ fun ChatScreen(
)
}
val backgroundColor = MaterialTheme.colorScheme.background
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundGradient =
remember(backgroundColor, surfaceVariantColor, primaryColor) {
Brush.verticalGradient(
colors =
listOf(
backgroundColor,
surfaceVariantColor.copy(alpha = 0.3f),
primaryColor.copy(alpha = 0.05f),
),
)
}
Brush.verticalGradient(
colors =
listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f),
),
)
// Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
@@ -447,15 +441,13 @@ fun ChatScreen(
val isLastMessage = index == state.messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (state.typingEffectEnabled &&
isLastMessage &&
isAssistant &&
lastAnimatedMessageId != message.id
) {
if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = state.typingEffectDelayMs,
typingDelayMs = typingDelayMs,
isDark = isDark,
onAnimationComplete = {
lastAnimatedMessageId = message.id
@@ -474,10 +466,13 @@ fun ChatScreen(
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (state.typingEffectEnabled && streaming.isStreaming) {
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (typingEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = state.typingEffectDelayMs,
typingDelayMs = typingDelayMs,
isDark = isDark,
)
} else {
@@ -721,7 +716,7 @@ private fun ChatInputBar(
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredCommands, key = { it }) { cmd ->
items(filteredCommands) { cmd ->
val description =
when (cmd) {
"/help" -> stringResource(R.string.command_help_desc)
@@ -57,9 +57,6 @@ data class ChatUiState(
val searchQuery: String = "",
val searchMatchIndices: List<Int> = emptyList(),
val currentSearchMatchIndex: Int = -1,
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -96,12 +93,6 @@ class ChatViewModel(
private var pendingCleanupJob: Job? = null
private val streamingBuffer = java.lang.StringBuilder()
private var lastFlushMs = 0L
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
// ── Public state ─────────────────────────────────────────────────────
/**
@@ -121,7 +112,6 @@ class ChatViewModel(
)
init {
refreshSettings()
connectWebSocket()
viewModelScope.launch {
wsClient.events.collect { event ->
@@ -254,11 +244,6 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
streamingBuffer.clear()
thinkingBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
// Create the streaming message as a standalone state field.
val msg =
ChatMessage(
@@ -296,61 +281,41 @@ class ChatViewModel(
private fun handleMessageToken(event: WsEvent.MessageToken) {
if (!isCurrentSession(event.sessionId)) return
streamingBuffer.append(event.token)
val now = System.currentTimeMillis()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = streamingBuffer.toString()
lastFlushMs = now
_uiState.update { state ->
val current = state.streamingMessage
if (current != null) {
state.copy(
streamingMessage =
current.copy(
content = currentContent,
),
isThinking = false,
_uiState.update { state ->
val current = state.streamingMessage
if (current != null) {
state.copy(
streamingMessage =
current.copy(
content = current.content + event.token,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.token,
isStreaming = true,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
isStreaming = true,
)
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
}
}
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return
thinkingBuffer.append(event.token)
val now = System.currentTimeMillis()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
}
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
@@ -360,9 +325,6 @@ class ChatViewModel(
var finalizedMsg: ChatMessage? = null
var sessionId: String? = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
@@ -409,20 +371,9 @@ class ChatViewModel(
var orphan: ChatMessage? = null
var sessionId: String? = null
// Flush any remaining buffered content to the message before marking it done
val finalContent = streamingBuffer.toString()
val finalThinkingText = thinkingBuffer.toString()
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
streaming?.copy(
content = finalContent.ifEmpty { streaming.content },
isStreaming = false,
)
val msg = streaming?.copy(isStreaming = false)
orphan = msg
sessionId = state.currentSessionId
if (msg != null) {
@@ -460,7 +411,7 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val contentJson = event.data?.let { Gson().toJson(it) } ?: ""
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
@@ -509,7 +460,7 @@ class ChatViewModel(
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val contentJson = event.data?.let { Gson().toJson(it) } ?: ""
val updated =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
@@ -599,8 +550,6 @@ class ChatViewModel(
}
WsMethods.SESSION_INTERRUPT -> {
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update {
it.copy(
isAgentTyping = false,
@@ -864,22 +813,11 @@ class ChatViewModel(
loadSessionMessages(sessionId)
}
fun refreshSettings() {
_uiState.update { state ->
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
)
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state
streamingMessageId = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
@@ -1105,13 +1043,13 @@ class ChatViewModel(
while (true) {
delay(PENDING_REQUEST_TIMEOUT_MS)
val now = System.currentTimeMillis()
val iterator = pendingRequests.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value.createdAt > PENDING_REQUEST_TIMEOUT_MS) {
iterator.remove()
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
val stale =
pendingRequests.entries.filter {
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS
}
for (entry in stale) {
pendingRequests.remove(entry.key)
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
}
}
}
@@ -1197,21 +1135,6 @@ class ChatViewModel(
}
}
private var isTestEnv: Boolean? = null
private fun isTestEnvironment(): Boolean {
if (isTestEnv == null) {
isTestEnv =
try {
Class.forName("org.junit.Test")
true
} catch (e: ClassNotFoundException) {
false
}
}
return isTestEnv == true
}
// ── Lifecycle ─────────────────────────────────────────────────────────
override fun onCleared() {
@@ -1219,8 +1142,4 @@ class ChatViewModel(
pendingCleanupJob?.cancel()
wsClient.disconnect()
}
companion object {
private val gson = Gson()
}
}
@@ -246,7 +246,7 @@ fun FilterChipRow(
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips, key = { it }) { chip ->
items(chips) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
@@ -39,22 +39,6 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
loadSavedValues()
}
private suspend fun isHostAllowed(host: String): Boolean =
withContext(Dispatchers.IO) {
if (host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0") return@withContext false
if (host.startsWith("169.254.")) return@withContext false
if (host == "metadata.google.internal") return@withContext false
try {
val address = java.net.InetAddress.getByName(host)
if (address.isLoopbackAddress || address.isAnyLocalAddress || address.isLinkLocalAddress) {
return@withContext false
}
true
} catch (e: Exception) {
false
}
}
fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
@@ -147,15 +131,17 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
// Update ApiClient temporarily with current inputs to verify connection
val tempApi = ApiClient.createTempService(state.host, port, state.token)
safeApiCall { tempApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
// Only persist credentials to global state upon successful verification
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
if (state.saveProfile) {
val currentProfiles = AuthManager.getConnectionProfiles()
val existingIndex =
@@ -203,10 +189,6 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
is NetworkError.Http -> {
when (err.code) {
401 -> {
AuthManager.setToken(null)
if (AuthManager.getSelectedProfileId() != null) {
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
}
app.getString(R.string.connect_error_401)
}
@@ -283,25 +265,15 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) {
viewModelScope.launch {
if (!isHostAllowed(host)) {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_invalid_host),
)
}
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
}
@@ -319,25 +291,15 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
val token = json.get("token")?.asString
if (host != null && port != null && token != null) {
viewModelScope.launch {
if (!isHostAllowed(host)) {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_invalid_host),
)
}
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
// Decoded valid JSON but missing required fields
@@ -89,7 +89,7 @@ fun CronJobsScreen(
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.jobs, key = { it.id }) { job ->
items(state.jobs) { job ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -85,16 +85,17 @@ fun KanbanScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() },
) {
) { paddingValues ->
when {
state.isLoading && state.boards.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBoards() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -106,10 +107,11 @@ fun KanbanScreen(
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(paddingValues),
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
) {
SearchBar(
query = query,
@@ -147,7 +149,7 @@ fun KanbanScreen(
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.columns, key = { it.name }) { column ->
items(state.columns) { column ->
val colName = column.name
val colTasks =
filteredTasks.filter {
@@ -176,7 +178,7 @@ fun KanbanScreen(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
items(colTasks) { task ->
TaskCard(
task = task,
onMoveLeft =
@@ -125,7 +125,7 @@ fun KeysScreen(
placeholder = "Search keys...",
)
}
items(filteredEnvVars.toList(), key = { it.first }) { (key, config) ->
items(filteredEnvVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
@@ -116,7 +116,7 @@ fun McpServersScreen(
placeholder = "Search MCP servers...",
)
}
items(filteredServers, key = { it.name }) { server ->
items(filteredServers) { server ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -134,7 +134,7 @@ fun ModelScreen(
placeholder = "Search models...",
)
}
items(filteredProviders, key = { it.slug }) { provider ->
items(filteredProviders) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
@@ -121,7 +121,7 @@ fun PairingScreen(
}
}
items(pending, key = { "${it.platform}_${it.code ?: it.user_id}" }) { item ->
items(pending) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -201,7 +201,7 @@ fun PairingScreen(
}
}
} else {
items(approved, key = { "${it.platform}_${it.user_id}" }) { item ->
items(approved) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
@@ -119,7 +119,7 @@ fun PluginsScreen(
placeholder = "Search plugins...",
)
}
items(filteredPlugins, key = { it.name }) { plugin ->
items(filteredPlugins) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -118,7 +118,7 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles, key = { it.name }) { profile ->
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
@@ -105,7 +105,7 @@ fun SessionsScreen(
placeholder = "Search sessions...",
)
}
items(filteredSessions, key = { it.id }) { session ->
items(filteredSessions) { session ->
Card(
modifier =
Modifier
@@ -237,7 +237,7 @@ fun SkillsScreen(
}
}
} else {
items(filtered, key = { it.name }) { skill ->
items(filtered) { skill ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -133,7 +133,7 @@ fun ToolsetsScreen(
placeholder = "Search toolsets...",
)
}
items(filteredToolsets, key = { it.name }) { toolset ->
items(filteredToolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -192,7 +192,7 @@ fun WebhooksScreen(
}
}
} else {
items(filteredSubscriptions, key = { it.name }) { sub ->
items(filteredSubscriptions) { sub ->
Card(
modifier = Modifier.fillMaxWidth(),
) {