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