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
+2 -2
View File
@@ -34,7 +34,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
await page.getByRole("button", { name: "Save" }).click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
@@ -79,7 +79,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
}
// --- Form-state assertions ---
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { openSettingsSection } from "./helpers/settings";
test("Settings language selector switches General labels", async ({ page }) => {
test.setTimeout(120_000);
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "general");
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("button", { name: "Simplified Chinese", exact: true }).click();
await expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "简体中文", exact: true }).click();
await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});
+3
View File
@@ -73,6 +73,7 @@
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
@@ -80,12 +81,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
+24 -11
View File
@@ -10,6 +10,7 @@ import React, {
type ComponentProps,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
emptyText: string;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
</View>
);
}
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
ref,
) {
const { t } = useTranslation();
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
[renderModel, emptyStateStyle],
() =>
renderListEmptyComponent({
renderModel,
emptyStateStyle,
emptyText: t("agentStream.empty"),
}),
[renderModel, emptyStateStyle, t],
);
const { boundary, auxiliary } = renderModel;
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel="Scroll to bottom"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
@@ -906,11 +914,14 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const title = isPlanRequest
? t("agentStream.permission.plan")
: (request.title ?? request.name ?? t("agentStream.permission.required"));
const description = request.description ?? "";
const resolvedToolCallDetail = useMemo(
() =>
@@ -931,19 +942,21 @@ function PermissionRequestCard({
return [
{
id: "reject",
label: "Deny",
label: t("agentStream.permission.deny"),
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest ? "Implement" : "Accept",
label: isPlanRequest
? t("agentStream.permission.implement")
: t("agentStream.permission.accept"),
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request]);
}, [isPlanRequest, request, t]);
const planMarkdown = useMemo(() => {
if (!request) {
@@ -968,7 +981,7 @@ function PermissionRequestCard({
response: AgentPermissionResponse;
}) => {
if (!client) {
throw new Error("Daemon client unavailable");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return client.respondToPermissionAndWait(
input.agentId,
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed?
{t("agentStream.permission.question")}
</Text>
<View style={optionsContainerStyle}>
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
{planMarkdown ? (
<PlanCard
title="Proposed plan"
title={t("agentStream.permission.proposedPlan")}
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing
+10 -7
View File
@@ -69,6 +69,7 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
@@ -934,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider>
);
}
+14 -16
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
export default function PairScanScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
Alert.alert("Error", message);
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
Alert.alert(t("pairing.scan.errorTitle"), message);
} finally {
setIsPairing(false);
}
},
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={handleRouterBack} />
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
<Text style={styles.permissionBody}>
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
</Text>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
</View>
</View>
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} />
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text>
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable>
</View>
) : (
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
</View>
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
</View>
</View>
)}
@@ -4,6 +4,7 @@ import {
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
href: string;
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) {
super(`No file found for ${token}`);
super(i18n.t("common.errors.noFileFound", { token }));
this.name = "UnresolvedFileLinkError";
}
}
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -11,7 +12,6 @@ import {
import {
classifyForResolution,
fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
} from "./resolver";
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient();
const stableSource = useStableSource(source);
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
disposition,
context,
queryClient,
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
});
},
);
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>;
formatNoFileFoundMessage: (token: string) => string;
}): void {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
} catch (error) {
await dispatchUnresolvedError({
error,
fallbackToken: capturedResolution.token,
noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
async function dispatchUnresolvedError(input: {
error: unknown;
fallbackToken: string;
noFileFoundMessage: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
) {
return;
}
const token =
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
current.toast?.show(input.noFileFoundMessage, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
argumentHint: string;
kind: ClientSlashCommandKind;
execution: ClientSlashCommandExecution;
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "exit",
aliases: ["quit", "q"],
description: "Archive the current agent",
descriptionKey: "composer.clientCommands.archiveAgent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
name: "clear",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
descriptionKey: "composer.clientCommands.freshDraft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
@@ -1,6 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
@@ -304,6 +305,7 @@ export function SheetHeaderView({
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -327,7 +329,7 @@ export function SheetHeaderView({
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
testID="sheet-header-back"
>
{({ pressed }) => (
@@ -347,7 +349,11 @@ export function SheetHeaderView({
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<Pressable
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => (
<X
size={16}
@@ -363,7 +369,7 @@ export function SheetHeaderView({
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholder={search.placeholder ?? t("common.actions.search")}
resetKey={search.resetKey}
onChangeText={handleSearchChange}
autoCapitalize="none"
@@ -379,6 +385,7 @@ export function SheetHeaderView({
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
accessibilityLabel={
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
}
testID="sheet-header-back"
>
{({ pressed }) => (
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholder={header.search.placeholder ?? t("common.actions.search")}
resetKey={header.search.resetKey}
onChangeText={header.search.onChange}
autoCapitalize="none"
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
presentation,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
@@ -1,12 +1,11 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({
option: {
flexDirection: "row",
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
onPasteLink,
}: AddHostMethodModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
const handleDirect = useCallback(() => {
onDirectConnection();
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
return (
<AdaptiveModalSheet
header={ADD_CONNECTION_HEADER}
header={header}
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
accessibilityLabel="Direct connection"
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</Pressable>
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
accessibilityLabel="Scan QR code"
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
>
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</Pressable>
) : null}
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
accessibilityLabel="Paste pairing link"
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</Pressable>
</AdaptiveModalSheet>
+111 -45
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -15,7 +16,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft {
host: string;
@@ -31,6 +31,20 @@ interface PreparedDirectConnection {
password?: string;
}
interface DirectConnectionLabels {
hostRequired: string;
invalidPort: string;
invalidConnection: string;
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -128,14 +142,17 @@ function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
@@ -147,8 +164,11 @@ function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
function prepareDirectConnection(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
@@ -178,7 +198,10 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
@@ -197,22 +220,24 @@ function formatTechnicalTransportDetails(details: (string | null)[]): string | n
});
if (allGeneric) {
return `${unique[0]} (no additional details provided)`;
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
@@ -228,26 +253,25 @@ function buildConnectionFailureCopy(
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection refused. Is the server running at this address?";
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
detail = labels.tlsError;
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
detail = labels.unableToConnect;
}
return { title, detail, raw };
@@ -267,6 +291,7 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
@@ -308,6 +333,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const directConnectionLabels = useMemo<DirectConnectionLabels>(
() => ({
hostRequired: t("pairing.direct.errors.hostRequired"),
invalidPort: t("pairing.direct.errors.invalidPort"),
invalidConnection: t("pairing.direct.errors.invalidConnection"),
failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }),
noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }),
timedOut: t("pairing.direct.errors.timedOut"),
refused: t("pairing.direct.errors.refused"),
hostNotFound: t("pairing.direct.errors.hostNotFound"),
hostUnreachable: t("pairing.direct.errors.hostUnreachable"),
tlsError: t("pairing.direct.errors.tlsError"),
unableToConnect: t("pairing.direct.errors.unableToConnect"),
}),
[t],
);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -328,9 +370,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection({ host, port, useTls, password });
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid connection";
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
setErrorMessage(message);
return;
}
@@ -349,10 +395,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
const {
title,
detail,
raw: rawDetail,
} = buildConnectionFailureCopy({
endpoint: connection.uri,
error,
labels: directConnectionLabels,
});
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", {
detail: rawDetail,
})}`;
} else if (detail) {
combined = `${title}\n${detail}`;
} else {
@@ -360,13 +416,14 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
Alert.alert("Connection failed", combined);
Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
}
} finally {
setIsSaving(false);
}
}, [
daemons,
directConnectionLabels,
handleClose,
host,
isMobile,
@@ -375,6 +432,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
password,
port,
probeAndUpsertDirectConnection,
t,
useTls,
]);
@@ -398,7 +456,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
}
@@ -419,27 +479,27 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
header={DIRECT_CONNECTION_HEADER}
header={header}
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host}
@@ -455,11 +515,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port}
@@ -482,7 +542,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
@@ -493,21 +553,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Password</Text>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
@@ -522,7 +582,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
accessibilityLabel={
isPasswordVisible
? t("pairing.direct.passwordVisibility.hide")
: t("pairing.direct.passwordVisibility.show")
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
@@ -536,17 +600,19 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
accessibilityLabel={
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
@@ -572,7 +638,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onPress={handleCancel}
disabled={isSaving}
>
Cancel
{t("pairing.direct.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
@@ -582,7 +648,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
leftIcon={connectIcon}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
</Button>
</View>
</AdaptiveModalSheet>
+68 -34
View File
@@ -11,6 +11,8 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
@@ -33,8 +35,18 @@ interface AgentListProps {
showAttentionIndicator?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
const DATE_SECTION_ORDER = [
"today",
"yesterday",
"thisWeek",
"thisMonth",
"older",
] as const satisfies readonly DateSectionKey[];
type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
@@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) {
});
}
function deriveDateSectionLabel(lastActivityAt: Date): string {
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
);
if (activityStart.getTime() >= todayStart.getTime()) {
return "Today";
return "today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return "Yesterday";
return "yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return "This week";
return "thisWeek";
}
if (diffDays <= 30) {
return "This month";
return "thisMonth";
}
return "Older";
return "older";
}
function formatStatusLabel(status: AggregatedAgent["status"]): string {
function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
switch (section) {
case "today":
return t("agentList.dateSections.today");
case "yesterday":
return t("agentList.dateSections.yesterday");
case "thisWeek":
return t("agentList.dateSections.thisWeek");
case "thisMonth":
return t("agentList.dateSections.thisMonth");
case "older":
return t("agentList.dateSections.older");
}
}
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
switch (status) {
case "initializing":
return "Starting";
return t("agentList.status.initializing");
case "idle":
return "Idle";
return t("agentList.status.idle");
case "running":
return "Running";
return t("agentList.status.running");
case "error":
return "Error";
return t("agentList.status.error");
case "closed":
return "Closed";
return t("agentList.status.closed");
default:
return status;
}
@@ -188,12 +215,14 @@ function SessionRow({
onLongPress: (agent: AggregatedAgent) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const statusLabel = formatStatusLabel(t, agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -231,14 +260,19 @@ function SessionRow({
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || "New session"}
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</View>
{isMobile && (
@@ -272,7 +306,7 @@ function SessionRow({
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
</Pressable>
@@ -289,6 +323,7 @@ export function AgentList({
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
@@ -354,22 +389,21 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
const section = deriveDateSectionKey(agent.lastActivityAt);
const existing = buckets.get(section) ?? [];
existing.push(agent);
buckets.set(label, existing);
buckets.set(section, existing);
}
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label);
for (const section of DATE_SECTION_ORDER) {
const data = buckets.get(section);
if (!data || data.length === 0) {
continue;
}
result.push({ type: "header", key: `header:${label}`, title: label });
result.push({ type: "header", key: `header:${section}`, section });
for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
@@ -382,7 +416,7 @@ export function AgentList({
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
<Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
</View>
);
}
@@ -397,7 +431,7 @@ export function AgentList({
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -454,8 +488,8 @@ export function AgentList({
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
? "Host offline"
: "This agent is still running. Archiving it will stop the agent."}
? t("agentList.archiveSheet.hostOffline")
: t("agentList.archiveSheet.runningAgent")}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -463,7 +497,7 @@ export function AgentList({
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
<Text style={styles.sheetCancelText}>Cancel</Text>
<Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
@@ -471,7 +505,7 @@ export function AgentList({
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
<Text style={sheetArchiveTextStyle}>Archive</Text>
<Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
</Pressable>
</View>
</View>
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps {
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
<Text style={styles.calloutText}>This agent is archived</Text>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
Unarchive
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>
@@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({
isNative: false,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
({
"message.attachments.closeImage": "Close image",
"message.attachments.dismissImage": "Dismiss image",
"message.attachments.imageLoadFailed": "Couldn't load image",
})[key] ?? key,
}),
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform";
@@ -15,6 +16,7 @@ interface AttachmentLightboxProps {
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata);
const [errored, setErrored] = useState(false);
@@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel="Dismiss image"
accessibilityLabel={t("message.attachments.dismissImage")}
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn&apos;t load image</Text>
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
<Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage
@@ -86,7 +88,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<Pressable
testID="attachment-lightbox-close"
accessibilityRole="button"
accessibilityLabel="Close image"
accessibilityLabel={t("message.attachments.closeImage")}
hitSlop={8}
onPress={onClose}
style={closeButtonStyle}
@@ -3,6 +3,7 @@ import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxProps } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -27,6 +28,7 @@ export function BranchSwitcher({
isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
@@ -91,7 +93,7 @@ export function BranchSwitcher({
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
@@ -101,10 +103,10 @@ export function BranchSwitcher({
value={currentBranchName}
onSelect={handleBranchSelect}
searchable
placeholder="Switch branch..."
searchPlaceholder="Filter branches..."
emptyText="No branches found."
title="Switch branch"
placeholder={t("branchSwitcher.placeholder")}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={t("branchSwitcher.empty")}
title={t("branchSwitcher.title")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
@@ -10,6 +10,7 @@ import {
import { Pressable, Text, TextInput, View } from "react-native";
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachments,
@@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
}
function getWebviewLoadErrorMessage(event: Event): string | null {
function getWebviewLoadErrorMessage(event: Event, failedToLoadLabel: string): string | null {
const details = event as Event & {
errorCode?: unknown;
errorDescription?: unknown;
@@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
const description =
typeof details.errorDescription === "string" && details.errorDescription.trim()
? details.errorDescription.trim()
: "Failed to load page";
: failedToLoadLabel;
const url =
typeof details.validatedURL === "string" && details.validatedURL.trim()
? details.validatedURL.trim()
@@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
return url ? `${description}: ${url}` : description;
}
function getLoadUrlRejectionMessage(error: unknown): string | null {
function getLoadUrlRejectionMessage(error: unknown, failedToLoadLabel: string): string | null {
if (error instanceof Error && error.message.trim()) {
if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) {
return null;
@@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null {
}
return error.trim();
}
return "Failed to load page";
return failedToLoadLabel;
}
function getUnsafeNavigationMessage(url: string): string | null {
function getUnsafeNavigationMessage(
url: string,
labels: { invalidUrl: string; unsupportedProtocol: (protocol: string) => string },
): string | null {
try {
const parsed = new URL(url);
if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") {
return null;
}
return `Blocked unsupported browser URL: ${parsed.protocol}`;
return labels.unsupportedProtocol(parsed.protocol);
} catch {
return "Invalid browser URL";
return labels.invalidUrl;
}
}
@@ -283,6 +287,7 @@ export function BrowserPane({
onFocusPane?: () => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
const webviewRef = useRef<ElectronWebview | null>(null);
@@ -327,6 +332,17 @@ export function BrowserPane({
() => [styles.metaError, { color: theme.colors.palette.red[500] }],
[theme.colors.palette.red],
);
const browserErrorLabels = useMemo(
() => ({
failedToLoad: t("workspace.browser.errors.failedToLoad"),
invalidUrl: t("workspace.browser.errors.invalidUrl"),
unsupportedProtocol: (protocol: string) =>
t("workspace.browser.errors.unsupportedProtocol", { protocol }),
}),
[t],
);
const browserErrorLabelsRef = useRef(browserErrorLabels);
browserErrorLabelsRef.current = browserErrorLabels;
useEffect(() => {
const nextUrl = browser?.url ?? "https://example.com";
@@ -384,7 +400,10 @@ export function BrowserPane({
host.replaceChildren();
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current);
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(
initialUrlRef.current,
browserErrorLabelsRef.current,
);
const webview = document.createElement("webview") as ElectronWebview;
webviewRef.current = webview;
webview.setAttribute("partition", `persist:paseo-browser-${browserId}`);
@@ -459,7 +478,7 @@ export function BrowserPane({
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
};
const handleLoadFailed = (event: Event) => {
const message = getWebviewLoadErrorMessage(event);
const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad);
if (!message) {
return;
}
@@ -519,43 +538,46 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]);
const navigate = useCallback((nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl);
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
updateBrowserRef.current(browserIdRef.current, {
url: normalizedUrl,
isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
});
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
if (unsafeNavigationMessage) {
const navigate = useCallback(
(nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels);
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: unsafeNavigationMessage,
url: normalizedUrl,
isLoading: unsafeNavigationMessage === null,
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
lastError: null,
});
return;
}
if (webview?.loadURL) {
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
const message = getLoadUrlRejectionMessage(error);
if (!message) {
return;
}
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
if (unsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: message,
lastError: unsafeNavigationMessage,
});
});
return;
}
if (webview) {
webview.setAttribute("src", normalizedUrl);
}
}, []);
return;
}
if (webview?.loadURL) {
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
const message = getLoadUrlRejectionMessage(error, browserErrorLabels.failedToLoad);
if (!message) {
return;
}
updateBrowserRef.current(browserIdRef.current, {
isLoading: false,
lastError: message,
});
});
return;
}
if (webview) {
webview.setAttribute("src", normalizedUrl);
}
},
[browserErrorLabels],
);
const handleBack = useCallback(() => {
webviewRef.current?.goBack?.();
@@ -929,10 +951,8 @@ export function BrowserPane({
if (!isElectronRuntime()) {
return (
<View style={styles.unavailableState}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
</View>
);
}
@@ -943,7 +963,7 @@ export function BrowserPane({
<View style={styles.chromeLeft}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityLabel={t("workspace.browser.controls.back")}
disabled={!browser?.canGoBack}
onPress={handleBack}
style={backIconButtonStyle}
@@ -952,7 +972,7 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Forward"
accessibilityLabel={t("workspace.browser.controls.forward")}
disabled={!browser?.canGoForward}
onPress={handleForward}
style={forwardIconButtonStyle}
@@ -961,7 +981,11 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={browser?.isLoading ? "Stop loading" : "Refresh"}
accessibilityLabel={
browser?.isLoading
? t("workspace.browser.controls.stopLoading")
: t("workspace.browser.controls.refresh")
}
onPress={handleRefresh}
style={baseIconButtonStyle}
>
@@ -970,13 +994,13 @@ export function BrowserPane({
</View>
<View style={styles.urlBarWrap}>
<TextInput
accessibilityLabel="Browser URL"
accessibilityLabel={t("workspace.browser.controls.browserUrl")}
autoCapitalize="none"
autoCorrect={false}
onChangeText={setDraftUrl}
onFocus={handleUrlBarFocus}
onSubmitEditing={handleNavigateDraftUrl}
placeholder="Enter URL"
placeholder={t("workspace.browser.controls.enterUrl")}
placeholderTextColor={theme.colors.foregroundMuted}
ref={urlInputRef}
style={urlInputStyle}
@@ -988,7 +1012,7 @@ export function BrowserPane({
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open browser dev tools"
accessibilityLabel={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
@@ -996,7 +1020,11 @@ export function BrowserPane({
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
accessibilityLabel={
selectorActive
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.selectElement")
}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
+4 -2
View File
@@ -1,6 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface BrowserPaneProps {
browserId: string;
@@ -13,6 +14,7 @@ interface BrowserPaneProps {
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps {
@@ -12,6 +13,7 @@ interface BrowserPaneProps {
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
@@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
@@ -144,6 +145,7 @@ function ModelRow({
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback(
@@ -166,7 +168,9 @@ function ModelRow({
hitSlop={8}
style={favoriteButtonStyle}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
accessibilityLabel={
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => {
@@ -193,6 +197,7 @@ function ModelRow({
theme.colors.palette.amber,
theme.colors.foregroundMuted,
theme.colors.border,
t,
],
);
@@ -256,6 +261,7 @@ function FavoritesSection({
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { t } = useTranslation();
if (favoriteRows.length === 0) {
return null;
}
@@ -263,7 +269,7 @@ function FavoritesSection({
return (
<View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
<Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
</View>
{favoriteRows.map((row) => (
<SelectableModelRow
@@ -295,6 +301,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
@@ -306,7 +313,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
if (selection.kind === "models") {
const count = selection.rows.length;
stateNode = (
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
<Text style={styles.drillDownCount}>
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
count,
})}
</Text>
);
} else if (selection.kind === "loading") {
stateNode = (
@@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.drillDownCount}>Loading</Text>
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text>
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
</View>
);
}
@@ -431,6 +442,7 @@ function ProviderErrorEmptyState({
isRetryingProvider: boolean;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleRetry = useCallback(() => {
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
@@ -440,7 +452,7 @@ function ProviderErrorEmptyState({
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? "Retrying…" : "Retry"}
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
</Button>
) : null}
</View>
@@ -461,6 +473,7 @@ function SelectorContent({
isRetryingProvider,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
() =>
@@ -484,7 +497,7 @@ function SelectorContent({
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
</View>
);
@@ -501,7 +514,7 @@ function SelectorContent({
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.emptyStateText}>Loading</Text>
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
</View>
);
}
@@ -569,6 +582,7 @@ export function CombinedModelSelector({
serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
@@ -653,12 +667,15 @@ export function CombinedModelSelector({
}, [providers, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
if (
selectedModelLabel === t("modelSelector.loading") ||
selectedModelLabel === t("modelSelector.selectModel")
) {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel]);
}, [selectedModelLabel, t]);
useEffect(() => {
if (platformIsWeb) {
@@ -713,7 +730,7 @@ export function CombinedModelSelector({
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
return { title: t("modelSelector.title") };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
@@ -723,7 +740,9 @@ export function CombinedModelSelector({
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`}
accessibilityLabel={t("modelSelector.openProviderSettings", {
provider: view.providerLabel,
})}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
@@ -742,7 +761,7 @@ export function CombinedModelSelector({
search: {
onChange: handleSearchQueryChange,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
placeholder: t("modelSelector.searchPlaceholder"),
autoFocus: platformIsWeb,
testID: "model-search-input",
},
@@ -757,6 +776,7 @@ export function CombinedModelSelector({
handleBackToAll,
handleSearchQueryChange,
searchResetKey,
t,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
@@ -771,7 +791,7 @@ export function CombinedModelSelector({
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger ? (
@@ -823,7 +843,7 @@ export function CombinedModelSelector({
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
</View>
)}
</Combobox>
+11 -6
View File
@@ -8,6 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps {
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
@@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
</View>
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{agent.title || "New agent"}
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
@@ -256,10 +258,12 @@ function AgentItemsSection({
sectionDividerStyle,
sectionLabelStyle,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>Agents</Text>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index;
const agent = item.agent;
@@ -283,6 +287,7 @@ function AgentItemsSection({
export function CommandCenter() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
open,
inputRef,
@@ -442,12 +447,12 @@ export function CommandCenter() {
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
@@ -501,7 +506,7 @@ export function CommandCenter() {
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..."
placeholder={t("shell.commandCenter.placeholder")}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -534,7 +539,7 @@ export function CommandCenter() {
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholder={t("shell.commandCenter.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
@@ -1,6 +1,7 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface ContextWindowMeterProps {
@@ -81,6 +82,7 @@ export function ContextWindowMeter({
showPercentage = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
@@ -106,7 +108,9 @@ export function ContextWindowMeter({
<Pressable
style={containerStyle}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
>
<Svg
width={svgSize}
@@ -143,13 +147,20 @@ export function ContextWindowMeter({
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
<Text style={styles.tooltipText}>
{t("contextWindow.used", { percentage: roundedPercentage })}
</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
</View>
</TooltipContent>
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
@@ -43,6 +44,7 @@ export function DictationControls({
disabled = false,
}: DictationControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
@@ -71,7 +73,7 @@ export function DictationControls({
onPress={onStart}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel="Start voice dictation"
accessibilityLabel={t("message.dictation.start")}
style={micButtonStyle}
>
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
@@ -89,7 +91,7 @@ export function DictationControls({
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityLabel="Cancel dictation"
accessibilityLabel={t("message.dictation.cancel")}
style={cancelButtonStyle}
>
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
@@ -102,7 +104,7 @@ export function DictationControls({
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityLabel="Retry dictation"
accessibilityLabel={t("message.dictation.retry")}
style={ACTION_CONFIRM_STYLE}
>
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -112,14 +114,14 @@ export function DictationControls({
<>
<Pressable
onPress={onAccept}
accessibilityLabel="Insert transcription"
accessibilityLabel={t("message.dictation.insert")}
style={ACTION_SECONDARY_STYLE}
>
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send"
accessibilityLabel={t("message.dictation.insertAndSend")}
style={ACTION_CONFIRM_STYLE}
>
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
@@ -149,6 +151,7 @@ export function DictationOverlay({
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
@@ -189,7 +192,7 @@ export function DictationOverlay({
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
accessibilityLabel={t("message.dictation.cancel")}
style={overlayCancelButtonStyle}
>
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
@@ -208,7 +211,9 @@ export function DictationOverlay({
</View>
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
</Text>
) : null}
</View>
@@ -223,7 +228,7 @@ export function DictationOverlay({
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
accessibilityLabel={t("message.dictation.retry")}
style={overlayRetryButtonStyle}
>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
@@ -234,7 +239,7 @@ export function DictationOverlay({
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
accessibilityLabel={t("message.dictation.insert")}
style={OVERLAY_ACCEPT_BUTTON_STYLE}
>
<Pencil
@@ -246,7 +251,7 @@ export function DictationOverlay({
<Pressable
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
accessibilityLabel={t("message.dictation.insertAndSend")}
style={overlayConfirmButtonStyle}
>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
+5 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
@@ -120,10 +121,12 @@ function DiffSegment({
export function DiffViewer({
diffLines,
maxHeight,
emptyLabel = "No changes to display",
emptyLabel,
fillAvailableHeight = false,
}: DiffViewerProps) {
const { t } = useTranslation();
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
const webScrollbarStyle = useWebScrollbarStyle();
const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) =>
@@ -159,7 +162,7 @@ export function DiffViewer({
if (!diffLines.length) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
<Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
</View>
);
}
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store
const AUTO_DISMISS_DELAY = 3000;
function getDownloadStatusText(download: Download): string {
function getDownloadStatusText(download: Download, t: TFunction): string {
if (download.status === "downloading") {
if (download.progress) {
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
}
return "Starting...";
return t("common.states.starting");
}
if (download.status === "complete") return "Download complete";
return download.message ?? "Download failed";
if (download.status === "complete") return t("common.states.downloadComplete");
return download.message ?? t("common.states.downloadFailed");
}
export function DownloadToast() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const downloads = useDownloadStore((state) => state.downloads);
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
@@ -78,7 +81,7 @@ export function DownloadToast() {
<Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName}
</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<ProgressFill percent={activeDownload.progress.percent} />
@@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "@/git/pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
@@ -418,6 +419,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
@@ -448,7 +450,7 @@ function SidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label="Changes"
label={t("workspace.tabs.explorer.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -456,7 +458,7 @@ function SidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label="Files"
label={t("workspace.tabs.explorer.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
@@ -16,6 +17,7 @@ interface FileDropZoneProps {
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
@@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>Drop images here</Text>
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
</View>
</Animated.View>
</View>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
@@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "modified", label: "Modified" },
{ value: "size", label: "Size" },
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
{ value: "modified" },
{ value: "size" },
];
const INDENT_PER_LEVEL = 16;
@@ -115,6 +116,7 @@ function TreeRowItem({
onDownloadEntry,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const handlePress = useCallback(() => {
@@ -181,7 +183,7 @@ function TreeRowItem({
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Size
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
@@ -189,7 +191,7 @@ function TreeRowItem({
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
@@ -198,11 +200,11 @@ function TreeRowItem({
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
Copy path
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
Download
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -229,6 +231,7 @@ export function FileExplorerPane({
workspaceRoot,
onOpenFile,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -395,7 +398,15 @@ export function FileExplorerPane({
void refetchExplorer();
}, [refetchExplorer]);
const currentSortLabel = resolveCurrentSortLabel(sortOption);
const sortLabels = useMemo(
() => ({
name: t("workspace.fileExplorer.sort.name"),
modified: t("workspace.fileExplorer.sort.modified"),
size: t("workspace.fileExplorer.sort.size"),
}),
[t],
);
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => resolveTreeRows({ directories, expandedPaths, sortOption }),
@@ -453,7 +464,7 @@ export function FileExplorerPane({
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Workspace is unavailable</Text>
<Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
</View>
);
}
@@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps {
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
error,
showInitialLoading,
@@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
<View style={styles.errorActions}>
{showBackFromError ? (
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
<Text style={styles.retryButtonText}>Back</Text>
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
</Pressable>
) : null}
<Pressable style={styles.retryButton} onPress={handleRetry}>
<Text style={styles.retryButtonText}>Retry</Text>
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
</Pressable>
</View>
</View>
@@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading files…</Text>
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View>
);
}
@@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (treeRows.length === 0) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No files</Text>
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
</View>
);
}
@@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -704,8 +720,11 @@ function resolveShowInitialLoading({
);
}
function resolveCurrentSortLabel(sortOption: SortOption): string {
return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
function resolveCurrentSortLabel(
sortOption: SortOption,
labels: Record<SortOption, string>,
): string {
return labels[sortOption] ?? labels.name;
}
function resolveTreeRows({
+12 -6
View File
@@ -17,6 +17,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
@@ -503,6 +504,7 @@ function FilePreviewBody({
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
@@ -562,7 +564,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
@@ -570,7 +572,7 @@ function FilePreviewBody({
if (!preview) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
}
@@ -659,7 +661,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
@@ -689,7 +691,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
@@ -704,6 +706,7 @@ export function FilePane({
workspaceRoot: string;
location: WorkspaceFileLocation;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
@@ -726,7 +729,10 @@ export function FilePane({
enabled: Boolean(client && readTarget),
queryFn: async () => {
if (!client || !readTarget) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
return {
file: null as ExplorerFile | null,
error: t("workspace.terminal.hostDisconnected"),
};
}
try {
const file = await client.readFile(readTarget.cwd, readTarget.path);
@@ -740,7 +746,7 @@ export function FilePane({
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : "Failed to load file",
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
};
}
},
@@ -1,4 +1,5 @@
import { useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,7 @@ function goBack(): void {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
if (onBack) {
onBack();
@@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
onPress={handleBack}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityLabel={t("common.actions.back")}
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -1,4 +1,5 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
@@ -48,6 +49,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
@@ -68,7 +70,7 @@ export function SidebarMenuToggle({
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel="Toggle sidebar"
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
@@ -76,7 +78,7 @@ export function SidebarMenuToggle({
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -153,6 +154,7 @@ interface CopyButtonProps {
const COPIED_RESET_MS = 1500;
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
style={wrapperStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy code"}
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
hitSlop={8}
>
{({ hovered }) => {
@@ -1,5 +1,6 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
@@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
return i18n.t("importSession.preview.untitledSession");
}
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
return (
entry.lastPromptPreview?.trim() ||
entry.firstPromptPreview?.trim() ||
i18n.t("importSession.preview.noPrompt")
);
}
export interface EmptyStateInputs {
@@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): {
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
return {
showEmptyState,
emptyStateTitle: i18n.t("importSession.empty.noProviderSessions", { provider: label }),
};
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
return {
showEmptyState,
emptyStateTitle: i18n.t("importSession.empty.alreadyImported"),
};
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noRecent") };
}
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
@@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { i18n } from "@/i18n/i18next";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
@@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: {
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
@@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: {
enabled,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
return await client.fetchRecentProviderSessions({
...(cwd ? { cwd } : {}),
@@ -105,33 +109,36 @@ function SheetStatusMessages({
importErrored,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
return <Text style={styles.statusText}>{t("importSession.status.connectHost")}</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
return <Text style={styles.statusText}>{t("importSession.status.updateHost")}</Text>;
}
return (
<>
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
<Text style={styles.statusText}>{t("importSession.status.noProviders")}</Text>
) : null}
{isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
</View>
) : null}
{allQueriesErrored ? (
<Text style={styles.statusText}>Could not load recent sessions.</Text>
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}>
Could not load sessions for {erroredProviderLabels.join(", ")}.
{t("importSession.status.failedProviders", {
providers: erroredProviderLabels.join(", "),
})}
</Text>
) : null}
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
) : null}
</>
);
@@ -139,6 +146,7 @@ function SheetStatusMessages({
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
@@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityLabel={t("importSession.actions.refresh")}
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
@@ -192,6 +200,7 @@ function ImportSessionSheetRow({
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
@@ -229,7 +238,9 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text>
<Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
@@ -253,6 +264,7 @@ export function ImportSessionSheet({
onImportedAgent,
onImported,
}: ImportSessionSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { theme } = useUnistyles();
@@ -284,8 +296,9 @@ export function ImportSessionSheet({
visible,
client,
cwd,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
);
const queries = useQueries({ queries: queriesConfig });
@@ -318,19 +331,20 @@ export function ImportSessionSheet({
const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [
{ id: ALL_FILTER_VALUE, label: "All providers" },
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
...filterProviders.map((provider) => ({
id: provider,
label: providerLabelById.get(provider) ?? provider,
})),
],
[filterProviders, providerLabelById],
[filterProviders, providerLabelById, t],
);
const selectedProviderLabel = useMemo(
() =>
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
[filterComboboxOptions, selectedProvider],
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ??
t("importSession.filters.all"),
[filterComboboxOptions, selectedProvider, t],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
@@ -385,7 +399,7 @@ export function ImportSessionSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("workspace.terminal.hostDisconnected"));
}
if (!entry.cwd) {
throw new Error("Session is missing a working directory");
@@ -430,10 +444,10 @@ export function ImportSessionSheet({
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
title: t("importSession.title"),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
[isRefreshing, handleRefresh, t],
);
const isSnapshotUnsupported = !supportsSnapshot;
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
@@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
@@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() {
);
const handleClose = useCallback(() => setOpen(false), [setOpen]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
return (
<AdaptiveModalSheet
header={SHORTCUTS_HEADER}
header={header}
visible={open}
onClose={handleClose}
testID="keyboard-shortcuts-dialog"
@@ -35,13 +37,15 @@ export function KeyboardShortcutsDialog() {
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
<View style={styles.rows}>
{section.rows.map((row) => (
<View key={row.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{row.label}</Text>
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
{row.note ? (
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
) : null}
</View>
<Shortcut keys={row.keys} style={styles.rowShortcut} />
</View>
+53 -12
View File
@@ -1,5 +1,6 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
type Dispatch,
memo,
@@ -104,6 +105,7 @@ interface SidebarSharedProps {
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
@@ -112,6 +114,16 @@ interface SidebarSharedProps {
}) => ReactElement;
}
interface SidebarLabels {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
sessions: string;
closeSidebar: string;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
@@ -132,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({
void _selectedAgentId;
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
@@ -146,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host";
if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
}, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
@@ -271,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({
[pathname],
);
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
home: t("sidebar.actions.home"),
settings: t("sidebar.actions.settings"),
switchHost: t("sidebar.host.switchTitle"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
);
const sharedProps = {
theme,
activeServerId,
@@ -291,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
labels,
};
if (isCompactLayout) {
@@ -425,12 +452,14 @@ function FooterIconButton({
function AddProjectTooltipContent({
newAgentKeys,
label,
}: {
newAgentKeys: ReturnType<typeof useShortcutKeys>;
label: string;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Text style={styles.tooltipText}>{label}</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
);
@@ -465,6 +494,7 @@ function SidebarFooter({
handleOpenProject,
handleHome,
handleSettings,
labels,
}: {
theme: SidebarTheme;
activeServerId: string | null;
@@ -479,6 +509,13 @@ function SidebarFooter({
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: {
addProject: string;
home: string;
settings: string;
switchHost: string;
searchHosts: string;
};
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
@@ -498,26 +535,26 @@ function SidebarFooter({
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
accessibilityLabel={labels.addProject}
icon={FolderPlus}
theme={theme}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
accessibilityLabel="Home"
accessibilityLabel={labels.home}
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
accessibilityLabel="Settings"
accessibilityLabel={labels.settings}
icon={Settings}
theme={theme}
/>
@@ -528,8 +565,8 @@ function SidebarFooter({
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
@@ -563,6 +600,7 @@ function MobileSidebar({
handleOpenProject,
handleHome,
handleSettings,
labels,
insetsTop,
insetsBottom,
isOpen,
@@ -748,7 +786,7 @@ function MobileSidebar({
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
@@ -765,7 +803,7 @@ function MobileSidebar({
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel="Close sidebar"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -810,6 +848,7 @@ function MobileSidebar({
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
/>
</View>
</Animated.View>
@@ -842,6 +881,7 @@ function DesktopSidebar({
handleOpenProject,
handleHome,
handleSettings,
labels,
insetsTop,
isOpen,
handleViewMore,
@@ -919,7 +959,7 @@ function DesktopSidebar({
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
@@ -963,6 +1003,7 @@ function DesktopSidebar({
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
/>
{/* Resize handle - absolutely positioned over right border */}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => {
@@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => {
);
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
});
it("renders labels in the active app language", async () => {
await i18n.changeLanguage("zh-CN");
try {
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("正在压缩...");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export interface CompactionMarkerLabelInput {
status: "loading" | "completed";
trigger?: "auto" | "manual";
@@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({
trigger,
preTokens,
}: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting...";
if (trigger === "auto") return "Context automatically compacted";
if (trigger === "manual") return "Context manually compacted";
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
return "Context compacted";
if (status === "loading") return i18n.t("message.compaction.loading");
if (trigger === "auto") return i18n.t("message.compaction.auto");
if (trigger === "manual") return i18n.t("message.compaction.manual");
if (preTokens) {
return i18n.t("message.compaction.withTokens", {
tokens: Math.round(preTokens / 1000),
});
}
return i18n.t("message.compaction.completed");
}
+45 -18
View File
@@ -10,6 +10,7 @@ import {
ViewStyle,
type TextStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import * as React from "react";
@@ -448,18 +449,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
}
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
function getUserMessageAttachmentLabel(
attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"],
): string {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
return count === 1
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count });
}
case "github_pr":
return `PR #${attachment.number}`;
case "github_issue":
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? "Text attachment";
return attachment.title ?? t("message.attachments.textAttachment");
default:
return "";
}
@@ -480,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
@@ -562,7 +569,7 @@ export const UserMessage = memo(function UserMessage({
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
{getUserMessageAttachmentLabel(attachment, t)}
</Text>
</View>
))}
@@ -588,7 +595,7 @@ export const UserMessage = memo(function UserMessage({
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
accessibilityLabel="Copy message"
accessibilityLabel={t("message.actions.copyMessage")}
/>
</View>
) : null}
@@ -862,6 +869,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
const handleImageError = useCallback(() => {
setLoadState({ status: "error" });
}, []);
const { t } = useTranslation();
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
@@ -887,7 +895,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
</Text>
) : null}
</View>
</View>
@@ -924,6 +934,7 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
@@ -953,7 +964,7 @@ function AssistantMarkdownImage({
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error("Image preview unavailable.");
throw new Error(t("message.attachments.imagePreviewUnavailable"));
}
return await persistAttachmentFromBytes({
@@ -1026,7 +1037,11 @@ function AssistantMarkdownImage({
);
}
const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error);
const errorText = resolveAssistantImageErrorText(
query.error,
dataImageQuery.error,
t("message.attachments.imagePreviewLoadFailed"),
);
return (
<View style={stateFrameStyle}>
@@ -1035,10 +1050,14 @@ function AssistantMarkdownImage({
);
}
function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string {
function resolveAssistantImageErrorText(
fileError: unknown,
dataError: unknown,
fallbackText: string,
): string {
if (fileError instanceof Error) return fileError.message;
if (dataError instanceof Error) return dataError.message;
return "Unable to load image preview.";
return fallbackText;
}
function getInlineCodeAutoLinkUrl(
@@ -1144,6 +1163,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
accessibilityLabel,
copiedAccessibilityLabel,
}: TurnCopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1185,7 +1205,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
copied
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
: (accessibilityLabel ?? t("message.actions.copyTurn"))
}
>
{({ hovered }) => {
@@ -1966,6 +1988,7 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo(
() => [
@@ -1979,7 +2002,7 @@ export const SpeakMessage = memo(function SpeakMessage({
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
</View>
@@ -2080,6 +2103,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
@@ -2149,7 +2173,9 @@ export const ActivityLog = memo(function ActivityLog({
</Text>
{metadata && (
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
<Text style={activityLogStylesheet.detailsText}>
{t("message.activity.details")}
</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
@@ -2306,6 +2332,7 @@ export const TodoListCard = memo(function TodoListCard({
items,
disableOuterSpacing,
}: TodoListCardProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2319,7 +2346,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.detailsWrapper}>
<View style={todoListCardStylesheet.list}>
{items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text>
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : (
items.map((item) => (
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
@@ -2328,11 +2355,11 @@ export const TodoListCard = memo(function TodoListCard({
</View>
</View>
);
}, [items]);
}, [items, t]);
return (
<ExpandableBadge
label="Tasks"
label={t("message.todo.title")}
secondaryLabel={nextTask}
icon={CheckSquare}
isExpanded={isExpanded}
@@ -2475,6 +2502,7 @@ function ExpandableBadgeLabelRow({
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { t } = useTranslation();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -2499,7 +2527,7 @@ function ExpandableBadgeLabelRow({
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel="Open file"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
@@ -3169,7 +3197,6 @@ export const ToolCall = memo(function ToolCall({
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
<PlanCard
title="Plan"
text={effectiveDetail.text}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}
+21 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -12,7 +13,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({
helper: {
@@ -61,6 +61,7 @@ export interface PairLinkModalProps {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
@@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
if (isSaving) return;
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=")) {
setErrorMessage("Link must include #offer=...");
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
@@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
const idx = raw.indexOf("#offer=");
const encoded = raw.slice(idx + "#offer=".length).trim();
if (!encoded) {
throw new Error("Offer payload is empty");
throw new Error(t("pairing.link.errors.emptyOffer"));
}
const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid pairing link";
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Pairing failed", message);
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
return null;
}
@@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host";
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Pairing failed", message);
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
const handleChangeOfferUrl = useCallback((next: string) => {
offerUrlRef.current = next;
@@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
void handleSave();
}, [handleSave]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
return (
<AdaptiveModalSheet
header={PAIR_LINK_HEADER}
header={header}
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<Text style={styles.helper}>{t("pairing.link.helper")}</Text>
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
ref={inputRef}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
@@ -204,9 +208,9 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving}
testID="pair-link-cancel"
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityLabel={t("pairing.link.actions.cancel")}
>
Cancel
{t("pairing.link.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
@@ -215,10 +219,10 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
disabled={isSaving}
testID="pair-link-submit"
accessibilityRole="button"
accessibilityLabel="Pair"
accessibilityLabel={t("pairing.link.actions.pair")}
leftIcon={pairIcon}
>
{isSaving ? "Pairing..." : "Pair"}
{isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
</Button>
</View>
</AdaptiveModalSheet>
+5 -2
View File
@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import Markdown, { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
@@ -194,7 +195,7 @@ function createPlanMarkdownRules() {
}
export function PlanCard({
title = "Plan",
title,
description,
text,
footer,
@@ -209,8 +210,10 @@ export function PlanCard({
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
const resolvedTitle = title ?? t("agentStream.permission.plan");
const containerStyle = useMemo(
() => [
@@ -234,7 +237,7 @@ export function PlanCard({
return (
<View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{title}</Text>
<Text style={titleStyle}>{resolvedTitle}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
@@ -11,6 +11,7 @@ import {
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
@@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
@@ -235,7 +237,7 @@ export function ProjectPickerModal() {
ref={inputRef}
value={query}
onChangeText={handleChangeQuery}
placeholder="Type a directory path..."
placeholder={t("projectPicker.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
@@ -253,9 +255,9 @@ export function ProjectPickerModal() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>Opening project...</Text> : null}
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>Start typing a path</Text>
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -48,6 +49,10 @@ interface CatalogRowProps {
}
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
const { t } = useTranslation();
const actionLabel = installing
? t("providerCatalog.actions.adding")
: t("providerCatalog.actions.add");
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
@@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
</Text>
<Pressable
accessibilityRole="link"
accessibilityLabel={`${entry.title} install instructions`}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
onPress={handleOpenInstallLink}
style={styles.installLink}
>
<Text style={styles.installLinkText} numberOfLines={1}>
Install instructions
{t("providerCatalog.actions.installInstructions")}
</Text>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</Pressable>
@@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
style={styles.actionButton}
testID={`install-provider-${entry.id}`}
>
{installing ? "Adding" : "Add"}
{actionLabel}
</Button>
</View>
);
@@ -114,6 +121,7 @@ export function ProviderCatalogList({
installingProviderId,
onInstall,
}: ProviderCatalogListProps) {
const { t } = useTranslation();
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState("");
@@ -139,10 +147,10 @@ export function ProviderCatalogList({
</View>
<AdaptiveTextInput
testID="provider-catalog-search"
accessibilityLabel="Search providers"
value={search}
onChangeText={setSearch}
placeholder="Search providers"
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
style={styles.searchInput}
autoCapitalize="none"
autoCorrect={false}
@@ -151,9 +159,7 @@ export function ProviderCatalogList({
{availableEntries.length === 0 ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
</Text>
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
<View style={styles.list}>
@@ -1,5 +1,7 @@
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
@@ -79,6 +81,7 @@ function CustomModelRow({
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback(
@@ -110,7 +113,7 @@ function CustomModelRow({
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`}
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
@@ -148,6 +151,7 @@ function AddCustomModelSubSheet({
onClose: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
@@ -182,12 +186,15 @@ function AddCustomModelSubSheet({
.then(() => refresh([provider]))
.then(() => onClose())
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
})
.finally(() => setSaving(false));
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]);
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
const header = useMemo<SheetHeader>(() => ({ title: "Add custom model" }), []);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.providers.models.addCustomTitle") }),
[t],
);
return (
<AdaptiveModalSheet
@@ -199,14 +206,14 @@ function AddCustomModelSubSheet({
testID="add-custom-model-sheet"
>
<View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text>
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
<AdaptiveTextInput
initialValue={input}
resetKey={`add-custom-${visible}`}
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder="e.g. openai/gpt-5"
placeholder={t("settings.providers.models.modelIdPlaceholder")}
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
@@ -217,10 +224,10 @@ function AddCustomModelSubSheet({
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
Cancel
{t("common.actions.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
{saving ? "Adding…" : "Add"}
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
</Button>
</View>
</View>
@@ -239,6 +246,7 @@ function DiagnosticSubSheet({
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
@@ -251,11 +259,13 @@ function DiagnosticSubSheet({
const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
setDiagnostic(
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
);
} finally {
setLoading(false);
}
}, [client, provider]);
}, [client, provider, t]);
useEffect(() => {
if (visible) {
@@ -280,7 +290,7 @@ function DiagnosticSubSheet({
const header = useMemo<SheetHeader>(
() => ({
title: "Diagnostic",
title: t("settings.providers.diagnostic.title"),
actions: (
<Pressable
onPress={handleRefreshPress}
@@ -288,7 +298,11 @@ function DiagnosticSubSheet({
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={loading ? "Refreshing diagnostic" : "Refresh diagnostic"}
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -302,6 +316,7 @@ function DiagnosticSubSheet({
handleRefreshPress,
loading,
refreshButtonStyle,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
@@ -312,7 +327,7 @@ function DiagnosticSubSheet({
body = (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
);
} else if (diagnostic) {
@@ -328,7 +343,7 @@ function DiagnosticSubSheet({
} else {
body = (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
);
}
@@ -366,6 +381,7 @@ interface ProviderSheetFooterInput {
fetchedAtLabel: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
onOpenAddSheet: () => void;
onOpenDiagSheet: () => void;
onRefreshModels: () => void;
@@ -375,6 +391,7 @@ function renderProviderSheetFooter({
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet,
onOpenDiagSheet,
onRefreshModels,
@@ -388,7 +405,7 @@ function renderProviderSheetFooter({
<View style={contentStyle}>
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text>
) : null}
<View style={actionsStyle}>
@@ -399,7 +416,7 @@ function renderProviderSheetFooter({
onPress={onOpenAddSheet}
style={buttonStyle}
>
Add model
{t("settings.providers.models.addModel")}
</Button>
<Button
variant="secondary"
@@ -408,7 +425,7 @@ function renderProviderSheetFooter({
onPress={onOpenDiagSheet}
style={buttonStyle}
>
Diagnostic
{t("settings.providers.diagnostic.button")}
</Button>
<Button
variant="default"
@@ -418,7 +435,9 @@ function renderProviderSheetFooter({
disabled={modelsRefreshing}
style={buttonStyle}
>
{modelsRefreshing ? "Refreshing…" : "Refresh"}
{modelsRefreshing
? t("settings.providers.diagnostic.refreshing")
: t("settings.providers.diagnostic.refresh")}
</Button>
</View>
</View>
@@ -426,6 +445,7 @@ function renderProviderSheetFooter({
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const {
discoveredCount,
additionalCount,
@@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models…</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View>
);
}
@@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing ? "Retrying…" : "Retry"}
{modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
</Button>
</View>
);
@@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models match your search</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
</View>
);
}
if (discoveredCount === 0 && additionalCount === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected</Text>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
</View>
);
}
@@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<>
{filteredDiscovered.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader title="Discovered" count={filteredDiscovered.length} />
<SectionHeader
title={t("settings.providers.models.discovered")}
count={filteredDiscovered.length}
/>
<View style={settingsStyles.card}>
{filteredDiscovered.map((model) => (
<DiscoveredModelRow key={model.id} model={model} />
@@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
) : null}
{filteredCustom.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader title="Custom models" count={filteredCustom.length} />
<SectionHeader
title={t("settings.providers.models.custom")}
count={filteredCustom.length}
/>
<View style={settingsStyles.card}>
{filteredCustom.map((model) => (
<CustomModelRow
@@ -511,6 +539,7 @@ export function ProviderDiagnosticSheet({
onClose,
serverId,
}: ProviderDiagnosticSheetProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
@@ -531,7 +560,9 @@ export function ProviderDiagnosticSheet({
);
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
providerEntry?.status === "error"
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
: null;
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
@@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
@@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({
title: providerLabel,
search: {
onChange: setQuery,
placeholder: "Search models",
placeholder: t("settings.providers.models.searchPlaceholder"),
testID: "provider-settings-search",
},
}),
[providerLabel],
[providerLabel, t],
);
return (
@@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({
fetchedAtLabel,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet: handleOpenAddSheet,
onOpenDiagSheet: handleOpenDiagSheet,
onRefreshModels: handleRefreshModels,
@@ -138,6 +138,9 @@ export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): b
);
}
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss";
export function resolveDismissLabel(
questions: QuestionFormQuestion[],
fallbackLabel = "Dismiss",
): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel;
}
@@ -10,6 +10,7 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
@@ -33,9 +34,17 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb;
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string {
function getQuestionInputPlaceholder({
question,
answerPlaceholder,
otherPlaceholder,
}: {
question: QuestionFormQuestion;
answerPlaceholder: string;
otherPlaceholder: string;
}): string {
return (
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
question.placeholder ?? (question.options.length === 0 ? answerPlaceholder : otherPlaceholder)
);
}
@@ -239,6 +248,7 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
@@ -367,7 +377,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
);
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion ? "Submit" : "Next";
const primaryActionLabel = isLastQuestion
? t("message.question.submit")
: t("message.question.next");
const submitButtonStyle = useCallback(
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
@@ -417,7 +429,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return null;
}
const dismissLabel = resolveDismissLabel(questions);
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
@@ -470,7 +482,11 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
placeholder={getQuestionInputPlaceholder({
question: activeQuestion,
answerPlaceholder: t("message.question.answerPlaceholder"),
otherPlaceholder: t("message.question.otherPlaceholder"),
})}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handlePrimaryAction}
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getIsElectronRuntime } from "@/constants/layout";
import { listenToDesktopEvent } from "@/desktop/electron/events";
export function QuittingOverlay() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const [quitting, setQuitting] = useState(false);
@@ -37,8 +39,8 @@ export function QuittingOverlay() {
return (
<View style={styles.overlay}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.title}>Quitting Paseo…</Text>
<Text style={styles.detail}>Stopping the local daemon.</Text>
<Text style={styles.title}>{t("desktop.quitting.title")}</Text>
<Text style={styles.detail}>{t("desktop.quitting.detail")}</Text>
</View>
);
}
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
@@ -23,6 +24,7 @@ export function RealtimeVoiceOverlay({
onStop,
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { volume, isSpeaking } = useVoiceTelemetry();
const muteButtonStyle = useMemo(
() => [
@@ -53,7 +55,9 @@ export function RealtimeVoiceOverlay({
onPress={onToggleMute}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
accessibilityLabel={
isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute")
}
style={muteButtonStyle}
>
{isMuted ? (
@@ -67,7 +71,7 @@ export function RealtimeVoiceOverlay({
onPress={onStop}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn"
accessibilityLabel={t("realtimeVoice.actions.stop")}
style={stopButtonStyle}
>
{isSwitching ? (
+10 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
@@ -27,13 +28,14 @@ export function AdaptiveRenameModal({
title,
initialValue,
placeholder,
submitLabel = "Rename",
submitLabel,
onClose,
onSubmit,
validate,
maxLength,
testID,
}: AdaptiveRenameModalProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
@@ -64,10 +66,10 @@ export function AdaptiveRenameModal({
const computeError = useCallback(
(value: string): string | null => {
if (!value.trim()) return "Name is required";
if (!value.trim()) return t("common.errors.nameRequired");
return validate ? validate(value) : null;
},
[validate],
[validate, t],
);
const handleChange = useCallback((value: string) => {
@@ -91,10 +93,11 @@ export function AdaptiveRenameModal({
onClose();
} catch (err) {
setIsPending(false);
const message = err instanceof Error && err.message ? err.message : "Unable to save";
const message =
err instanceof Error && err.message ? err.message : t("common.errors.unableToSave");
setError(message);
}
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
}, [isPending, draft, initialValue, computeError, onSubmit, onClose, t]);
const handleCancel = useCallback(() => {
if (isPending) return;
@@ -147,7 +150,7 @@ export function AdaptiveRenameModal({
disabled={isPending}
testID={cancelTestID}
>
Cancel
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
@@ -157,7 +160,7 @@ export function AdaptiveRenameModal({
disabled={submitDisabled}
testID={submitTestID}
>
{isPending ? "Saving..." : submitLabel}
{isPending ? t("renameModal.saving") : (submitLabel ?? t("renameModal.rename"))}
</Button>
</View>
</View>
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -42,7 +43,16 @@ export const RewindMenu = memo(function RewindMenu({
testID = "rewind-menu",
}: RewindMenuProps) {
const { theme } = useUnistyles();
const items = useRewindCapabilities(capabilities);
const { t } = useTranslation();
const rewindLabels = useMemo(
() => ({
conversation: t("rewind.actions.conversation"),
files: t("rewind.actions.files"),
both: t("rewind.actions.both"),
}),
[t],
);
const items = useRewindCapabilities(capabilities, rewindLabels);
const [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const isLocked = isPendingProp || pendingMode !== null;
@@ -79,10 +89,10 @@ export const RewindMenu = memo(function RewindMenu({
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Rewind to this message</Text>
<Text style={styles.tooltipText}>{t("rewind.tooltip")}</Text>
</TooltipContent>
),
[],
[t],
);
if (items.length === 0) {
@@ -95,7 +105,7 @@ export const RewindMenu = memo(function RewindMenu({
<TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel="Rewind to this message"
accessibilityLabel={t("rewind.tooltip")}
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
@@ -114,7 +124,7 @@ export const RewindMenu = memo(function RewindMenu({
</Tooltip>
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
<View style={styles.warningHeader}>
<Text style={styles.warningText}>This action cannot be undone</Text>
<Text style={styles.warningText}>{t("rewind.warning")}</Text>
</View>
<DropdownMenuSeparator />
{items.map((item) => (
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,11 +26,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
isPending: boolean;
} {
const toast = useToast();
const { t } = useTranslation();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
@@ -59,7 +61,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
},
});
@@ -33,4 +33,21 @@ describe("resolveRewindMenuItems", () => {
},
]);
});
test("uses caller-provided labels for available capabilities", () => {
expect(
resolveRewindMenuItems(
{
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: false,
},
{
conversation: "Conversation label",
files: "Files label",
both: "Both label",
},
).map((item) => item.label),
).toEqual(["Conversation label", "Files label"]);
});
});
@@ -9,6 +9,18 @@ export interface RewindMenuItem {
testID: string;
}
export interface RewindMenuLabels {
conversation: string;
files: string;
both: string;
}
const DEFAULT_REWIND_MENU_LABELS: RewindMenuLabels = {
conversation: "Rewind conversation",
files: "Rewind files",
both: "Rewind conversation and files",
};
export function resolveRewindMenuItems(
capabilities:
| Pick<
@@ -17,29 +29,31 @@ export function resolveRewindMenuItems(
>
| null
| undefined,
labelsInput?: Partial<RewindMenuLabels>,
): RewindMenuItem[] {
if (!capabilities) {
return [];
}
const labels = { ...DEFAULT_REWIND_MENU_LABELS, ...labelsInput };
const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) {
items.push({
mode: "conversation",
label: "Rewind conversation",
label: labels.conversation,
testID: "rewind-menu-conversation",
});
}
if (capabilities.supportsRewindFiles) {
items.push({
mode: "files",
label: "Rewind files",
label: labels.files,
testID: "rewind-menu-files",
});
}
if (capabilities.supportsRewindBoth) {
items.push({
mode: "both",
label: "Rewind conversation and files",
label: labels.both,
testID: "rewind-menu-both",
});
}
@@ -48,6 +62,7 @@ export function resolveRewindMenuItems(
export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
labels?: Partial<RewindMenuLabels>,
): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
return useMemo(() => resolveRewindMenuItems(capabilities, labels), [capabilities, labels]);
}
@@ -2,6 +2,7 @@ import { X } from "lucide-react-native";
import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
export type SidebarCalloutActionVariant = "primary" | "secondary";
@@ -38,6 +39,7 @@ export function SidebarCallout({
onDismiss,
testID,
}: SidebarCalloutProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
@@ -69,7 +71,7 @@ export function SidebarCallout({
hitSlop={8}
style={styles.dismissButton}
testID={testID ? `${testID}-dismiss` : undefined}
accessibilityLabel="Dismiss"
accessibilityLabel={t("sidebarCallout.dismiss")}
accessibilityRole="button"
>
{({ hovered }) => (
@@ -27,6 +27,7 @@ import {
type MutableRefObject,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { router, usePathname, type Href } from "expo-router";
import {
navigateToWorkspace,
@@ -123,7 +124,10 @@ import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
@@ -183,6 +187,40 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
: theme.colors.palette.amber[500],
});
function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.addedLine"
: "workspace.git.actions.archiveWarning.addedLines",
{ count },
),
deletedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.deletedLine"
: "workspace.git.actions.archiveWarning.deletedLines",
{ count },
),
unpushedCommit: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.unpushedCommit"
: "workspace.git.actions.archiveWarning.unpushedCommits",
{ count },
),
};
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
@@ -307,6 +345,7 @@ function getWorkspaceArchiveStatus(
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
@@ -330,7 +369,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
@@ -572,6 +613,7 @@ function ProjectKebabMenu({
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
@@ -589,16 +631,16 @@ function ProjectKebabMenu({
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error("Couldn't open a new window");
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
});
}, [projectPath, toast]);
}, [projectPath, t, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={projectKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Project actions"
accessibilityLabel={t("sidebar.project.actions.menu")}
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderKebabTriggerIcon}
@@ -610,7 +652,7 @@ function ProjectKebabMenu({
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
{t("sidebar.project.actions.openSettings")}
</DropdownMenuItem>
) : null}
{canOpenInNewWindow ? (
@@ -619,17 +661,17 @@ function ProjectKebabMenu({
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
Open in new window
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel="Removing..."
pendingLabel={t("sidebar.project.actions.removing")}
onSelect={onRemoveProject}
>
Remove project
{t("sidebar.project.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -669,13 +711,16 @@ function WorkspaceRowRightGroup({
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
@@ -733,6 +778,7 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
@@ -743,7 +789,7 @@ function WorkspaceKebabMenu({
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
@@ -755,7 +801,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
@@ -764,7 +810,7 @@ function WorkspaceKebabMenu({
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
@@ -773,7 +819,7 @@ function WorkspaceKebabMenu({
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
@@ -793,7 +839,7 @@ function WorkspaceKebabMenu({
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -912,6 +958,7 @@ function NewWorktreeButton({
testID: string;
showShortcutHint?: boolean;
}) {
const { t } = useTranslation();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
@@ -940,7 +987,9 @@ function NewWorktreeButton({
onPress={handlePress}
disabled={loading}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`Create a new workspace for ${displayName}`}
accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", {
projectName: displayName,
})}
testID={testID}
>
{({ hovered, pressed }) =>
@@ -959,7 +1008,9 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
<Text style={styles.projectActionTooltipText}>
{t("sidebar.workspace.actions.newWorkspace")}
</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
@@ -1481,6 +1532,7 @@ function WorkspaceRowWithMenu({
canCopyBranchName: boolean;
isCreating?: boolean;
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
@@ -1513,12 +1565,15 @@ function WorkspaceRowWithMenu({
return;
}
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) {
return;
@@ -1530,12 +1585,16 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
return;
}
@@ -1546,10 +1605,11 @@ function WorkspaceRowWithMenu({
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
const message =
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed");
toast.error(message);
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
}, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
@@ -1561,10 +1621,10 @@ function WorkspaceRowWithMenu({
}
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -1573,7 +1633,7 @@ function WorkspaceRowWithMenu({
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
@@ -1585,11 +1645,13 @@ function WorkspaceRowWithMenu({
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation();
@@ -1603,23 +1665,27 @@ function WorkspaceRowWithMenu({
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error("Host is not connected");
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
@@ -1627,7 +1693,7 @@ function WorkspaceRowWithMenu({
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? "Failed to rename branch");
throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName"));
}
return { targetCwd };
},
@@ -1654,11 +1720,14 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const validateRenameSlug = useCallback((value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? "Invalid branch name";
}, []);
const validateRenameSlug = useCallback(
(value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
},
[t],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
@@ -1700,9 +1769,17 @@ function WorkspaceRowWithMenu({
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveLabel={
isWorktree
? t("sidebar.workspace.actions.archiveWorktree")
: t("sidebar.workspace.actions.hideFromSidebar")
}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
archivePendingLabel={
isWorktree
? t("sidebar.workspace.actions.archiving")
: t("sidebar.workspace.actions.hiding")
}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
@@ -1712,10 +1789,10 @@ function WorkspaceRowWithMenu({
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
submitLabel={t("sidebar.workspace.rename.submit")}
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
@@ -1751,6 +1828,7 @@ function NonGitProjectRowWithMenuContent({
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
const contextMenu = useContextMenu();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
@@ -1769,10 +1847,12 @@ function NonGitProjectRowWithMenuContent({
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", {
workspaceName: workspace.name,
}),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -1781,7 +1861,7 @@ function NonGitProjectRowWithMenuContent({
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
@@ -1794,13 +1874,15 @@ function NonGitProjectRowWithMenuContent({
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
return (
<>
@@ -1831,11 +1913,11 @@ function NonGitProjectRowWithMenuContent({
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel="Hiding..."
pendingLabel={t("sidebar.workspace.actions.hiding")}
destructive
onSelect={handleArchiveWorkspace}
>
Hide from sidebar
{t("sidebar.workspace.actions.hideFromSidebar")}
</ContextMenuItem>
</ContextMenuContent>
</>
@@ -2212,6 +2294,7 @@ function ProjectBlock({
);
const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false);
const handleRemoveProject = useCallback(() => {
@@ -2221,10 +2304,10 @@ function ProjectBlock({
void (async () => {
const confirmed = await confirmDialog({
title: "Remove project?",
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Remove",
cancelLabel: "Cancel",
title: t("sidebar.project.confirmations.removeTitle"),
message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }),
confirmLabel: t("sidebar.project.confirmations.removeConfirm"),
cancelLabel: t("sidebar.project.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
@@ -2233,7 +2316,7 @@ function ProjectBlock({
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("sidebar.project.toasts.hostDisconnected"));
return;
}
@@ -2243,13 +2326,13 @@ function ProjectBlock({
workspaces: project.workspaces,
}).then((failures) => {
if (failures.length > 0) {
toast.error("Failed to remove some workspaces");
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
});
})();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
const flattenedRowWorkspaceId =
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
@@ -2476,6 +2559,7 @@ function ProjectModeList({
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
@@ -2688,10 +2772,10 @@ function ProjectModeList({
<>
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Text style={styles.emptyTitle}>{t("sidebar.project.empty.title")}</Text>
<Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
Add project
{t("sidebar.actions.addProject")}
</Button>
</View>
) : (
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -348,6 +349,7 @@ function StatusWorkspaceRowWithMenu({
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
@@ -419,7 +421,7 @@ function StatusWorkspaceRowWithMenu({
if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
toast.error(t("workspace.terminal.hostDisconnected"));
return;
}
setIsArchivingWorkspace(true);
@@ -434,7 +436,7 @@ function StatusWorkspaceRowWithMenu({
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
@@ -459,7 +461,7 @@ function StatusWorkspaceRowWithMenu({
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error("Host is not connected");
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -660,6 +661,7 @@ function DragOverlayTabChipInner({
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const chipStyle = useMemo(
@@ -684,7 +686,8 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={chipStyle}>
@@ -10,6 +10,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible";
@@ -169,6 +170,7 @@ export function TerminalPane({
onOpenFileExplorer,
onOpenWorkspaceFile,
}: TerminalPaneProps) {
const { t } = useTranslation();
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const { settings } = useAppSettings();
@@ -750,7 +752,7 @@ export function TerminalPane({
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
<Text style={styles.stateText}>Host is not connected</Text>
<Text style={styles.stateText}>{t("workspace.terminal.hostDisconnected")}</Text>
</View>
);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
@@ -49,6 +50,7 @@ export function useToastHost(): {
dismiss: () => void;
} {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
@@ -85,13 +87,13 @@ export function useToastHost(): {
() => ({
show,
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), {
variant: "success",
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show, theme.colors.foreground],
[show, theme.colors.foreground, t],
);
const dismiss = useCallback(() => {
@@ -8,6 +8,8 @@ import {
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
@@ -240,11 +242,12 @@ function WorktreeSetupDetailSection({
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
fallbackText: string,
): string {
if (subAgentType && description) {
return `${subAgentType}: ${description}`;
}
return subAgentType ?? description ?? "Sub-agent activity";
return subAgentType ?? description ?? fallbackText;
}
interface SubAgentDetailProps {
@@ -365,8 +368,13 @@ function SubAgentDetailSection({
description,
ds,
}: SubAgentDetailProps) {
const { t } = useTranslation();
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description);
const fallbackHeader = resolveSubAgentFallbackHeader(
subAgentType,
description,
t("toolCallDetails.subAgentActivity"),
);
const hasActions = actions.length > 0;
return (
<View style={ds.sectionFillStyle}>
@@ -594,7 +602,7 @@ interface UnknownDetail {
output: unknown;
}
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] {
function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunction): ReactNode[] {
const plainInputText =
typeof detail.input === "string" && detail.output === null ? detail.input : null;
@@ -609,8 +617,8 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
}
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
{ title: t("toolCallDetails.input"), value: detail.input },
{ title: t("toolCallDetails.output"), value: detail.output },
].filter((entry) =>
hasMeaningfulToolCallDetail({
type: "unknown",
@@ -653,6 +661,7 @@ function buildDetailSections(
detail: ToolCallDetail | undefined,
diffLines: DiffLine[] | undefined,
ds: DetailStyles,
t: TFunction,
): ReactNode[] {
if (!detail) return [];
if (detail.type === "shell") {
@@ -723,15 +732,16 @@ function buildDetailSections(
return [<PlainTextSection key="plain-text" text={detail.text} />];
}
if (detail.type === "unknown") {
return buildUnknownSections(detail, ds);
return buildUnknownSections(detail, ds, t);
}
return [];
}
function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) {
const { t } = useTranslation();
return (
<View style={styles.section}>
<Text style={SECTION_TITLE_ERROR_STYLE}>Error</Text>
<Text style={SECTION_TITLE_ERROR_STYLE}>{t("toolCallDetails.error")}</Text>
<ScrollView
horizontal
nestedScrollEnabled
@@ -772,11 +782,12 @@ function ToolCallDetailsContentInner({
fillAvailableHeight = false,
showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) {
const { t } = useTranslation();
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
const diffLines = useDiffLines(detail);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
if (errorText) {
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
@@ -786,7 +797,7 @@ function ToolCallDetailsContentInner({
if (showLoadingSkeleton) {
return <LoadingSkeleton containerStyle={ds.loadingContainerStyle} />;
}
return <Text style={styles.emptyStateText}>No additional details available</Text>;
return <Text style={styles.emptyStateText}>{t("toolCallDetails.empty")}</Text>;
}
return <View style={ds.fullBleedContainerStyle}>{sections}</View>;
@@ -10,6 +10,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
@@ -122,11 +123,14 @@ export function Autocomplete({
onSelect,
isLoading = false,
errorMessage,
loadingText = "Loading...",
emptyText = "No results found",
loadingText,
emptyText,
maxHeight = 220,
}: AutocompleteProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const resolvedLoadingText = loadingText ?? t("common.states.loading");
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
@@ -213,7 +217,7 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text>
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
</View>
</View>
);
@@ -233,7 +237,7 @@ export function Autocomplete({
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text>
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
</View>
</View>
);
+12 -7
View File
@@ -23,6 +23,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -1240,15 +1241,15 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
placeholder = "Search...",
placeholder,
searchPlaceholder,
emptyText = "No options match your search.",
emptyText,
allowCustomValue = false,
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = "below-search",
title = "Select",
title,
header,
mobileChildrenScrollEnabled = true,
presentation,
@@ -1263,7 +1264,11 @@ export function Combobox({
anchorRef,
children,
}: ComboboxProps): ReactElement | null {
const { t } = useTranslation();
const { theme } = useUnistyles();
const resolvedPlaceholder = placeholder ?? t("common.placeholders.search");
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
@@ -1519,7 +1524,7 @@ export function Combobox({
[],
);
const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder;
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
const hasChildren = Boolean(children);
if (isMobile) {
@@ -1531,7 +1536,7 @@ export function Combobox({
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={title}
title={resolvedTitle}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader}
@@ -1547,7 +1552,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions}
value={value}
activeIndex={activeIndex}
emptyText={emptyText}
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
>
@@ -1580,7 +1585,7 @@ export function Combobox({
orderedVisibleOptions={orderedVisibleOptions}
value={value}
activeIndex={activeIndex}
emptyText={emptyText}
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
hasChildren={hasChildren}
@@ -12,6 +12,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
@@ -385,6 +386,7 @@ export function ContextMenuContent({
mobileMode?: MobileMenuMode;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
@@ -560,7 +562,7 @@ export function ContextMenuContent({
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
@@ -11,6 +11,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Modal,
@@ -442,6 +443,7 @@ export function DropdownMenuContent({
scrollable?: boolean;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
@@ -625,7 +627,7 @@ export function DropdownMenuContent({
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
+10 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -157,6 +158,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
@@ -206,7 +208,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
? [
{
key: "direct-connection",
label: "Direct connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
@@ -214,7 +216,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
@@ -224,7 +226,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
: [
{
key: "scan-qr",
label: "Scan QR code",
label: t("pairing.connectionMethods.scanQr.title"),
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
@@ -232,7 +234,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "direct-connection",
label: "Direct connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
@@ -240,7 +242,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
@@ -264,8 +266,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
<Text style={styles.title}>{t("onboarding.title")}</Text>
<Text style={styles.subtitle}>{t("onboarding.subtitle")}</Text>
{isNative ? (
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
@@ -288,7 +290,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
style={styles.settingsButton}
testID="welcome-open-settings"
>
Settings
{t("onboarding.actions.settings")}
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
@@ -212,6 +213,7 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
const { t } = useTranslation();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -274,7 +276,7 @@ function WorkspaceHoverCardContent({
collapsable={false}
onLayout={handleLayout}
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
frameStyle={frameStyle}
@@ -373,6 +375,7 @@ function ChecksSummaryContent({
checks: NonNullable<PrHint["checks"]>;
hovered: boolean;
}) {
const { t } = useTranslation();
const { passed, failed, pending } = getChecksSummaryCounts(checks);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
@@ -385,7 +388,7 @@ function ChecksSummaryContent({
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
)}
<Text style={labelStyle}>Checks</Text>
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
<ChecksSummaryPill count={passed} kind="passed" />
<ChecksSummaryPill count={failed} kind="failed" />
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/composer";
@@ -96,8 +97,13 @@ async function callWorkspaceCreation({
return connectedClient.openProject(input.cwd);
}
function failureMessageForCreationMethod(method: "create_worktree" | "open_project") {
return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project";
function failureMessageForCreationMethod(
method: "create_worktree" | "open_project",
t: ReturnType<typeof useTranslation>["t"],
) {
return method === "create_worktree"
? t("workspaceSetup.errors.failedCreateWorktree")
: t("workspaceSetup.errors.failedOpenProject");
}
function buildCreateAgentOptions({
@@ -140,6 +146,7 @@ function buildCreateAgentOptions({
}
export function WorkspaceSetupDialog() {
const { t } = useTranslation();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -170,7 +177,7 @@ export function WorkspaceSetupDialog() {
});
const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required");
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
const { icon: projectIcon } = useProjectIconQuery({
@@ -218,15 +225,15 @@ export function WorkspaceSetupDialog() {
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
throw new Error("Host is not connected");
throw new Error(t("workspaceSetup.errors.hostDisconnected"));
}
return client;
}, [client, isConnected]);
}, [client, isConnected, t]);
const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
if (!pendingWorkspaceSetup) {
throw new Error("No workspace setup is pending");
throw new Error(t("workspaceSetup.errors.pendingRequired"));
}
if (createdWorkspace) {
@@ -242,7 +249,7 @@ export function WorkspaceSetupDialog() {
if (payload.error || !payload.workspace) {
throw new Error(
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod),
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t),
);
}
@@ -259,6 +266,7 @@ export function WorkspaceSetupDialog() {
mergeWorkspaces,
pendingWorkspaceSetup,
setHasHydratedWorkspaces,
t,
withConnectedClient,
],
);
@@ -284,10 +292,10 @@ export function WorkspaceSetupDialog() {
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
if (!composerState.selectedProvider) {
throw new Error("Select a model");
throw new Error(t("workspaceSetup.errors.selectModel"));
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
@@ -334,6 +342,7 @@ export function WorkspaceSetupDialog() {
serverId,
setAgents,
ensureWorkspace,
t,
toast,
withConnectedClient,
],
@@ -392,8 +401,8 @@ export function WorkspaceSetupDialog() {
);
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }),
[subtitleContent],
() => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }),
[subtitleContent, t],
);
if (!pendingWorkspaceSetup || !sourceDirectory) {
@@ -1,4 +1,5 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
@@ -64,10 +65,9 @@ export function buildWorktreeSetupCalloutPolicy(
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
+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";
}
@@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react";
import { Buffer } from "buffer";
import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) {
}
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
@@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
setVoiceMode: async (enabled, agentId) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.setVoiceMode(enabled, agentId);
},
sendVoiceAudioChunk: async (audioData, mimeType) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.sendVoiceAudioChunk(audioData, mimeType);
},
audioPlayed: async (chunkId) => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.audioPlayed(chunkId);
},
abortRequest: async () => {
if (!client) {
throw new Error("Daemon unavailable");
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.abortRequest();
},
@@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
});
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
useEffect(() => {
voiceRuntime?.updateSessionConnection(serverId, isConnected);
@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
labels: {
granted: string;
request: string;
requesting: string;
busyExtraAction: (label: string) => string;
};
showBorder?: boolean;
onRequest: () => void;
extraActionLabel?: string;
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
title,
status,
isRequesting,
labels,
showBorder,
onRequest,
extraActionLabel,
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
<View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
</Button>
) : null}
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"}
{isRequesting ? labels.requesting : labels.request}
</Button>
)}
{shouldShowDetail ? (
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
export function DesktopPermissionsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const {
isDesktopApp,
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isBusy}
accessibilityLabel="Refresh desktop permissions"
accessibilityLabel={t("settings.permissions.refreshAccessibility")}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
</Button>
),
[refreshIcon, handleRefreshPress, isBusy, isRefreshing],
[refreshIcon, handleRefreshPress, isBusy, isRefreshing, t],
);
const permissionLabels = useMemo(
() => ({
granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"),
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
}),
[t],
);
if (!isDesktopApp) {
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
}
return (
<SettingsSection title="Permissions" trailing={refreshButton}>
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title="Notifications"
title={t("settings.permissions.notifications")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
extraActionLabel="Test"
labels={permissionLabels}
extraActionLabel={t("settings.permissions.test")}
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={handleSendTestNotification}
/>
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow
title="Microphone"
title={t("settings.permissions.microphone")}
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={handleRequestMicrophone}
labels={permissionLabels}
/>
</View>
</SettingsSection>
@@ -1,6 +1,7 @@
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
}
function useDaemonCliStatusModal() {
const { t } = useTranslation();
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setCliStatusOutput(t("desktop.daemon.fullStatus.fetchFailed", { message }));
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
}, [t]);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
}, [cliStatusOutput, t]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
}
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
const { t } = useTranslation();
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const handleCopyLogPath = useCallback(() => {
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Log path copied.");
Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
Alert.alert(t("common.errors.error"), t("desktop.daemon.logs.copyFailed"));
});
}, [daemonLogs?.logPath]);
}, [daemonLogs?.logPath, t]);
const handleOpenLogs = useCallback(() => {
if (!daemonLogs) {
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
}
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={DAEMON_LOGS_HEADER}
header={header}
testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
</Text>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
{daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")}
</Text>
</View>
</AdaptiveModalSheet>
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
cliStatusOutput,
onCopy,
}: DaemonCliStatusModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
[t],
);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={DAEMON_STATUS_HEADER}
header={header}
testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}>
Close
{t("common.actions.close")}
</Button>
<Button size="sm" onPress={onCopy}>
Copy
{t("common.actions.copy")}
</Button>
</View>
</View>
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
}
function DaemonInfoCard(props: DaemonInfoCardProps) {
const { t } = useTranslation();
const {
daemonStatusStateText,
daemonStatusDetailText,
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
</View>
<Switch
value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
accessibilityLabel="Manage built-in daemon"
accessibilityLabel={t("desktop.daemon.management.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
</View>
<Switch
value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit"
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable"}
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
Copy path
{t("desktop.daemon.logs.copyPath")}
</Button>
) : null}
<Button
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
{t("desktop.daemon.logs.open")}
</Button>
</View>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output
</Text>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
</View>
<Button
variant="outline"
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
{isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
</Button>
</View>
</View>
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
}
export function LocalDaemonSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion();
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
statusError ??
(daemonStatus?.status === "running"
? t("desktop.daemon.status.running")
: t("desktop.daemon.status.notRunning"));
const daemonStatusDetailText = t("desktop.daemon.status.pid", {
pid: daemonStatus?.pid ? daemonStatus.pid : "—",
});
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings}
accessibilityLabel="Open advanced daemon settings"
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
>
Advanced settings
{t("desktop.daemon.advancedSettings")}
</Button>
),
[advancedSettingsIcon, handleOpenAdvancedSettings],
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
);
if (!showSection) {
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
return (
<SettingsSection
title="Daemon"
title={t("desktop.daemon.title")}
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
{
"App and daemon versions don't match. Update both to the same version for the best experience."
}
</Text>
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
</View>
) : null}
</>
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
@@ -1,4 +1,6 @@
import { useCallback, useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,16 +10,34 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
add: "settings.integrations.operations.add",
update: "settings.integrations.operations.update",
delete: "settings.integrations.operations.delete",
};
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const {
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.updateTitle"),
message:
ops.length > 0
? formatUpdateMessage(ops, t)
: t("settings.integrations.skills.updateFallback"),
confirmLabel: t("settings.integrations.actions.update"),
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, updateSkills]);
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?",
message: UNINSTALL_MESSAGE,
confirmLabel: "Uninstall",
title: t("settings.integrations.skills.uninstallTitle"),
message: t("settings.integrations.skills.uninstallMessage"),
confirmLabel: t("settings.integrations.actions.uninstall"),
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]);
}, [isSkillsWorking, t, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenCliDocs}
accessibilityLabel="Open CLI documentation"
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
CLI docs
{t("settings.integrations.docs.cli")}
</Button>
<Button
variant="ghost"
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenSkillsDocs}
accessibilityLabel="Open skills documentation"
accessibilityLabel={t("settings.integrations.docs.openSkills")}
>
Skills docs
{t("settings.integrations.docs.skills")}
</Button>
</View>
),
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
);
if (!showSection) {
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
const skillsState = skillsStatus?.state ?? null;
return (
<SettingsSection title="Integrations" trailing={trailing}>
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.commandLine.title")}
</Text>
</View>
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "Install"}
{isInstallingCli
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{skillsState === "drift"
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
? t("settings.integrations.skills.updateAvailable")
: t("settings.integrations.skills.description")}
</Text>
</View>
<SkillsActions
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
if (state === "up-to-date") {
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking ? "Working..." : "Update"}
{isWorking
? t("settings.integrations.actions.working")
: t("settings.integrations.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
{isWorking
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
);
}
@@ -1,3 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
}
const SNAP_POINTS: string[] = ["82%", "94%"];
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
[t],
);
return (
<AdaptiveModalSheet
header={PAIR_DEVICE_HEADER}
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
isError: boolean;
error: unknown;
data: { url?: string | null; relayEnabled?: boolean } | undefined;
labels: {
failedToLoadOffer: string;
relayDisabled: string;
unavailable: string;
};
}): PairingViewState {
if (args.isPending) return { tag: "loading" };
if (args.isError) {
const message =
args.error instanceof Error ? args.error.message : "Failed to load pairing offer.";
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
return { tag: "error", message };
}
if (!args.data?.url) {
const message =
args.data?.relayEnabled === false
? "Relay is not enabled. Enable relay to pair a device."
: "Pairing offer unavailable.";
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
return { tag: "unavailable", message };
}
return { tag: "ready", url: args.data.url };
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
export function PairDeviceSection() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const showSection = shouldUseDesktopDaemon();
const [copied, setCopied] = useState(false);
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
),
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
);
const bodyLabels = useMemo(
() => ({
loadingOffer: t("pairing.device.loadingOffer"),
hint: t("pairing.device.hint"),
qrUnavailable: t("pairing.device.qrUnavailable"),
retry: t("pairing.device.retry"),
copy: t("pairing.device.copy"),
copied: t("pairing.device.copied"),
}),
[t],
);
if (!showSection) return null;
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
isError: pairingQuery.isError,
error: pairingQuery.error,
data: pairingQuery.data,
labels: {
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
relayDisabled: t("pairing.device.relayDisabled"),
unavailable: t("pairing.device.unavailable"),
},
});
return (
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
copied={copied}
handleRefetch={handleRefetch}
handleCopyPress={handleCopyPress}
labels={bodyLabels}
/>
</View>
</View>
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
copied: boolean;
handleRefetch: () => void;
handleCopyPress: () => void;
labels: {
loadingOffer: string;
hint: string;
qrUnavailable: string;
retry: string;
copy: string;
copied: string;
};
}
function PairDeviceBody(props: PairDeviceBodyProps) {
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
copied,
handleRefetch,
handleCopyPress,
labels,
} = props;
if (viewState.tag === "loading") {
return (
<View style={styles.centered}>
<ActivityIndicator size="small" />
<Text style={styles.hint}>Loading pairing offer…</Text>
<Text style={styles.hint}>{labels.loadingOffer}</Text>
</View>
);
}
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
<View style={styles.centered}>
<Text style={styles.hint}>{viewState.message}</Text>
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
Retry
{labels.retry}
</Button>
</View>
);
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
return (
<View style={styles.content}>
<Text style={styles.hint}>
Scan this QR code with Paseo on your phone, or copy the link below.
</Text>
<Text style={styles.hint}>{labels.hint}</Text>
<View style={styles.qrContainer}>
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} />
<PairDeviceQrContent
qrImageSource={qrImageSource}
qrQuery={qrQuery}
unavailableLabel={labels.qrUnavailable}
/>
</View>
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
/>
</View>
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
{copied ? "Copied" : "Copy"}
{copied ? labels.copied : labels.copy}
</Button>
</View>
</View>
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
function PairDeviceQrContent(props: {
qrImageSource: { uri: string } | null;
qrQuery: { isError: boolean };
unavailableLabel: string;
}) {
if (props.qrImageSource) {
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
}
if (props.qrQuery.isError) {
return <Text style={styles.hint}>QR code unavailable.</Text>;
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
}
return <ActivityIndicator size="small" />;
}
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) {
super(message);
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
if (presentationError instanceof DaemonConnectionRegistrationError) {
return {
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
message: i18n.t("desktop.daemon.management.registrationFailed"),
refreshStatus: true,
};
}
if (wasManagingDaemon) {
return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
message: i18n.t("desktop.daemon.management.pausedStopFailed"),
refreshStatus: false,
};
}
return {
message: "Unable to update built-in daemon management.",
message: i18n.t("desktop.daemon.management.updateFailed"),
refreshStatus: false,
};
}
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
type DesktopDaemonStatus,
startDesktopDaemon,
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
export function useBuiltInDaemonManagement(
input: UseBuiltInDaemonManagementInput,
): UseBuiltInDaemonManagementResult {
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
title: t("desktop.daemon.management.pauseTitle"),
message: t("desktop.daemon.management.pauseMessage"),
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getDesktopDaemonLogs,
getDesktopDaemonStatus,
@@ -17,6 +18,7 @@ interface DaemonStatusData {
}
export function useDaemonStatus() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
});
useDesktopIpcQueryErrorToast({
error: query.error,
message: "Unable to load desktop daemon status.",
message: t("desktop.daemon.loadFailed"),
logLabel: "[DesktopDaemon] Failed to load daemon status",
});
@@ -5,6 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
});
it("uses the active language for CLI install errors", async () => {
await i18n.changeLanguage("zh-CN");
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
});
});
describe("useSkillsStatus", () => {
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getCliInstallStatus,
getSkillsStatus,
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
}
export function useCliInstall(): DesktopInstallHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check CLI install status.",
message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status",
});
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install the Paseo CLI.",
message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI",
});
},
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
}
export function useSkillsStatus(): SkillsStatusHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills status.",
message: t("desktop.integrations.skills.statusFailed"),
logLabel: "[Integrations] Failed to load skills status",
});
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install orchestration skills.",
message: t("desktop.integrations.skills.installFailed"),
logLabel: "[Integrations] Failed to install skills",
});
},
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
message: t("desktop.integrations.skills.updateFailed"),
logLabel: "[Integrations] Failed to update skills",
});
},
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
message: t("desktop.integrations.skills.uninstallFailed"),
logLabel: "[Integrations] Failed to uninstall skills",
});
},
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { DesktopHostBridge } from "@/desktop/host";
import { i18n } from "@/i18n/i18next";
import {
createDesktopPermissions,
type DesktopPermissionEnvironment,
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
expect(result.state).toBe("denied");
});
it("uses the active app language for local status details", async () => {
await i18n.changeLanguage("zh-CN");
try {
const permissions = createDesktopPermissions(
fakeEnvironment({
notification: { permission: "granted" },
navigator: {
permissions: {
query: vi.fn(async () => ({ state: "prompt" })),
},
mediaDevices: { getUserMedia: vi.fn() },
},
}),
);
const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.notifications.detail).toBe("系统已允许通知。");
expect(snapshot.microphone.detail).toBe("麦克风权限尚未授予。");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,5 +1,6 @@
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
import { isNative, isWeb } from "@/constants/platform";
import { i18n } from "@/i18n/i18next";
export type DesktopPermissionKind = "notifications" | "microphone";
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
if (permission === "granted") {
return status({
state: "granted",
detail: "Notifications are allowed by the OS.",
detail: i18n.t("desktop.permissions.notifications.allowed"),
});
}
if (permission === "denied") {
return status({
state: "denied",
detail: "Notifications are denied in system settings.",
detail: i18n.t("desktop.permissions.notifications.denied"),
});
}
if (permission === "default") {
return status({
state: "prompt",
detail: "Notifications have not been granted yet.",
detail: i18n.t("desktop.permissions.notifications.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected notification permission state: ${permission}`,
detail: i18n.t("desktop.permissions.notifications.unexpectedState", {
state: permission,
}),
});
}
@@ -127,7 +130,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification status is only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.webOnly"),
});
}
@@ -138,8 +141,8 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: supported ? "granted" : "unavailable",
detail: supported
? "Desktop notifications are supported."
: "Desktop notifications are not supported on this platform.",
? i18n.t("desktop.permissions.notifications.supported")
: i18n.t("desktop.permissions.notifications.unsupported"),
});
} catch {
// Fall through to web API check
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: "unavailable",
detail: "Web Notification API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.notifications.apiUnavailable"),
});
}
@@ -161,7 +164,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.webOnly"),
});
}
@@ -169,7 +172,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator) {
return status({
state: "unavailable",
detail: "Navigator is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.navigatorUnavailable"),
});
}
@@ -180,36 +183,39 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (result?.state === "granted") {
return status({
state: "granted",
detail: "Microphone access is granted.",
detail: i18n.t("desktop.permissions.microphone.granted"),
});
}
if (result?.state === "denied") {
return status({
state: "denied",
detail: "Microphone access is denied in system settings.",
detail: i18n.t("desktop.permissions.microphone.denied"),
});
}
if (result?.state === "prompt") {
return status({
state: "prompt",
detail: "Microphone permission has not been granted yet.",
detail: i18n.t("desktop.permissions.microphone.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
state: result?.state ?? "unknown",
}),
});
} catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({
state: "unknown",
detail:
"Microphone status API is unavailable in this runtime. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
});
}
return status({
state: "unknown",
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.queryFailed", {
message: getErrorMessage(error),
}),
});
}
}
@@ -217,13 +223,13 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
});
}
return status({
state: "unknown",
detail: "Permission status API is unavailable. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.permissionApiUnavailable"),
});
}
@@ -231,7 +237,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.requestsWebOnly"),
});
}
@@ -246,14 +252,16 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
} catch (error) {
return status({
state: "unknown",
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.notifications.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}
return status({
state: "unavailable",
detail: "Web Notification API requestPermission() is unavailable.",
detail: i18n.t("desktop.permissions.notifications.requestUnavailable"),
});
}
@@ -261,7 +269,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.requestsWebOnly"),
});
}
@@ -269,7 +277,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureApiUnavailable"),
});
}
@@ -287,18 +295,20 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
return status({
state: "denied",
detail: "Microphone permission was denied by the user or system.",
detail: i18n.t("desktop.permissions.microphone.requestDenied"),
});
}
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return status({
state: "unavailable",
detail: "No microphone device was found.",
detail: i18n.t("desktop.permissions.microphone.noDevice"),
});
}
return status({
state: "unknown",
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
sendTestNotification: () => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
state: "unknown" as const,
detail: "Notification status has not been checked yet.",
};
const EMPTY_MICROPHONE_STATUS = {
state: "unknown" as const,
detail: "Microphone status has not been checked yet.",
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
notifications: {
state: "unknown",
detail: t("desktop.permissions.empty.notifications"),
},
microphone: {
state: "unknown",
detail: t("desktop.permissions.empty.microphone"),
},
};
if (kind === "notifications") {
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktopApp, refreshPermissions],
[isDesktopApp, refreshPermissions, t],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setTestNotificationError(null);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
title: t("desktop.permissions.testNotification.title"),
body: t("desktop.permissions.testNotification.body"),
});
if (!sent) {
setTestNotificationError(
"Notification was not delivered. Check System Settings > Notifications.",
);
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
}
} catch {
setTestNotificationError("Failed to send notification.");
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
}
}
}, [isDesktopApp]);
}, [isDesktopApp, t]);
useEffect(() => {
if (!isDesktopApp) {
@@ -7,6 +7,7 @@ import {
useDesktopIpcQueryErrorToast,
} from "@/desktop/hooks/desktop-ipc-error";
import type { ReleaseChannel } from "@/hooks/use-settings";
import { i18n } from "@/i18n/i18next";
const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
@@ -53,7 +54,7 @@ export function useDesktopSettings(): {
});
useDesktopIpcQueryErrorToast({
error: loadError,
message: "Unable to load desktop settings.",
message: i18n.t("desktop.settings.loadFailed"),
logLabel: "[DesktopSettings] Failed to load settings",
});
@@ -83,7 +84,7 @@ export function useDesktopSettings(): {
}
reportError({
error: saveError,
message: "Unable to save desktop settings.",
message: i18n.t("desktop.settings.saveFailed"),
logLabel: "[DesktopSettings] Failed to save settings",
});
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
createDesktopAppUpdater,
formatStatusText,
@@ -292,4 +293,32 @@ describe("formatStatusText", () => {
}),
).toBe("Restart now");
});
it("uses the active app language for local status wrappers", async () => {
await i18n.changeLanguage("zh-CN");
try {
expect(
formatStatusText({
status: "checking",
availableUpdate: null,
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("正在检查 app 更新...");
expect(
formatStatusText({
status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("更新已就绪:v1.2.3");
} finally {
await i18n.changeLanguage("en");
}
});
});

Some files were not shown because too many files have changed in this diff Show More