Add Simplified Chinese (zh) localization (#901)

* feat: add Simplified Chinese (zh) localization

- Add complete values-zh/strings.xml (921 strings, all keys covered)
- Translate 107 hardcoded UI strings across 34 Kotlin files
- Add 简体中文 option to in-app language picker
- Terminology aligned with official Hermes dashboard zh translations

* fix: resource-ize hardcoded Chinese strings per review

Address Hy4ri's review on PR #901:
- Move 104 hardcoded Chinese UI strings to string resources
  (English in values/strings.xml, Chinese in values-zh/strings.xml)
  referenced via stringResource()
- SystemViewModel labels -> @StringRes + AndroidViewModel with
  resource-based toast templates
- Make TodoTaskCardTest assertion locale-agnostic via getString()
- Update cookie tests to assert new host-agnostic session cookie
  behavior (WiFi/Tailscale switch-safe)
- Session cookie mirroring to WILDCARD bucket survives host switches

* style: apply ktlintFormat per review

---------

Co-authored-by: hermes-i18n <hermes-i18n@localhost>
This commit is contained in:
gslzwhlandhermes-i18n authored and GitHub committed 2026-08-15 11:33:43 +03:00
1 parent 0a5eb7a5b0
commit fdbd98af3e
46 files changed
+1420 -133

No files matched your search

@@ -518,7 +518,7 @@ internal fun InlineAttachment(
} else {
Icon(
imageVector = Icons.Default.Download,
contentDescription = "Save ${attachment.name}",
contentDescription = stringResource(R.string.chat_save_attachment, attachment.name),
tint = MaterialTheme.colorScheme.primary,
)
}
@@ -786,7 +786,7 @@ fun ChatScreen(
if (state.showModelPicker) {
ModelPickerDialog(
providers = state.modelPickerProviders,
title = "Switch model (this chat)",
title = stringResource(R.string.chat_switch_model_title),
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
pinnedModels = state.modelPickerPinned,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
@@ -2968,8 +2968,11 @@ class ChatViewModel(
if (items.isEmpty()) return
val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken()
if (baseUrl.isBlank() || token.isNullOrBlank()) return
if (baseUrl.isBlank()) return
// NOTE: no token gate here. Gated (basic-auth) dashboards download via
// the session cookie, and even loopback hosts can serve files with an
// empty query token — requiring a non-blank token dropped every MEDIA
// attachment on gate-auth connections where getToken() returns null.
val existingUrls =
current.attachments
@@ -2978,7 +2981,7 @@ class ChatViewModel(
.toSet()
val newAttachments =
items.mapNotNull { item ->
val url = GatewayFileClient.buildDownloadUrl(baseUrl, token, item.path) ?: return@mapNotNull null
val url = GatewayFileClient.buildDownloadUrl(baseUrl, "", item.path) ?: return@mapNotNull null
if (url in existingUrls) return@mapNotNull null
Attachment(
uri = url,
@@ -32,9 +32,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable
@@ -76,7 +78,7 @@ internal fun TodoTaskCard(
)
}
Text(
text = "Tasks $completed/${items.size}",
text = stringResource(R.string.todo_task_count, completed, items.size),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
@@ -491,7 +491,7 @@ private fun ExpandedToolContent(
// ── Duration footer ──
view.durationLabel?.let {
Text(
text = "Duration: $it",
text = stringResource(R.string.tool_duration, it),
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
@@ -593,7 +593,7 @@ internal fun SecurityRiskChip(
)
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
Text(
text = "· redacted",
text = stringResource(R.string.tool_redacted),
style = MaterialTheme.typography.labelSmall,
color = chipColor.copy(alpha = 0.7f),
)
@@ -30,8 +30,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/**
* Bottom toolbar row for the chat composer.
@@ -71,7 +73,7 @@ fun ComposerToolbar(
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Attach file",
contentDescription = stringResource(R.string.chat_attach_file),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -20,8 +20,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ContextBreakdown
import kotlin.math.min
@@ -67,7 +69,7 @@ fun ContextDetailSheet(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Context window",
text = stringResource(R.string.context_window),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -117,15 +119,15 @@ fun ContextDetailSheet(
)
// Breakdown rows
ContextRow(label = "Prompt (input)", value = breakdown.inputTokens)
ContextRow(label = "Completion (output)", value = breakdown.outputTokens)
ContextRow(label = "Cache read", value = breakdown.cacheReadTokens)
ContextRow(label = "Cache write", value = breakdown.cacheWriteTokens)
ContextRow(label = stringResource(R.string.context_prompt_input), value = breakdown.inputTokens)
ContextRow(label = stringResource(R.string.context_completion_output), value = breakdown.outputTokens)
ContextRow(label = stringResource(R.string.context_cache_read), value = breakdown.cacheReadTokens)
ContextRow(label = stringResource(R.string.context_cache_write), value = breakdown.cacheWriteTokens)
ContextRow(label = "Reasoning", value = breakdown.reasoningTokens)
ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true)
Text(
text = "Prompt tokens reflect total context used by this session so far.",
text = stringResource(R.string.context_tokens_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
@@ -15,8 +15,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import kotlin.math.min
@@ -103,7 +105,7 @@ fun ContextUsageChip(
) {
if (compressionCount != null && compressionCount > 0) {
Text(
text = "compressed ×$compressionCount",
text = stringResource(R.string.context_compressed, compressionCount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
maxLines = 1,
@@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.m57.hermescontrol.R
@Composable
fun GifImageThumbnail(
@@ -125,7 +127,7 @@ fun GifImageThumbnail(
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play GIF",
contentDescription = stringResource(R.string.gif_play),
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
@@ -21,8 +21,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
/**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
@@ -57,7 +59,7 @@ fun InlineVideoPlayer(
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video",
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
@@ -153,7 +153,7 @@ fun ReasoningCard(
)
Spacer(Modifier.width(8.dp))
Text(
text = "Reasoning · $stepCount steps",
text = stringResource(R.string.message_reasoning_steps, stepCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
@@ -491,7 +491,7 @@ fun ClarifyBubble(
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text("Your response") },
label = { Text(stringResource(R.string.message_your_response)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -30,11 +30,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem
@@ -78,7 +80,7 @@ fun SubagentInspectionSheet(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Task & Plan Inspection",
text = stringResource(R.string.subagent_task_plan_inspection),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@@ -96,7 +98,7 @@ fun SubagentInspectionSheet(
if (indicators.isEmpty() && todos.isEmpty()) {
Text(
text = "No active tasks or plan items.",
text = stringResource(R.string.subagent_no_active),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
@@ -120,7 +122,7 @@ fun SubagentInspectionSheet(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "AGENT PLAN",
text = stringResource(R.string.subagent_agent_plan),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
@@ -242,7 +244,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
if (!indicator.summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Summary: ${indicator.summary}",
text = stringResource(R.string.subagent_summary, indicator.summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -260,7 +262,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "LIVE TRANSCRIPT",
text = stringResource(R.string.subagent_live_transcript),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.outline,
@@ -236,7 +236,7 @@ fun VideoViewerDialog(
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video",
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp),
)