Complete client UI i18n migration

Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
chyendongnhanh338
2026-06-11 14:15:35 +08:00
committed by GitHub
parent 41cf070fd8
commit 08ebe5e7e1
199 changed files with 16426 additions and 1821 deletions
+68 -34
View File
@@ -11,6 +11,8 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
@@ -33,8 +35,18 @@ interface AgentListProps {
showAttentionIndicator?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
const DATE_SECTION_ORDER = [
"today",
"yesterday",
"thisWeek",
"thisMonth",
"older",
] as const satisfies readonly DateSectionKey[];
type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
@@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) {
});
}
function deriveDateSectionLabel(lastActivityAt: Date): string {
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
);
if (activityStart.getTime() >= todayStart.getTime()) {
return "Today";
return "today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return "Yesterday";
return "yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return "This week";
return "thisWeek";
}
if (diffDays <= 30) {
return "This month";
return "thisMonth";
}
return "Older";
return "older";
}
function formatStatusLabel(status: AggregatedAgent["status"]): string {
function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
switch (section) {
case "today":
return t("agentList.dateSections.today");
case "yesterday":
return t("agentList.dateSections.yesterday");
case "thisWeek":
return t("agentList.dateSections.thisWeek");
case "thisMonth":
return t("agentList.dateSections.thisMonth");
case "older":
return t("agentList.dateSections.older");
}
}
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
switch (status) {
case "initializing":
return "Starting";
return t("agentList.status.initializing");
case "idle":
return "Idle";
return t("agentList.status.idle");
case "running":
return "Running";
return t("agentList.status.running");
case "error":
return "Error";
return t("agentList.status.error");
case "closed":
return "Closed";
return t("agentList.status.closed");
default:
return status;
}
@@ -188,12 +215,14 @@ function SessionRow({
onLongPress: (agent: AggregatedAgent) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const statusLabel = formatStatusLabel(t, agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -231,14 +260,19 @@ function SessionRow({
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || "New session"}
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</View>
{isMobile && (
@@ -272,7 +306,7 @@ function SessionRow({
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
</Pressable>
@@ -289,6 +323,7 @@ export function AgentList({
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
@@ -354,22 +389,21 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
const section = deriveDateSectionKey(agent.lastActivityAt);
const existing = buckets.get(section) ?? [];
existing.push(agent);
buckets.set(label, existing);
buckets.set(section, existing);
}
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label);
for (const section of DATE_SECTION_ORDER) {
const data = buckets.get(section);
if (!data || data.length === 0) {
continue;
}
result.push({ type: "header", key: `header:${label}`, title: label });
result.push({ type: "header", key: `header:${section}`, section });
for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
@@ -382,7 +416,7 @@ export function AgentList({
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
<Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
</View>
);
}
@@ -397,7 +431,7 @@ export function AgentList({
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -454,8 +488,8 @@ export function AgentList({
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
? "Host offline"
: "This agent is still running. Archiving it will stop the agent."}
? t("agentList.archiveSheet.hostOffline")
: t("agentList.archiveSheet.runningAgent")}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -463,7 +497,7 @@ export function AgentList({
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
<Text style={styles.sheetCancelText}>Cancel</Text>
<Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
@@ -471,7 +505,7 @@ export function AgentList({
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
<Text style={sheetArchiveTextStyle}>Archive</Text>
<Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
</Pressable>
</View>
</View>