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
+47 -65
View File
@@ -22,6 +22,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import { computeCanStartDictation } from "./state";
export interface AttachmentMenuItem {
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
@@ -259,18 +265,24 @@ function AttachmentDropdown({
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
addAttachmentLabel,
}: {
isConnected: boolean;
disabled: boolean;
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
attachmentMenuItems: AttachmentMenuItem[];
addAttachmentLabel: string;
}) {
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
const isButtonDisabled = !isConnected || disabled;
const attachmentSheetHeader = useMemo<SheetHeader>(
() => ({ title: addAttachmentLabel }),
[addAttachmentLabel],
);
const handleOpenSheet = useCallback(() => {
if (isButtonDisabled) return;
setIsSheetOpen(true);
@@ -306,7 +318,7 @@ function AttachmentDropdown({
<>
<Pressable
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
onPress={handleOpenSheet}
@@ -315,7 +327,7 @@ function AttachmentDropdown({
{renderMobileAttachButtonIcon}
</Pressable>
<AdaptiveModalSheet
header={ATTACHMENT_SHEET_HEADER}
header={attachmentSheetHeader}
visible={isSheetOpen}
onClose={handleCloseSheet}
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
@@ -333,7 +345,7 @@ function AttachmentDropdown({
<TooltipTrigger asChild>
<DropdownMenuTrigger
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
style={attachButtonStyle}
@@ -342,7 +354,7 @@ function AttachmentDropdown({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Add attachment</Text>
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -429,49 +441,6 @@ function SendButtonContent({
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
canPressLoadingButton: boolean;
defaultActionQueues: boolean;
isAgentRunning: boolean;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
if (input.canPressLoadingButton) return "Interrupt agent";
if (input.defaultActionQueues) return "Queue message";
if (input.isAgentRunning) return "Send and interrupt";
return "Send message";
}
function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
isDictating: boolean;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode";
}
if (input.isDictating) return "Stop dictation";
return "Start dictation";
}
function resolveVoiceTooltipText(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted ? "Unmute voice" : "Mute voice";
}
return "Dictation";
}
function resolveSendTooltipLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
return input.defaultActionQueues ? "Queue" : "Send";
}
interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
submitOnEnter: boolean;
@@ -749,14 +718,16 @@ function MessageInputOverlay({
function FocusHint({
visible,
focusInputKeys,
label,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
}) {
if (!visible || !focusInputKeys) return null;
if (!visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
{label}
</Text>
);
}
@@ -812,9 +783,8 @@ function SendButtonTooltip({
isSubmitLoading,
submitIcon,
buttonIconSize,
submitButtonAccessibilityLabel,
defaultActionQueues,
sendKeys,
sendTooltipLabel,
}: {
shouldShow: boolean;
canPressLoadingButton: boolean;
@@ -826,9 +796,8 @@ function SendButtonTooltip({
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
buttonIconSize: number;
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
sendKeys: ShortcutChord | null | undefined;
sendTooltipLabel: string;
}) {
if (!shouldShow) return null;
return (
@@ -847,10 +816,7 @@ function SendButtonTooltip({
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
sendKeys={sendKeys}
/>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
</TooltipContent>
</Tooltip>
);
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
return;
}
if (ctx.isAgentRunning) {
ctx.toast.error("Interrupt the agent before starting voice mode");
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string;
placeholder: string | undefined;
autoFocus: boolean;
autoFocusKey: string | undefined;
disabled: boolean;
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
onAddImages: props.onAddImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder ?? "Message...",
placeholder: props.placeholder,
autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false,
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
inputWrapperStyle,
attachmentSlot,
} = resolveMessageInputProps(props);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const maxInputHeight = resolveMaxInputHeight(windowHeight);
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
});
}, [
disabled,
handleStopRealtimeVoice,
isAgentRunning,
isConnected,
t,
toast,
voice,
voiceAgentId,
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
defaultActionQueues,
isAgentRunning,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
isDictating,
t,
});
const voiceTooltipText = resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
t,
});
const sendTooltipLabel = resolveSendTooltipLabel({
submitButtonAccessibilityLabel,
defaultActionQueues,
t,
});
const handleInputChange = useCallback(
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel="Message agent..."
accessibilityLabel={t("composer.input.accessibilityLabel")}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
style={textInputStyle}
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<FocusHint
visible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
label={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</View>
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/>
{leftContent}
</View>
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
buttonIconSize={buttonIconSize}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
defaultActionQueues={defaultActionQueues}
sendKeys={sendKeys}
sendTooltipLabel={sendTooltipLabel}
/>
</View>
</View>
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
const translations: Record<string, string> = {
"composer.input.interruptAgent": "Interrupt agent",
"composer.input.queueMessage": "Queue message",
"composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendMessage": "Send message",
"composer.input.queue": "Queue",
"composer.input.send": "Send",
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
"composer.voice.muteVoiceMode": "Mute Voice mode",
"composer.voice.stopDictation": "Stop dictation",
"composer.voice.startDictation": "Start dictation",
"composer.voice.unmuteVoice": "Unmute voice",
"composer.voice.muteVoice": "Mute voice",
"composer.voice.dictation": "Dictation",
};
const t = ((key: string) => translations[key] ?? key) as never;
describe("composer input labels", () => {
it("resolves submit accessibility labels from translations", () => {
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: true,
defaultActionQueues: false,
isAgentRunning: true,
t,
}),
).toBe("Interrupt agent");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: true,
isAgentRunning: true,
t,
}),
).toBe("Queue message");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: true,
t,
}),
).toBe("Send and interrupt");
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: undefined,
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: false,
t,
}),
).toBe("Send message");
});
it("keeps explicit submit labels untouched", () => {
expect(
resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel: "Run now",
canPressLoadingButton: false,
defaultActionQueues: false,
isAgentRunning: false,
t,
}),
).toBe("Run now");
});
it("resolves voice labels from translations", () => {
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: true,
isMuted: true,
isDictating: false,
t,
}),
).toBe("Unmute Voice mode");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: true,
isMuted: false,
isDictating: false,
t,
}),
).toBe("Mute Voice mode");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
isDictating: true,
t,
}),
).toBe("Stop dictation");
expect(
resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
isDictating: false,
t,
}),
).toBe("Start dictation");
});
it("resolves tooltip labels from translations", () => {
expect(
resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent: false,
isMuted: false,
t,
}),
).toBe("Dictation");
expect(
resolveSendTooltipLabel({
submitButtonAccessibilityLabel: undefined,
defaultActionQueues: true,
t,
}),
).toBe("Queue");
expect(
resolveSendTooltipLabel({
submitButtonAccessibilityLabel: undefined,
defaultActionQueues: false,
t,
}),
).toBe("Send");
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { TFunction } from "i18next";
export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
canPressLoadingButton: boolean;
defaultActionQueues: boolean;
isAgentRunning: boolean;
t: TFunction;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
return input.t("composer.input.sendMessage");
}
export function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
isDictating: boolean;
t: TFunction;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted
? input.t("composer.voice.unmuteVoiceMode")
: input.t("composer.voice.muteVoiceMode");
}
if (input.isDictating) return input.t("composer.voice.stopDictation");
return input.t("composer.voice.startDictation");
}
export function resolveVoiceTooltipText(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
t: TFunction;
}): string {
if (input.isRealtimeVoiceForCurrentAgent) {
return input.isMuted
? input.t("composer.voice.unmuteVoice")
: input.t("composer.voice.muteVoice");
}
return input.t("composer.voice.dictation");
}
export function resolveSendTooltipLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
defaultActionQueues: boolean;
t: TFunction;
}): string {
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
return input.defaultActionQueues
? input.t("composer.input.queue")
: input.t("composer.input.send");
}