Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user