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