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
@@ -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}`,
};