Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
db3f8c736e
commit
5bc6e5f855
14 files changed
+461
-182
No files matched your search
@@ -54,10 +54,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
|
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
|
||||||
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
|
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
|
||||||
val themePreset by AuthManager.themePresetFlow.collectAsState()
|
val themePreset by AuthManager.themePresetFlow.collectAsState()
|
||||||
|
val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState()
|
||||||
HermesControlTheme(
|
HermesControlTheme(
|
||||||
themePreference = themePreference,
|
themePreference = themePreference,
|
||||||
useDynamicColors = useDynamicColors,
|
useDynamicColors = useDynamicColors,
|
||||||
themePreset = themePreset,
|
themePreset = themePreset,
|
||||||
|
chatFontScale = chatFontScale,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ data class ServerStoreState(
|
|||||||
val wsAuthParam: String = "token",
|
val wsAuthParam: String = "token",
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val chatFontScale: Float = 1.0f,
|
||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ object AuthManager {
|
|||||||
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
||||||
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
|
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _chatFontScaleFlow = MutableStateFlow<Float>(1.0f)
|
||||||
|
val chatFontScaleFlow: StateFlow<Float> = _chatFontScaleFlow.asStateFlow()
|
||||||
|
|
||||||
private val _tokenFlow = MutableStateFlow<String?>(null)
|
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||||
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
||||||
|
|
||||||
@@ -180,6 +183,7 @@ object AuthManager {
|
|||||||
_themePreferenceFlow.value = state.themePreference
|
_themePreferenceFlow.value = state.themePreference
|
||||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||||
_themePresetFlow.value = state.themePreset
|
_themePresetFlow.value = state.themePreset
|
||||||
|
_chatFontScaleFlow.value = state.chatFontScale
|
||||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||||
}
|
}
|
||||||
@@ -640,6 +644,15 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||||
|
|
||||||
|
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||||
|
|
||||||
|
fun setChatFontScale(scale: Float) {
|
||||||
|
serverStore.update { it.copy(chatFontScale = scale) }
|
||||||
|
_chatFontScaleFlow.value = scale
|
||||||
|
}
|
||||||
|
|
||||||
// ── In-app update check (issue #867) ─────────────────────────────────
|
// ── In-app update check (issue #867) ─────────────────────────────────
|
||||||
|
|
||||||
/** App version the silent update check last completed for (null = never). */
|
/** App version the silent update check last completed for (null = never). */
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD
|
|||||||
|
|
||||||
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||||
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
||||||
|
val LocalChatFontScale = compositionLocalOf { 1.0f }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 6 preset themes — one file each, all built from the same
|
* The 6 preset themes — one file each, all built from the same
|
||||||
@@ -75,6 +76,7 @@ fun HermesControlTheme(
|
|||||||
themePreference: ThemePreference = LocalThemePreference.current,
|
themePreference: ThemePreference = LocalThemePreference.current,
|
||||||
useDynamicColors: Boolean = true,
|
useDynamicColors: Boolean = true,
|
||||||
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
|
chatFontScale: Float = LocalChatFontScale.current,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val darkTheme =
|
val darkTheme =
|
||||||
@@ -103,6 +105,7 @@ fun HermesControlTheme(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalThemePreference provides themePreference,
|
LocalThemePreference provides themePreference,
|
||||||
LocalThemePreset provides themePreset,
|
LocalThemePreset provides themePreset,
|
||||||
|
LocalChatFontScale provides chatFontScale,
|
||||||
LocalHermesStatusColors provides statusColors,
|
LocalHermesStatusColors provides statusColors,
|
||||||
LocalSpacing provides SpacingDefaults,
|
LocalSpacing provides SpacingDefaults,
|
||||||
LocalMotion provides MotionDefaults,
|
LocalMotion provides MotionDefaults,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -43,14 +44,17 @@ 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
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
@@ -141,38 +145,50 @@ fun GroupChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
val currentDensity = LocalDensity.current
|
||||||
state = listState,
|
val chatFontScale = LocalChatFontScale.current
|
||||||
modifier =
|
val chatDensity =
|
||||||
Modifier
|
remember(currentDensity, chatFontScale) {
|
||||||
.weight(1f)
|
Density(
|
||||||
.fillMaxWidth(),
|
density = currentDensity.density,
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
fontScale = currentDensity.fontScale * chatFontScale,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
)
|
||||||
) {
|
|
||||||
items(
|
|
||||||
items = state.messages,
|
|
||||||
key = { it.id },
|
|
||||||
) { message ->
|
|
||||||
GroupMessageCard(message = message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.activeSpeaker?.let { speaker ->
|
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||||
item(key = "active_speaker") {
|
LazyColumn(
|
||||||
Row(
|
state = listState,
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 4.dp),
|
modifier =
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Modifier
|
||||||
) {
|
.weight(1f)
|
||||||
CircularProgressIndicator(
|
.fillMaxWidth(),
|
||||||
modifier = Modifier.size(14.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
strokeWidth = 2.dp,
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
)
|
) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
items(
|
||||||
Text(
|
items = state.messages,
|
||||||
text = stringResource(R.string.group_chat_thinking, speaker),
|
key = { it.id },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) { message ->
|
||||||
color = MaterialTheme.colorScheme.primary,
|
GroupMessageCard(message = message)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
state.activeSpeaker?.let { speaker ->
|
||||||
|
item(key = "active_speaker") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_thinking, speaker),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||||
import com.m57.hermescontrol.ui.chat.ChatBubble
|
import com.m57.hermescontrol.ui.chat.ChatBubble
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
@@ -116,179 +120,191 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
val currentDensity = LocalDensity.current
|
||||||
state = listState,
|
val chatFontScale = LocalChatFontScale.current
|
||||||
modifier = Modifier.fillMaxSize(),
|
val chatDensity =
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
remember(currentDensity, chatFontScale) {
|
||||||
) {
|
Density(
|
||||||
if (isLoadingOlder) {
|
density = currentDensity.density,
|
||||||
item(key = "loading-older") {
|
fontScale = currentDensity.fontScale * chatFontScale,
|
||||||
Box(
|
)
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryIndex = 0
|
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||||
turns.forEach { turn ->
|
LazyColumn(
|
||||||
when (turn) {
|
state = listState,
|
||||||
is ChatTurn.User -> {
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Eager captures: item lambda reads these at
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
// composition time (lazy), so capture now.
|
) {
|
||||||
val userMessage = turn.message
|
if (isLoadingOlder) {
|
||||||
val milestone = toolMilestones[entryIndex]
|
item(key = "loading-older") {
|
||||||
item(key = "user-${userMessage.id}") {
|
Box(
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
renderChatBubble(
|
contentAlignment = Alignment.Center,
|
||||||
message = userMessage,
|
) {
|
||||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
isCurrentMatch =
|
|
||||||
searchState.currentMatchId != null &&
|
|
||||||
searchState.currentMatchId == userMessage.id,
|
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
|
||||||
onSaveAttachment = onSaveAttachment,
|
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
|
||||||
milestone?.let { count ->
|
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
var entryIndex = 0
|
||||||
var firstProseSeen = false
|
turns.forEach { turn ->
|
||||||
// Reasoning hoist: the turn's reasoning renders at the
|
when (turn) {
|
||||||
// TOP of the turn — above tool rows — so thinking
|
is ChatTurn.User -> {
|
||||||
// leads, then the tool work, then the answer. The
|
// Eager captures: item lambda reads these at
|
||||||
// matching prose entry renders without its own card.
|
// composition time (lazy), so capture now.
|
||||||
val turnReasoning =
|
val userMessage = turn.message
|
||||||
turn.entries
|
val milestone = toolMilestones[entryIndex]
|
||||||
.filterIsInstance<AgentEntry.Prose>()
|
item(key = "user-${userMessage.id}") {
|
||||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
if (turnReasoning != null) {
|
renderChatBubble(
|
||||||
val reasoning = turnReasoning.message
|
message = userMessage,
|
||||||
item(key = "reasoning-${reasoning.id}") {
|
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
isCurrentMatch =
|
||||||
// Lead the turn with the assistant identity
|
searchState.currentMatchId != null &&
|
||||||
// and timestamp, then the thinking block.
|
searchState.currentMatchId == userMessage.id,
|
||||||
AssistantTurnHeader(timestamp = reasoning.timestamp)
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
ReasoningCard(
|
onSaveAttachment = onSaveAttachment,
|
||||||
reasoningText = reasoning.reasoningText,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
isStreaming = reasoning.isStreaming,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
|
milestone?.let { count ->
|
||||||
|
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
entryIndex++
|
||||||
}
|
}
|
||||||
turn.entries.forEach { entry ->
|
|
||||||
when (entry) {
|
|
||||||
is AgentEntry.Prose -> {
|
|
||||||
val proseMessage = entry.message
|
|
||||||
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
|
||||||
val hoistedReasoning =
|
|
||||||
turnReasoning != null &&
|
|
||||||
proseMessage.id == turnReasoning.message.id
|
|
||||||
val milestone = toolMilestones[entryIndex]
|
|
||||||
item(key = "prose-${proseMessage.id}") {
|
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
|
||||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
|
||||||
StreamingFullBleedWithTypingEffect(
|
|
||||||
streaming = proseMessage,
|
|
||||||
typingDelayMs = typingEffectDelayMs,
|
|
||||||
isDark = isDark,
|
|
||||||
showTurnHeader = showTurnHeader,
|
|
||||||
showReasoning = !hoistedReasoning,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
FullBleedAgentMessage(
|
|
||||||
message = proseMessage,
|
|
||||||
showTurnHeader = showTurnHeader,
|
|
||||||
isDarkTheme = isDark,
|
|
||||||
// Highlight only bubbles that actually contain a match —
|
|
||||||
// the rest skip the highlight scan entirely.
|
|
||||||
searchQuery =
|
|
||||||
if (searchState.isActive &&
|
|
||||||
proseMessage.id in searchState.matchedIds
|
|
||||||
) {
|
|
||||||
searchState.query
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
},
|
|
||||||
isCurrentMatch =
|
|
||||||
searchState.currentMatchId != null &&
|
|
||||||
searchState.currentMatchId == proseMessage.id,
|
|
||||||
showReasoning = !hoistedReasoning,
|
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
|
||||||
onSaveAttachment = onSaveAttachment,
|
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
milestone?.let { count ->
|
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
firstProseSeen = true
|
|
||||||
entryIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
is ChatTurn.Agent -> {
|
||||||
val toolMessage = entry.message
|
var firstProseSeen = false
|
||||||
val milestone = toolMilestones[entryIndex]
|
// Reasoning hoist: the turn's reasoning renders at the
|
||||||
item(key = "tool-${toolMessage.id}") {
|
// TOP of the turn — above tool rows — so thinking
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
// leads, then the tool work, then the answer. The
|
||||||
FullBleedToolRow(toolMessage)
|
// matching prose entry renders without its own card.
|
||||||
milestone?.let { count ->
|
val turnReasoning =
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
turn.entries
|
||||||
}
|
.filterIsInstance<AgentEntry.Prose>()
|
||||||
}
|
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||||
|
if (turnReasoning != null) {
|
||||||
|
val reasoning = turnReasoning.message
|
||||||
|
item(key = "reasoning-${reasoning.id}") {
|
||||||
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
|
// Lead the turn with the assistant identity
|
||||||
|
// and timestamp, then the thinking block.
|
||||||
|
AssistantTurnHeader(timestamp = reasoning.timestamp)
|
||||||
|
ReasoningCard(
|
||||||
|
reasoningText = reasoning.reasoningText,
|
||||||
|
isStreaming = reasoning.isStreaming,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
is AgentEntry.SystemEvent -> {
|
turn.entries.forEach { entry ->
|
||||||
val sysMessage = entry.message
|
when (entry) {
|
||||||
item(key = "sys-${sysMessage.id}") {
|
is AgentEntry.Prose -> {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
val proseMessage = entry.message
|
||||||
if (sysMessage.displayKind != null) {
|
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
||||||
// Timeline marker (issue #904):
|
val hoistedReasoning =
|
||||||
// model/personality switches and
|
turnReasoning != null &&
|
||||||
// auto-continues render as a chip.
|
proseMessage.id == turnReasoning.message.id
|
||||||
TimelineMarkerChip(message = sysMessage)
|
val milestone = toolMilestones[entryIndex]
|
||||||
} else {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
FullBleedSystemEvent(
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
message = sysMessage,
|
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
StreamingFullBleedWithTypingEffect(
|
||||||
)
|
streaming = proseMessage,
|
||||||
|
typingDelayMs = typingEffectDelayMs,
|
||||||
|
isDark = isDark,
|
||||||
|
showTurnHeader = showTurnHeader,
|
||||||
|
showReasoning = !hoistedReasoning,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FullBleedAgentMessage(
|
||||||
|
message = proseMessage,
|
||||||
|
showTurnHeader = showTurnHeader,
|
||||||
|
isDarkTheme = isDark,
|
||||||
|
// Highlight only bubbles that actually contain a match —
|
||||||
|
// the rest skip the highlight scan entirely.
|
||||||
|
searchQuery =
|
||||||
|
if (searchState.isActive &&
|
||||||
|
proseMessage.id in searchState.matchedIds
|
||||||
|
) {
|
||||||
|
searchState.query
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
isCurrentMatch =
|
||||||
|
searchState.currentMatchId != null &&
|
||||||
|
searchState.currentMatchId == proseMessage.id,
|
||||||
|
showReasoning = !hoistedReasoning,
|
||||||
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onSaveAttachment = onSaveAttachment,
|
||||||
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
|
onImageClick = onImageClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
milestone?.let { count ->
|
||||||
|
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
firstProseSeen = true
|
||||||
|
entryIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
is AgentEntry.ToolRow -> {
|
||||||
|
val toolMessage = entry.message
|
||||||
|
val milestone = toolMilestones[entryIndex]
|
||||||
|
item(key = "tool-${toolMessage.id}") {
|
||||||
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
|
FullBleedToolRow(toolMessage)
|
||||||
|
milestone?.let { count ->
|
||||||
|
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
is AgentEntry.SystemEvent -> {
|
||||||
|
val sysMessage = entry.message
|
||||||
|
item(key = "sys-${sysMessage.id}") {
|
||||||
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
|
if (sysMessage.displayKind != null) {
|
||||||
|
// Timeline marker (issue #904):
|
||||||
|
// model/personality switches and
|
||||||
|
// auto-continues render as a chip.
|
||||||
|
TimelineMarkerChip(message = sysMessage)
|
||||||
|
} else {
|
||||||
|
FullBleedSystemEvent(
|
||||||
|
message = sysMessage,
|
||||||
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryIndex++
|
||||||
}
|
}
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clarify bubble — rendered at the very bottom
|
// Clarify bubble — rendered at the very bottom
|
||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
ClarifyBubble(
|
ClarifyBubble(
|
||||||
text = clarifyRequest.text,
|
text = clarifyRequest.text,
|
||||||
options = clarifyRequest.options,
|
options = clarifyRequest.options,
|
||||||
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
||||||
onDismiss = { onDismissClarify?.invoke() },
|
onDismiss = { onDismissClarify?.invoke() },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ internal fun SettingsChatPage(
|
|||||||
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
||||||
|
chatFontScale = state.chatFontScale,
|
||||||
|
onChatFontScaleChange = viewModel::onChatFontScaleChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ data class SettingsUiState(
|
|||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val typingEffectEnabled: Boolean = false,
|
val typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val chatFontScale: Float = 1.0f,
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -76,6 +77,7 @@ class SettingsViewModel(
|
|||||||
val themePreset = AuthManager.getThemePreset()
|
val themePreset = AuthManager.getThemePreset()
|
||||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
|
val chatFontScale = AuthManager.getChatFontScale()
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -95,6 +97,7 @@ class SettingsViewModel(
|
|||||||
themePreset = themePreset,
|
themePreset = themePreset,
|
||||||
typingEffectEnabled = typingEffectEnabled,
|
typingEffectEnabled = typingEffectEnabled,
|
||||||
typingEffectDelayMs = typingEffectDelayMs,
|
typingEffectDelayMs = typingEffectDelayMs,
|
||||||
|
chatFontScale = chatFontScale,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -310,6 +313,11 @@ class SettingsViewModel(
|
|||||||
AuthManager.setTypingEffectDelayMs(delayMs)
|
AuthManager.setTypingEffectDelayMs(delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onChatFontScaleChange(scale: Float) {
|
||||||
|
_uiState.update { it.copy(chatFontScale = scale, isSaved = false) }
|
||||||
|
AuthManager.setChatFontScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear all auth credentials — logs out and returns to landing screen. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
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.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -18,22 +19,29 @@ import androidx.compose.material3.SegmentedButton
|
|||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
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
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AppearanceSection(
|
internal fun AppearanceSection(
|
||||||
@@ -194,10 +202,172 @@ internal fun ChatSection(
|
|||||||
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||||
|
chatFontScale: Float = 1.0f,
|
||||||
|
onChatFontScaleChange: (Float) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
||||||
|
val currentIndex =
|
||||||
|
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f }
|
||||||
|
.takeIf { it >= 0 } ?: 1
|
||||||
|
|
||||||
SectionCard {
|
SectionCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_item_chat_font_size),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_desc_chat_font_size),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val scaleLabel =
|
||||||
|
when (currentIndex) {
|
||||||
|
0 -> stringResource(R.string.settings_font_scale_small)
|
||||||
|
1 -> stringResource(R.string.settings_font_scale_default)
|
||||||
|
2 -> stringResource(R.string.settings_font_scale_large)
|
||||||
|
3 -> stringResource(R.string.settings_font_scale_xlarge)
|
||||||
|
else -> stringResource(R.string.settings_font_scale_huge)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = scaleLabel,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = currentIndex.toFloat(),
|
||||||
|
onValueChange = { onChatFontScaleChange(fontScaleOptions[it.roundToInt()]) },
|
||||||
|
valueRange = 0f..(fontScaleOptions.size - 1).toFloat(),
|
||||||
|
steps = fontScaleOptions.size - 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "0.85×",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "1.0×",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "1.5×",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_chat_preview_title),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val currentDensity = LocalDensity.current
|
||||||
|
val previewDensity =
|
||||||
|
remember(currentDensity, chatFontScale) {
|
||||||
|
Density(
|
||||||
|
density = currentDensity.density,
|
||||||
|
fontScale = currentDensity.fontScale * chatFontScale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalDensity provides previewDensity) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// User bubble preview
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape =
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStart = 14.dp,
|
||||||
|
topEnd = 14.dp,
|
||||||
|
bottomStart = 14.dp,
|
||||||
|
bottomEnd = 4.dp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_chat_preview_user),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant bubble preview
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape =
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStart = 14.dp,
|
||||||
|
topEnd = 14.dp,
|
||||||
|
bottomStart = 4.dp,
|
||||||
|
bottomEnd = 14.dp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_chat_preview_agent),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|||||||
@@ -187,6 +187,17 @@
|
|||||||
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
|
<string name="settings_item_chat_font_size">채팅 글꼴 크기</string>
|
||||||
|
<string name="settings_desc_chat_font_size">채팅 메시지의 텍스트 크기를 조절합니다</string>
|
||||||
|
<string name="settings_font_scale_value">글꼴 크기: %1$s</string>
|
||||||
|
<string name="settings_font_scale_small">작게 (0.85×)</string>
|
||||||
|
<string name="settings_font_scale_default">기본 (1.0×)</string>
|
||||||
|
<string name="settings_font_scale_large">크게 (1.15×)</string>
|
||||||
|
<string name="settings_font_scale_xlarge">아주 크게 (1.3×)</string>
|
||||||
|
<string name="settings_font_scale_huge">최대 (1.5×)</string>
|
||||||
|
<string name="settings_chat_preview_title">미리보기</string>
|
||||||
|
<string name="settings_chat_preview_user">채팅 텍스트 크기를 조절할 수 있나요?</string>
|
||||||
|
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
|
||||||
<string name="settings_action_test_connection">연결 테스트</string>
|
<string name="settings_action_test_connection">연결 테스트</string>
|
||||||
<string name="settings_sec_about">정보</string>
|
<string name="settings_sec_about">정보</string>
|
||||||
<string name="settings_about_app_name">헤르메스 모바일</string>
|
<string name="settings_about_app_name">헤르메스 모바일</string>
|
||||||
|
|||||||
@@ -184,6 +184,17 @@
|
|||||||
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
||||||
<string name="settings_delay_10ms">10 毫秒</string>
|
<string name="settings_delay_10ms">10 毫秒</string>
|
||||||
<string name="settings_delay_100ms">100 毫秒</string>
|
<string name="settings_delay_100ms">100 毫秒</string>
|
||||||
|
<string name="settings_item_chat_font_size">聊天字体大小</string>
|
||||||
|
<string name="settings_desc_chat_font_size">缩放聊天消息文字大小</string>
|
||||||
|
<string name="settings_font_scale_value">字体大小:%1$s</string>
|
||||||
|
<string name="settings_font_scale_small">小 (0.85×)</string>
|
||||||
|
<string name="settings_font_scale_default">默认 (1.0×)</string>
|
||||||
|
<string name="settings_font_scale_large">大 (1.15×)</string>
|
||||||
|
<string name="settings_font_scale_xlarge">特大 (1.3×)</string>
|
||||||
|
<string name="settings_font_scale_huge">极大 (1.5×)</string>
|
||||||
|
<string name="settings_chat_preview_title">预览</string>
|
||||||
|
<string name="settings_chat_preview_user">可以调整聊天字体大小吗?</string>
|
||||||
|
<string name="settings_chat_preview_agent">已搞定!你可以在这里自定义字体缩放比例。</string>
|
||||||
<string name="settings_action_test_connection">测试连接</string>
|
<string name="settings_action_test_connection">测试连接</string>
|
||||||
<string name="settings_sec_about">关于</string>
|
<string name="settings_sec_about">关于</string>
|
||||||
<string name="settings_sec_language">语言</string>
|
<string name="settings_sec_language">语言</string>
|
||||||
|
|||||||
@@ -207,6 +207,17 @@
|
|||||||
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
|
<string name="settings_item_chat_font_size">Chat Font Size</string>
|
||||||
|
<string name="settings_desc_chat_font_size">Scale text size in chat messages</string>
|
||||||
|
<string name="settings_font_scale_value">Font Size: %1$s</string>
|
||||||
|
<string name="settings_font_scale_small">Small (0.85×)</string>
|
||||||
|
<string name="settings_font_scale_default">Default (1.0×)</string>
|
||||||
|
<string name="settings_font_scale_large">Large (1.15×)</string>
|
||||||
|
<string name="settings_font_scale_xlarge">Extra Large (1.3×)</string>
|
||||||
|
<string name="settings_font_scale_huge">Huge (1.5×)</string>
|
||||||
|
<string name="settings_chat_preview_title">Preview</string>
|
||||||
|
<string name="settings_chat_preview_user">Can you adjust the chat text size?</string>
|
||||||
|
<string name="settings_chat_preview_agent">Done! You can customize the font scale right here.</string>
|
||||||
<string name="settings_action_test_connection">Test Connection</string>
|
<string name="settings_action_test_connection">Test Connection</string>
|
||||||
<string name="settings_sec_about">About</string>
|
<string name="settings_sec_about">About</string>
|
||||||
<string name="settings_sec_language">Language</string>
|
<string name="settings_sec_language">Language</string>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ServerStoreTest {
|
|||||||
assertEquals(9119, state.port)
|
assertEquals(9119, state.port)
|
||||||
assertTrue(state.autoReconnect)
|
assertTrue(state.autoReconnect)
|
||||||
assertEquals("token", state.wsAuthParam)
|
assertEquals("token", state.wsAuthParam)
|
||||||
|
assertEquals(1.0f, state.chatFontScale)
|
||||||
assertTrue(state.connectionProfiles.isEmpty())
|
assertTrue(state.connectionProfiles.isEmpty())
|
||||||
assertNull(state.selectedProfileId)
|
assertNull(state.selectedProfileId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
|
every { AuthManager.getChatFontScale() } returns 1.0f
|
||||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||||
@@ -79,6 +80,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.setThemePreset(any()) } returns Unit
|
every { AuthManager.setThemePreset(any()) } returns Unit
|
||||||
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
||||||
|
every { AuthManager.setChatFontScale(any()) } returns Unit
|
||||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||||
storedSelectedProfileId = firstArg()
|
storedSelectedProfileId = firstArg()
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns false
|
every { AuthManager.isTypingEffectEnabled() } returns false
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
||||||
|
every { AuthManager.getChatFontScale() } returns 1.30f
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
every { AuthManager.getAppLanguage() } returns "fr"
|
every { AuthManager.getAppLanguage() } returns "fr"
|
||||||
|
|
||||||
@@ -146,12 +149,23 @@ class SettingsViewModelTest {
|
|||||||
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
|
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
|
||||||
assertEquals(false, state.typingEffectEnabled)
|
assertEquals(false, state.typingEffectEnabled)
|
||||||
assertEquals(15, state.typingEffectDelayMs)
|
assertEquals(15, state.typingEffectDelayMs)
|
||||||
|
assertEquals(1.30f, state.chatFontScale)
|
||||||
assertEquals(testProfiles, state.profiles)
|
assertEquals(testProfiles, state.profiles)
|
||||||
assertEquals("prof-2", state.selectedProfileId)
|
assertEquals("prof-2", state.selectedProfileId)
|
||||||
assertEquals("Home", state.renameProfileName)
|
assertEquals("Home", state.renameProfileName)
|
||||||
assertEquals("fr", state.appLanguage)
|
assertEquals("fr", state.appLanguage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOnChatFontScaleChange_updatesStateAndAuthManager() {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.onChatFontScaleChange(1.15f)
|
||||||
|
|
||||||
|
assertEquals(1.15f, viewModel.uiState.value.chatFontScale)
|
||||||
|
verify { AuthManager.setChatFontScale(1.15f) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
|
|||||||
Reference in new issue
Block a user