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
+6 -1
View File
@@ -17,6 +17,7 @@ import {
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
export interface QueuedComposerMessage {
id: string;
@@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput {
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
failedToSendMessage?: string;
}
export type SendQueuedComposerMessageNowResult =
@@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow(
});
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : "Failed to send message",
errorMessage:
error instanceof Error
? error.message
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
};
}
}
@@ -8,6 +8,7 @@ import {
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHint,
getAgentControlHintKey,
formatThinkingOptionLabel,
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
@@ -415,6 +416,7 @@ function ControlledAgentControls({
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
@@ -432,7 +434,11 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayProvider = findOptionLabel(
providerOptions,
selectedProviderId,
t("agentControls.provider.fallback"),
);
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
[thinkingOptions],
@@ -440,7 +446,7 @@ function ControlledAgentControls({
const displayThinking = findOptionLabel(
formattedThinkingOptions,
selectedThinkingOptionId,
formattedThinkingOptions[0]?.label ?? "Unknown",
formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"),
);
const ProviderIcon = resolveProviderIcon(provider);
@@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6;
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
provider,
providerOptions,
@@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleProviderPress}
style={providerPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
accessibilityLabel={t("agentControls.provider.select")}
testID="agent-provider-selector"
>
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
@@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("model")}</Text>
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("model"))}</Text>
</TooltipContent>
</Tooltip>
) : null}
@@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onPress={handleThinkingPress}
style={thinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select thinking option (${displayThinking})`}
accessibilityLabel={t("agentControls.thinking.selectWithValue", {
value: displayThinking,
})}
testID="agent-thinking-selector"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getAgentControlHint("thinking")}</Text>
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("thinking"))}</Text>
</TooltipContent>
</Tooltip>
<Combobox
@@ -901,6 +910,7 @@ interface SheetAgentControlsContentProps {
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
provider,
selectedModelId,
@@ -936,6 +946,10 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const hasThinking = comboboxThinkingOptions.length > 0;
const hasFeatures = Boolean(features && features.length > 0);
const featuresSheetHeader = useMemo<SheetHeader>(
() => ({ title: t("agentControls.features.title") }),
[t],
);
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
@@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
accessibilityLabel={t("agentControls.thinking.select")}
testID="agent-controls-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={disabled}
style={featuresButtonStyle}
accessibilityRole="button"
accessibilityLabel="Open agent features"
accessibilityLabel={t("agentControls.features.open")}
testID="agent-controls-features"
>
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose}
searchable={false}
title="Thinking"
title={t("agentControls.thinking.title")}
open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef}
@@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
) : null}
<AdaptiveModalSheet
header={FEATURES_SHEET_HEADER}
header={featuresSheetHeader}
visible={activeSheet === "features"}
onClose={handleCloseSheet}
testID="agent-features-sheet"
@@ -1206,6 +1220,7 @@ function SheetFeatureItem({
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const handleFeatureOpenChange = useMemo(
@@ -1257,7 +1272,9 @@ function SheetFeatureItem({
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
<Text style={styles.modeBadgeText}>
{feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
</Text>
</Pressable>
</View>
);
@@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({
);
}
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
@@ -7,6 +7,7 @@ import {
type ComponentType,
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
@@ -108,6 +109,7 @@ function AgentModeControlView({
disabled = false,
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -182,14 +184,14 @@ function AgentModeControlView({
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: "Mode",
title: t("agentControls.mode.title"),
search: {
onChange: setSearchQuery,
placeholder: "Search modes...",
placeholder: t("agentControls.mode.searchPlaceholder"),
testID: "mode-search-input",
},
}),
[],
[t],
);
if (!selectedMode) return null;
@@ -203,7 +205,9 @@ function AgentModeControlView({
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
@@ -3,17 +3,17 @@ import {
formatAgentModeLabel,
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHint,
getAgentControlHintKey,
formatThinkingOptionLabel,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
describe("getAgentControlHint", () => {
it("explains what each editable agent control does", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
expect(getAgentControlHint("model")).toBe("Change model");
expect(getAgentControlHint("mode")).toBe("Change permission mode");
describe("getAgentControlHintKey", () => {
it("returns translation keys for each editable agent control hint", () => {
expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking");
expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model");
expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode");
});
});
@@ -1,16 +1,21 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
export type AgentControlHintKey =
| "agentControls.hints.thinking"
| "agentControls.hints.model"
| "agentControls.hints.mode";
export function getAgentControlHint(selector: ExplainedAgentControl): string {
export function getAgentControlHintKey(selector: ExplainedAgentControl): AgentControlHintKey {
switch (selector) {
case "thinking":
return "Thinking mode";
return "agentControls.hints.thinking";
case "model":
return "Change model";
return "agentControls.hints.model";
case "mode":
return "Change permission mode";
return "agentControls.hints.mode";
default:
throw new Error("unreachable");
}
@@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high";
return i18n.t("agentControls.thinking.extraHigh");
}
return formatControlLabel(option, true);
@@ -143,17 +148,19 @@ function resolveModelDisplay(
selectedModel: AgentModelDefinition | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
unknownModelLabel: string,
): { activeModelId: string | null; displayModel: string } {
return {
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
displayModel:
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel,
};
}
function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null,
unknownThinkingLabel: string,
): string {
if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking);
@@ -163,7 +170,7 @@ function resolveThinkingDisplay(
return formatThinkingOptionLabel({ id: selectedThinkingId });
}
return "Unknown";
return unknownThinkingLabel;
}
export function resolveAgentModelSelection(input: {
@@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: {
selectedModel,
preferredModelId,
fallbackModel,
i18n.t("agentControls.model.unknown"),
);
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
const displayThinking = resolveThinkingDisplay(
effectiveThinking,
selectedThinkingId,
i18n.t("agentControls.thinking.unknown"),
);
return {
selectedModel,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
@@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
let label: string;
if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`;
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
} else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count: attachment.commentCount });
}
const handleOpen = useCallback(() => {
onOpen(attachment);
@@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({
onRemove={handleRemove}
openAccessibilityLabel={
attachment.kind === "browser_element"
? "Open browser element attachment"
: "Open review attachment"
? t("composer.attachments.openBrowserElement")
: t("composer.attachments.openReview")
}
removeAccessibilityLabel={
attachment.kind === "browser_element"
? "Remove browser element attachment"
: "Remove review attachment"
? t("composer.attachments.removeBrowserElement")
: t("composer.attachments.removeReview")
}
disabled={disabled}
>
+10 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer(
reducer,
initialAttempt,
@@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
const resolved =
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
@@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onBeforeSubmit,
onCreateError,
onCreateSuccess,
t,
updatePendingAgentId,
],
);
@@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error("Already loading");
throw new Error(t("composer.errors.alreadyLoading"));
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
@@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
const error = new Error(t("composer.errors.initialPromptRequired"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
@@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
t,
validateBeforeSubmit,
],
);
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
@@ -13,6 +14,7 @@ interface ComposerImportPillProps {
}
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
@@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
<Pressable
testID="composer-import-agent-pill"
accessibilityRole="button"
accessibilityLabel="Import session"
accessibilityLabel={t("importSession.title")}
onPress={onPress}
disabled={disabled}
onHoverIn={handleHoverIn}
@@ -31,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={styles.label} numberOfLines={1}>
Import session
{t("importSession.title")}
</Text>
</Pressable>
</View>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: {
effectiveThinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
const {
attempt,
@@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: {
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) {
throw new Error("Host is not connected");
throw new Error(input.hostDisconnectedMessage);
}
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
throw new Error(input.selectModelMessage);
}
const modeIdOverride = resolveDraftModeIdOverride({
autoSubmitConfig,
@@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: {
selectedProvider: string | null;
agentControls: { features?: Agent["features"] };
};
selectModelMessage: string;
}): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
invariant(workspaceDirectory, "Workspace directory is required");
@@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: {
});
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
throw new Error(input.selectModelMessage);
}
return {
serverId,
@@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig,
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments }) =>
submitDraftCreateRequest({
@@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({
workspaceExecutionAuthority,
autoSubmitConfig,
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
+104 -35
View File
@@ -6,6 +6,7 @@ import {
Image,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
import {
useState,
useEffect,
@@ -16,6 +17,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
@@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
return isDesktopWebBreakpoint
? t("composer.placeholders.desktop")
: t("composer.placeholders.mobile");
}
function resolveGithubSearchEnabled(
@@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs {
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
labels: {
openImage: string;
removeImage: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
}
function renderComposerFooter(
@@ -272,8 +282,13 @@ function renderComposerFooter(
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
args;
const {
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels,
} = args;
if (selectedAttachments.length === 0) return null;
return (
<View style={styles.attachmentTray} testID="composer-attachment-tray">
@@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
disabled: isComposerLocked,
onOpen: handleOpenAttachment,
onRemove: handleRemoveAttachment,
labels,
}),
)}
</View>
@@ -294,10 +310,13 @@ interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>;
editLabel: string;
sendNowLabel: string;
}
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } =
args;
if (queuedMessages.length === 0) return null;
return (
<View style={styles.queueTrack}>
@@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
item={item}
onEdit={handleEditQueuedMessage}
onSendNow={handleSendQueuedNow}
editLabel={editLabel}
sendNowLabel={sendNowLabel}
/>
))}
</View>
@@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
labels: RenderAttachmentTrayArgs["labels"];
}
function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement {
const { attachment, index, disabled, onOpen, onRemove } = args;
const { attachment, index, disabled, onOpen, onRemove, labels } = args;
if (attachment.kind === "image") {
return (
<ImageAttachmentPill
@@ -332,6 +354,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openImage}
removeLabel={labels.removeImage}
/>
);
}
@@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
@@ -472,9 +498,17 @@ interface QueuedMessageRowProps {
item: QueuedMessage;
onEdit: (id: string) => void;
onSendNow: (id: string) => void;
editLabel: string;
sendNowLabel: string;
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
function QueuedMessageRow({
item,
onEdit,
onSendNow,
editLabel,
sendNowLabel,
}: QueuedMessageRowProps) {
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
accessibilityLabel={editLabel}
accessibilityRole="button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
@@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
accessibilityLabel={sendNowLabel}
accessibilityRole="button"
>
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
@@ -523,6 +557,8 @@ interface ImageAttachmentPillProps {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: string;
removeLabel: string;
}
function ImageAttachmentPill({
@@ -531,6 +567,8 @@ function ImageAttachmentPill({
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: ImageAttachmentPillProps) {
const handleOpen = useCallback(() => {
onOpen(attachment);
@@ -543,8 +581,8 @@ function ImageAttachmentPill({
testID="composer-image-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
@@ -558,6 +596,8 @@ interface GithubAttachmentPillProps {
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
}
function GithubAttachmentPill({
@@ -566,6 +606,8 @@ function GithubAttachmentPill({
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
@@ -580,8 +622,8 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
disabled={disabled}
>
<View style={styles.githubPillBody}>
@@ -693,8 +735,6 @@ interface ComposerProps {
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
@@ -714,6 +754,7 @@ interface ComposerCancelButtonProps {
isConnected: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
function ComposerCancelButton({
@@ -723,8 +764,11 @@ function ComposerCancelButton({
isConnected,
isCancellingAgent,
agentInterruptKeys,
t,
}: ComposerCancelButtonProps) {
const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent";
const accessibilityLabel = isCancellingAgent
? t("composer.cancel.cancelingAgent")
: t("composer.cancel.stopAgent");
const icon = isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
@@ -744,7 +788,7 @@ function ComposerCancelButton({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Text style={styles.tooltipText}>{t("composer.cancel.interrupt")}</Text>
{shortcutNode}
</View>
</TooltipContent>
@@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps {
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
@@ -819,6 +864,7 @@ function ComposerVoiceModeButton({
isVoiceSwitching,
realtimeVoiceButtonStyle,
voiceToggleKeys,
t,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
@@ -836,7 +882,7 @@ function ComposerVoiceModeButton({
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityLabel={t("composer.voice.enableVoiceMode")}
accessibilityRole="button"
style={realtimeVoiceButtonStyle}
>
@@ -844,7 +890,7 @@ function ComposerVoiceModeButton({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
{shortcutNode}
</View>
</TooltipContent>
@@ -887,6 +933,7 @@ export function Composer({
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -920,7 +967,7 @@ export function Composer({
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value;
const setUserInput = onChangeText;
const {
@@ -1070,11 +1117,11 @@ export function Composer({
return;
}
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
},
[cwd, onMessageSent],
[cwd, onMessageSent, t],
);
useEffect(() => {
@@ -1088,7 +1135,7 @@ export function Composer({
sendAttachments: ComposerAttachment[],
) => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
@@ -1106,7 +1153,7 @@ export function Composer({
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]);
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1181,6 +1228,7 @@ export function Composer({
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
failedToSendMessage: t("composer.errors.failedToSend"),
});
completeSubmit({
result,
@@ -1198,6 +1246,7 @@ export function Composer({
setUserInput,
submitBehavior,
submitMessage,
t,
],
);
@@ -1376,12 +1425,13 @@ export function Composer({
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
failedToSendMessage: t("composer.errors.failedToSend"),
});
if (result.status === "failed") {
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, submitMessage],
[agentId, queueWriter, submitMessage, t],
);
const handleQueue = useCallback(
@@ -1433,6 +1483,7 @@ export function Composer({
isConnected={isConnected}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
/>
),
[
@@ -1445,6 +1496,7 @@ export function Composer({
isCancellingAgent,
isConnected,
isProcessing,
t,
],
);
@@ -1463,6 +1515,7 @@ export function Composer({
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
cancelButton={cancelButton}
/>
),
@@ -1479,6 +1532,7 @@ export function Composer({
isVoiceModeForAgent,
isVoiceSwitching,
realtimeVoiceButtonStyle,
t,
voiceToggleKeys,
],
);
@@ -1528,7 +1582,7 @@ export function Composer({
() => [
{
id: "image",
label: "Add image",
label: t("composer.attachments.addImage"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
@@ -1536,14 +1590,14 @@ export function Composer({
},
{
id: "github",
label: "Add issue or PR",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage],
[handlePickImage, t],
);
const handleToggleGithubItem = useCallback(
@@ -1641,13 +1695,28 @@ export function Composer({
isComposerLocked,
handleOpenAttachment,
handleRemoveAttachment,
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments],
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
);
const queueList = useMemo(
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
() =>
renderQueueTrack({
queuedMessages,
handleEditQueuedMessage,
handleSendQueuedNow,
editLabel: t("composer.attachments.editQueuedMessage"),
sendNowLabel: t("composer.attachments.sendQueuedMessageNow"),
}),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t],
);
const messageInputContainerRef = useRef<View>(null);
@@ -1660,8 +1729,8 @@ export function Composer({
[sendError],
);
const githubEmptyText = githubSearchResultsQuery.isFetching
? "Searching..."
: "No results found.";
? t("composer.github.searching")
: t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
@@ -1732,8 +1801,8 @@ export function Composer({
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder="Search issues and PRs..."
title="Attach issue or PR"
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
+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");
}
+8 -1
View File
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TAttachment> {
@@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
failedToSendMessage?: string;
}
export async function submitAgentInput<TAttachment>(
@@ -67,7 +70,11 @@ export async function submitAgentInput<TAttachment>(
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setSendError(
error instanceof Error
? error.message
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
);
input.setIsProcessing(false);
return "failed";
}